Repository: pulumi/pulumi (go)

Issue:
Views: Synthesize delete view steps during `pulumi destroy` previews
We don't currently synthesize delete steps for views during destroy previews. Delete steps are only published for views from the provider's `Delete` rpc, which doesn't run during preview.

This means if a resource has views and you run `pulumi destroy`, the preview will show that the owning resource will be deleted, but it doesn't show a preview of any of its child views.

Worse, if the owning resource is using `RefreshBeforeUpdate`, it will show the views will be refreshed, but it doesn't show that that they will be deleted, which looks confusing, e.g.:

```
$ pulumi destroy
Previewing destroy (dev)

View in Browser (Ctrl+O): ...

     Type                                         Name                                                    Plan        Info
 -   pulumi:pulumi:Stack                          vpcmodtest-dev                                          delete
 -   └─ vpcmod:index:Module                       test-vpc                                                delete
 ~      ├─ vpcmod:tf:aws_route_table_association  module.test-vpc.aws_route_table_association.private[0]  refresh
 ~      ├─ vpcmod:tf:aws_subnet                   module.test-vpc.aws_subnet.public[0]                    refresh
 ~      ├─ vpcmod:tf:aws_subnet                   module.test-vpc.aws_subnet.public[1]                    refresh
 ~      ├─ vpcmod:tf:aws_default_network_acl      module.test-vpc.aws_default_network_acl.this[0]         refresh     [diff: ~egress,ingress]
 ~      ├─ vpcmod:tf:aws_subnet                   module.test-vpc.aws_subnet.private[0]                   refresh
 ~      ├─ vpcmod:tf:aws_route                    module.test-vpc.aws_route.private_nat_gateway[0]        refresh
 ~      ├─ vpcmod:tf:aws_subnet                   module.test-vpc.aws_subnet.private[1]                   refresh
 ~      ├─ vpcmod:tf:aws_default_security_group   module.test-vpc.aws_default_security_group.this[0]      refresh
 ~      ├─ vpcmod:tf:aws_route_table_association  module.test-vpc.aws_route_table_association.public[0]   refresh
 ~      ├─ vpcmod:tf:aws_vpc                      module.test-vpc.aws_vpc.this[0]                         refresh
 ~      ├─ vpcmod:tf:aws_route_table              module.test-vpc.aws_route_table.public[0]               refresh     [diff: ~route]
 ~      ├─ vpcmod:tf:aws_route                    module.test-vpc.aws_route.public_internet_gateway[0]    refresh
 ~      ├─ vpcmod:tf:aws_internet_gateway         module.test-vpc.aws_internet_gateway.this[0]            refresh
 ~      ├─ vpcmod:tf:aws_route_table_association  module.test-vpc.aws_route_table_association.public[1]   refresh
 ~      ├─ vpcmod:tf:aws_route_table_association  module.test-vpc.aws_route_table_association.private[1]  refresh
 ~      ├─ vpcmod:tf:aws_default_route_table      module.test-vpc.aws_default_route_table.default[0]      refresh     [diff: +propagating_vgws]
 ~      ├─ vpcmod:tf:aws_eip                      module.test-vpc.aws_eip.nat[0]                          refresh     [diff: +private_dns~association_id,network_interface
 ~      ├─ vpcmod:tf:aws_nat_gateway              module.test-vpc.aws_nat_gateway.this[0]                 refresh     [diff: +secondary_allocation_ids]
 ~      └─ vpcmod:tf:aws_route_table              module.test-vpc.aws_route_table.private[0]              refresh     [diff: ~route]
```

To fix this, we should synthesize delete view steps during a destroy preview, so that the output shows the child views will be deleted.

Pull request:
Synthesize delete steps for views during preview
A provider's `Delete` RPC is responsible for publishing delete steps for any views of the resource to properly report any partial delete failures. However, `Delete` is not called during preview, so views are currently not getting delete steps which makes it look like only the owning resource will be deleted during a `pulumi destroy` preview -- there is nothing in the display about any of the resource's views.

This change fixes this by synthesizing and publishing delete steps for any views of a resource that is going to be deleted during a preview. This way, the deletes for the views are displayed to the user.

Fixes #19929

