diff --git a/cliv2/cmd/cliv2/behavior/output.go b/cliv2/cmd/cliv2/behavior/output.go new file mode 100644 index 0000000000..05c74447aa --- /dev/null +++ b/cliv2/cmd/cliv2/behavior/output.go @@ -0,0 +1,155 @@ +package behavior + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strings" + + "github.com/snyk/error-catalog-golang-public/cli" + "github.com/snyk/error-catalog-golang-public/errorcodes" + "github.com/snyk/error-catalog-golang-public/snyk_errors" + "github.com/snyk/go-application-framework/pkg/configuration" + "github.com/snyk/go-application-framework/pkg/local_workflows/output_workflow" +) + +var toonNumericString = regexp.MustCompile(`^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$`) + +type StructuredError struct { + Ok bool `json:"ok"` + ErrorMsg string `json:"error"` + Path string `json:"path"` +} + +type OutputFormat string + +const ( + outputFormatTOON OutputFormat = output_workflow.OUTPUT_CONFIG_KEY_TOON + outputFormatJSON OutputFormat = output_workflow.OUTPUT_CONFIG_KEY_JSON + outputFormatSARIF OutputFormat = output_workflow.OUTPUT_CONFIG_KEY_SARIF + outputFormatHTML OutputFormat = output_workflow.OUTPUT_CONFIG_KEY_HTML +) + +var outputFormats = []OutputFormat{ + outputFormatTOON, + outputFormatSARIF, + outputFormatJSON, + outputFormatHTML, +} + +// sarif+json predates --toon/--html and must stay accepted for every command; +// every other pairing is new and safe to reject. +func ValidateOutputFormatSelection(command string, config configuration.Configuration) error { + selected := []string{command} + aliasedAway := map[string]bool{} + if config.GetBool(string(outputFormatSARIF)) && config.GetBool(string(outputFormatJSON)) { + aliasedAway[string(outputFormatJSON)] = true + } + + for _, format := range outputFormats { + key := string(format) + if aliasedAway[key] || !config.GetBool(key) { + continue + } + + selected = append(selected, key) + if len(selected) > 2 { + detail := "The following option combination is not currently supported: " + strings.Join(selected, " + ") + return cli.NewInvalidFlagOptionError(detail) + } + + for _, alt := range config.GetAlternativeKeys(key) { + aliasedAway[alt] = true + } + } + + return nil +} + +func SelectErrorOutputWriter(config configuration.Configuration, stdout, stderr io.Writer) io.Writer { + if output_workflow.DefaultOutputIsStructured(config) { + return stderr + } + + return stdout +} + +func StructuredErrorOutputFormat(config configuration.Configuration) (OutputFormat, bool) { + for _, format := range []OutputFormat{outputFormatTOON, outputFormatJSON} { + if config.GetBool(string(format)) { + return format, true + } + } + + return "", false +} + +func IsDataRenderingError(err error) bool { + var catalogError snyk_errors.Error + return errors.As(err, &catalogError) && catalogError.ErrorCode == errorcodes.CLI.DataRenderingError +} + +func RenderStructuredError(format OutputFormat, err StructuredError) ([]byte, error) { + switch format { + case outputFormatTOON: + return renderTOONError(err), nil + case outputFormatJSON: + return json.MarshalIndent(err, "", " ") + case outputFormatSARIF, outputFormatHTML: + } + + return nil, fmt.Errorf("unsupported structured error output format: %s", format) +} + +// ok is omitted here: this renderer only ever runs from the error path, where +// it is always false, so it carries no information for a TOON consumer. +func renderTOONError(err StructuredError) []byte { + var document strings.Builder + document.WriteString("error: ") + document.WriteString(formatTOONString(err.ErrorMsg)) + document.WriteString("\npath: ") + document.WriteString(formatTOONString(err.Path)) + return []byte(document.String()) +} + +func formatTOONString(value string) string { + needsQuotes := value == "" || + strings.TrimSpace(value) != value || + value == "true" || value == "false" || value == "null" || + toonNumericString.MatchString(value) || + strings.ContainsAny(value, ":\\\"[]{}") || + strings.IndexFunc(value, func(character rune) bool { return character < 0x20 }) >= 0 || + strings.HasPrefix(value, "-") || + strings.HasPrefix(value, "#") + if !needsQuotes { + return value + } + + var escaped strings.Builder + escaped.Grow(len(value) + 2) + escaped.WriteByte('"') + for _, character := range value { + switch character { + case '\\': + escaped.WriteString(`\\`) + case '"': + escaped.WriteString(`\"`) + case '\n': + escaped.WriteString(`\n`) + case '\r': + escaped.WriteString(`\r`) + case '\t': + escaped.WriteString(`\t`) + default: + if character < 0x20 { + escaped.WriteString(fmt.Sprintf(`\u%04x`, character)) + } else { + escaped.WriteRune(character) + } + } + } + escaped.WriteByte('"') + return escaped.String() +} diff --git a/cliv2/cmd/cliv2/behavior/output_test.go b/cliv2/cmd/cliv2/behavior/output_test.go new file mode 100644 index 0000000000..75d76498e1 --- /dev/null +++ b/cliv2/cmd/cliv2/behavior/output_test.go @@ -0,0 +1,261 @@ +package behavior + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/snyk/error-catalog-golang-public/snyk_errors" + "github.com/snyk/go-application-framework/pkg/configuration" + "github.com/snyk/go-application-framework/pkg/local_workflows/output_workflow" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateOutputFormatSelection(t *testing.T) { + formats := []OutputFormat{ + outputFormatTOON, + outputFormatSARIF, + outputFormatJSON, + outputFormatHTML, + } + + for _, format := range formats { + t.Run("allows "+string(format)+" by itself", func(t *testing.T) { + config := outputConfig(format) + + assert.NoError(t, ValidateOutputFormatSelection("test", config)) + }) + } + + for index, first := range formats { + for _, second := range formats[index+1:] { + if (first == outputFormatSARIF && second == outputFormatJSON) || + (first == outputFormatJSON && second == outputFormatSARIF) { + t.Run("allows "+string(first)+" with "+string(second), func(t *testing.T) { + config := outputConfig(first, second) + + assert.NoError(t, ValidateOutputFormatSelection("test", config)) + }) + continue + } + + t.Run("rejects "+string(first)+" with "+string(second), func(t *testing.T) { + config := outputConfig(first, second) + + err := ValidateOutputFormatSelection("test", config) + require.Error(t, err) + var catalogError snyk_errors.Error + require.ErrorAs(t, err, &catalogError) + assert.Equal(t, "SNYK-CLI-0004", catalogError.ErrorCode) + assert.Equal( + t, + "The following option combination is not currently supported: test + "+string(first)+" + "+string(second), + catalogError.Detail, + ) + }) + } + } +} + +func TestValidateOutputFormatSelection_ConfigFileConflict(t *testing.T) { + // A CLI-1828 regression case: nothing on the command line changed the flags, + // but the resolved config still carries both formats (e.g. from a config + // file or env var), so the conflict must still be caught. + config := configuration.NewWithOpts() + config.Set(string(outputFormatTOON), true) + config.Set(string(outputFormatJSON), true) + + err := ValidateOutputFormatSelection("test", config) + + require.Error(t, err) + var catalogError snyk_errors.Error + require.ErrorAs(t, err, &catalogError) + assert.Equal(t, "SNYK-CLI-0004", catalogError.ErrorCode) +} + +func TestValidateOutputFormatSelection_RejectsConfigConflict(t *testing.T) { + // Exact repro from the CLI-1828 review: no flag was ever Changed, but the + // config file alone enables two conflicting formats. + t.Chdir(t.TempDir()) + require.NoError(t, os.WriteFile( + "output-conflict.json", + []byte(`{"toon":true,"json":true}`), + 0o600, + )) + config := configuration.NewWithOpts(configuration.WithFiles("output-conflict")) + require.True(t, config.GetBool(string(outputFormatTOON))) + require.True(t, config.GetBool(string(outputFormatJSON))) + + err := ValidateOutputFormatSelection("test", config) + + require.Error(t, err, "toon and json from config must conflict") +} + +func TestValidateOutputFormatSelection_AllowsSarifAndJSONForAnyCommand(t *testing.T) { + // Preserves behavior (before --toon/--html existed) for every + // command, not just code test/secrets test's explicit aliasing. + config := configuration.NewWithOpts() + config.Set(string(outputFormatSARIF), true) + config.Set(string(outputFormatJSON), true) + + assert.NoError(t, ValidateOutputFormatSelection("iac test", config)) +} + +func TestValidateOutputFormatSelection_AllowsAliasedFormats(t *testing.T) { + // code test/secrets test register sarif and json as interchangeable via + // AddAlternativeKeys; that pair must not be flagged as a conflict. + config := configuration.NewWithOpts() + config.AddAlternativeKeys(string(outputFormatSARIF), []string{string(outputFormatJSON)}) + config.Set(string(outputFormatJSON), true) + + assert.NoError(t, ValidateOutputFormatSelection("code test", config)) +} + +func TestValidateOutputFormatSelection_IgnoresFileOutputFlags(t *testing.T) { + // *-file-output flags write to a file, not stdout, so they never compete + // with a console format or each other. + config := configuration.NewWithOpts() + config.Set(string(outputFormatHTML), true) + config.Set(output_workflow.OUTPUT_CONFIG_KEY_JSON_FILE, true) + config.Set(output_workflow.OUTPUT_CONFIG_KEY_SARIF_FILE, true) + config.Set(output_workflow.OUTPUT_CONFIG_KEY_TOON_FILE, true) + + assert.NoError(t, ValidateOutputFormatSelection("test", config)) +} + +func outputConfig(selected ...OutputFormat) configuration.Configuration { + config := configuration.NewWithOpts() + for _, format := range selected { + config.Set(string(format), true) + } + return config +} + +func TestStructuredErrorOutputFormat(t *testing.T) { + for _, testCase := range []struct { + format OutputFormat + selected bool + }{ + {format: outputFormatJSON, selected: true}, + {format: outputFormatTOON, selected: true}, + {format: outputFormatSARIF, selected: false}, + {format: outputFormatHTML, selected: false}, + } { + t.Run(string(testCase.format), func(t *testing.T) { + config := configuration.NewWithOpts() + config.Set(string(testCase.format), true) + + actual, selected := StructuredErrorOutputFormat(config) + + assert.Equal(t, testCase.selected, selected) + if selected { + assert.Equal(t, testCase.format, actual) + } + }) + } +} + +func TestSelectErrorOutputWriter(t *testing.T) { + for _, testCase := range []struct { + name string + selected []OutputFormat + wantStderr bool + }{ + {name: "no format selected (legacy human output)", selected: nil, wantStderr: false}, + {name: "json alone", selected: []OutputFormat{outputFormatJSON}, wantStderr: true}, + {name: "toon alone", selected: []OutputFormat{outputFormatTOON}, wantStderr: true}, + {name: "sarif alone", selected: []OutputFormat{outputFormatSARIF}, wantStderr: true}, + {name: "html alone", selected: []OutputFormat{outputFormatHTML}, wantStderr: true}, + {name: "sarif and json", selected: []OutputFormat{outputFormatSARIF, outputFormatJSON}, wantStderr: true}, + {name: "sarif and html", selected: []OutputFormat{outputFormatSARIF, outputFormatHTML}, wantStderr: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + config := configuration.NewWithOpts() + for _, format := range testCase.selected { + config.Set(string(format), true) + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + writer := SelectErrorOutputWriter(config, stdout, stderr) + + if testCase.wantStderr { + assert.Same(t, io.Writer(stderr), writer) + } else { + assert.Same(t, io.Writer(stdout), writer) + } + }) + } +} + +func TestRenderStructuredError_matchesApprovedToonFixture(t *testing.T) { + expected, err := os.ReadFile("testdata/error.toon") + require.NoError(t, err) + expected = bytes.TrimSuffix(expected, []byte("\n")) + + actual, err := RenderStructuredError(outputFormatTOON, StructuredError{ + Ok: false, + ErrorMsg: "No supported files found", + Path: "/workspace", + }) + + require.NoError(t, err) + assert.Equal(t, string(expected), string(actual)) +} + +func TestRenderStructuredError_quotesToonStrings(t *testing.T) { + tests := []struct { + name string + input StructuredError + expected string + }{ + { + name: "TOON syntax and whitespace", + input: StructuredError{ + Ok: false, + ErrorMsg: "scan: failed\nretry", + Path: " /workspace ", + }, + expected: "error: \"scan: failed\\nretry\"\npath: \" /workspace \"", + }, + { + name: "control characters", + input: StructuredError{ + Ok: false, + ErrorMsg: "invalid\x01detail", + }, + expected: "error: \"invalid\\u0001detail\"\npath: \"\"", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, err := RenderStructuredError(outputFormatTOON, test.input) + + require.NoError(t, err) + assert.Equal(t, test.expected, string(actual)) + }) + } +} + +func TestRenderStructuredError_keepsSafeToonStringsUnquoted(t *testing.T) { + actual, err := RenderStructuredError(outputFormatTOON, StructuredError{ + Ok: false, + ErrorMsg: "scan failed, retry", + Path: `\\server\share`, + }) + + require.NoError(t, err) + assert.Equal(t, "error: scan failed, retry\npath: \"\\\\\\\\server\\\\share\"", string(actual)) + + actual, err = RenderStructuredError(outputFormatTOON, StructuredError{ + Ok: false, + ErrorMsg: "true", + Path: "#comment", + }) + + require.NoError(t, err) + assert.Equal(t, "error: \"true\"\npath: \"#comment\"", string(actual)) +} diff --git a/cliv2/cmd/cliv2/behavior/testdata/error.toon b/cliv2/cmd/cliv2/behavior/testdata/error.toon new file mode 100644 index 0000000000..ed6a069396 --- /dev/null +++ b/cliv2/cmd/cliv2/behavior/testdata/error.toon @@ -0,0 +1,2 @@ +error: No supported files found +path: /workspace diff --git a/cliv2/pkg/core/main.go b/cliv2/pkg/core/main.go index ae7d31f0d4..dd208f6cab 100644 --- a/cliv2/pkg/core/main.go +++ b/cliv2/pkg/core/main.go @@ -9,7 +9,6 @@ import ( import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -33,6 +32,7 @@ import ( "github.com/snyk/go-application-framework/pkg/instrumentation" "github.com/snyk/go-application-framework/pkg/logging" + "github.com/snyk/cli/cliv2/cmd/cliv2/behavior" "github.com/snyk/cli/cliv2/cmd/cliv2/behavior/legacy" "github.com/snyk/cli/cliv2/internal/cliv2" "github.com/snyk/cli/cliv2/internal/constants" @@ -105,12 +105,6 @@ const ( teardownTimeout = 5 * time.Second ) -type JsonErrorStruct struct { - Ok bool `json:"ok"` - ErrorMsg string `json:"error"` - Path string `json:"path"` -} - type HandleError int const ( @@ -193,13 +187,14 @@ func runMainWorkflow(config configuration.Configuration, cmd *cobra.Command, arg } // init UI - errorUI := consoleui.WithErrorOutput(os.Stdout) - if output_workflow.DefaultOutputIsStructured(config) { - errorUI = consoleui.WithErrorOutput(os.Stderr) - } + errorUI := consoleui.WithErrorOutput(behavior.SelectErrorOutputWriter(config, os.Stdout, os.Stderr)) mainUI := consoleui.New(consoleui.WithInput(os.Stdin), consoleui.WithOutput(os.Stdout), consoleui.WithProgressWriter(os.Stderr), errorUI) globalEngine.SetUserInterface(mainUI) + if err := behavior.ValidateOutputFormatSelection(getFullCommandString(cmd), config); err != nil { + return err + } + // global handling of experimental commands if config_utils.IsExperimental(cmd.Flags()) { if !globalConfiguration.GetBool(configuration.FLAG_EXPERIMENTAL) { @@ -479,19 +474,27 @@ func displayError(err error, userInterface ui.UserInterface, config configuratio return } - if config.GetBool(output_workflow.OUTPUT_CONFIG_KEY_JSON) { + outputFormat, structuredOutputSelected := behavior.StructuredErrorOutputFormat(config) + if structuredOutputSelected && !behavior.IsDataRenderingError(err) { message := getErrorMessage(err) - jsonError := JsonErrorStruct{ + structuredError := behavior.StructuredError{ Ok: false, ErrorMsg: message, - Path: globalConfiguration.GetString(configuration.INPUT_DIRECTORY), + Path: config.GetString(configuration.INPUT_DIRECTORY), + } + + output, renderErr := behavior.RenderStructuredError(outputFormat, structuredError) + if renderErr != nil { + _ = userInterface.OutputError(renderErr) + return } - jsonErrorBuffer, _ := json.MarshalIndent(jsonError, "", " ") // This document is the command's structured output, so it goes to // stdout; OutputError would route it to stderr in structured mode. - _ = userInterface.Output(string(jsonErrorBuffer)) + if outputErr := userInterface.Output(string(output)); outputErr != nil { + _ = userInterface.OutputError(outputErr) + } } else { ctx = context.WithValue(ctx, uitypes.ErrorTipKey, doctorTip(isCI)) uiError := userInterface.OutputError(err, ui.WithContext(ctx)) diff --git a/cliv2/pkg/core/main_test.go b/cliv2/pkg/core/main_test.go index 458a037c05..1aa9452b30 100644 --- a/cliv2/pkg/core/main_test.go +++ b/cliv2/pkg/core/main_test.go @@ -1,6 +1,7 @@ package core import ( + "bytes" "encoding/json" "errors" "fmt" @@ -13,7 +14,9 @@ import ( "github.com/golang/mock/gomock" "github.com/rs/zerolog" + catalogcli "github.com/snyk/error-catalog-golang-public/cli" "github.com/snyk/error-catalog-golang-public/code" + "github.com/snyk/error-catalog-golang-public/snyk" "github.com/snyk/error-catalog-golang-public/snyk_errors" "github.com/snyk/go-application-framework/pkg/analytics" "github.com/snyk/go-application-framework/pkg/apiclients/testapi" @@ -26,6 +29,7 @@ import ( "github.com/snyk/go-application-framework/pkg/logging" "github.com/snyk/go-application-framework/pkg/mocks" "github.com/snyk/go-application-framework/pkg/networking" + "github.com/snyk/go-application-framework/pkg/ui/consoleui" "github.com/snyk/go-application-framework/pkg/utils/ufm" "github.com/snyk/go-application-framework/pkg/workflow" "github.com/spf13/cobra" @@ -33,6 +37,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/snyk/cli/cliv2/cmd/cliv2/behavior" "github.com/snyk/cli/cliv2/internal/helpdocs" "github.com/snyk/cli/cliv2/internal/helprouting" @@ -758,6 +763,143 @@ func Test_displayError(t *testing.T) { config := configuration.NewWithOpts(configuration.WithAutomaticEnv()) displayError(err, userInterface, config, t.Context(), false) }) + + t.Run("renders supported catalog errors as TOON without diagnostics", func(t *testing.T) { + tests := []struct { + name string + err error + }{ + {name: "scan", err: catalogcli.NewNoSupportedFilesFoundError("scan failed")}, + {name: "authentication", err: snyk.NewUnauthorisedError("authentication failed")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + console := consoleui.New(consoleui.WithOutput(&stdout), consoleui.WithErrorOutput(&stderr)) + config := configuration.NewWithOpts() + config.Set(output_workflow.OUTPUT_CONFIG_KEY_TOON, true) + config.Set(configuration.INPUT_DIRECTORY, "/workspace") + + displayError(tt.err, console, config, t.Context(), false) + + expected := fmt.Sprintf("error: %s\npath: /workspace\n", getErrorMessage(tt.err)) + assert.Equal(t, expected, stdout.String()) + assert.Empty(t, stderr.String()) + }) + } + }) + + t.Run("preserves JSON error output", func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + console := consoleui.New(consoleui.WithOutput(&stdout), consoleui.WithErrorOutput(&stderr)) + config := configuration.NewWithOpts() + config.Set(output_workflow.OUTPUT_CONFIG_KEY_JSON, true) + config.Set(configuration.INPUT_DIRECTORY, "/workspace") + + displayError(catalogcli.NewNoSupportedFilesFoundError("scan failed"), console, config, t.Context(), false) + + assert.JSONEq(t, `{"ok":false,"error":"scan failed","path":"/workspace"}`, stdout.String()) + assert.Empty(t, stderr.String()) + }) + + t.Run("renders TOON and HTML conflicts with the legacy detail", func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + console := consoleui.New(consoleui.WithOutput(&stdout), consoleui.WithErrorOutput(&stderr)) + config := configuration.NewWithOpts() + config.Set(output_workflow.OUTPUT_CONFIG_KEY_TOON, true) + config.Set(output_workflow.OUTPUT_CONFIG_KEY_HTML, true) + err := behavior.ValidateOutputFormatSelection("test", config) + require.Error(t, err) + + displayError(err, console, config, t.Context(), false) + + assert.Contains(t, stdout.String(), "The following option combination is not currently supported: test + toon + html") + assert.Empty(t, stderr.String()) + }) + + t.Run("keeps data rendering errors on stderr", func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + console := consoleui.New(consoleui.WithOutput(&stdout), consoleui.WithErrorOutput(&stderr)) + config := configuration.NewWithOpts() + config.Set(output_workflow.OUTPUT_CONFIG_KEY_TOON, true) + + displayError(catalogcli.NewDataRenderingError("render failed"), console, config, t.Context(), false) + + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "render failed") + }) + + t.Run("reports TOON output failures as diagnostics", func(t *testing.T) { + err := catalogcli.NewNoSupportedFilesFoundError("scan failed") + userInterface.EXPECT().Output(gomock.Any()).Return(assert.AnError).Times(1) + userInterface.EXPECT().OutputError(assert.AnError).Return(nil).Times(1) + + config := configuration.NewWithOpts() + config.Set(output_workflow.OUTPUT_CONFIG_KEY_TOON, true) + displayError(err, userInterface, config, t.Context(), false) + }) +} + +// Test_displayError_errorStreamSelection wires displayError to the same +// behavior.SelectErrorOutputWriter used by runMainWorkflow, so it catches +// regressions in that stream selection rather than a copy of its logic. +// It's the regression test for CLI-1828: `snyk test --html > out.html` +// must not leak errors into the redirected file. +func Test_displayError_errorStreamSelection(t *testing.T) { + for _, tc := range []struct { + name string + formats []string + wantStderr bool + }{ + {"no format flags", nil, false}, + {"json alone", []string{output_workflow.OUTPUT_CONFIG_KEY_JSON}, false}, + {"toon alone", []string{output_workflow.OUTPUT_CONFIG_KEY_TOON}, false}, + {"sarif alone", []string{output_workflow.OUTPUT_CONFIG_KEY_SARIF}, true}, + {"html alone", []string{output_workflow.OUTPUT_CONFIG_KEY_HTML}, true}, + {"json + sarif (legacy-compatible, no conflict)", []string{output_workflow.OUTPUT_CONFIG_KEY_JSON, output_workflow.OUTPUT_CONFIG_KEY_SARIF}, false}, + {"json + html conflict", []string{output_workflow.OUTPUT_CONFIG_KEY_JSON, output_workflow.OUTPUT_CONFIG_KEY_HTML}, false}, + {"json + toon conflict", []string{output_workflow.OUTPUT_CONFIG_KEY_JSON, output_workflow.OUTPUT_CONFIG_KEY_TOON}, false}, + {"sarif + html conflict", []string{output_workflow.OUTPUT_CONFIG_KEY_SARIF, output_workflow.OUTPUT_CONFIG_KEY_HTML}, true}, + {"sarif + toon conflict", []string{output_workflow.OUTPUT_CONFIG_KEY_SARIF, output_workflow.OUTPUT_CONFIG_KEY_TOON}, false}, + {"toon + html conflict", []string{output_workflow.OUTPUT_CONFIG_KEY_TOON, output_workflow.OUTPUT_CONFIG_KEY_HTML}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + config := configuration.NewWithOpts() + for _, format := range tc.formats { + config.Set(format, true) + } + + var err error + conflictErr := behavior.ValidateOutputFormatSelection("test", config) + switch { + case conflictErr != nil: + err = conflictErr + default: + err = catalogcli.NewNoSupportedFilesFoundError("scan failed") + } + + var stdout, stderr bytes.Buffer + errorWriter := behavior.SelectErrorOutputWriter(config, &stdout, &stderr) + console := consoleui.New(consoleui.WithOutput(&stdout), consoleui.WithErrorOutput(errorWriter)) + + displayError(err, console, config, t.Context(), false) + + message := getErrorMessage(err) + if tc.wantStderr { + // OutputError wraps text at a fixed width, so collapse whitespace before matching. + assert.Contains(t, strings.Join(strings.Fields(stderr.String()), " "), message) + assert.Empty(t, stdout.String()) + } else { + assert.Contains(t, stdout.String(), message) + assert.Empty(t, stderr.String()) + } + }) + } } func Test_doctorTip(t *testing.T) { diff --git a/test/jest/acceptance/cli-args.spec.ts b/test/jest/acceptance/cli-args.spec.ts index b23c3dc399..cd73d4f56d 100644 --- a/test/jest/acceptance/cli-args.spec.ts +++ b/test/jest/acceptance/cli-args.spec.ts @@ -406,15 +406,14 @@ describe.each(userJourneyWorkflows)( }); }); - test('iac test with flags not allowed with --sarif', async () => { - const { code, stdout } = await runSnykCLI(`iac test --sarif --json`, { + test('iac test allows --sarif with --json (legacy behavior preserved)', async () => { + const { stdout } = await runSnykCLI(`iac test --sarif --json`, { env, }); - expect(stdout).toContainText( - new UnsupportedOptionCombinationError(['test', 'sarif', 'json']) + expect(stdout).not.toContainText( + new UnsupportedOptionCombinationError(['iac test', 'sarif', 'json']) .userMessage, ); - expect(code).toEqual(2); }); test('container test with flags not allowed with --sarif', async () => {