diff --git a/command/output_test.go b/command/output_test.go index 3824c0fdc0..22e8afe236 100644 --- a/command/output_test.go +++ b/command/output_test.go @@ -47,58 +47,6 @@ func TestOutput(t *testing.T) { } } -func TestOutput_nestedListAndMap(t *testing.T) { - originalState := states.BuildState(func(s *states.SyncState) { - s.SetOutputValue( - addrs.OutputValue{Name: "foo"}.Absolute(addrs.RootModuleInstance), - cty.ListVal([]cty.Value{ - cty.MapVal(map[string]cty.Value{ - "key": cty.StringVal("value"), - "key2": cty.StringVal("value2"), - }), - cty.MapVal(map[string]cty.Value{ - "key": cty.StringVal("value"), - }), - }), - false, - ) - }) - statePath := testStateFile(t, originalState) - - view, done := testView(t) - c := &OutputCommand{ - Meta: Meta{ - testingOverrides: metaOverridesForProvider(testProvider()), - View: view, - }, - } - - args := []string{ - "-state", statePath, - } - code := c.Run(args) - output := done(t) - if code != 0 { - t.Fatalf("bad: \n%s", output.Stderr()) - } - - actual := strings.TrimSpace(output.Stdout()) - expected := strings.TrimSpace(` -foo = tolist([ - tomap({ - "key" = "value" - "key2" = "value2" - }), - tomap({ - "key" = "value" - }), -]) -`) - if actual != expected { - t.Fatalf("wrong output\ngot: %s\nwant: %s", actual, expected) - } -} - func TestOutput_json(t *testing.T) { originalState := states.BuildState(func(s *states.SyncState) { s.SetOutputValue( @@ -163,36 +111,6 @@ func TestOutput_emptyOutputs(t *testing.T) { } } -func TestOutput_jsonEmptyOutputs(t *testing.T) { - originalState := states.NewState() - statePath := testStateFile(t, originalState) - - p := testProvider() - view, done := testView(t) - c := &OutputCommand{ - Meta: Meta{ - testingOverrides: metaOverridesForProvider(p), - View: view, - }, - } - - args := []string{ - "-state", statePath, - "-json", - } - code := c.Run(args) - output := done(t) - if code != 0 { - t.Fatalf("bad: \n%s", output.Stderr()) - } - - actual := strings.TrimSpace(output.Stdout()) - expected := "{}" - if actual != expected { - t.Fatalf("bad:\n%#v\n%#v", expected, actual) - } -} - func TestOutput_badVar(t *testing.T) { originalState := states.BuildState(func(s *states.SyncState) { s.SetOutputValue( diff --git a/command/views/output.go b/command/views/output.go index e3c998ef63..8500858749 100644 --- a/command/views/output.go +++ b/command/views/output.go @@ -166,10 +166,9 @@ func (v *OutputRaw) Output(name string, outputs map[string]*states.OutputValue) return diags } // If we get out here then we should have a valid string to print. - // We're writing it directly to the output here so that a shell caller - // will get exactly the value and no extra whitespace. - str := strV.AsString() - fmt.Fprint(v.streams.Stdout.File, str) + // We're writing it using Print here so that a shell caller will get + // exactly the value and no extra whitespace (including trailing newline). + v.streams.Print(strV.AsString()) return nil } diff --git a/command/views/output_test.go b/command/views/output_test.go index 2cfa95a33b..a1204950af 100644 --- a/command/views/output_test.go +++ b/command/views/output_test.go @@ -1,13 +1,256 @@ package views import ( + "strings" "testing" + "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/states" "github.com/zclconf/go-cty/cty" ) +// Test various single output values for human-readable UI. Note that since +// OutputHuman defers to repl.FormatValue to render a single value, most of the +// test coverage should be in that package. +func TestOutputHuman_single(t *testing.T) { + testCases := map[string]struct { + value cty.Value + want string + wantErr bool + }{ + "string": { + value: cty.StringVal("hello"), + want: "\"hello\"\n", + }, + "list of maps": { + value: cty.ListVal([]cty.Value{ + cty.MapVal(map[string]cty.Value{ + "key": cty.StringVal("value"), + "key2": cty.StringVal("value2"), + }), + cty.MapVal(map[string]cty.Value{ + "key": cty.StringVal("value"), + }), + }), + want: `tolist([ + tomap({ + "key" = "value" + "key2" = "value2" + }), + tomap({ + "key" = "value" + }), +]) +`, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOutput(arguments.ViewHuman, NewView(streams)) + + outputs := map[string]*states.OutputValue{ + "foo": {Value: tc.value}, + } + diags := v.Output("foo", outputs) + + if diags.HasErrors() { + if !tc.wantErr { + t.Fatalf("unexpected diagnostics: %s", diags) + } + } else if tc.wantErr { + t.Fatalf("succeeded, but want error") + } + + if got, want := done(t).Stdout(), tc.want; got != want { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + } + }) + } +} + +// Sensitive output values are rendered to the console intentionally when +// requesting a single output. +func TestOutput_sensitive(t *testing.T) { + testCases := map[string]arguments.ViewType{ + "human": arguments.ViewHuman, + "json": arguments.ViewJSON, + "raw": arguments.ViewRaw, + } + for name, vt := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOutput(vt, NewView(streams)) + + outputs := map[string]*states.OutputValue{ + "foo": { + Value: cty.StringVal("secret"), + Sensitive: true, + }, + } + diags := v.Output("foo", outputs) + + if diags.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diags) + } + + // Test for substring match here because we don't care about exact + // output format in this test, just the presence of the sensitive + // value. + if got, want := done(t).Stdout(), "secret"; !strings.Contains(got, want) { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + } + }) + } +} + +// Showing all outputs is supported by human and JSON output format. +func TestOutput_all(t *testing.T) { + outputs := map[string]*states.OutputValue{ + "foo": { + Value: cty.StringVal("secret"), + Sensitive: true, + }, + "bar": { + Value: cty.ListVal([]cty.Value{cty.True, cty.False, cty.True}), + }, + "baz": { + Value: cty.ObjectVal(map[string]cty.Value{ + "boop": cty.NumberIntVal(5), + "beep": cty.StringVal("true"), + }), + }, + } + + testCases := map[string]struct { + vt arguments.ViewType + want string + }{ + "human": { + arguments.ViewHuman, + `bar = tolist([ + true, + false, + true, +]) +baz = { + "beep" = "true" + "boop" = 5 +} +foo = +`, + }, + "json": { + arguments.ViewJSON, + `{ + "bar": { + "sensitive": false, + "type": [ + "list", + "bool" + ], + "value": [ + true, + false, + true + ] + }, + "baz": { + "sensitive": false, + "type": [ + "object", + { + "beep": "string", + "boop": "number" + } + ], + "value": { + "beep": "true", + "boop": 5 + } + }, + "foo": { + "sensitive": true, + "type": "string", + "value": "secret" + } +} +`, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOutput(tc.vt, NewView(streams)) + diags := v.Output("", outputs) + + if diags.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diags) + } + + if got := done(t).Stdout(); got != tc.want { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, tc.want) + } + }) + } +} + +// JSON output format supports empty outputs by rendering an empty object +// without diagnostics. +func TestOutputJSON_empty(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOutput(arguments.ViewJSON, NewView(streams)) + + diags := v.Output("", map[string]*states.OutputValue{}) + + if diags.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diags) + } + + if got, want := done(t).Stdout(), "{}\n"; got != want { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + } +} + +// Human and raw formats render a warning if there are no outputs. +func TestOutput_emptyWarning(t *testing.T) { + testCases := map[string]arguments.ViewType{ + "human": arguments.ViewHuman, + "raw": arguments.ViewRaw, + } + + for name, vt := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOutput(vt, NewView(streams)) + + diags := v.Output("", map[string]*states.OutputValue{}) + + if got, want := done(t).Stdout(), ""; got != want { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + } + + if len(diags) != 1 { + t.Fatalf("expected 1 diagnostic, got %d", len(diags)) + } + + if diags.HasErrors() { + t.Fatalf("unexpected error diagnostics: %s", diags) + } + + if got, want := diags[0].Description().Summary, "No outputs found"; got != want { + t.Errorf("unexpected diagnostics: %s", diags) + } + }) + } +} + +// Raw output is a simple unquoted output format designed for shell scripts, +// which relies on the cty.AsString() implementation. This test covers +// formatting for supported value types. func TestOutputRaw(t *testing.T) { values := map[string]cty.Value{ "str": cty.StringVal("bar"), @@ -16,6 +259,7 @@ func TestOutputRaw(t *testing.T) { "bool": cty.True, "obj": cty.EmptyObjectVal, "null": cty.NullVal(cty.String), + "unknown": cty.UnknownVal(cty.String), } tests := map[string]struct { @@ -28,15 +272,13 @@ func TestOutputRaw(t *testing.T) { "bool": {WantOutput: "true"}, "obj": {WantErr: true}, "null": {WantErr: true}, + "unknown": {WantErr: true}, } for name, test := range tests { t.Run(name, func(t *testing.T) { streams, done := terminal.StreamsForTesting(t) - view := NewView(streams) - v := &OutputRaw{ - View: *view, - } + v := NewOutput(arguments.ViewRaw, NewView(streams)) value := values[name] outputs := map[string]*states.OutputValue{ @@ -58,3 +300,64 @@ func TestOutputRaw(t *testing.T) { }) } } + +// Raw cannot render all outputs. +func TestOutputRaw_all(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOutput(arguments.ViewRaw, NewView(streams)) + + outputs := map[string]*states.OutputValue{ + "foo": {Value: cty.StringVal("secret")}, + "bar": {Value: cty.True}, + } + diags := v.Output("", outputs) + + if got, want := done(t).Stdout(), ""; got != want { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + } + + if !diags.HasErrors() { + t.Fatalf("expected diagnostics, got %s", diags) + } + + if got, want := diags.Err().Error(), "Raw output format is only supported for single outputs"; got != want { + t.Errorf("unexpected diagnostics: %s", diags) + } +} + +// All outputs render an error if a specific output is requested which is +// missing from the map of outputs. +func TestOutput_missing(t *testing.T) { + testCases := map[string]arguments.ViewType{ + "human": arguments.ViewHuman, + "json": arguments.ViewJSON, + "raw": arguments.ViewRaw, + } + + for name, vt := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewOutput(vt, NewView(streams)) + + diags := v.Output("foo", map[string]*states.OutputValue{ + "bar": {Value: cty.StringVal("boop")}, + }) + + if len(diags) != 1 { + t.Fatalf("expected 1 diagnostic, got %d", len(diags)) + } + + if !diags.HasErrors() { + t.Fatalf("expected error diagnostics, got %s", diags) + } + + if got, want := diags[0].Description().Summary, `Output "foo" not found`; got != want { + t.Errorf("unexpected diagnostics: %s", diags) + } + + if got, want := done(t).Stdout(), ""; got != want { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + } + }) + } +}