github.com/opentofu/opentofu@v1.7.1/internal/tofu/transform_destroy_edge_test.go (about) 1 // Copyright (c) The OpenTofu Authors 2 // SPDX-License-Identifier: MPL-2.0 3 // Copyright (c) 2023 HashiCorp, Inc. 4 // SPDX-License-Identifier: MPL-2.0 5 6 package tofu 7 8 import ( 9 "fmt" 10 "strings" 11 "testing" 12 13 "github.com/davecgh/go-spew/spew" 14 "github.com/zclconf/go-cty/cty" 15 16 "github.com/opentofu/opentofu/internal/addrs" 17 "github.com/opentofu/opentofu/internal/dag" 18 "github.com/opentofu/opentofu/internal/plans" 19 "github.com/opentofu/opentofu/internal/states" 20 ) 21 22 func TestDestroyEdgeTransformer_basic(t *testing.T) { 23 g := Graph{Path: addrs.RootModuleInstance} 24 g.Add(testDestroyNode("test_object.A")) 25 g.Add(testDestroyNode("test_object.B")) 26 27 state := states.NewState() 28 root := state.EnsureModule(addrs.RootModuleInstance) 29 root.SetResourceInstanceCurrent( 30 mustResourceInstanceAddr("test_object.A").Resource, 31 &states.ResourceInstanceObjectSrc{ 32 Status: states.ObjectReady, 33 AttrsJSON: []byte(`{"id":"A"}`), 34 }, 35 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 36 ) 37 root.SetResourceInstanceCurrent( 38 mustResourceInstanceAddr("test_object.B").Resource, 39 &states.ResourceInstanceObjectSrc{ 40 Status: states.ObjectReady, 41 AttrsJSON: []byte(`{"id":"B","test_string":"x"}`), 42 Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A")}, 43 }, 44 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 45 ) 46 if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { 47 t.Fatal(err) 48 } 49 50 tf := &DestroyEdgeTransformer{} 51 if err := tf.Transform(&g); err != nil { 52 t.Fatalf("err: %s", err) 53 } 54 55 actual := strings.TrimSpace(g.String()) 56 expected := strings.TrimSpace(testTransformDestroyEdgeBasicStr) 57 if actual != expected { 58 t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) 59 } 60 } 61 62 func TestDestroyEdgeTransformer_multi(t *testing.T) { 63 g := Graph{Path: addrs.RootModuleInstance} 64 g.Add(testDestroyNode("test_object.A")) 65 g.Add(testDestroyNode("test_object.B")) 66 g.Add(testDestroyNode("test_object.C")) 67 68 state := states.NewState() 69 root := state.EnsureModule(addrs.RootModuleInstance) 70 root.SetResourceInstanceCurrent( 71 mustResourceInstanceAddr("test_object.A").Resource, 72 &states.ResourceInstanceObjectSrc{ 73 Status: states.ObjectReady, 74 AttrsJSON: []byte(`{"id":"A"}`), 75 }, 76 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 77 ) 78 root.SetResourceInstanceCurrent( 79 mustResourceInstanceAddr("test_object.B").Resource, 80 &states.ResourceInstanceObjectSrc{ 81 Status: states.ObjectReady, 82 AttrsJSON: []byte(`{"id":"B","test_string":"x"}`), 83 Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A")}, 84 }, 85 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 86 ) 87 root.SetResourceInstanceCurrent( 88 mustResourceInstanceAddr("test_object.C").Resource, 89 &states.ResourceInstanceObjectSrc{ 90 Status: states.ObjectReady, 91 AttrsJSON: []byte(`{"id":"C","test_string":"x"}`), 92 Dependencies: []addrs.ConfigResource{ 93 mustConfigResourceAddr("test_object.A"), 94 mustConfigResourceAddr("test_object.B"), 95 }, 96 }, 97 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 98 ) 99 100 if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { 101 t.Fatal(err) 102 } 103 104 tf := &DestroyEdgeTransformer{} 105 if err := tf.Transform(&g); err != nil { 106 t.Fatalf("err: %s", err) 107 } 108 109 actual := strings.TrimSpace(g.String()) 110 expected := strings.TrimSpace(testTransformDestroyEdgeMultiStr) 111 if actual != expected { 112 t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) 113 } 114 } 115 116 func TestDestroyEdgeTransformer_selfRef(t *testing.T) { 117 g := Graph{Path: addrs.RootModuleInstance} 118 g.Add(testDestroyNode("test_object.A")) 119 tf := &DestroyEdgeTransformer{} 120 if err := tf.Transform(&g); err != nil { 121 t.Fatalf("err: %s", err) 122 } 123 124 actual := strings.TrimSpace(g.String()) 125 expected := strings.TrimSpace(testTransformDestroyEdgeSelfRefStr) 126 if actual != expected { 127 t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) 128 } 129 } 130 131 func TestDestroyEdgeTransformer_module(t *testing.T) { 132 g := Graph{Path: addrs.RootModuleInstance} 133 g.Add(testDestroyNode("module.child.test_object.b")) 134 g.Add(testDestroyNode("test_object.a")) 135 state := states.NewState() 136 root := state.EnsureModule(addrs.RootModuleInstance) 137 child := state.EnsureModule(addrs.RootModuleInstance.Child("child", addrs.NoKey)) 138 root.SetResourceInstanceCurrent( 139 mustResourceInstanceAddr("test_object.a").Resource, 140 &states.ResourceInstanceObjectSrc{ 141 Status: states.ObjectReady, 142 AttrsJSON: []byte(`{"id":"a"}`), 143 Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("module.child.test_object.b")}, 144 }, 145 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 146 ) 147 child.SetResourceInstanceCurrent( 148 mustResourceInstanceAddr("test_object.b").Resource, 149 &states.ResourceInstanceObjectSrc{ 150 Status: states.ObjectReady, 151 AttrsJSON: []byte(`{"id":"b","test_string":"x"}`), 152 }, 153 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 154 ) 155 156 if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { 157 t.Fatal(err) 158 } 159 160 tf := &DestroyEdgeTransformer{} 161 if err := tf.Transform(&g); err != nil { 162 t.Fatalf("err: %s", err) 163 } 164 165 actual := strings.TrimSpace(g.String()) 166 expected := strings.TrimSpace(testTransformDestroyEdgeModuleStr) 167 if actual != expected { 168 t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) 169 } 170 } 171 172 func TestDestroyEdgeTransformer_moduleOnly(t *testing.T) { 173 g := Graph{Path: addrs.RootModuleInstance} 174 175 state := states.NewState() 176 for moduleIdx := 0; moduleIdx < 2; moduleIdx++ { 177 g.Add(testDestroyNode(fmt.Sprintf("module.child[%d].test_object.a", moduleIdx))) 178 g.Add(testDestroyNode(fmt.Sprintf("module.child[%d].test_object.b", moduleIdx))) 179 g.Add(testDestroyNode(fmt.Sprintf("module.child[%d].test_object.c", moduleIdx))) 180 181 child := state.EnsureModule(addrs.RootModuleInstance.Child("child", addrs.IntKey(moduleIdx))) 182 child.SetResourceInstanceCurrent( 183 mustResourceInstanceAddr("test_object.a").Resource, 184 &states.ResourceInstanceObjectSrc{ 185 Status: states.ObjectReady, 186 AttrsJSON: []byte(`{"id":"a"}`), 187 }, 188 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 189 ) 190 child.SetResourceInstanceCurrent( 191 mustResourceInstanceAddr("test_object.b").Resource, 192 &states.ResourceInstanceObjectSrc{ 193 Status: states.ObjectReady, 194 AttrsJSON: []byte(`{"id":"b","test_string":"x"}`), 195 Dependencies: []addrs.ConfigResource{ 196 mustConfigResourceAddr("module.child.test_object.a"), 197 }, 198 }, 199 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 200 ) 201 child.SetResourceInstanceCurrent( 202 mustResourceInstanceAddr("test_object.c").Resource, 203 &states.ResourceInstanceObjectSrc{ 204 Status: states.ObjectReady, 205 AttrsJSON: []byte(`{"id":"c","test_string":"x"}`), 206 Dependencies: []addrs.ConfigResource{ 207 mustConfigResourceAddr("module.child.test_object.a"), 208 mustConfigResourceAddr("module.child.test_object.b"), 209 }, 210 }, 211 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 212 ) 213 } 214 215 if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { 216 t.Fatal(err) 217 } 218 219 tf := &DestroyEdgeTransformer{} 220 if err := tf.Transform(&g); err != nil { 221 t.Fatalf("err: %s", err) 222 } 223 224 // The analyses done in the destroy edge transformer are between 225 // not-yet-expanded objects, which is conservative and so it will generate 226 // edges that aren't strictly necessary. As a special case we filter out 227 // any edges that are between resources instances that are in different 228 // instances of the same module, because those edges are never needed 229 // (one instance of a module cannot depend on another instance of the 230 // same module) and including them can, in complex cases, cause cycles due 231 // to unnecessary interactions between destroyed and created module 232 // instances in the same plan. 233 // 234 // Therefore below we expect to see the dependencies within each instance 235 // of module.child reflected, but we should not see any dependencies 236 // _between_ instances of module.child. 237 238 actual := strings.TrimSpace(g.String()) 239 expected := strings.TrimSpace(` 240 module.child[0].test_object.a (destroy) 241 module.child[0].test_object.b (destroy) 242 module.child[0].test_object.c (destroy) 243 module.child[0].test_object.b (destroy) 244 module.child[0].test_object.c (destroy) 245 module.child[0].test_object.c (destroy) 246 module.child[1].test_object.a (destroy) 247 module.child[1].test_object.b (destroy) 248 module.child[1].test_object.c (destroy) 249 module.child[1].test_object.b (destroy) 250 module.child[1].test_object.c (destroy) 251 module.child[1].test_object.c (destroy) 252 `) 253 if actual != expected { 254 t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) 255 } 256 } 257 258 func TestDestroyEdgeTransformer_destroyThenUpdate(t *testing.T) { 259 g := Graph{Path: addrs.RootModuleInstance} 260 g.Add(testUpdateNode("test_object.A")) 261 g.Add(testDestroyNode("test_object.B")) 262 263 state := states.NewState() 264 root := state.EnsureModule(addrs.RootModuleInstance) 265 root.SetResourceInstanceCurrent( 266 mustResourceInstanceAddr("test_object.A").Resource, 267 &states.ResourceInstanceObjectSrc{ 268 Status: states.ObjectReady, 269 AttrsJSON: []byte(`{"id":"A","test_string":"old"}`), 270 }, 271 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 272 ) 273 root.SetResourceInstanceCurrent( 274 mustResourceInstanceAddr("test_object.B").Resource, 275 &states.ResourceInstanceObjectSrc{ 276 Status: states.ObjectReady, 277 AttrsJSON: []byte(`{"id":"B","test_string":"x"}`), 278 Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A")}, 279 }, 280 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 281 ) 282 283 if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { 284 t.Fatal(err) 285 } 286 287 tf := &DestroyEdgeTransformer{} 288 if err := tf.Transform(&g); err != nil { 289 t.Fatalf("err: %s", err) 290 } 291 292 expected := strings.TrimSpace(` 293 test_object.A 294 test_object.B (destroy) 295 test_object.B (destroy) 296 `) 297 actual := strings.TrimSpace(g.String()) 298 299 if actual != expected { 300 t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) 301 } 302 } 303 304 func TestPruneUnusedNodesTransformer_rootModuleOutputValues(t *testing.T) { 305 // This is a kinda-weird test case covering the very narrow situation 306 // where a root module output value depends on a resource, where we 307 // need to make sure that the output value doesn't block pruning of 308 // the resource from the graph. This special case exists because although 309 // root module objects are "expanders", they in practice always expand 310 // to exactly one instance and so don't have the usual requirement of 311 // needing to stick around in order to support downstream expanders 312 // when there are e.g. nested expanding modules. 313 314 // In order to keep this test focused on the pruneUnusedNodesTransformer 315 // as much as possible we're using a minimal graph construction here which 316 // is just enough to get the nodes we need, but this does mean that this 317 // test might be invalidated by future changes to the apply graph builder, 318 // and so if something seems off here it might help to compare the 319 // following with the real apply graph transformer and verify whether 320 // this smaller construction is still realistic enough to be a valid test. 321 // It might be valid to change or remove this test to "make it work", as 322 // long as you verify that there is still _something_ upholding the 323 // invariant that a root module output value should not block a resource 324 // node from being pruned from the graph. 325 326 concreteResource := func(a *NodeAbstractResource) dag.Vertex { 327 return &nodeExpandApplyableResource{ 328 NodeAbstractResource: a, 329 } 330 } 331 332 concreteResourceInstance := func(a *NodeAbstractResourceInstance) dag.Vertex { 333 return &NodeApplyableResourceInstance{ 334 NodeAbstractResourceInstance: a, 335 } 336 } 337 338 resourceInstAddr := mustResourceInstanceAddr("test.a") 339 providerCfgAddr := addrs.AbsProviderConfig{ 340 Module: addrs.RootModule, 341 Provider: addrs.MustParseProviderSourceString("foo/test"), 342 } 343 emptyObjDynamicVal, err := plans.NewDynamicValue(cty.EmptyObjectVal, cty.EmptyObject) 344 if err != nil { 345 t.Fatal(err) 346 } 347 nullObjDynamicVal, err := plans.NewDynamicValue(cty.NullVal(cty.EmptyObject), cty.EmptyObject) 348 if err != nil { 349 t.Fatal(err) 350 } 351 352 config := testModuleInline(t, map[string]string{ 353 "main.tf": ` 354 resource "test" "a" { 355 } 356 357 output "test" { 358 value = test.a.foo 359 } 360 `, 361 }) 362 state := states.BuildState(func(s *states.SyncState) { 363 s.SetResourceInstanceCurrent( 364 resourceInstAddr, 365 &states.ResourceInstanceObjectSrc{ 366 Status: states.ObjectReady, 367 AttrsJSON: []byte(`{}`), 368 }, 369 providerCfgAddr, 370 ) 371 }) 372 changes := plans.NewChanges() 373 changes.SyncWrapper().AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{ 374 Addr: resourceInstAddr, 375 PrevRunAddr: resourceInstAddr, 376 ProviderAddr: providerCfgAddr, 377 ChangeSrc: plans.ChangeSrc{ 378 Action: plans.Delete, 379 Before: emptyObjDynamicVal, 380 After: nullObjDynamicVal, 381 }, 382 }) 383 384 builder := &BasicGraphBuilder{ 385 Steps: []GraphTransformer{ 386 &ConfigTransformer{ 387 Concrete: concreteResource, 388 Config: config, 389 }, 390 &OutputTransformer{ 391 Config: config, 392 }, 393 &DiffTransformer{ 394 Concrete: concreteResourceInstance, 395 State: state, 396 Changes: changes, 397 }, 398 &ReferenceTransformer{}, 399 &AttachDependenciesTransformer{}, 400 &pruneUnusedNodesTransformer{}, 401 &CloseRootModuleTransformer{}, 402 }, 403 } 404 graph, diags := builder.Build(addrs.RootModuleInstance) 405 assertNoDiagnostics(t, diags) 406 407 // At this point, thanks to pruneUnusedNodesTransformer, we should still 408 // have the node for the output value, but the "test.a (expand)" node 409 // should've been pruned in recognition of the fact that we're performing 410 // a destroy and therefore we only need the "test.a (destroy)" node. 411 412 nodesByName := make(map[string]dag.Vertex) 413 nodesByResourceExpand := make(map[string]dag.Vertex) 414 for _, n := range graph.Vertices() { 415 name := dag.VertexName(n) 416 if _, exists := nodesByName[name]; exists { 417 t.Fatalf("multiple nodes have name %q", name) 418 } 419 nodesByName[name] = n 420 421 if exp, ok := n.(*nodeExpandApplyableResource); ok { 422 addr := exp.Addr 423 if _, exists := nodesByResourceExpand[addr.String()]; exists { 424 t.Fatalf("multiple nodes are expanders for %s", addr) 425 } 426 nodesByResourceExpand[addr.String()] = exp 427 } 428 } 429 430 // NOTE: The following is sensitive to the current name string formats we 431 // use for these particular node types. These names are not contractual 432 // so if this breaks in future it is fine to update these names to the new 433 // names as long as you verify first that the new names correspond to 434 // the same meaning as what we're assuming below. 435 if _, exists := nodesByName["test.a (destroy)"]; !exists { 436 t.Errorf("missing destroy node for resource instance test.a") 437 } 438 if _, exists := nodesByName["output.test (expand)"]; !exists { 439 t.Errorf("missing expand for output value 'test'") 440 } 441 442 // We _must not_ have any node that expands a resource. 443 if len(nodesByResourceExpand) != 0 { 444 t.Errorf("resource expand nodes remain the graph after transform; should've been pruned\n%s", spew.Sdump(nodesByResourceExpand)) 445 } 446 } 447 448 // NoOp changes should not be participating in the destroy sequence 449 func TestDestroyEdgeTransformer_noOp(t *testing.T) { 450 g := Graph{Path: addrs.RootModuleInstance} 451 g.Add(testDestroyNode("test_object.A")) 452 g.Add(testUpdateNode("test_object.B")) 453 g.Add(testDestroyNode("test_object.C")) 454 455 state := states.NewState() 456 root := state.EnsureModule(addrs.RootModuleInstance) 457 root.SetResourceInstanceCurrent( 458 mustResourceInstanceAddr("test_object.A").Resource, 459 &states.ResourceInstanceObjectSrc{ 460 Status: states.ObjectReady, 461 AttrsJSON: []byte(`{"id":"A"}`), 462 }, 463 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 464 ) 465 root.SetResourceInstanceCurrent( 466 mustResourceInstanceAddr("test_object.B").Resource, 467 &states.ResourceInstanceObjectSrc{ 468 Status: states.ObjectReady, 469 AttrsJSON: []byte(`{"id":"B","test_string":"x"}`), 470 Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A")}, 471 }, 472 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 473 ) 474 root.SetResourceInstanceCurrent( 475 mustResourceInstanceAddr("test_object.C").Resource, 476 &states.ResourceInstanceObjectSrc{ 477 Status: states.ObjectReady, 478 AttrsJSON: []byte(`{"id":"C","test_string":"x"}`), 479 Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A"), 480 mustConfigResourceAddr("test_object.B")}, 481 }, 482 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 483 ) 484 485 if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { 486 t.Fatal(err) 487 } 488 489 tf := &DestroyEdgeTransformer{ 490 // We only need a minimal object to indicate GraphNodeCreator change is 491 // a NoOp here. 492 Changes: &plans.Changes{ 493 Resources: []*plans.ResourceInstanceChangeSrc{ 494 { 495 Addr: mustResourceInstanceAddr("test_object.B"), 496 ChangeSrc: plans.ChangeSrc{Action: plans.NoOp}, 497 }, 498 }, 499 }, 500 } 501 if err := tf.Transform(&g); err != nil { 502 t.Fatalf("err: %s", err) 503 } 504 505 expected := strings.TrimSpace(` 506 test_object.A (destroy) 507 test_object.C (destroy) 508 test_object.B 509 test_object.C (destroy)`) 510 511 actual := strings.TrimSpace(g.String()) 512 if actual != expected { 513 t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) 514 } 515 } 516 517 func TestDestroyEdgeTransformer_dataDependsOn(t *testing.T) { 518 g := Graph{Path: addrs.RootModuleInstance} 519 520 addrA := mustResourceInstanceAddr("test_object.A") 521 instA := NewNodeAbstractResourceInstance(addrA) 522 a := &NodeDestroyResourceInstance{NodeAbstractResourceInstance: instA} 523 g.Add(a) 524 525 // B here represents a data sources, which is effectively an update during 526 // apply, but won't have dependencies stored in the state. 527 addrB := mustResourceInstanceAddr("test_object.B") 528 instB := NewNodeAbstractResourceInstance(addrB) 529 instB.Dependencies = append(instB.Dependencies, addrA.ConfigResource()) 530 b := &NodeApplyableResourceInstance{NodeAbstractResourceInstance: instB} 531 532 g.Add(b) 533 534 state := states.NewState() 535 root := state.EnsureModule(addrs.RootModuleInstance) 536 root.SetResourceInstanceCurrent( 537 mustResourceInstanceAddr("test_object.A").Resource, 538 &states.ResourceInstanceObjectSrc{ 539 Status: states.ObjectReady, 540 AttrsJSON: []byte(`{"id":"A"}`), 541 }, 542 mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`), 543 ) 544 545 if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { 546 t.Fatal(err) 547 } 548 549 tf := &DestroyEdgeTransformer{} 550 if err := tf.Transform(&g); err != nil { 551 t.Fatalf("err: %s", err) 552 } 553 554 actual := strings.TrimSpace(g.String()) 555 expected := strings.TrimSpace(` 556 test_object.A (destroy) 557 test_object.B 558 test_object.A (destroy) 559 `) 560 if actual != expected { 561 t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) 562 } 563 } 564 565 func testDestroyNode(addrString string) GraphNodeDestroyer { 566 instAddr := mustResourceInstanceAddr(addrString) 567 inst := NewNodeAbstractResourceInstance(instAddr) 568 return &NodeDestroyResourceInstance{NodeAbstractResourceInstance: inst} 569 } 570 571 func testUpdateNode(addrString string) GraphNodeCreator { 572 instAddr := mustResourceInstanceAddr(addrString) 573 inst := NewNodeAbstractResourceInstance(instAddr) 574 return &NodeApplyableResourceInstance{NodeAbstractResourceInstance: inst} 575 } 576 577 const testTransformDestroyEdgeBasicStr = ` 578 test_object.A (destroy) 579 test_object.B (destroy) 580 test_object.B (destroy) 581 ` 582 583 const testTransformDestroyEdgeMultiStr = ` 584 test_object.A (destroy) 585 test_object.B (destroy) 586 test_object.C (destroy) 587 test_object.B (destroy) 588 test_object.C (destroy) 589 test_object.C (destroy) 590 ` 591 592 const testTransformDestroyEdgeSelfRefStr = ` 593 test_object.A (destroy) 594 ` 595 596 const testTransformDestroyEdgeModuleStr = ` 597 module.child.test_object.b (destroy) 598 test_object.a (destroy) 599 test_object.a (destroy) 600 `