github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/command/views/json_view_test.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package views 5 6 import ( 7 "encoding/json" 8 "fmt" 9 "strings" 10 "testing" 11 "time" 12 13 "github.com/google/go-cmp/cmp" 14 15 "github.com/terramate-io/tf/addrs" 16 viewsjson "github.com/terramate-io/tf/command/views/json" 17 "github.com/terramate-io/tf/plans" 18 "github.com/terramate-io/tf/terminal" 19 "github.com/terramate-io/tf/tfdiags" 20 tfversion "github.com/terramate-io/tf/version" 21 ) 22 23 // Calling NewJSONView should also always output a version message, which is a 24 // convenient way to test that NewJSONView works. 25 func TestNewJSONView(t *testing.T) { 26 streams, done := terminal.StreamsForTesting(t) 27 NewJSONView(NewView(streams)) 28 29 version := tfversion.String() 30 want := []map[string]interface{}{ 31 { 32 "@level": "info", 33 "@message": fmt.Sprintf("Terraform %s", version), 34 "@module": "terraform.ui", 35 "type": "version", 36 "terraform": version, 37 "ui": JSON_UI_VERSION, 38 }, 39 } 40 41 testJSONViewOutputEqualsFull(t, done(t).Stdout(), want) 42 } 43 44 func TestJSONView_Log(t *testing.T) { 45 streams, done := terminal.StreamsForTesting(t) 46 jv := NewJSONView(NewView(streams)) 47 48 jv.Log("hello, world") 49 50 want := []map[string]interface{}{ 51 { 52 "@level": "info", 53 "@message": "hello, world", 54 "@module": "terraform.ui", 55 "type": "log", 56 }, 57 } 58 testJSONViewOutputEquals(t, done(t).Stdout(), want) 59 } 60 61 // This test covers only the basics of JSON diagnostic rendering, as more 62 // complex diagnostics are tested elsewhere. 63 func TestJSONView_Diagnostics(t *testing.T) { 64 streams, done := terminal.StreamsForTesting(t) 65 jv := NewJSONView(NewView(streams)) 66 67 var diags tfdiags.Diagnostics 68 diags = diags.Append(tfdiags.Sourceless( 69 tfdiags.Warning, 70 `Improper use of "less"`, 71 `You probably mean "10 buckets or fewer"`, 72 )) 73 diags = diags.Append(tfdiags.Sourceless( 74 tfdiags.Error, 75 "Unusually stripey cat detected", 76 "Are you sure this random_pet isn't a cheetah?", 77 )) 78 79 jv.Diagnostics(diags) 80 81 want := []map[string]interface{}{ 82 { 83 "@level": "warn", 84 "@message": `Warning: Improper use of "less"`, 85 "@module": "terraform.ui", 86 "type": "diagnostic", 87 "diagnostic": map[string]interface{}{ 88 "severity": "warning", 89 "summary": `Improper use of "less"`, 90 "detail": `You probably mean "10 buckets or fewer"`, 91 }, 92 }, 93 { 94 "@level": "error", 95 "@message": "Error: Unusually stripey cat detected", 96 "@module": "terraform.ui", 97 "type": "diagnostic", 98 "diagnostic": map[string]interface{}{ 99 "severity": "error", 100 "summary": "Unusually stripey cat detected", 101 "detail": "Are you sure this random_pet isn't a cheetah?", 102 }, 103 }, 104 } 105 testJSONViewOutputEquals(t, done(t).Stdout(), want) 106 } 107 108 func TestJSONView_DiagnosticsWithMetadata(t *testing.T) { 109 streams, done := terminal.StreamsForTesting(t) 110 jv := NewJSONView(NewView(streams)) 111 112 var diags tfdiags.Diagnostics 113 diags = diags.Append(tfdiags.Sourceless( 114 tfdiags.Warning, 115 `Improper use of "less"`, 116 `You probably mean "10 buckets or fewer"`, 117 )) 118 diags = diags.Append(tfdiags.Sourceless( 119 tfdiags.Error, 120 "Unusually stripey cat detected", 121 "Are you sure this random_pet isn't a cheetah?", 122 )) 123 124 jv.Diagnostics(diags, "@meta", "extra_info") 125 126 want := []map[string]interface{}{ 127 { 128 "@level": "warn", 129 "@message": `Warning: Improper use of "less"`, 130 "@module": "terraform.ui", 131 "type": "diagnostic", 132 "diagnostic": map[string]interface{}{ 133 "severity": "warning", 134 "summary": `Improper use of "less"`, 135 "detail": `You probably mean "10 buckets or fewer"`, 136 }, 137 "@meta": "extra_info", 138 }, 139 { 140 "@level": "error", 141 "@message": "Error: Unusually stripey cat detected", 142 "@module": "terraform.ui", 143 "type": "diagnostic", 144 "diagnostic": map[string]interface{}{ 145 "severity": "error", 146 "summary": "Unusually stripey cat detected", 147 "detail": "Are you sure this random_pet isn't a cheetah?", 148 }, 149 "@meta": "extra_info", 150 }, 151 } 152 testJSONViewOutputEquals(t, done(t).Stdout(), want) 153 } 154 155 func TestJSONView_PlannedChange(t *testing.T) { 156 streams, done := terminal.StreamsForTesting(t) 157 jv := NewJSONView(NewView(streams)) 158 159 foo, diags := addrs.ParseModuleInstanceStr("module.foo") 160 if len(diags) > 0 { 161 t.Fatal(diags.Err()) 162 } 163 managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"} 164 cs := &plans.ResourceInstanceChangeSrc{ 165 Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), 166 PrevRunAddr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), 167 ChangeSrc: plans.ChangeSrc{ 168 Action: plans.Create, 169 }, 170 } 171 jv.PlannedChange(viewsjson.NewResourceInstanceChange(cs)) 172 173 want := []map[string]interface{}{ 174 { 175 "@level": "info", 176 "@message": `module.foo.test_instance.bar["boop"]: Plan to create`, 177 "@module": "terraform.ui", 178 "type": "planned_change", 179 "change": map[string]interface{}{ 180 "action": "create", 181 "resource": map[string]interface{}{ 182 "addr": `module.foo.test_instance.bar["boop"]`, 183 "implied_provider": "test", 184 "module": "module.foo", 185 "resource": `test_instance.bar["boop"]`, 186 "resource_key": "boop", 187 "resource_name": "bar", 188 "resource_type": "test_instance", 189 }, 190 }, 191 }, 192 } 193 testJSONViewOutputEquals(t, done(t).Stdout(), want) 194 } 195 196 func TestJSONView_ResourceDrift(t *testing.T) { 197 streams, done := terminal.StreamsForTesting(t) 198 jv := NewJSONView(NewView(streams)) 199 200 foo, diags := addrs.ParseModuleInstanceStr("module.foo") 201 if len(diags) > 0 { 202 t.Fatal(diags.Err()) 203 } 204 managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"} 205 cs := &plans.ResourceInstanceChangeSrc{ 206 Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), 207 PrevRunAddr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), 208 ChangeSrc: plans.ChangeSrc{ 209 Action: plans.Update, 210 }, 211 } 212 jv.ResourceDrift(viewsjson.NewResourceInstanceChange(cs)) 213 214 want := []map[string]interface{}{ 215 { 216 "@level": "info", 217 "@message": `module.foo.test_instance.bar["boop"]: Drift detected (update)`, 218 "@module": "terraform.ui", 219 "type": "resource_drift", 220 "change": map[string]interface{}{ 221 "action": "update", 222 "resource": map[string]interface{}{ 223 "addr": `module.foo.test_instance.bar["boop"]`, 224 "implied_provider": "test", 225 "module": "module.foo", 226 "resource": `test_instance.bar["boop"]`, 227 "resource_key": "boop", 228 "resource_name": "bar", 229 "resource_type": "test_instance", 230 }, 231 }, 232 }, 233 } 234 testJSONViewOutputEquals(t, done(t).Stdout(), want) 235 } 236 237 func TestJSONView_ChangeSummary(t *testing.T) { 238 streams, done := terminal.StreamsForTesting(t) 239 jv := NewJSONView(NewView(streams)) 240 241 jv.ChangeSummary(&viewsjson.ChangeSummary{ 242 Add: 1, 243 Change: 2, 244 Remove: 3, 245 Operation: viewsjson.OperationApplied, 246 }) 247 248 want := []map[string]interface{}{ 249 { 250 "@level": "info", 251 "@message": "Apply complete! Resources: 1 added, 2 changed, 3 destroyed.", 252 "@module": "terraform.ui", 253 "type": "change_summary", 254 "changes": map[string]interface{}{ 255 "add": float64(1), 256 "import": float64(0), 257 "change": float64(2), 258 "remove": float64(3), 259 "operation": "apply", 260 }, 261 }, 262 } 263 testJSONViewOutputEquals(t, done(t).Stdout(), want) 264 } 265 266 func TestJSONView_ChangeSummaryWithImport(t *testing.T) { 267 streams, done := terminal.StreamsForTesting(t) 268 jv := NewJSONView(NewView(streams)) 269 270 jv.ChangeSummary(&viewsjson.ChangeSummary{ 271 Add: 1, 272 Change: 2, 273 Remove: 3, 274 Import: 1, 275 Operation: viewsjson.OperationApplied, 276 }) 277 278 want := []map[string]interface{}{ 279 { 280 "@level": "info", 281 "@message": "Apply complete! Resources: 1 imported, 1 added, 2 changed, 3 destroyed.", 282 "@module": "terraform.ui", 283 "type": "change_summary", 284 "changes": map[string]interface{}{ 285 "add": float64(1), 286 "change": float64(2), 287 "remove": float64(3), 288 "import": float64(1), 289 "operation": "apply", 290 }, 291 }, 292 } 293 testJSONViewOutputEquals(t, done(t).Stdout(), want) 294 } 295 296 func TestJSONView_Hook(t *testing.T) { 297 streams, done := terminal.StreamsForTesting(t) 298 jv := NewJSONView(NewView(streams)) 299 300 foo, diags := addrs.ParseModuleInstanceStr("module.foo") 301 if len(diags) > 0 { 302 t.Fatal(diags.Err()) 303 } 304 managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"} 305 addr := managed.Instance(addrs.StringKey("boop")).Absolute(foo) 306 hook := viewsjson.NewApplyComplete(addr, plans.Create, "id", "boop-beep", 34*time.Second) 307 308 jv.Hook(hook) 309 310 want := []map[string]interface{}{ 311 { 312 "@level": "info", 313 "@message": `module.foo.test_instance.bar["boop"]: Creation complete after 34s [id=boop-beep]`, 314 "@module": "terraform.ui", 315 "type": "apply_complete", 316 "hook": map[string]interface{}{ 317 "resource": map[string]interface{}{ 318 "addr": `module.foo.test_instance.bar["boop"]`, 319 "implied_provider": "test", 320 "module": "module.foo", 321 "resource": `test_instance.bar["boop"]`, 322 "resource_key": "boop", 323 "resource_name": "bar", 324 "resource_type": "test_instance", 325 }, 326 "action": "create", 327 "id_key": "id", 328 "id_value": "boop-beep", 329 "elapsed_seconds": float64(34), 330 }, 331 }, 332 } 333 testJSONViewOutputEquals(t, done(t).Stdout(), want) 334 } 335 336 func TestJSONView_Outputs(t *testing.T) { 337 streams, done := terminal.StreamsForTesting(t) 338 jv := NewJSONView(NewView(streams)) 339 340 jv.Outputs(viewsjson.Outputs{ 341 "boop_count": { 342 Sensitive: false, 343 Value: json.RawMessage(`92`), 344 Type: json.RawMessage(`"number"`), 345 }, 346 "password": { 347 Sensitive: true, 348 Value: json.RawMessage(`"horse-battery"`), 349 Type: json.RawMessage(`"string"`), 350 }, 351 }) 352 353 want := []map[string]interface{}{ 354 { 355 "@level": "info", 356 "@message": "Outputs: 2", 357 "@module": "terraform.ui", 358 "type": "outputs", 359 "outputs": map[string]interface{}{ 360 "boop_count": map[string]interface{}{ 361 "sensitive": false, 362 "value": float64(92), 363 "type": "number", 364 }, 365 "password": map[string]interface{}{ 366 "sensitive": true, 367 "value": "horse-battery", 368 "type": "string", 369 }, 370 }, 371 }, 372 } 373 testJSONViewOutputEquals(t, done(t).Stdout(), want) 374 } 375 376 // This helper function tests a possibly multi-line JSONView output string 377 // against a slice of structs representing the desired log messages. It 378 // verifies that the output of JSONView is in JSON log format, one message per 379 // line. 380 func testJSONViewOutputEqualsFull(t *testing.T, output string, want []map[string]interface{}, options ...cmp.Option) { 381 t.Helper() 382 383 // Remove final trailing newline 384 output = strings.TrimSuffix(output, "\n") 385 386 // Split log into lines, each of which should be a JSON log message 387 gotLines := strings.Split(output, "\n") 388 389 if len(gotLines) != len(want) { 390 t.Errorf("unexpected number of messages. got %d, want %d", len(gotLines), len(want)) 391 } 392 393 // Unmarshal each line and compare to the expected value 394 for i := range gotLines { 395 var gotStruct map[string]interface{} 396 if i >= len(want) { 397 t.Error("reached end of want messages too soon") 398 break 399 } 400 wantStruct := want[i] 401 402 if err := json.Unmarshal([]byte(gotLines[i]), &gotStruct); err != nil { 403 t.Fatal(err) 404 } 405 406 if timestamp, ok := gotStruct["@timestamp"]; !ok { 407 t.Errorf("message has no timestamp: %#v", gotStruct) 408 } else { 409 // Remove the timestamp value from the struct to allow comparison 410 delete(gotStruct, "@timestamp") 411 412 // Verify the timestamp format 413 if _, err := time.Parse("2006-01-02T15:04:05.000000Z07:00", timestamp.(string)); err != nil { 414 t.Errorf("error parsing timestamp on line %d: %s", i, err) 415 } 416 } 417 418 if !cmp.Equal(wantStruct, gotStruct, options...) { 419 t.Errorf("unexpected output on line %d:\n%s", i, cmp.Diff(wantStruct, gotStruct)) 420 } 421 } 422 } 423 424 // testJSONViewOutputEquals skips the first line of output, since it ought to 425 // be a version message that we don't care about for most of our tests. 426 func testJSONViewOutputEquals(t *testing.T, output string, want []map[string]interface{}, options ...cmp.Option) { 427 t.Helper() 428 429 // Remove up to the first newline 430 index := strings.Index(output, "\n") 431 if index >= 0 { 432 output = output[index+1:] 433 } 434 testJSONViewOutputEqualsFull(t, output, want, options...) 435 }