| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package views | |
| import ( | |
| "encoding/json" | |
| "fmt" | |
| "strings" | |
| "testing" | |
| "time" | |
| "github.com/google/go-cmp/cmp" | |
| "github.com/hashicorp/terraform/internal/addrs" | |
| viewsjson "github.com/hashicorp/terraform/internal/command/views/json" | |
| "github.com/hashicorp/terraform/internal/plans" | |
| "github.com/hashicorp/terraform/internal/terminal" | |
| "github.com/hashicorp/terraform/internal/tfdiags" | |
| tfversion "github.com/hashicorp/terraform/version" | |
| ) | |
| // Calling NewJSONView should also always output a version message, which is a | |
| // convenient way to test that NewJSONView works. | |
| func TestNewJSONView(t *testing.T) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| NewJSONView(NewView(streams)) | |
| version := tfversion.String() | |
| want := []map[string]interface{}{ | |
| { | |
| "@level": "info", | |
| "@message": fmt.Sprintf("Terraform %s", version), | |
| "@module": "terraform.ui", | |
| "type": "version", | |
| "terraform": version, | |
| "ui": JSON_UI_VERSION, | |
| }, | |
| } | |
| testJSONViewOutputEqualsFull(t, done(t).Stdout(), want) | |
| } | |
| func TestJSONView_Log(t *testing.T) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| jv := NewJSONView(NewView(streams)) | |
| jv.Log("hello, world") | |
| want := []map[string]interface{}{ | |
| { | |
| "@level": "info", | |
| "@message": "hello, world", | |
| "@module": "terraform.ui", | |
| "type": "log", | |
| }, | |
| } | |
| testJSONViewOutputEquals(t, done(t).Stdout(), want) | |
| } | |
| // This test covers only the basics of JSON diagnostic rendering, as more | |
| // complex diagnostics are tested elsewhere. | |
| func TestJSONView_Diagnostics(t *testing.T) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| jv := NewJSONView(NewView(streams)) | |
| var diags tfdiags.Diagnostics | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Warning, | |
| `Improper use of "less"`, | |
| `You probably mean "10 buckets or fewer"`, | |
| )) | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Unusually stripey cat detected", | |
| "Are you sure this random_pet isn't a cheetah?", | |
| )) | |
| jv.Diagnostics(diags) | |
| want := []map[string]interface{}{ | |
| { | |
| "@level": "warn", | |
| "@message": `Warning: Improper use of "less"`, | |
| "@module": "terraform.ui", | |
| "type": "diagnostic", | |
| "diagnostic": map[string]interface{}{ | |
| "severity": "warning", | |
| "summary": `Improper use of "less"`, | |
| "detail": `You probably mean "10 buckets or fewer"`, | |
| }, | |
| }, | |
| { | |
| "@level": "error", | |
| "@message": "Error: Unusually stripey cat detected", | |
| "@module": "terraform.ui", | |
| "type": "diagnostic", | |
| "diagnostic": map[string]interface{}{ | |
| "severity": "error", | |
| "summary": "Unusually stripey cat detected", | |
| "detail": "Are you sure this random_pet isn't a cheetah?", | |
| }, | |
| }, | |
| } | |
| testJSONViewOutputEquals(t, done(t).Stdout(), want) | |
| } | |
| func TestJSONView_DiagnosticsWithMetadata(t *testing.T) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| jv := NewJSONView(NewView(streams)) | |
| var diags tfdiags.Diagnostics | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Warning, | |
| `Improper use of "less"`, | |
| `You probably mean "10 buckets or fewer"`, | |
| )) | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Unusually stripey cat detected", | |
| "Are you sure this random_pet isn't a cheetah?", | |
| )) | |
| jv.Diagnostics(diags, "@meta", "extra_info") | |
| want := []map[string]interface{}{ | |
| { | |
| "@level": "warn", | |
| "@message": `Warning: Improper use of "less"`, | |
| "@module": "terraform.ui", | |
| "type": "diagnostic", | |
| "diagnostic": map[string]interface{}{ | |
| "severity": "warning", | |
| "summary": `Improper use of "less"`, | |
| "detail": `You probably mean "10 buckets or fewer"`, | |
| }, | |
| "@meta": "extra_info", | |
| }, | |
| { | |
| "@level": "error", | |
| "@message": "Error: Unusually stripey cat detected", | |
| "@module": "terraform.ui", | |
| "type": "diagnostic", | |
| "diagnostic": map[string]interface{}{ | |
| "severity": "error", | |
| "summary": "Unusually stripey cat detected", | |
| "detail": "Are you sure this random_pet isn't a cheetah?", | |
| }, | |
| "@meta": "extra_info", | |
| }, | |
| } | |
| testJSONViewOutputEquals(t, done(t).Stdout(), want) | |
| } | |
| func TestJSONView_PlannedChange(t *testing.T) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| jv := NewJSONView(NewView(streams)) | |
| foo, diags := addrs.ParseModuleInstanceStr("module.foo") | |
| if len(diags) > 0 { | |
| t.Fatal(diags.Err()) | |
| } | |
| managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"} | |
| cs := &plans.ResourceInstanceChangeSrc{ | |
| Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), | |
| PrevRunAddr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), | |
| ChangeSrc: plans.ChangeSrc{ | |
| Action: plans.Create, | |
| }, | |
| } | |
| jv.PlannedChange(viewsjson.NewResourceInstanceChange(cs)) | |
| want := []map[string]interface{}{ | |
| { | |
| "@level": "info", | |
| "@message": `module.foo.test_instance.bar["boop"]: Plan to create`, | |
| "@module": "terraform.ui", | |
| "type": "planned_change", | |
| "change": map[string]interface{}{ | |
| "action": "create", | |
| "resource": map[string]interface{}{ | |
| "addr": `module.foo.test_instance.bar["boop"]`, | |
| "implied_provider": "test", | |
| "module": "module.foo", | |
| "resource": `test_instance.bar["boop"]`, | |
| "resource_key": "boop", | |
| "resource_name": "bar", | |
| "resource_type": "test_instance", | |
| }, | |
| }, | |
| }, | |
| } | |
| testJSONViewOutputEquals(t, done(t).Stdout(), want) | |
| } | |
| func TestJSONView_ResourceDrift(t *testing.T) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| jv := NewJSONView(NewView(streams)) | |
| foo, diags := addrs.ParseModuleInstanceStr("module.foo") | |
| if len(diags) > 0 { | |
| t.Fatal(diags.Err()) | |
| } | |
| managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"} | |
| cs := &plans.ResourceInstanceChangeSrc{ | |
| Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), | |
| PrevRunAddr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), | |
| ChangeSrc: plans.ChangeSrc{ | |
| Action: plans.Update, | |
| }, | |
| } | |
| jv.ResourceDrift(viewsjson.NewResourceInstanceChange(cs)) | |
| want := []map[string]interface{}{ | |
| { | |
| "@level": "info", | |
| "@message": `module.foo.test_instance.bar["boop"]: Drift detected (update)`, | |
| "@module": "terraform.ui", | |
| "type": "resource_drift", | |
| "change": map[string]interface{}{ | |
| "action": "update", | |
| "resource": map[string]interface{}{ | |
| "addr": `module.foo.test_instance.bar["boop"]`, | |
| "implied_provider": "test", | |
| "module": "module.foo", | |
| "resource": `test_instance.bar["boop"]`, | |
| "resource_key": "boop", | |
| "resource_name": "bar", | |
| "resource_type": "test_instance", | |
| }, | |
| }, | |
| }, | |
| } | |
| testJSONViewOutputEquals(t, done(t).Stdout(), want) | |
| } | |
| func TestJSONView_ChangeSummary(t *testing.T) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| jv := NewJSONView(NewView(streams)) | |
| jv.ChangeSummary(&viewsjson.ChangeSummary{ | |
| Add: 1, | |
| Change: 2, | |
| Remove: 3, | |
| Operation: viewsjson.OperationApplied, | |
| }) | |
| want := []map[string]interface{}{ | |
| { | |
| "@level": "info", | |
| "@message": "Apply complete! Resources: 1 added, 2 changed, 3 destroyed.", | |
| "@module": "terraform.ui", | |
| "type": "change_summary", | |
| "changes": map[string]interface{}{ | |
| "action_fail": float64(0), | |
| "action_invocation": float64(0), | |
| "add": float64(1), | |
| "import": float64(0), | |
| "change": float64(2), | |
| "remove": float64(3), | |
| "operation": "apply", | |
| }, | |
| }, | |
| } | |
| testJSONViewOutputEquals(t, done(t).Stdout(), want) | |
| } | |
| func TestJSONView_ChangeSummaryWithImport(t *testing.T) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| jv := NewJSONView(NewView(streams)) | |
| jv.ChangeSummary(&viewsjson.ChangeSummary{ | |
| Add: 1, | |
| Change: 2, | |
| Remove: 3, | |
| Import: 1, | |
| Operation: viewsjson.OperationApplied, | |
| }) | |
| want := []map[string]interface{}{ | |
| { | |
| "@level": "info", | |
| "@message": "Apply complete! Resources: 1 imported, 1 added, 2 changed, 3 destroyed.", | |
| "@module": "terraform.ui", | |
| "type": "change_summary", | |
| "changes": map[string]interface{}{ | |
| "action_fail": float64(0), | |
| "action_invocation": float64(0), | |
| "add": float64(1), | |
| "change": float64(2), | |
| "remove": float64(3), | |
| "import": float64(1), | |
| "operation": "apply", | |
| }, | |
| }, | |
| } | |
| testJSONViewOutputEquals(t, done(t).Stdout(), want) | |
| } | |
| func TestJSONView_ChangeSummaryWithActionInvocations(t *testing.T) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| jv := NewJSONView(NewView(streams)) | |
| jv.ChangeSummary(&viewsjson.ChangeSummary{ | |
| Add: 1, | |
| Change: 2, | |
| Remove: 3, | |
| ActionInvocation: 23, | |
| Operation: viewsjson.OperationApplied, | |
| }) | |
| want := []map[string]interface{}{ | |
| { | |
| "@level": "info", | |
| "@message": "Apply complete! Resources: 1 added, 2 changed, 3 destroyed. Actions: 23 invoked.", | |
| "@module": "terraform.ui", | |
| "type": "change_summary", | |
| "changes": map[string]interface{}{ | |
| "action_fail": float64(0), | |
| "action_invocation": float64(23), | |
| "add": float64(1), | |
| "change": float64(2), | |
| "remove": float64(3), | |
| "import": float64(0), | |
| "operation": "apply", | |
| }, | |
| }, | |
| } | |
| testJSONViewOutputEquals(t, done(t).Stdout(), want) | |
| } | |
| func TestJSONView_ChangeSummaryWithActionInvocationsAndImports(t *testing.T) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| jv := NewJSONView(NewView(streams)) | |
| jv.ChangeSummary(&viewsjson.ChangeSummary{ | |
| Add: 1, | |
| Change: 2, | |
| Remove: 3, | |
| Import: 2, | |
| ActionInvocation: 23, | |
| Operation: viewsjson.OperationApplied, | |
| }) | |
| want := []map[string]interface{}{ | |
| { | |
| "@level": "info", | |
| "@message": "Apply complete! Resources: 2 imported, 1 added, 2 changed, 3 destroyed. Actions: 23 invoked.", | |
| "@module": "terraform.ui", | |
| "type": "change_summary", | |
| "changes": map[string]interface{}{ | |
| "action_fail": float64(0), | |
| "action_invocation": float64(23), | |
| "add": float64(1), | |
| "change": float64(2), | |
| "remove": float64(3), | |
| "import": float64(2), | |
| "operation": "apply", | |
| }, | |
| }, | |
| } | |
| testJSONViewOutputEquals(t, done(t).Stdout(), want) | |
| } | |
| func TestJSONView_Hook(t *testing.T) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| jv := NewJSONView(NewView(streams)) | |
| foo, diags := addrs.ParseModuleInstanceStr("module.foo") | |
| if len(diags) > 0 { | |
| t.Fatal(diags.Err()) | |
| } | |
| managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"} | |
| addr := managed.Instance(addrs.StringKey("boop")).Absolute(foo) | |
| hook := viewsjson.NewApplyComplete(addr, plans.Create, "id", "boop-beep", 34*time.Second) | |
| jv.Hook(hook) | |
| want := []map[string]interface{}{ | |
| { | |
| "@level": "info", | |
| "@message": `module.foo.test_instance.bar["boop"]: Creation complete after 34s [id=boop-beep]`, | |
| "@module": "terraform.ui", | |
| "type": "apply_complete", | |
| "hook": map[string]interface{}{ | |
| "resource": map[string]interface{}{ | |
| "addr": `module.foo.test_instance.bar["boop"]`, | |
| "implied_provider": "test", | |
| "module": "module.foo", | |
| "resource": `test_instance.bar["boop"]`, | |
| "resource_key": "boop", | |
| "resource_name": "bar", | |
| "resource_type": "test_instance", | |
| }, | |
| "action": "create", | |
| "id_key": "id", | |
| "id_value": "boop-beep", | |
| "elapsed_seconds": float64(34), | |
| }, | |
| }, | |
| } | |
| testJSONViewOutputEquals(t, done(t).Stdout(), want) | |
| } | |
| func TestJSONView_Outputs(t *testing.T) { | |
| streams, done := terminal.StreamsForTesting(t) | |
| jv := NewJSONView(NewView(streams)) | |
| jv.Outputs(viewsjson.Outputs{ | |
| "boop_count": { | |
| Sensitive: false, | |
| Value: json.RawMessage(`92`), | |
| Type: json.RawMessage(`"number"`), | |
| }, | |
| "password": { | |
| Sensitive: true, | |
| Value: json.RawMessage(`"horse-battery"`), | |
| Type: json.RawMessage(`"string"`), | |
| }, | |
| }) | |
| want := []map[string]interface{}{ | |
| { | |
| "@level": "info", | |
| "@message": "Outputs: 2", | |
| "@module": "terraform.ui", | |
| "type": "outputs", | |
| "outputs": map[string]interface{}{ | |
| "boop_count": map[string]interface{}{ | |
| "sensitive": false, | |
| "value": float64(92), | |
| "type": "number", | |
| }, | |
| "password": map[string]interface{}{ | |
| "sensitive": true, | |
| "value": "horse-battery", | |
| "type": "string", | |
| }, | |
| }, | |
| }, | |
| } | |
| testJSONViewOutputEquals(t, done(t).Stdout(), want) | |
| } | |
| // This helper function tests a possibly multi-line JSONView output string | |
| // against a slice of structs representing the desired log messages. It | |
| // verifies that the output of JSONView is in JSON log format, one message per | |
| // line. | |
| func testJSONViewOutputEqualsFull(t *testing.T, output string, want []map[string]interface{}, options ...cmp.Option) { | |
| t.Helper() | |
| // Remove final trailing newline | |
| output = strings.TrimSuffix(output, "\n") | |
| // Split log into lines, each of which should be a JSON log message | |
| gotLines := strings.Split(output, "\n") | |
| if len(gotLines) != len(want) { | |
| t.Errorf("unexpected number of messages. got %d, want %d", len(gotLines), len(want)) | |
| } | |
| // Unmarshal each line and compare to the expected value | |
| for i := range gotLines { | |
| var gotStruct map[string]interface{} | |
| if i >= len(want) { | |
| t.Error("reached end of want messages too soon") | |
| break | |
| } | |
| wantStruct := want[i] | |
| if err := json.Unmarshal([]byte(gotLines[i]), &gotStruct); err != nil { | |
| t.Fatal(err) | |
| } | |
| if timestamp, ok := gotStruct["@timestamp"]; !ok { | |
| t.Errorf("message has no timestamp: %#v", gotStruct) | |
| } else { | |
| // Remove the timestamp value from the struct to allow comparison | |
| delete(gotStruct, "@timestamp") | |
| // Verify the timestamp format | |
| if _, err := time.Parse(time.RFC3339, timestamp.(string)); err != nil { | |
| t.Errorf("error parsing timestamp on line %d: %s", i, err) | |
| } | |
| } | |
| if !cmp.Equal(wantStruct, gotStruct, options...) { | |
| t.Errorf("unexpected output on line %d:\n%s", i, cmp.Diff(wantStruct, gotStruct)) | |
| } | |
| } | |
| } | |
| // testJSONViewOutputEquals skips the first line of output, since it ought to | |
| // be a version message that we don't care about for most of our tests. | |
| // | |
| // This is used for testing multi-line, structured JSON output from a command like init/plan/apply. | |
| // This helper is not appropriate for testing single, static log output from views. | |
| func testJSONViewOutputEquals(t *testing.T, output string, want []map[string]interface{}, options ...cmp.Option) { | |
| t.Helper() | |
| // Remove up to the first newline | |
| index := strings.Index(output, "\n") | |
| if index >= 0 { | |
| output = output[index+1:] | |
| } | |
| testJSONViewOutputEqualsFull(t, output, want, options...) | |
| } |