github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/command/views/hook_ui_test.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package views 5 6 import ( 7 "fmt" 8 "regexp" 9 "testing" 10 "time" 11 12 "strings" 13 14 "github.com/zclconf/go-cty/cty" 15 16 "github.com/terramate-io/tf/addrs" 17 "github.com/terramate-io/tf/command/arguments" 18 "github.com/terramate-io/tf/plans" 19 "github.com/terramate-io/tf/providers" 20 "github.com/terramate-io/tf/states" 21 "github.com/terramate-io/tf/terminal" 22 "github.com/terramate-io/tf/terraform" 23 ) 24 25 // Test the PreApply hook for creating a new resource 26 func TestUiHookPreApply_create(t *testing.T) { 27 streams, done := terminal.StreamsForTesting(t) 28 view := NewView(streams) 29 h := NewUiHook(view) 30 h.resources = map[string]uiResourceState{ 31 "test_instance.foo": { 32 Op: uiResourceCreate, 33 Start: time.Now(), 34 }, 35 } 36 37 addr := addrs.Resource{ 38 Mode: addrs.ManagedResourceMode, 39 Type: "test_instance", 40 Name: "foo", 41 }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) 42 43 priorState := cty.NullVal(cty.Object(map[string]cty.Type{ 44 "id": cty.String, 45 "bar": cty.List(cty.String), 46 })) 47 plannedNewState := cty.ObjectVal(map[string]cty.Value{ 48 "id": cty.StringVal("test"), 49 "bar": cty.ListVal([]cty.Value{ 50 cty.StringVal("baz"), 51 }), 52 }) 53 54 action, err := h.PreApply(addr, states.CurrentGen, plans.Create, priorState, plannedNewState) 55 if err != nil { 56 t.Fatal(err) 57 } 58 if action != terraform.HookActionContinue { 59 t.Fatalf("Expected hook to continue, given: %#v", action) 60 } 61 62 // stop the background writer 63 uiState := h.resources[addr.String()] 64 close(uiState.DoneCh) 65 <-uiState.done 66 67 expectedOutput := "test_instance.foo: Creating...\n" 68 result := done(t) 69 output := result.Stdout() 70 if output != expectedOutput { 71 t.Fatalf("Output didn't match.\nExpected: %q\nGiven: %q", expectedOutput, output) 72 } 73 74 expectedErrOutput := "" 75 errOutput := result.Stderr() 76 if errOutput != expectedErrOutput { 77 t.Fatalf("Error output didn't match.\nExpected: %q\nGiven: %q", expectedErrOutput, errOutput) 78 } 79 } 80 81 // Test the PreApply hook's use of a periodic timer to display "still working" 82 // log lines 83 func TestUiHookPreApply_periodicTimer(t *testing.T) { 84 streams, done := terminal.StreamsForTesting(t) 85 view := NewView(streams) 86 h := NewUiHook(view) 87 h.periodicUiTimer = 1 * time.Second 88 h.resources = map[string]uiResourceState{ 89 "test_instance.foo": { 90 Op: uiResourceModify, 91 Start: time.Now(), 92 }, 93 } 94 95 addr := addrs.Resource{ 96 Mode: addrs.ManagedResourceMode, 97 Type: "test_instance", 98 Name: "foo", 99 }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) 100 101 priorState := cty.ObjectVal(map[string]cty.Value{ 102 "id": cty.StringVal("test"), 103 "bar": cty.ListValEmpty(cty.String), 104 }) 105 plannedNewState := cty.ObjectVal(map[string]cty.Value{ 106 "id": cty.StringVal("test"), 107 "bar": cty.ListVal([]cty.Value{ 108 cty.StringVal("baz"), 109 }), 110 }) 111 112 action, err := h.PreApply(addr, states.CurrentGen, plans.Update, priorState, plannedNewState) 113 if err != nil { 114 t.Fatal(err) 115 } 116 if action != terraform.HookActionContinue { 117 t.Fatalf("Expected hook to continue, given: %#v", action) 118 } 119 120 time.Sleep(3100 * time.Millisecond) 121 122 // stop the background writer 123 uiState := h.resources[addr.String()] 124 close(uiState.DoneCh) 125 <-uiState.done 126 127 expectedOutput := `test_instance.foo: Modifying... [id=test] 128 test_instance.foo: Still modifying... [id=test, 1s elapsed] 129 test_instance.foo: Still modifying... [id=test, 2s elapsed] 130 test_instance.foo: Still modifying... [id=test, 3s elapsed] 131 ` 132 result := done(t) 133 output := result.Stdout() 134 if output != expectedOutput { 135 t.Fatalf("Output didn't match.\nExpected: %q\nGiven: %q", expectedOutput, output) 136 } 137 138 expectedErrOutput := "" 139 errOutput := result.Stderr() 140 if errOutput != expectedErrOutput { 141 t.Fatalf("Error output didn't match.\nExpected: %q\nGiven: %q", expectedErrOutput, errOutput) 142 } 143 } 144 145 // Test the PreApply hook's destroy path, including passing a deposed key as 146 // the gen argument. 147 func TestUiHookPreApply_destroy(t *testing.T) { 148 streams, done := terminal.StreamsForTesting(t) 149 view := NewView(streams) 150 h := NewUiHook(view) 151 h.resources = map[string]uiResourceState{ 152 "test_instance.foo": { 153 Op: uiResourceDestroy, 154 Start: time.Now(), 155 }, 156 } 157 158 addr := addrs.Resource{ 159 Mode: addrs.ManagedResourceMode, 160 Type: "test_instance", 161 Name: "foo", 162 }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) 163 164 priorState := cty.ObjectVal(map[string]cty.Value{ 165 "id": cty.StringVal("abc123"), 166 "verbs": cty.ListVal([]cty.Value{ 167 cty.StringVal("boop"), 168 }), 169 }) 170 plannedNewState := cty.NullVal(cty.Object(map[string]cty.Type{ 171 "id": cty.String, 172 "verbs": cty.List(cty.String), 173 })) 174 175 key := states.NewDeposedKey() 176 action, err := h.PreApply(addr, key, plans.Delete, priorState, plannedNewState) 177 if err != nil { 178 t.Fatal(err) 179 } 180 if action != terraform.HookActionContinue { 181 t.Fatalf("Expected hook to continue, given: %#v", action) 182 } 183 184 // stop the background writer 185 uiState := h.resources[addr.String()] 186 close(uiState.DoneCh) 187 <-uiState.done 188 189 result := done(t) 190 expectedOutput := fmt.Sprintf("test_instance.foo (deposed object %s): Destroying... [id=abc123]\n", key) 191 output := result.Stdout() 192 if output != expectedOutput { 193 t.Fatalf("Output didn't match.\nExpected: %q\nGiven: %q", expectedOutput, output) 194 } 195 196 expectedErrOutput := "" 197 errOutput := result.Stderr() 198 if errOutput != expectedErrOutput { 199 t.Fatalf("Error output didn't match.\nExpected: %q\nGiven: %q", expectedErrOutput, errOutput) 200 } 201 } 202 203 // Verify that colorize is called on format strings, not user input, by adding 204 // valid color codes as resource names and IDs. 205 func TestUiHookPostApply_colorInterpolation(t *testing.T) { 206 streams, done := terminal.StreamsForTesting(t) 207 view := NewView(streams) 208 view.Configure(&arguments.View{NoColor: false}) 209 h := NewUiHook(view) 210 h.resources = map[string]uiResourceState{ 211 "test_instance.foo[\"[red]\"]": { 212 Op: uiResourceCreate, 213 Start: time.Now(), 214 }, 215 } 216 217 addr := addrs.Resource{ 218 Mode: addrs.ManagedResourceMode, 219 Type: "test_instance", 220 Name: "foo", 221 }.Instance(addrs.StringKey("[red]")).Absolute(addrs.RootModuleInstance) 222 223 newState := cty.ObjectVal(map[string]cty.Value{ 224 "id": cty.StringVal("[blue]"), 225 }) 226 227 action, err := h.PostApply(addr, states.CurrentGen, newState, nil) 228 if err != nil { 229 t.Fatal(err) 230 } 231 if action != terraform.HookActionContinue { 232 t.Fatalf("Expected hook to continue, given: %#v", action) 233 } 234 result := done(t) 235 236 reset := "\x1b[0m" 237 bold := "\x1b[1m" 238 wantPrefix := reset + bold + `test_instance.foo["[red]"]: Creation complete after` 239 wantSuffix := "[id=[blue]]" + reset + "\n" 240 output := result.Stdout() 241 242 if !strings.HasPrefix(output, wantPrefix) { 243 t.Fatalf("wrong output prefix\n got: %#v\nwant: %#v", output, wantPrefix) 244 } 245 246 if !strings.HasSuffix(output, wantSuffix) { 247 t.Fatalf("wrong output suffix\n got: %#v\nwant: %#v", output, wantSuffix) 248 } 249 250 expectedErrOutput := "" 251 errOutput := result.Stderr() 252 if errOutput != expectedErrOutput { 253 t.Fatalf("Error output didn't match.\nExpected: %q\nGiven: %q", expectedErrOutput, errOutput) 254 } 255 } 256 257 // Test that the PostApply hook renders a total time. 258 func TestUiHookPostApply_emptyState(t *testing.T) { 259 streams, done := terminal.StreamsForTesting(t) 260 view := NewView(streams) 261 h := NewUiHook(view) 262 h.resources = map[string]uiResourceState{ 263 "data.google_compute_zones.available": { 264 Op: uiResourceDestroy, 265 Start: time.Now(), 266 }, 267 } 268 269 addr := addrs.Resource{ 270 Mode: addrs.DataResourceMode, 271 Type: "google_compute_zones", 272 Name: "available", 273 }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) 274 275 newState := cty.NullVal(cty.Object(map[string]cty.Type{ 276 "id": cty.String, 277 "names": cty.List(cty.String), 278 })) 279 280 action, err := h.PostApply(addr, states.CurrentGen, newState, nil) 281 if err != nil { 282 t.Fatal(err) 283 } 284 if action != terraform.HookActionContinue { 285 t.Fatalf("Expected hook to continue, given: %#v", action) 286 } 287 result := done(t) 288 289 expectedRegexp := "^data.google_compute_zones.available: Destruction complete after -?[a-z0-9µ.]+\n$" 290 output := result.Stdout() 291 if matched, _ := regexp.MatchString(expectedRegexp, output); !matched { 292 t.Fatalf("Output didn't match regexp.\nExpected: %q\nGiven: %q", expectedRegexp, output) 293 } 294 295 expectedErrOutput := "" 296 errOutput := result.Stderr() 297 if errOutput != expectedErrOutput { 298 t.Fatalf("Error output didn't match.\nExpected: %q\nGiven: %q", expectedErrOutput, errOutput) 299 } 300 } 301 302 func TestPreProvisionInstanceStep(t *testing.T) { 303 streams, done := terminal.StreamsForTesting(t) 304 view := NewView(streams) 305 h := NewUiHook(view) 306 307 addr := addrs.Resource{ 308 Mode: addrs.ManagedResourceMode, 309 Type: "test_instance", 310 Name: "foo", 311 }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) 312 313 action, err := h.PreProvisionInstanceStep(addr, "local-exec") 314 if err != nil { 315 t.Fatal(err) 316 } 317 if action != terraform.HookActionContinue { 318 t.Fatalf("Expected hook to continue, given: %#v", action) 319 } 320 result := done(t) 321 322 if got, want := result.Stdout(), "test_instance.foo: Provisioning with 'local-exec'...\n"; got != want { 323 t.Fatalf("unexpected output\n got: %q\nwant: %q", got, want) 324 } 325 } 326 327 // Test ProvisionOutput, including lots of edge cases for the output 328 // whitespace/line ending logic. 329 func TestProvisionOutput(t *testing.T) { 330 addr := addrs.Resource{ 331 Mode: addrs.ManagedResourceMode, 332 Type: "test_instance", 333 Name: "foo", 334 }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) 335 336 testCases := map[string]struct { 337 provisioner string 338 input string 339 wantOutput string 340 }{ 341 "single line": { 342 "local-exec", 343 "foo\n", 344 "test_instance.foo (local-exec): foo\n", 345 }, 346 "multiple lines": { 347 "x", 348 `foo 349 bar 350 baz 351 `, 352 `test_instance.foo (x): foo 353 test_instance.foo (x): bar 354 test_instance.foo (x): baz 355 `, 356 }, 357 "trailing whitespace": { 358 "x", 359 "foo \nbar\n", 360 "test_instance.foo (x): foo\ntest_instance.foo (x): bar\n", 361 }, 362 "blank lines": { 363 "x", 364 "foo\n\nbar\n\n\nbaz\n", 365 `test_instance.foo (x): foo 366 test_instance.foo (x): bar 367 test_instance.foo (x): baz 368 `, 369 }, 370 "no final newline": { 371 "x", 372 `foo 373 bar`, 374 `test_instance.foo (x): foo 375 test_instance.foo (x): bar 376 `, 377 }, 378 "CR, no LF": { 379 "MacOS 9?", 380 "foo\rbar\r", 381 `test_instance.foo (MacOS 9?): foo 382 test_instance.foo (MacOS 9?): bar 383 `, 384 }, 385 "CRLF": { 386 "winrm", 387 "foo\r\nbar\r\n", 388 `test_instance.foo (winrm): foo 389 test_instance.foo (winrm): bar 390 `, 391 }, 392 } 393 394 for name, tc := range testCases { 395 t.Run(name, func(t *testing.T) { 396 streams, done := terminal.StreamsForTesting(t) 397 view := NewView(streams) 398 h := NewUiHook(view) 399 400 h.ProvisionOutput(addr, tc.provisioner, tc.input) 401 result := done(t) 402 403 if got := result.Stdout(); got != tc.wantOutput { 404 t.Fatalf("unexpected output\n got: %q\nwant: %q", got, tc.wantOutput) 405 } 406 }) 407 } 408 } 409 410 // Test the PreRefresh hook in the normal path where the resource exists with 411 // an ID key and value in the state. 412 func TestPreRefresh(t *testing.T) { 413 streams, done := terminal.StreamsForTesting(t) 414 view := NewView(streams) 415 h := NewUiHook(view) 416 417 addr := addrs.Resource{ 418 Mode: addrs.ManagedResourceMode, 419 Type: "test_instance", 420 Name: "foo", 421 }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) 422 423 priorState := cty.ObjectVal(map[string]cty.Value{ 424 "id": cty.StringVal("test"), 425 "bar": cty.ListValEmpty(cty.String), 426 }) 427 428 action, err := h.PreRefresh(addr, states.CurrentGen, priorState) 429 430 if err != nil { 431 t.Fatal(err) 432 } 433 if action != terraform.HookActionContinue { 434 t.Fatalf("Expected hook to continue, given: %#v", action) 435 } 436 result := done(t) 437 438 if got, want := result.Stdout(), "test_instance.foo: Refreshing state... [id=test]\n"; got != want { 439 t.Fatalf("unexpected output\n got: %q\nwant: %q", got, want) 440 } 441 } 442 443 // Test that PreRefresh still works if no ID key and value can be determined 444 // from state. 445 func TestPreRefresh_noID(t *testing.T) { 446 streams, done := terminal.StreamsForTesting(t) 447 view := NewView(streams) 448 h := NewUiHook(view) 449 450 addr := addrs.Resource{ 451 Mode: addrs.ManagedResourceMode, 452 Type: "test_instance", 453 Name: "foo", 454 }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) 455 456 priorState := cty.ObjectVal(map[string]cty.Value{ 457 "bar": cty.ListValEmpty(cty.String), 458 }) 459 460 action, err := h.PreRefresh(addr, states.CurrentGen, priorState) 461 462 if err != nil { 463 t.Fatal(err) 464 } 465 if action != terraform.HookActionContinue { 466 t.Fatalf("Expected hook to continue, given: %#v", action) 467 } 468 result := done(t) 469 470 if got, want := result.Stdout(), "test_instance.foo: Refreshing state...\n"; got != want { 471 t.Fatalf("unexpected output\n got: %q\nwant: %q", got, want) 472 } 473 } 474 475 // Test the very simple PreImportState hook. 476 func TestPreImportState(t *testing.T) { 477 streams, done := terminal.StreamsForTesting(t) 478 view := NewView(streams) 479 h := NewUiHook(view) 480 481 addr := addrs.Resource{ 482 Mode: addrs.ManagedResourceMode, 483 Type: "test_instance", 484 Name: "foo", 485 }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) 486 487 action, err := h.PreImportState(addr, "test") 488 489 if err != nil { 490 t.Fatal(err) 491 } 492 if action != terraform.HookActionContinue { 493 t.Fatalf("Expected hook to continue, given: %#v", action) 494 } 495 result := done(t) 496 497 if got, want := result.Stdout(), "test_instance.foo: Importing from ID \"test\"...\n"; got != want { 498 t.Fatalf("unexpected output\n got: %q\nwant: %q", got, want) 499 } 500 } 501 502 // Test the PostImportState UI hook. Again, this hook behaviour seems odd to 503 // me (see below), so please don't consider these tests as justification for 504 // keeping this behaviour. 505 func TestPostImportState(t *testing.T) { 506 streams, done := terminal.StreamsForTesting(t) 507 view := NewView(streams) 508 h := NewUiHook(view) 509 510 addr := addrs.Resource{ 511 Mode: addrs.ManagedResourceMode, 512 Type: "test_instance", 513 Name: "foo", 514 }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance) 515 516 // The "Prepared [...] for import" lines display the type name of each of 517 // the imported resources passed to the hook. I'm not sure how it's 518 // possible for an import to result in a different resource type name than 519 // the target address, but the hook works like this so we're covering it. 520 imported := []providers.ImportedResource{ 521 { 522 TypeName: "test_some_instance", 523 State: cty.ObjectVal(map[string]cty.Value{ 524 "id": cty.StringVal("test"), 525 }), 526 }, 527 { 528 TypeName: "test_other_instance", 529 State: cty.ObjectVal(map[string]cty.Value{ 530 "id": cty.StringVal("test"), 531 }), 532 }, 533 } 534 535 action, err := h.PostImportState(addr, imported) 536 537 if err != nil { 538 t.Fatal(err) 539 } 540 if action != terraform.HookActionContinue { 541 t.Fatalf("Expected hook to continue, given: %#v", action) 542 } 543 result := done(t) 544 545 want := `test_instance.foo: Import prepared! 546 Prepared test_some_instance for import 547 Prepared test_other_instance for import 548 ` 549 if got := result.Stdout(); got != want { 550 t.Fatalf("unexpected output\n got: %q\nwant: %q", got, want) 551 } 552 } 553 554 func TestTruncateId(t *testing.T) { 555 testCases := []struct { 556 Input string 557 Expected string 558 MaxLen int 559 }{ 560 { 561 Input: "Hello world", 562 Expected: "H...d", 563 MaxLen: 3, 564 }, 565 { 566 Input: "Hello world", 567 Expected: "H...d", 568 MaxLen: 5, 569 }, 570 { 571 Input: "Hello world", 572 Expected: "He...d", 573 MaxLen: 6, 574 }, 575 { 576 Input: "Hello world", 577 Expected: "He...ld", 578 MaxLen: 7, 579 }, 580 { 581 Input: "Hello world", 582 Expected: "Hel...ld", 583 MaxLen: 8, 584 }, 585 { 586 Input: "Hello world", 587 Expected: "Hel...rld", 588 MaxLen: 9, 589 }, 590 { 591 Input: "Hello world", 592 Expected: "Hell...rld", 593 MaxLen: 10, 594 }, 595 { 596 Input: "Hello world", 597 Expected: "Hello world", 598 MaxLen: 11, 599 }, 600 { 601 Input: "Hello world", 602 Expected: "Hello world", 603 MaxLen: 12, 604 }, 605 { 606 Input: "あいうえおかきくけこさ", 607 Expected: "あ...さ", 608 MaxLen: 3, 609 }, 610 { 611 Input: "あいうえおかきくけこさ", 612 Expected: "あ...さ", 613 MaxLen: 5, 614 }, 615 { 616 Input: "あいうえおかきくけこさ", 617 Expected: "あい...さ", 618 MaxLen: 6, 619 }, 620 { 621 Input: "あいうえおかきくけこさ", 622 Expected: "あい...こさ", 623 MaxLen: 7, 624 }, 625 { 626 Input: "あいうえおかきくけこさ", 627 Expected: "あいう...こさ", 628 MaxLen: 8, 629 }, 630 { 631 Input: "あいうえおかきくけこさ", 632 Expected: "あいう...けこさ", 633 MaxLen: 9, 634 }, 635 { 636 Input: "あいうえおかきくけこさ", 637 Expected: "あいうえ...けこさ", 638 MaxLen: 10, 639 }, 640 { 641 Input: "あいうえおかきくけこさ", 642 Expected: "あいうえおかきくけこさ", 643 MaxLen: 11, 644 }, 645 { 646 Input: "あいうえおかきくけこさ", 647 Expected: "あいうえおかきくけこさ", 648 MaxLen: 12, 649 }, 650 } 651 for i, tc := range testCases { 652 testName := fmt.Sprintf("%d", i) 653 t.Run(testName, func(t *testing.T) { 654 out := truncateId(tc.Input, tc.MaxLen) 655 if out != tc.Expected { 656 t.Fatalf("Expected %q to be shortened to %d as %q (given: %q)", 657 tc.Input, tc.MaxLen, tc.Expected, out) 658 } 659 }) 660 } 661 }