github.com/kastenhq/syft@v0.0.0-20230821225854-0710af25cdbe/cmd/syft/cli/eventloop/event_loop_test.go (about) 1 package eventloop 2 3 import ( 4 "fmt" 5 "os" 6 "syscall" 7 "testing" 8 "time" 9 10 "github.com/stretchr/testify/assert" 11 "github.com/stretchr/testify/mock" 12 "github.com/wagoodman/go-partybus" 13 14 "github.com/anchore/clio" 15 "github.com/kastenhq/syft/syft/event" 16 ) 17 18 var _ clio.UI = (*uiMock)(nil) 19 20 type uiMock struct { 21 t *testing.T 22 finalEvent partybus.Event 23 subscription partybus.Unsubscribable 24 mock.Mock 25 } 26 27 func (u *uiMock) Setup(unsubscribe partybus.Unsubscribable) error { 28 u.t.Helper() 29 u.t.Logf("UI Setup called") 30 u.subscription = unsubscribe 31 return u.Called(unsubscribe.Unsubscribe).Error(0) 32 } 33 34 func (u *uiMock) Handle(event partybus.Event) error { 35 u.t.Helper() 36 u.t.Logf("UI Handle called: %+v", event.Type) 37 if event == u.finalEvent { 38 assert.NoError(u.t, u.subscription.Unsubscribe()) 39 } 40 return u.Called(event).Error(0) 41 } 42 43 func (u *uiMock) Teardown(_ bool) error { 44 u.t.Helper() 45 u.t.Logf("UI Teardown called") 46 return u.Called().Error(0) 47 } 48 49 func Test_EventLoop_gracefulExit(t *testing.T) { 50 test := func(t *testing.T) { 51 52 testBus := partybus.NewBus() 53 subscription := testBus.Subscribe() 54 t.Cleanup(testBus.Close) 55 56 finalEvent := partybus.Event{ 57 Type: event.CLIExit, 58 } 59 60 worker := func() <-chan error { 61 ret := make(chan error) 62 go func() { 63 t.Log("worker running") 64 // send an empty item (which is ignored) ensuring we've entered the select statement, 65 // then close (a partial shutdown). 66 ret <- nil 67 t.Log("worker sent nothing") 68 close(ret) 69 t.Log("worker closed") 70 // do the other half of the shutdown 71 testBus.Publish(finalEvent) 72 t.Log("worker published final event") 73 }() 74 return ret 75 } 76 77 signaler := func() <-chan os.Signal { 78 return nil 79 } 80 81 ux := &uiMock{ 82 t: t, 83 finalEvent: finalEvent, 84 } 85 86 // ensure the mock sees at least the final event 87 ux.On("Handle", finalEvent).Return(nil) 88 // ensure the mock sees basic setup/teardown events 89 ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) 90 ux.On("Teardown").Return(nil) 91 92 var cleanupCalled bool 93 cleanupFn := func() { 94 t.Log("cleanup called") 95 cleanupCalled = true 96 } 97 98 assert.NoError(t, 99 EventLoop( 100 worker(), 101 signaler(), 102 subscription, 103 cleanupFn, 104 ux, 105 ), 106 ) 107 108 assert.True(t, cleanupCalled, "cleanup function not called") 109 ux.AssertExpectations(t) 110 } 111 112 // if there is a bug, then there is a risk of the event loop never returning 113 testWithTimeout(t, 5*time.Second, test) 114 } 115 116 func Test_EventLoop_workerError(t *testing.T) { 117 test := func(t *testing.T) { 118 119 testBus := partybus.NewBus() 120 subscription := testBus.Subscribe() 121 t.Cleanup(testBus.Close) 122 123 workerErr := fmt.Errorf("worker error") 124 125 worker := func() <-chan error { 126 ret := make(chan error) 127 go func() { 128 t.Log("worker running") 129 // send an empty item (which is ignored) ensuring we've entered the select statement, 130 // then close (a partial shutdown). 131 ret <- nil 132 t.Log("worker sent nothing") 133 ret <- workerErr 134 t.Log("worker sent error") 135 close(ret) 136 t.Log("worker closed") 137 // note: NO final event is fired 138 }() 139 return ret 140 } 141 142 signaler := func() <-chan os.Signal { 143 return nil 144 } 145 146 ux := &uiMock{ 147 t: t, 148 } 149 150 // ensure the mock sees basic setup/teardown events 151 ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) 152 ux.On("Teardown").Return(nil) 153 154 var cleanupCalled bool 155 cleanupFn := func() { 156 t.Log("cleanup called") 157 cleanupCalled = true 158 } 159 160 // ensure we see an error returned 161 assert.ErrorIs(t, 162 EventLoop( 163 worker(), 164 signaler(), 165 subscription, 166 cleanupFn, 167 ux, 168 ), 169 workerErr, 170 "should have seen a worker error, but did not", 171 ) 172 173 assert.True(t, cleanupCalled, "cleanup function not called") 174 ux.AssertExpectations(t) 175 } 176 177 // if there is a bug, then there is a risk of the event loop never returning 178 testWithTimeout(t, 5*time.Second, test) 179 } 180 181 func Test_EventLoop_unsubscribeError(t *testing.T) { 182 test := func(t *testing.T) { 183 184 testBus := partybus.NewBus() 185 subscription := testBus.Subscribe() 186 t.Cleanup(testBus.Close) 187 188 finalEvent := partybus.Event{ 189 Type: event.CLIExit, 190 } 191 192 worker := func() <-chan error { 193 ret := make(chan error) 194 go func() { 195 t.Log("worker running") 196 // send an empty item (which is ignored) ensuring we've entered the select statement, 197 // then close (a partial shutdown). 198 ret <- nil 199 t.Log("worker sent nothing") 200 close(ret) 201 t.Log("worker closed") 202 // do the other half of the shutdown 203 testBus.Publish(finalEvent) 204 t.Log("worker published final event") 205 }() 206 return ret 207 } 208 209 signaler := func() <-chan os.Signal { 210 return nil 211 } 212 213 ux := &uiMock{ 214 t: t, 215 finalEvent: finalEvent, 216 } 217 218 // ensure the mock sees at least the final event... note the unsubscribe error here 219 ux.On("Handle", finalEvent).Return(partybus.ErrUnsubscribe) 220 // ensure the mock sees basic setup/teardown events 221 ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) 222 ux.On("Teardown").Return(nil) 223 224 var cleanupCalled bool 225 cleanupFn := func() { 226 t.Log("cleanup called") 227 cleanupCalled = true 228 } 229 230 // unsubscribe errors should be handled and ignored, not propagated. We are additionally asserting that 231 // this case is handled as a controlled shutdown (this test should not timeout) 232 assert.NoError(t, 233 EventLoop( 234 worker(), 235 signaler(), 236 subscription, 237 cleanupFn, 238 ux, 239 ), 240 ) 241 242 assert.True(t, cleanupCalled, "cleanup function not called") 243 ux.AssertExpectations(t) 244 } 245 246 // if there is a bug, then there is a risk of the event loop never returning 247 testWithTimeout(t, 5*time.Second, test) 248 } 249 250 func Test_EventLoop_handlerError(t *testing.T) { 251 test := func(t *testing.T) { 252 253 testBus := partybus.NewBus() 254 subscription := testBus.Subscribe() 255 t.Cleanup(testBus.Close) 256 257 finalEvent := partybus.Event{ 258 Type: event.CLIExit, 259 Error: fmt.Errorf("an exit error occured"), 260 } 261 262 worker := func() <-chan error { 263 ret := make(chan error) 264 go func() { 265 t.Log("worker running") 266 // send an empty item (which is ignored) ensuring we've entered the select statement, 267 // then close (a partial shutdown). 268 ret <- nil 269 t.Log("worker sent nothing") 270 close(ret) 271 t.Log("worker closed") 272 // do the other half of the shutdown 273 testBus.Publish(finalEvent) 274 t.Log("worker published final event") 275 }() 276 return ret 277 } 278 279 signaler := func() <-chan os.Signal { 280 return nil 281 } 282 283 ux := &uiMock{ 284 t: t, 285 finalEvent: finalEvent, 286 } 287 288 // ensure the mock sees at least the final event... note the event error is propagated 289 ux.On("Handle", finalEvent).Return(finalEvent.Error) 290 // ensure the mock sees basic setup/teardown events 291 ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) 292 ux.On("Teardown").Return(nil) 293 294 var cleanupCalled bool 295 cleanupFn := func() { 296 t.Log("cleanup called") 297 cleanupCalled = true 298 } 299 300 // handle errors SHOULD propagate the event loop. We are additionally asserting that this case is 301 // handled as a controlled shutdown (this test should not timeout) 302 assert.ErrorIs(t, 303 EventLoop( 304 worker(), 305 signaler(), 306 subscription, 307 cleanupFn, 308 ux, 309 ), 310 finalEvent.Error, 311 "should have seen a event error, but did not", 312 ) 313 314 assert.True(t, cleanupCalled, "cleanup function not called") 315 ux.AssertExpectations(t) 316 } 317 318 // if there is a bug, then there is a risk of the event loop never returning 319 testWithTimeout(t, 5*time.Second, test) 320 } 321 322 func Test_EventLoop_signalsStopExecution(t *testing.T) { 323 test := func(t *testing.T) { 324 325 testBus := partybus.NewBus() 326 subscription := testBus.Subscribe() 327 t.Cleanup(testBus.Close) 328 329 worker := func() <-chan error { 330 // the worker will never return work and the event loop will always be waiting... 331 return make(chan error) 332 } 333 334 signaler := func() <-chan os.Signal { 335 ret := make(chan os.Signal) 336 go func() { 337 ret <- syscall.SIGINT 338 // note: we do NOT close the channel to ensure the event loop does not depend on that behavior to exit 339 }() 340 return ret 341 } 342 343 ux := &uiMock{ 344 t: t, 345 } 346 347 // ensure the mock sees basic setup/teardown events 348 ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) 349 ux.On("Teardown").Return(nil) 350 351 var cleanupCalled bool 352 cleanupFn := func() { 353 t.Log("cleanup called") 354 cleanupCalled = true 355 } 356 357 assert.NoError(t, 358 EventLoop( 359 worker(), 360 signaler(), 361 subscription, 362 cleanupFn, 363 ux, 364 ), 365 ) 366 367 assert.True(t, cleanupCalled, "cleanup function not called") 368 ux.AssertExpectations(t) 369 } 370 371 // if there is a bug, then there is a risk of the event loop never returning 372 testWithTimeout(t, 5*time.Second, test) 373 } 374 375 func Test_EventLoop_uiTeardownError(t *testing.T) { 376 test := func(t *testing.T) { 377 378 testBus := partybus.NewBus() 379 subscription := testBus.Subscribe() 380 t.Cleanup(testBus.Close) 381 382 finalEvent := partybus.Event{ 383 Type: event.CLIExit, 384 } 385 386 worker := func() <-chan error { 387 ret := make(chan error) 388 go func() { 389 t.Log("worker running") 390 // send an empty item (which is ignored) ensuring we've entered the select statement, 391 // then close (a partial shutdown). 392 ret <- nil 393 t.Log("worker sent nothing") 394 close(ret) 395 t.Log("worker closed") 396 // do the other half of the shutdown 397 testBus.Publish(finalEvent) 398 t.Log("worker published final event") 399 }() 400 return ret 401 } 402 403 signaler := func() <-chan os.Signal { 404 return nil 405 } 406 407 ux := &uiMock{ 408 t: t, 409 finalEvent: finalEvent, 410 } 411 412 teardownError := fmt.Errorf("sorry, dave, the UI doesn't want to be torn down") 413 414 // ensure the mock sees at least the final event... note the event error is propagated 415 ux.On("Handle", finalEvent).Return(nil) 416 // ensure the mock sees basic setup/teardown events 417 ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) 418 ux.On("Teardown").Return(teardownError) 419 420 var cleanupCalled bool 421 cleanupFn := func() { 422 t.Log("cleanup called") 423 cleanupCalled = true 424 } 425 426 // ensure we see an error returned 427 assert.ErrorIs(t, 428 EventLoop( 429 worker(), 430 signaler(), 431 subscription, 432 cleanupFn, 433 ux, 434 ), 435 teardownError, 436 "should have seen a UI teardown error, but did not", 437 ) 438 439 assert.True(t, cleanupCalled, "cleanup function not called") 440 ux.AssertExpectations(t) 441 } 442 443 // if there is a bug, then there is a risk of the event loop never returning 444 testWithTimeout(t, 5*time.Second, test) 445 } 446 447 func testWithTimeout(t *testing.T, timeout time.Duration, test func(*testing.T)) { 448 done := make(chan bool) 449 go func() { 450 test(t) 451 done <- true 452 }() 453 454 select { 455 case <-time.After(timeout): 456 t.Fatal("test timed out") 457 case <-done: 458 } 459 }