Patch:
diff --git a/changelog/pending/20250626--engine--synthesize-delete-steps-for-views-during-preview.yaml b/changelog/pending/20250626--engine--synthesize-delete-steps-for-views-during-preview.yaml
new file mode 100644
index 0000000000..909fe1deb9
--- /dev/null
+++ b/changelog/pending/20250626--engine--synthesize-delete-steps-for-views-during-preview.yaml
@@ -0,0 +1,4 @@
+changes:
+- type: fix
+  scope: engine
+  description: Synthesize delete steps for views during preview
diff --git a/pkg/resource/deploy/resource_status.go b/pkg/resource/deploy/resource_status.go
index 77021ef184..b1522301e2 100644
--- a/pkg/resource/deploy/resource_status.go
+++ b/pkg/resource/deploy/resource_status.go
@@ -164,25 +164,35 @@ func (rs *resourceStatusServer) Address() string {
 }
 
 // ReserveToken reserves a token for a resource status operation.
-func (rs *resourceStatusServer) ReserveToken(urn resource.URN, refresh bool, persisted bool) (string, error) {
+func (rs *resourceStatusServer) ReserveToken(urn resource.URN, refresh, persisted bool) (string, error) {
 	if rs == nil {
 		return "", nil
 	}
+	token, _, err := rs.reserveToken(urn, refresh, persisted)
+	return token, err
+}
+
+// reserveToken reserves a token for a resource status operation, and returns the token and tokenInfo.
+func (rs *resourceStatusServer) reserveToken(urn resource.URN, refresh, persisted bool) (string, *tokenInfo, error) {
+	if rs == nil {
+		return "", nil, nil
+	}
 
 	logging.V(5).Infof("Reserving token for %s (refresh: %t)", urn, refresh)
 
 	token, err := uuid.NewV4()
 	if err != nil {
-		return "", fmt.Errorf("creating token: %w", err)
+		return "", nil, fmt.Errorf("creating token: %w", err)
 	}
 	tokenString := token.String()
 	rs.urns.Store(urn, tokenString)
-	rs.tokens.Store(tokenString, &tokenInfo{
+	info := &tokenInfo{
 		urn:       urn,
 		refresh:   refresh,
 		persisted: persisted,
-	})
-	return tokenString, nil
+	}
+	rs.tokens.Store(tokenString, info)
+	return tokenString, info, nil
 }
 
 // ReleaseToken returns the view steps published for the URN and releases the associated token so that no further
@@ -247,6 +257,10 @@ func (rs *resourceStatusServer) PublishViewSteps(ctx context.Context,
 	}
 	viewOf := info.urn
 
+	if len(req.Steps) == 0 {
+		return &pulumirpc.PublishViewStepsResponse{}, nil
+	}
+
 	// Unmarshal the steps.
 	steps, err := slice.MapError(req.Steps, func(step *pulumirpc.ViewStep) (Step, error) {
 		return rs.unmarshalViewStep(viewOf, step, info.refresh, info.persisted)
@@ -256,10 +270,6 @@ func (rs *resourceStatusServer) PublishViewSteps(ctx context.Context,
 		return nil, fmt.Errorf("unmarshaling steps: %w", err)
 	}
 
-	if len(steps) == 0 {
-		return &pulumirpc.PublishViewStepsResponse{}, nil
-	}
-
 	// Save any refresh steps.
 	for _, step := range steps {
 		if step.Op() == OpRefresh {
@@ -269,27 +279,44 @@ func (rs *resourceStatusServer) PublishViewSteps(ctx context.Context,
 		}
 	}
 
+	// Publish the steps.
+	if err := rs.publishViewSteps(info, steps); err != nil {
+		return nil, err
+	}
+
+	return &pulumirpc.PublishViewStepsResponse{}, nil
+}
+
+// publishViewStepsWithTokenInfo publishes the view steps.
+func (rs *resourceStatusServer) publishViewSteps(info *tokenInfo, steps []Step) error {
+	if rs == nil {
+		return nil
+	}
+
+	contract.Requiref(info != nil, "info", "must not be nil")
+
 	events := rs.deployment.events
+	if events == nil {
+		return nil
+	}
 
 	// Raise the OnResourceStepPre event and keep track of the payload context.
-	if events != nil {
-		for _, step := range steps {
-			payload, err := events.OnResourceStepPre(step)
-			if err != nil {
-				logging.V(5).Infof("ResourceStatus: error publishing view steps: %v", err)
-				return nil, fmt.Errorf("publishing view steps: %w", err)
-			}
-
-			info.mu.Lock()
-			info.steps = append(info.steps, stepInfo{
-				step:    step,
-				payload: payload,
-			})
-			info.mu.Unlock()
+	for _, step := range steps {
+		payload, err := events.OnResourceStepPre(step)
+		if err != nil {
+			logging.V(5).Infof("ResourceStatus: error publishing view steps: %v", err)
+			return fmt.Errorf("publishing view steps: %w", err)
 		}
+
+		info.mu.Lock()
+		info.steps = append(info.steps, stepInfo{
+			step:    step,
+			payload: payload,
+		})
+		info.mu.Unlock()
 	}
 
-	return &pulumirpc.PublishViewStepsResponse{}, nil
+	return nil
 }
 
 func (rs *resourceStatusServer) unmarshalViewStep(
diff --git a/pkg/resource/deploy/step.go b/pkg/resource/deploy/step.go
index 6494d8548a..9b0bc50614 100644
--- a/pkg/resource/deploy/step.go
+++ b/pkg/resource/deploy/step.go
@@ -28,6 +28,7 @@ import (
 	"github.com/pulumi/pulumi/sdk/v3/go/common/promise"
 	"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
 	"github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin"
+	"github.com/pulumi/pulumi/sdk/v3/go/common/slice"
 	"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
 	"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
 	"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
@@ -500,7 +501,13 @@ func (s *DeleteStep) Apply() (resource.Status, StepCompleteFunc, error) {
 	}
 
 	if s.deployment.opts.DryRun {
-		// Do nothing in preview
+		// Do nothing in preview other than synthesize and publish delete view steps for any views of this resource.
+		// We do this because when not running a preview the provider's Delete is responsible for publishing delete
+		// view steps so the provider can properly report partial failures, but during preview we don't call Delete
+		// on the provider, so we synthesize the view delete steps here for display purposes.
+		if err := s.publishSynthesizedViewSteps(); err != nil {
+			return resource.StatusOK, nil, err
+		}
 	} else if s.old.External {
 		// Deleting an External resource is a no-op, since Pulumi does not own the lifecycle.
 	} else if s.old.RetainOnDelete {
@@ -568,6 +575,34 @@ func (s *DeleteStep) Apply() (resource.Status, StepCompleteFunc, error) {
 	return resource.StatusOK, func() {}, nil
 }
 
+// publishSynthesizedViewSteps synthesizes delete view steps for any views of the resource
+// and publishes those view steps.
+func (s *DeleteStep) publishSynthesizedViewSteps() error {
+	oldViews := s.deployment.oldViews[s.old.URN]
+	if len(oldViews) == 0 {
+		return nil
+	}
+
+	// Synthesize delete view steps for any views.
+	deleteViewSteps := slice.Map(oldViews, func(res *resource.State) Step {
+		return NewViewStep(s.deployment, OpDelete, resource.StatusOK, "", res, nil, nil, nil, nil, "", false)
+	})
+
+	// Reserve a token.
+	_, tokenInfo, err := s.deployment.resourceStatus.reserveToken(
+		s.URN(), false /*refresh*/, false /*persisted*/)
+	if err != nil {
+		return err
+	}
+
+	// Publish the synthesized delete steps.
+	if err := s.deployment.resourceStatus.publishViewSteps(tokenInfo, deleteViewSteps); err != nil {
+		return err
+	}
+
+	return nil
+}
+
 func (s *DeleteStep) Fail() {
 	// Nothing to do here.
 }


Test patch:
diff --git a/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/diff.stderr.txt b/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/diff.stderr.txt
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/diff.stdout.txt b/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/diff.stdout.txt
new file mode 100644
index 0000000000..be3b60e17d
--- /dev/null
+++ b/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/diff.stdout.txt
@@ -0,0 +1,14 @@
+<{%fg 2%}>+ pulumi:providers:pkgA: (create)
+<{%fg 2%}>    [urn=urn:pulumi:test::test::pulumi:providers:pkgA::default]
+<{%reset%}><{%reset%}><{%fg 2%}>+ pkgA:m:typA: (create)
+<{%fg 2%}>    [urn=urn:pulumi:test::test::pkgA:m:typA::resA]
+<{%reset%}><{%fg 2%}>    foo: <{%reset%}><{%fg 2%}>"bar"<{%reset%}><{%fg 2%}>
+<{%reset%}><{%reset%}>    <{%fg 2%}>+ pkgA:m:typAView: (create)
+<{%fg 2%}>        [urn=urn:pulumi:test::test::pkgA:m:typA$pkgA:m:typAView::resA-child]
+<{%reset%}><{%fg 2%}>        result: <{%reset%}><{%fg 2%}>"bar"<{%reset%}><{%fg 2%}>
+<{%reset%}><{%reset%}><{%fg 2%}>        --outputs:--<{%reset%}>
+<{%fg 2%}>        result: <{%reset%}><{%fg 2%}>"bar"<{%reset%}><{%fg 2%}>
+<{%reset%}><{%fg 13%}><{%bold%}>Resources:<{%reset%}>
+    <{%fg 2%}>+ 2 created<{%reset%}>
+
+<{%fg 13%}><{%bold%}>Duration:<{%reset%}> 1s
diff --git a/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/eventstream.json b/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/eventstream.json
new file mode 100644
index 0000000000..917919bc9b
--- /dev/null
+++ b/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/eventstream.json
@@ -0,0 +1,9 @@
+{"sequence":0,"timestamp":0,"preludeEvent":{"config":{}}}
+{"sequence":0,"timestamp":0,"resourcePreEvent":{"metadata":{"op":"create","urn":"urn:pulumi:test::test::pulumi:providers:pkgA::default","type":"pulumi:providers:pkgA","old":null,"new":{"type":"pulumi:providers:pkgA","urn":"urn:pulumi:test::test::pulumi:providers:pkgA::default","custom":true,"id":"","parent":"","inputs":{},"outputs":{},"provider":""},"detailedDiff":null,"logical":true,"provider":""}}}
+{"sequence":0,"timestamp":0,"resOutputsEvent":{"metadata":{"op":"create","urn":"urn:pulumi:test::test::pulumi:providers:pkgA::default","type":"pulumi:providers:pkgA","old":null,"new":{"type":"pulumi:providers:pkgA","urn":"urn:pulumi:test::test::pulumi:providers:pkgA::default","custom":true,"id":"75ac7a85-bc6c-4e82-8c45-fbc6888bfc20","parent":"","inputs":{},"outputs":{},"provider":""},"detailedDiff":null,"logical":true,"provider":""}}}
+{"sequence":0,"timestamp":0,"resourcePreEvent":{"metadata":{"op":"create","urn":"urn:pulumi:test::test::pkgA:m:typA::resA","type":"pkgA:m:typA","old":null,"new":{"type":"pkgA:m:typA","urn":"urn:pulumi:test::test::pkgA:m:typA::resA","custom":true,"id":"","parent":"","inputs":{"foo":"bar"},"outputs":{},"provider":"urn:pulumi:test::test::pulumi:providers:pkgA::default::75ac7a85-bc6c-4e82-8c45-fbc6888bfc20"},"detailedDiff":null,"logical":true,"provider":"urn:pulumi:test::test::pulumi:providers:pkgA::default::75ac7a85-bc6c-4e82-8c45-fbc6888bfc20"}}}
+{"sequence":0,"timestamp":0,"resourcePreEvent":{"metadata":{"op":"create","urn":"urn:pulumi:test::test::pkgA:m:typA$pkgA:m:typAView::resA-child","type":"pkgA:m:typAView","old":null,"new":{"type":"pkgA:m:typAView","urn":"urn:pulumi:test::test::pkgA:m:typA$pkgA:m:typAView::resA-child","id":"","parent":"urn:pulumi:test::test::pkgA:m:typA::resA","inputs":{"input":"bar"},"outputs":{"result":"bar"},"provider":""},"detailedDiff":null,"logical":true,"provider":""}}}
+{"sequence":0,"timestamp":0,"resOutputsEvent":{"metadata":{"op":"create","urn":"urn:pulumi:test::test::pkgA:m:typA::resA","type":"pkgA:m:typA","old":null,"new":{"type":"pkgA:m:typA","urn":"urn:pulumi:test::test::pkgA:m:typA::resA","custom":true,"id":"new-id","parent":"","inputs":{"foo":"bar"},"outputs":{"foo":"bar"},"provider":"urn:pulumi:test::test::pulumi:providers:pkgA::default::75ac7a85-bc6c-4e82-8c45-fbc6888bfc20"},"detailedDiff":null,"logical":true,"provider":"urn:pulumi:test::test::pulumi:providers:pkgA::default::75ac7a85-bc6c-4e82-8c45-fbc6888bfc20"}}}
+{"sequence":0,"timestamp":0,"resOutputsEvent":{"metadata":{"op":"create","urn":"urn:pulumi:test::test::pkgA:m:typA$pkgA:m:typAView::resA-child","type":"pkgA:m:typAView","old":null,"new":{"type":"pkgA:m:typAView","urn":"urn:pulumi:test::test::pkgA:m:typA$pkgA:m:typAView::resA-child","id":"","parent":"urn:pulumi:test::test::pkgA:m:typA::resA","inputs":{"input":"bar"},"outputs":{"result":"bar"},"provider":""},"detailedDiff":null,"logical":true,"provider":""}}}
+{"sequence":0,"timestamp":0,"summaryEvent":{"maybeCorrupt":false,"durationSeconds":1,"resourceChanges":{"create":2},"PolicyPacks":{}}}
+{"sequence":0,"timestamp":0,"cancelEvent":{}}
diff --git a/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/progress.stderr.txt b/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/progress.stderr.txt
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/progress.stdout.txt b/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/progress.stdout.txt
new file mode 100644
index 0000000000..d44227f172
--- /dev/null
+++ b/pkg/engine/lifecycletest/testdata/output/TestViewsDestroyPreview/0/progress.stdout.txt
@@ -0,0 +1,14 @@
+<{%fg 13%}><{%bold%}>View Live: <{%underline%}><{%fg 12%}>http://example.com<{%reset%}>
+
+
+ <{%bold%}><{%fg 2%}>+ <{%reset%}> pulumi:providers:pkgA default <{%bold%}><{%fg 2%}>creating<{%reset%}> 
+ <{%fg 2%}>+ <{%reset%}> pulumi:providers:pkgA default <{%fg 2%}>created<{%reset%}> 
+ <{%bold%}><{%fg 2%}>+ <{%reset%}> pkgA:m:typA resA <{%bold%}><{%fg 2%}>creating<{%reset%}> 
+ <{%bold%}><{%fg 2%}>+ <{%reset%}> pkgA:m:typAView resA-child <{%bold%}><{%fg 2%}>creating<{%reset%}> 
+ <{%fg 2%}>+ <{%reset%}> pkgA:m:typA resA <{%fg 2%}>created<{%reset%}> 
+ <{%reset%}>  <{%reset%}> pulumi:pulumi:Stack project-stack <{%reset%}><{%reset%}> 
+<{%fg 13%}><{%bold%}>Resources:<{%reset%}>
+    <{%fg 2%}>+ 2 created<{%reset%}>
+
+<{%fg 13%}><{%bold%}>Duration:<{%reset%}> 1s
+
diff --git a/pkg/engine/lifecycletest/views_test.go b/pkg/engine/lifecycletest/views_test.go
index d7010985f0..757d980af6 100644
--- a/pkg/engine/lifecycletest/views_test.go
+++ b/pkg/engine/lifecycletest/views_test.go
@@ -2395,3 +2395,106 @@ func TestViewsRefreshDriftDeleteCreate_RefreshProgram(t *testing.T) {
 		"result": resource.NewProperty("baz"),
 	}, snap.Resources[2].Outputs)
 }
+
+// TestViewsDestroyPreview ensures delete steps for views are synthesized during
+// destroy previews.
+func TestViewsDestroyPreview(t *testing.T) {
+	t.Parallel()
+
+	loaders := []*deploytest.ProviderLoader{
+		deploytest.NewProviderLoader("pkgA", semver.MustParse("1.0.0"), func() (plugin.Provider, error) {
+			return &deploytest.Provider{
+				CreateF: func(_ context.Context, req plugin.CreateRequest) (plugin.CreateResponse, error) {
+					rs, err := deploytest.NewResourceStatus(req.ResourceStatusAddress)
+					require.NoError(t, err)
+					defer rs.Close()
+
+					err = rs.PublishViewSteps(req.ResourceStatusToken, []deploytest.ViewStep{
+						{
+							Op:     apitype.OpCreate,
+							Status: resource.StatusOK,
+							New: &deploytest.ViewStepState{
+								Type: tokens.Type("pkgA:m:typAView"),
+								Name: req.URN.Name() + "-child",
+								Inputs: resource.PropertyMap{
+									"input": req.Properties["foo"],
+								},
+								Outputs: resource.PropertyMap{
+									"result": req.Properties["foo"],
+								},
+							},
+						},
+					})
+					require.NoError(t, err)
+
+					return plugin.CreateResponse{
+						ID:         "new-id",
+						Properties: req.Properties,
+						Status:     resource.StatusOK,
+					}, nil
+				},
+			}, nil
+		}, deploytest.WithoutGrpc),
+	}
+
+	programF := deploytest.NewLanguageRuntimeF(func(_ plugin.RunInfo, monitor *deploytest.ResourceMonitor) error {
+		_, err := monitor.RegisterResource("pkgA:m:typA", "resA", true, deploytest.ResourceOptions{
+			Inputs: resource.PropertyMap{
+				"foo": resource.NewProperty("bar"),
+			},
+		})
+		assert.NoError(t, err)
+
+		return nil
+	})
+	hostF := deploytest.NewPluginHostF(nil, nil, programF, loaders...)
+
+	p := &lt.TestPlan{
+		Options: lt.TestUpdateOptions{T: t, HostF: hostF},
+	}
+
+	project := p.GetProject()
+
+	// Run an update to create the resources.
+	snap, err := lt.TestOp(Update).RunStep(project, p.GetTarget(t, nil), p.Options, false, p.BackendClient, nil, "0")
+	require.NoError(t, err)
+	require.NotNil(t, snap)
+	require.Len(t, snap.Resources, 3)
+	assert.Equal(t, "new-id", snap.Resources[1].ID.String())
+	assert.Equal(t, snap.Resources[1].URN, snap.Resources[2].ViewOf)
+	assert.Equal(t, "resA-child", snap.Resources[2].URN.Name())
+	assert.Equal(t, tokens.Type("pkgA:m:typAView"), snap.Resources[2].URN.Type())
+	assert.Equal(t, resource.PropertyMap{
+		"input": resource.NewStringProperty("bar"),
+	}, snap.Resources[2].Inputs)
+	assert.Equal(t, resource.PropertyMap{
+		"result": resource.NewStringProperty("bar"),
+	}, snap.Resources[2].Outputs)
+
+	// Run a destroy preview and ensure we got a delete event for the view.
+	_, err = lt.TestOp(Destroy).RunStep(project, p.GetTarget(t, snap), p.Options, true, p.BackendClient,
+		func(_ workspace.Project, _ deploy.Target, _ JournalEntries, events []Event, _ error) error {
+			var viewDeletePreEventFound, summaryEventFound bool
+			for _, e := range events {
+				//nolint:exhaustive // We intentionally do not handle all event types here.
+				switch e.Type {
+				case ResourcePreEvent:
+					payload := e.Payload().(ResourcePreEventPayload)
+					if payload.Metadata.URN.Name() == "resA-child" {
+						viewDeletePreEventFound = true
+					}
+				case SummaryEvent:
+					summaryEventFound = true
+					payload := e.Payload().(SummaryEventPayload)
+					assert.Equal(t, display.ResourceChanges{
+						deploy.OpDelete: 2,
+					}, payload.ResourceChanges)
+				}
+			}
+			assert.True(t, viewDeletePreEventFound, "view delete event found")
+			assert.True(t, summaryEventFound, "summary event found")
+
+			return nil
+		}, "1")
+	require.NoError(t, err)
+}