@@ -416,6 +416,48 @@ func TestApplyPostRunE_SuppressedWhenMultiComponent(t *testing.T) {
416416 assert .NoError (t , err , "PostRunE must fire normally in single-component mode" )
417417}
418418
419+ // TestOutputPostRunE_SuppressedWhenMultiComponent verifies that outputCmd.PostRunE
420+ // returns nil without error when wasMultiComponentExecution is true (multi-component
421+ // mode), mirroring the apply/deploy/plan PostRunE suppression contract.
422+ func TestOutputPostRunE_SuppressedWhenMultiComponent (t * testing.T ) {
423+ t .Chdir ("../../examples/demo-stacks" )
424+
425+ orig := wasMultiComponentExecution
426+ defer func () { wasMultiComponentExecution = orig }()
427+
428+ cmd := newHookTestCmd ()
429+ cmd .Use = "output"
430+
431+ wasMultiComponentExecution = true
432+ err := outputCmd .PostRunE (cmd , []string {"--stack" , "dev" , "myapp" })
433+ assert .NoError (t , err , "PostRunE must be suppressed when wasMultiComponentExecution is true" )
434+
435+ wasMultiComponentExecution = false
436+ err = outputCmd .PostRunE (cmd , []string {"--stack" , "dev" , "myapp" })
437+ assert .NoError (t , err , "PostRunE must fire normally in single-component mode" )
438+ }
439+
440+ // TestRefreshPostRunE_SuppressedWhenMultiComponent verifies that refreshCmd.PostRunE
441+ // returns nil without error when wasMultiComponentExecution is true (multi-component
442+ // mode), mirroring the apply/deploy/plan PostRunE suppression contract.
443+ func TestRefreshPostRunE_SuppressedWhenMultiComponent (t * testing.T ) {
444+ t .Chdir ("../../examples/demo-stacks" )
445+
446+ orig := wasMultiComponentExecution
447+ defer func () { wasMultiComponentExecution = orig }()
448+
449+ cmd := newHookTestCmd ()
450+ cmd .Use = "refresh"
451+
452+ wasMultiComponentExecution = true
453+ err := refreshCmd .PostRunE (cmd , []string {"--stack" , "dev" , "myapp" })
454+ assert .NoError (t , err , "PostRunE must be suppressed when wasMultiComponentExecution is true" )
455+
456+ wasMultiComponentExecution = false
457+ err = refreshCmd .PostRunE (cmd , []string {"--stack" , "dev" , "myapp" })
458+ assert .NoError (t , err , "PostRunE must fire normally in single-component mode" )
459+ }
460+
419461// TestDeployRunE_DeferGuard verifies the RunE defer-guard contract in
420462// deploy.go: the global error hook (runHooksOnErrorWithOutput) must fire
421463// when runErr is non-nil AND wasMultiComponentExecution is false, and must
@@ -521,6 +563,88 @@ func TestDeployRunE_DeferGuard(t *testing.T) {
521563 }
522564}
523565
566+ // TestOutputRefreshRunE_DeferGuard verifies the RunE defer-guard contract
567+ // shared by output.go and refresh.go: the global error hook
568+ // (runHooksOnErrorWithOutput) must fire when runErr is non-nil AND
569+ // wasMultiComponentExecution is false, and must be suppressed when
570+ // wasMultiComponentExecution is true (multi-component mode, where
571+ // per-component hooks already fired inside the affected/all/query
572+ // dispatch). Mirrors TestDeployRunE_DeferGuard for output.go's and
573+ // refresh.go's guards.
574+ func TestOutputRefreshRunE_DeferGuard (t * testing.T ) {
575+ subcommands := []struct {
576+ name string
577+ use string
578+ event hooks.HookEvent
579+ }{
580+ {name : "output" , use : "output" , event : hooks .AfterTerraformOutput },
581+ {name : "refresh" , use : "refresh" , event : hooks .AfterTerraformRefresh },
582+ }
583+
584+ for _ , sub := range subcommands {
585+ t .Run (sub .name , func (t * testing.T ) {
586+ origGuard := wasMultiComponentExecution
587+ origHook := runHooksOnErrorWithOutput
588+ defer func () {
589+ wasMultiComponentExecution = origGuard
590+ runHooksOnErrorWithOutput = origHook
591+ }()
592+
593+ var called bool
594+ var calledEvent hooks.HookEvent
595+ var calledErr error
596+ runHooksOnErrorWithOutput = func (event hooks.HookEvent , _ * cobra.Command , _ []string , cmdErr error , _ string ) {
597+ called = true
598+ calledEvent = event
599+ calledErr = cmdErr
600+ }
601+
602+ cmd := newHookTestCmd ()
603+ cmd .Use = sub .use
604+ args := []string {"--stack" , "dev" , "myapp" }
605+
606+ // invokeDefer mirrors the RunE defer-guard body in output.go/refresh.go.
607+ // Any change to the production guard must be reflected here.
608+ invokeDefer := func (runErr error ) {
609+ if runErr != nil && ! wasMultiComponentExecution {
610+ runHooksOnErrorWithOutput (sub .event , cmd , args , runErr , "" )
611+ }
612+ }
613+
614+ runErr := errors .New ("terraform " + sub .name + " failed" )
615+
616+ tests := []struct {
617+ name string
618+ runErr error
619+ multiComponent bool
620+ expectCalled bool
621+ }{
622+ {name : "non-nil error + single-component → hook fires" , runErr : runErr , multiComponent : false , expectCalled : true },
623+ {name : "non-nil error + multi-component → hook suppressed" , runErr : runErr , multiComponent : true , expectCalled : false },
624+ {name : "nil error + single-component → hook does not fire" , runErr : nil , multiComponent : false , expectCalled : false },
625+ {name : "nil error + multi-component → hook does not fire" , runErr : nil , multiComponent : true , expectCalled : false },
626+ }
627+
628+ for _ , tc := range tests {
629+ t .Run (tc .name , func (t * testing.T ) {
630+ called = false
631+ calledEvent = ""
632+ calledErr = nil
633+ wasMultiComponentExecution = tc .multiComponent
634+
635+ invokeDefer (tc .runErr )
636+
637+ assert .Equal (t , tc .expectCalled , called , "hook firing did not match expectation" )
638+ if tc .expectCalled {
639+ assert .Equal (t , sub .event , calledEvent , "hook event mismatch" )
640+ assert .Equal (t , runErr , calledErr , "hook did not receive original runErr" )
641+ }
642+ })
643+ }
644+ })
645+ }
646+ }
647+
524648// TestTerraformNodeHooksAfter_DeployExitCodeForwarding verifies that the exit
525649// code extracted from execErr is forwarded correctly, matching the plan
526650// component hook behaviour.
@@ -598,8 +722,8 @@ func TestTerraformNodeHooksAfter_ApplyExitCodeForwarding(t *testing.T) {
598722func TestWirePerComponentHook (t * testing.T ) {
599723 withoutCIDetection (t )
600724
601- t .Run ("plan/deploy/apply install a non-nil NodeHooks" , func (t * testing.T ) {
602- for _ , sub := range []string {"plan" , "deploy" , "apply" } {
725+ t .Run ("plan/deploy/apply/output/refresh install a non-nil NodeHooks" , func (t * testing.T ) {
726+ for _ , sub := range []string {"plan" , "deploy" , "apply" , "output" , "refresh" } {
603727 t .Run (sub , func (t * testing.T ) {
604728 info := & schema.ConfigAndStacksInfo {
605729 TerraformPlanCIResultHandler : nil ,
@@ -663,8 +787,8 @@ func TestWirePerComponentHook(t *testing.T) {
663787 t .Run ("unknown subcommand leaves NodeHooks unset" , func (t * testing.T ) {
664788 // `init`, `validate`, etc. are valid terraform subcommands but they do
665789 // not have per-component hooks today. The helper must be a no-op
666- // for anything outside the {plan, deploy, apply} set so other
667- // subcommands don't accidentally start firing hooks.
790+ // for anything outside the {plan, deploy, apply, output, refresh} set
791+ // so other subcommands don't accidentally start firing hooks.
668792 for _ , sub := range []string {"destroy" , "init" , "validate" , "" } {
669793 t .Run (sub , func (t * testing.T ) {
670794 info := & schema.ConfigAndStacksInfo {}
@@ -683,7 +807,7 @@ func TestWirePerComponentHook(t *testing.T) {
683807 t .Chdir ("../../examples/demo-stacks" )
684808 cmd := newHookTestCmd ()
685809
686- for _ , sub := range []string {"plan" , "deploy" , "apply" } {
810+ for _ , sub := range []string {"plan" , "deploy" , "apply" , "output" , "refresh" } {
687811 t .Run (sub , func (t * testing.T ) {
688812 info := & schema.ConfigAndStacksInfo {
689813 Stack : "dev" ,
@@ -702,7 +826,7 @@ func TestWirePerComponentHook(t *testing.T) {
702826 }
703827 })
704828
705- t .Run ("the three subcommands wire to distinct events" , func (t * testing.T ) {
829+ t .Run ("the five subcommands wire to distinct events" , func (t * testing.T ) {
706830 // Sanity check: a future edit that copy-pastes one case over another
707831 // (e.g. apply ends up using the plan event) wouldn't be caught by the
708832 // nil/non-nil assertions above. Assert the wired before/after events
@@ -714,16 +838,19 @@ func TestWirePerComponentHook(t *testing.T) {
714838 require .True (t , ok )
715839 return nodeHooks .beforeEvent , nodeHooks .afterEvent
716840 }
717- planBefore , planAfter := eventsFor ("plan" )
718- applyBefore , applyAfter := eventsFor ("apply" )
719- deployBefore , deployAfter := eventsFor ("deploy" )
720-
721- assert .NotEqual (t , planAfter , applyAfter , "plan and apply must fire different after-events" )
722- assert .NotEqual (t , planAfter , deployAfter , "plan and deploy must fire different after-events" )
723- assert .NotEqual (t , applyAfter , deployAfter , "apply and deploy must fire different after-events" )
724- assert .NotEqual (t , planBefore , applyBefore , "plan and apply must fire different before-events" )
725- assert .NotEqual (t , planBefore , deployBefore , "plan and deploy must fire different before-events" )
726- assert .NotEqual (t , applyBefore , deployBefore , "apply and deploy must fire different before-events" )
841+ subs := []string {"plan" , "apply" , "deploy" , "output" , "refresh" }
842+ before := make (map [string ]hooks.HookEvent , len (subs ))
843+ after := make (map [string ]hooks.HookEvent , len (subs ))
844+ for _ , sub := range subs {
845+ before [sub ], after [sub ] = eventsFor (sub )
846+ }
847+
848+ for i , a := range subs {
849+ for _ , b := range subs [i + 1 :] {
850+ assert .NotEqual (t , after [a ], after [b ], "%s and %s must fire different after-events" , a , b )
851+ assert .NotEqual (t , before [a ], before [b ], "%s and %s must fire different before-events" , a , b )
852+ }
853+ }
727854 })
728855}
729856
0 commit comments