From 238c5c00784779241a5692c73d4c0535e1a0d63d Mon Sep 17 00:00:00 2001 From: Vince Rose Date: Tue, 6 Jan 2026 11:17:21 -0700 Subject: [PATCH 1/3] continue outputting to stdout even with a report file is specified --- cmd/container-structure-test/app/cmd/test.go | 21 ++++++--------- .../app/cmd/test/util.go | 27 ++++++++++++++----- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/cmd/container-structure-test/app/cmd/test.go b/cmd/container-structure-test/app/cmd/test.go index 069b1464..cfe883fa 100644 --- a/cmd/container-structure-test/app/cmd/test.go +++ b/cmd/container-structure-test/app/cmd/test.go @@ -60,20 +60,15 @@ func NewCmdTest(out io.Writer) *cobra.Command { return test.ValidateArgs(opts) }, RunE: func(cmd *cobra.Command, _ []string) error { + // Open test report file if specified (for JUnit XML output) + var reportOut io.Writer if opts.TestReport != "" { - // Force JsonOutput - if opts.Output == unversioned.Text { - opts.JSON = true - opts.Output = unversioned.Json - - logrus.Warn("raw text format unsupported for writing output file, defaulting to JSON") - } testReportFile, err := os.Create(opts.TestReport) if err != nil { return err } - rootCmd.SetOutput(testReportFile) - out = testReportFile // override writer + defer testReportFile.Close() + reportOut = testReportFile } if opts.Quiet { @@ -86,7 +81,7 @@ func NewCmdTest(out io.Writer) *cobra.Command { opts.Output = unversioned.Json } - return run(out) + return run(out, reportOut) }, } @@ -94,7 +89,7 @@ func NewCmdTest(out io.Writer) *cobra.Command { return testCmd } -func run(out io.Writer) error { +func run(out, reportOut io.Writer) error { args = &drivers.DriverConfig{ Image: opts.ImagePath, Save: opts.Save, @@ -153,7 +148,7 @@ func run(out io.Writer) error { } var r string if r, err = daemon.Write(tag, img); err != nil { - logrus.Fatalf("error loading oci layout into daemon: %v, %s", err) + logrus.Fatalf("error loading oci layout into daemon: %v", err) } // For some reason, daemon.Write doesn't return errors for some edge cases. // We should always print what the daemon sent back so that errors are transparent. @@ -205,7 +200,7 @@ func run(out io.Writer) error { channel := make(chan interface{}, 1) go runTests(out, channel, args, driverImpl) // TODO(nkubala): put a sync.WaitGroup here - return test.ProcessResults(out, opts.Output, opts.JunitSuiteName, channel) + return test.ProcessResults(out, reportOut, opts.Output, opts.JunitSuiteName, channel) } func runTests(out io.Writer, channel chan interface{}, args *drivers.DriverConfig, driverImpl func(drivers.DriverConfig) (drivers.Driver, error)) { diff --git a/cmd/container-structure-test/app/cmd/test/util.go b/cmd/container-structure-test/app/cmd/test/util.go index ba912e34..0430f889 100644 --- a/cmd/container-structure-test/app/cmd/test/util.go +++ b/cmd/container-structure-test/app/cmd/test/util.go @@ -105,7 +105,13 @@ func Parse(fp string, args *drivers.DriverConfig, driverImpl func(drivers.Driver return tests, nil } -func ProcessResults(out io.Writer, format unversioned.OutputValue, junitSuiteName string, c chan interface{}) error { +// ProcessResults processes test results and writes output to the appropriate destinations. +// - out: primary output writer (stdout) - format controlled by the format parameter +// - reportOut: optional test report writer - when non-nil, receives JUnit XML output +// - format: output format for the primary output (text, json, or junit) +// - junitSuiteName: name for the JUnit test suite +// - c: channel of test results +func ProcessResults(out, reportOut io.Writer, format unversioned.OutputValue, junitSuiteName string, c chan interface{}) error { totalPass := 0 totalFail := 0 totalDuration := time.Duration(0) @@ -116,7 +122,7 @@ func ProcessResults(out io.Writer, format unversioned.OutputValue, junitSuiteNam } for _, r := range results { if format == unversioned.Text { - // output individual results if we're not in json mode + // output individual results if we're in text mode output.OutputResult(out, r) } if r.IsPass() { @@ -139,11 +145,20 @@ func ProcessResults(out io.Writer, format unversioned.OutputValue, junitSuiteNam Fail: totalFail, Duration: totalDuration, } - if format == unversioned.Json || format == unversioned.Junit { - // only output results here if we're in json mode - summary.Results = results + + summary.Results = results + + // Write final summary to primary output (stdout) + if outputErr := output.FinalResults(out, format, junitSuiteName, summary); outputErr != nil { + return outputErr + } + + // If a test report file is specified, write JUnit XML to it separately + if reportOut != nil { + if reportErr := output.FinalResults(reportOut, unversioned.Junit, junitSuiteName, summary); reportErr != nil { + return reportErr + } } - output.FinalResults(out, format, junitSuiteName, summary) return err } From eee849cfd5f1a5c5e961418ef9aa2101ecb731b6 Mon Sep 17 00:00:00 2001 From: Vince Rose Date: Tue, 6 Jan 2026 11:21:36 -0700 Subject: [PATCH 2/3] Update bazel rule --- bazel/container_structure_test.bzl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bazel/container_structure_test.bzl b/bazel/container_structure_test.bzl index cd5d0088..182a5be7 100644 --- a/bazel/container_structure_test.bzl +++ b/bazel/container_structure_test.bzl @@ -44,10 +44,11 @@ fi """ def _structure_test_impl(ctx): + # --test-report writes JUnit XML to the file specified by $XML_OUTPUT_FILE + # stdout uses default text format for human-readable output (captured as test.log) fixed_args = [ "--test-report $XML_OUTPUT_FILE", - "--output junit", - "--junit-suite-name $TEST_TARGET" + "--junit-suite-name $TEST_TARGET", ] test_bin = ctx.toolchains["@container_structure_test//bazel:structure_test_toolchain_type"].st_info.binary jq_bin = ctx.toolchains["@aspect_bazel_lib//lib:jq_toolchain_type"].jqinfo.bin From 84ae29ce22e9b019d14655a75bce7997f352ee23 Mon Sep 17 00:00:00 2001 From: Vince Rose Date: Tue, 6 Jan 2026 11:46:48 -0700 Subject: [PATCH 3/3] add report format flag --- bazel/container_structure_test.bzl | 4 +++- cmd/container-structure-test/app/cmd/test.go | 23 ++++++++++++++++--- .../app/cmd/test/util.go | 9 ++++---- pkg/config/options.go | 1 + 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/bazel/container_structure_test.bzl b/bazel/container_structure_test.bzl index 182a5be7..7c8264b7 100644 --- a/bazel/container_structure_test.bzl +++ b/bazel/container_structure_test.bzl @@ -44,10 +44,12 @@ fi """ def _structure_test_impl(ctx): - # --test-report writes JUnit XML to the file specified by $XML_OUTPUT_FILE + # --test-report writes to the file specified by $XML_OUTPUT_FILE + # --test-report-format junit ensures bazel-compatible JUnit XML output # stdout uses default text format for human-readable output (captured as test.log) fixed_args = [ "--test-report $XML_OUTPUT_FILE", + "--test-report-format junit", "--junit-suite-name $TEST_TARGET", ] test_bin = ctx.toolchains["@container_structure_test//bazel:structure_test_toolchain_type"].st_info.binary diff --git a/cmd/container-structure-test/app/cmd/test.go b/cmd/container-structure-test/app/cmd/test.go index cfe883fa..b64043b7 100644 --- a/cmd/container-structure-test/app/cmd/test.go +++ b/cmd/container-structure-test/app/cmd/test.go @@ -60,15 +60,30 @@ func NewCmdTest(out io.Writer) *cobra.Command { return test.ValidateArgs(opts) }, RunE: func(cmd *cobra.Command, _ []string) error { - // Open test report file if specified (for JUnit XML output) + // Open test report file if specified var reportOut io.Writer + reportFormatFlag := cmd.Flags().Lookup("test-report-format") + if opts.TestReport != "" { + // Validate report format - only json and junit are supported + if opts.TestReportFormat == unversioned.Text { + if reportFormatFlag.Changed { + // User explicitly set --test-report-format text, which is not supported + return fmt.Errorf("--test-report-format does not support 'text'; use 'json' or 'junit'") + } + // Default to JSON for backward compatibility (Text is zero value when flag not set) + opts.TestReportFormat = unversioned.Json + } + testReportFile, err := os.Create(opts.TestReport) if err != nil { return err } defer testReportFile.Close() reportOut = testReportFile + } else if reportFormatFlag.Changed { + // User specified --test-report-format without --test-report + return fmt.Errorf("--test-report-format requires --test-report to be specified") } if opts.Quiet { @@ -200,7 +215,7 @@ func run(out, reportOut io.Writer) error { channel := make(chan interface{}, 1) go runTests(out, channel, args, driverImpl) // TODO(nkubala): put a sync.WaitGroup here - return test.ProcessResults(out, reportOut, opts.Output, opts.JunitSuiteName, channel) + return test.ProcessResults(out, reportOut, opts.Output, opts.TestReportFormat, opts.JunitSuiteName, channel) } func runTests(out io.Writer, channel chan interface{}, args *drivers.DriverConfig, driverImpl func(drivers.DriverConfig) (drivers.Driver, error)) { @@ -245,5 +260,7 @@ func AddTestFlags(cmd *cobra.Command) { cmd.Flags().StringArrayVarP(&opts.ConfigFiles, "config", "c", []string{}, "test config files") cmd.MarkFlagRequired("config") - cmd.Flags().StringVar(&opts.TestReport, "test-report", "", "generate test report and write it to specified file (supported format: json, junit; default: json)") + cmd.Flags().StringVar(&opts.TestReport, "test-report", "", "generate test report and write it to specified file") + cmd.Flags().VarP(&opts.TestReportFormat, "test-report-format", "", "format for the test report file (json, junit)") + cmd.Flags().Lookup("test-report-format").DefValue = "json" } diff --git a/cmd/container-structure-test/app/cmd/test/util.go b/cmd/container-structure-test/app/cmd/test/util.go index 0430f889..6216bfba 100644 --- a/cmd/container-structure-test/app/cmd/test/util.go +++ b/cmd/container-structure-test/app/cmd/test/util.go @@ -107,11 +107,12 @@ func Parse(fp string, args *drivers.DriverConfig, driverImpl func(drivers.Driver // ProcessResults processes test results and writes output to the appropriate destinations. // - out: primary output writer (stdout) - format controlled by the format parameter -// - reportOut: optional test report writer - when non-nil, receives JUnit XML output +// - reportOut: optional test report writer - format controlled by reportFormat parameter // - format: output format for the primary output (text, json, or junit) +// - reportFormat: output format for the report file (json or junit) // - junitSuiteName: name for the JUnit test suite // - c: channel of test results -func ProcessResults(out, reportOut io.Writer, format unversioned.OutputValue, junitSuiteName string, c chan interface{}) error { +func ProcessResults(out, reportOut io.Writer, format, reportFormat unversioned.OutputValue, junitSuiteName string, c chan interface{}) error { totalPass := 0 totalFail := 0 totalDuration := time.Duration(0) @@ -153,9 +154,9 @@ func ProcessResults(out, reportOut io.Writer, format unversioned.OutputValue, ju return outputErr } - // If a test report file is specified, write JUnit XML to it separately + // If a test report file is specified, write to it using the report format if reportOut != nil { - if reportErr := output.FinalResults(reportOut, unversioned.Junit, junitSuiteName, summary); reportErr != nil { + if reportErr := output.FinalResults(reportOut, reportFormat, junitSuiteName, summary); reportErr != nil { return reportErr } } diff --git a/pkg/config/options.go b/pkg/config/options.go index ae24c3c3..23cc81a6 100644 --- a/pkg/config/options.go +++ b/pkg/config/options.go @@ -26,6 +26,7 @@ type StructureTestOptions struct { Platform string Metadata string TestReport string + TestReportFormat unversioned.OutputValue ConfigFiles []string JSON bool