diff --git a/.github/workflows/haskell.yml b/.github/workflows/haskell.yml index aafab6bf..f240dfb2 100644 --- a/.github/workflows/haskell.yml +++ b/.github/workflows/haskell.yml @@ -20,7 +20,7 @@ jobs: env: # Modify this value to "invalidate" the cabal cache. - CABAL_CACHE_VERSION: "2022-12-30" + CABAL_CACHE_VERSION: "2024-05-01-1" strategy: fail-fast: false diff --git a/cabal.project b/cabal.project index e6fdbadb..d2b2c4c1 100644 --- a/cabal.project +++ b/cabal.project @@ -1 +1,4 @@ packages: . + +test-show-details: direct + diff --git a/hedgehog-extras.cabal b/hedgehog-extras.cabal index 0836d6df..85cb020d 100644 --- a/hedgehog-extras.cabal +++ b/hedgehog-extras.cabal @@ -131,18 +131,26 @@ library Hedgehog.Extras.Test.MonadAssertion Hedgehog.Extras.Test.Network Hedgehog.Extras.Test.Process + Hedgehog.Extras.Test.TestWatchdog + Hedgehog.Extras.Test.Tripwire test-suite hedgehog-extras-test import: base, project-config, hedgehog, hedgehog-extras, network, + process, + resourcet, tasty, tasty-hedgehog, + transformers, + time, hs-source-dirs: test main-is: hedgehog-extras-test.hs type: exitcode-stdio-1.0 other-modules: Hedgehog.Extras.Stock.IO.Network.PortSpec + Hedgehog.Extras.Test.TestWatchdogSpec build-tool-depends: tasty-discover:tasty-discover + ghc-options: -threaded -rtsopts "-with-rtsopts=-N -T" diff --git a/src/Hedgehog/Extras/Test.hs b/src/Hedgehog/Extras/Test.hs index 0c0dc331..4d7275b3 100644 --- a/src/Hedgehog/Extras/Test.hs +++ b/src/Hedgehog/Extras/Test.hs @@ -2,9 +2,11 @@ module Hedgehog.Extras.Test ( module X ) where -import Hedgehog.Extras.Test.Base as X -import Hedgehog.Extras.Test.Concurrent as X -import Hedgehog.Extras.Test.File as X -import Hedgehog.Extras.Test.MonadAssertion as X -import Hedgehog.Extras.Test.Network as X -import Hedgehog.Extras.Test.Process as X +import Hedgehog.Extras.Test.Base as X +import Hedgehog.Extras.Test.Concurrent as X +import Hedgehog.Extras.Test.File as X +import Hedgehog.Extras.Test.MonadAssertion as X +import Hedgehog.Extras.Test.Network as X +import Hedgehog.Extras.Test.Process as X +import Hedgehog.Extras.Test.TestWatchdog as X +import Hedgehog.Extras.Test.Tripwire as X diff --git a/src/Hedgehog/Extras/Test/Concurrent.hs b/src/Hedgehog/Extras/Test/Concurrent.hs index f3dcb01c..e24720cf 100644 --- a/src/Hedgehog/Extras/Test/Concurrent.hs +++ b/src/Hedgehog/Extras/Test/Concurrent.hs @@ -66,14 +66,17 @@ __Don't use concurrency abstractions from this module, when you need to aggregat -} module Hedgehog.Extras.Test.Concurrent ( threadDelay + , asyncRegister_ -- * Re-exports of concurrency abstractions from @lifted-base@ , module Control.Concurrent.Async.Lifted + , module Control.Concurrent.MVar.Lifted , module System.Timeout.Lifted ) where import Control.Applicative import Control.Concurrent.Async.Lifted import qualified Control.Concurrent.Lifted as IO +import Control.Concurrent.MVar.Lifted import Control.Monad.Base import Control.Monad.IO.Class import Control.Monad.Trans.Control @@ -85,13 +88,28 @@ import System.IO (IO) import System.Timeout.Lifted import qualified UnliftIO +import Control.Monad +import Control.Monad.Catch (MonadCatch) +import GHC.Stack import Hedgehog import qualified Hedgehog as H -- | Delay the thread by 'n' milliseconds. -threadDelay :: (MonadTest m, MonadIO m) => Int -> m () +threadDelay :: (HasCallStack, MonadTest m, MonadIO m) => Int -> m () threadDelay n = GHC.withFrozenCallStack . H.evalIO $ IO.threadDelay n +-- | Runs an action in background, and registers its cancellation to 'MonadResource'. +asyncRegister_ :: HasCallStack + => MonadTest m + => MonadResource m + => MonadCatch m + => IO a -- ^ Action to run in background + -> m () +asyncRegister_ act = GHC.withFrozenCallStack $ void . H.evalM $ allocate (async act) cleanUp + where + cleanUp :: Async a -> IO () + cleanUp a = cancel a >> void (link a) + instance MonadBase IO (ResourceT IO) where liftBase = liftIO diff --git a/src/Hedgehog/Extras/Test/File.hs b/src/Hedgehog/Extras/Test/File.hs index 0d9a2e9f..8bc089b1 100644 --- a/src/Hedgehog/Extras/Test/File.hs +++ b/src/Hedgehog/Extras/Test/File.hs @@ -48,6 +48,7 @@ module Hedgehog.Extras.Test.File , appendFileTimeDelta , assertDirectoryMissing + , assertDirectoryExists ) where import Control.Applicative (Applicative (..)) diff --git a/src/Hedgehog/Extras/Test/TestWatchdog.hs b/src/Hedgehog/Extras/Test/TestWatchdog.hs new file mode 100644 index 00000000..87935a7b --- /dev/null +++ b/src/Hedgehog/Extras/Test/TestWatchdog.hs @@ -0,0 +1,175 @@ +{-# LANGUAGE ExistentialQuantification #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE NumericUnderscores #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE TypeApplications #-} + +-- | This module provides a test watchdog - an utility monitoring test cases and killing them if they don't +-- finish in time. 'Watchdog' thread runs in the background, and after specified timeout, it throws +-- 'WatchdogException' to the target thread. A user is able to 'kickWatchdog', which delays the killing and +-- 'poisonWatchdog' which stops the watchdog. +-- +-- To wrap a test case in a watchdog just use +-- +-- @ +-- runWithWatchdog watchdogConfig $ \\watchdog -> do +-- -- body of your test case +-- @ +-- +module Hedgehog.Extras.Test.TestWatchdog + ( + -- * Wrap in watchdog + runWithWatchdog_ + , runWithWatchdog + , runWithDefaultWatchdog_ + , runWithDefaultWatchdog + + -- * Watchdog control + , kickWatchdog + , poisonWatchdog + + -- * Types + , Watchdog + , WatchdogConfig(..) + , WatchdogException(..) + + -- * Low level API + -- | There is also a lower-level API available, giving the ability to provide target thread ID, which watchdog + -- will try to kill. + + , makeWatchdog + , runWatchdog + ) where + +import Control.Concurrent (myThreadId, threadDelay, throwTo) +import Control.Concurrent.STM (atomically) +import Control.Concurrent.STM.TChan (TChan, newTChanIO, tryReadTChan, writeTChan) +import Control.Exception (Exception) +import Control.Monad.IO.Class +import Data.Time (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime, + nominalDiffTimeToSeconds) +import GHC.Conc (ThreadId) +import GHC.Stack + +import Control.Monad.Base (MonadBase (..)) +import Control.Monad.Trans.Control (MonadBaseControl) +import qualified Hedgehog.Extras.Test.Concurrent as H +import Prelude + +-- | Configuration for the watchdog. +newtype WatchdogConfig = WatchdogConfig + { watchdogTimeout :: Int -- ^ Timeout in seconds after which watchdog will kill the test case + } + +-- | Default watchdog configuration with 10 minutes timeout. +defaultWatchdogConfig :: WatchdogConfig +defaultWatchdogConfig = WatchdogConfig + { watchdogTimeout = 600 + } + +-- | A watchdog instance. See the module header for more detailed description. +data Watchdog = Watchdog + { watchdogConfig :: !WatchdogConfig + , watchedThreadId :: !ThreadId -- ^ monitored thread id + , startTime :: !UTCTime -- ^ watchdog creation time + , kickChan :: TChan WatchdogCommand -- ^ a queue of watchdog commands + } + +instance Show Watchdog where + show Watchdog{watchdogConfig=WatchdogConfig{watchdogTimeout}, startTime, watchedThreadId} = mconcat + [ "Watchdog with timeout ", show watchdogTimeout + , ", started at ", show startTime + , ", watching thread ID ", show watchedThreadId + ] + +-- | Create manually a new watchdog, providing the target thread ID. After all watchdog timeouts expire, +-- the target thread will get 'WatchdogException' thrown to it asynchronously (using 'throwTo'). +makeWatchdog :: MonadBase IO m + => WatchdogConfig + -> ThreadId -- ^ thread id which will get killed after all kicks expire + -> m Watchdog +makeWatchdog config watchedThreadId' = liftBase $ do + watchdog <- Watchdog config watchedThreadId' <$> getCurrentTime <*> newTChanIO + kickWatchdog watchdog + pure watchdog + +-- | Run watchdog in a loop in the current thread. Usually this function should be used with 'H.withAsync' +-- to run it in the background. +runWatchdog :: MonadBase IO m + => Watchdog + -> m () +runWatchdog w@Watchdog{watchedThreadId, startTime, kickChan} = liftBase $ do + atomically (tryReadTChan kickChan) >>= \case + Just PoisonPill -> + -- deactivate watchdog + pure () + Just (Kick timeout) -> do + -- got a kick, wait for another period + threadDelay $ timeout * 1_000_000 + runWatchdog w + Nothing -> do + -- we are out of scheduled timeouts, kill the monitored thread + currentTime <- getCurrentTime + throwTo watchedThreadId . WatchdogException $ diffUTCTime currentTime startTime + +-- | Watchdog command +data WatchdogCommand + = Kick !Int -- ^ Add another delay in seconds + | PoisonPill -- ^ Stop the watchdog + +-- | Enqueue a kick for the watchdog. It will extend the timeout by another one defined in the watchdog +-- configuration. +kickWatchdog :: MonadIO m => Watchdog -> m () +kickWatchdog Watchdog{watchdogConfig=WatchdogConfig{watchdogTimeout}, kickChan} = liftIO $ + atomically $ writeTChan kickChan (Kick watchdogTimeout) + +-- | Enqueue a poison pill for the watchdog. It will stop the watchdog after all timeouts. +poisonWatchdog :: MonadIO m => Watchdog -> m () +poisonWatchdog Watchdog{kickChan} = liftIO $ + atomically $ writeTChan kickChan PoisonPill + + +-- | Execute a test case with a watchdog. +runWithWatchdog :: HasCallStack + => MonadBaseControl IO m + => WatchdogConfig -- ^ configuration + -> (HasCallStack => Watchdog -> m a) -- ^ a test case to be wrapped in watchdog + -> m a +runWithWatchdog config testCase = do + watchedThreadId <- liftBase myThreadId + watchdog <- liftBase $ makeWatchdog config watchedThreadId + H.withAsync (runWatchdog watchdog) $ + \_ -> testCase watchdog + +-- | Execute a test case with a watchdog. +runWithWatchdog_ :: HasCallStack + => MonadBaseControl IO m + => WatchdogConfig -- ^ configuration + -> (HasCallStack => m a) -- ^ a test case to be wrapped in watchdog + -> m a +runWithWatchdog_ config testCase = runWithWatchdog config (const testCase) + +-- | Execute a test case with watchdog with default config. +runWithDefaultWatchdog :: HasCallStack + => MonadBaseControl IO m + => (HasCallStack => Watchdog -> m a) -- ^ a test case to be wrapped in watchdog + -> m a +runWithDefaultWatchdog = runWithWatchdog defaultWatchdogConfig + +-- | Execute a test case with watchdog with default config. +runWithDefaultWatchdog_ :: HasCallStack + => MonadBaseControl IO m + => (HasCallStack => m a) -- ^ a test case to be wrapped in watchdog + -> m a +runWithDefaultWatchdog_ testCase = runWithDefaultWatchdog (const testCase) + +-- | An exception thrown to the test case thread. +newtype WatchdogException = WatchdogException { timeElapsed :: NominalDiffTime } + +instance Show WatchdogException where + show WatchdogException{timeElapsed} = + "WatchdogException: Test watchdog killed test case thread after " <> show @Int (round $ nominalDiffTimeToSeconds timeElapsed) <> " seconds." + +instance Exception WatchdogException diff --git a/src/Hedgehog/Extras/Test/Tripwire.hs b/src/Hedgehog/Extras/Test/Tripwire.hs new file mode 100644 index 00000000..44eb9443 --- /dev/null +++ b/src/Hedgehog/Extras/Test/Tripwire.hs @@ -0,0 +1,128 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE RankNTypes #-} + +-- | This modules provides a tripwire abstraction. You can use tripwire as a detection mechanism if the code +-- path was executed. Trip a tripwire with 'trip' in the place where you'd like to detect if it was +-- reached. The tripwire can then be checked in the other place in the code using for example 'isTripped' or +-- 'assertNotTripped'. +module Hedgehog.Extras.Test.Tripwire + ( + -- * Create a tripwire + Tripwire + , makeTripwire + , makeTripwireWithLabel + -- * Tripwire operations + , trip + , trip_ + , isTripped + , getTripSite + , resetTripwire + -- * Assertions + , assertNotTripped + , assertTripped + ) where + +import Control.Monad.IO.Class +import GHC.Stack + +import Control.Concurrent.MVar +import Control.Monad +import Data.IORef +import Data.Maybe +import Hedgehog (MonadTest) +import qualified Hedgehog.Extras.Test.Base as H +import qualified Hedgehog.Internal.Property as H +import Prelude +import System.IO.Unsafe (unsafePerformIO) + +-- | Counter used to allocate consecutive IDs to tripwires +tripwireCounter :: IORef Int +tripwireCounter = unsafePerformIO $ newIORef 0 +{-# NOINLINE tripwireCounter #-} + +-- | Represents a tripwire which can be tripped only once. It can be used to detect if a particular code path +-- was reached. +data Tripwire = Tripwire + { tripwireId :: !String -- ^ a label for identifying the tripwire + , tripSite :: MVar CallStack -- ^ call stack of the trip site + } + +instance Show Tripwire where + show Tripwire{tripwireId} = "Tripwire " <> tripwireId + +-- | Creates a new tripwire +makeTripwire :: MonadIO m => m Tripwire +makeTripwire = liftIO $ do + id' <- atomicModifyIORef' tripwireCounter (join (,) . (+1)) + Tripwire (show id') <$> newEmptyMVar + +-- | Creates a new tripwire with a label, which is visible when 'show'ed: @Tripwire mylabel@ +makeTripwireWithLabel :: MonadIO m + => String + -> m Tripwire +makeTripwireWithLabel label = liftIO $ do + Tripwire label <$> newEmptyMVar + +-- | Triggers the tripwire and registers the place of the first trigger. Idempotent. +-- Prints the information in the test log about tripping the tripwire. +trip :: HasCallStack + => MonadIO m + => MonadTest m + => Tripwire + -> m () +trip t@Tripwire{tripSite} = withFrozenCallStack $ do + H.note_ $ show t <> " has been tripped" + void . liftIO $ tryPutMVar tripSite callStack + +-- | Triggers the tripwire and registers the place of the first trigger. Idempotent. A silent variant of +-- 'trip' which does not require 'MonadTest', but also does not log the information about tripping. +trip_ :: HasCallStack + => MonadIO m + => Tripwire + -> m () +trip_ Tripwire{tripSite} = withFrozenCallStack $ do + void . liftIO $ tryPutMVar tripSite callStack + +-- | Restore tripwire to initial non triggered state +resetTripwire :: MonadIO m + => Tripwire + -> m () +resetTripwire Tripwire{tripSite} = liftIO $ void $ tryTakeMVar tripSite + +-- | Return the call stack, where the tripwire was tripped - if it was tripped. +getTripSite :: MonadIO m + => Tripwire + -> m (Maybe CallStack) +getTripSite Tripwire{tripSite} = liftIO $ tryReadMVar tripSite + +-- | Check if the tripwire was tripped. +isTripped :: MonadIO m + => Tripwire + -> m Bool +isTripped Tripwire{tripSite} = liftIO $ not <$> isEmptyMVar tripSite + +-- | Fails the test if the tripwire was triggered. Prints the call stack where the tripwire was triggered. +assertNotTripped :: HasCallStack + => MonadTest m + => MonadIO m + => Tripwire + -> m () +assertNotTripped tripwire = withFrozenCallStack $ do + mTripSite <- getTripSite tripwire + forM_ mTripSite $ \cs -> do + H.note_ $ show tripwire <> " has been tripped at: " <> prettyCallStack cs + H.failure + +-- | Fails the test if the tripwire was not triggered yet. +assertTripped :: HasCallStack + => MonadTest m + => MonadIO m + => Tripwire + -> m () +assertTripped tripwire = withFrozenCallStack $ do + mTripSite <- getTripSite tripwire + when (isNothing mTripSite) $ do + H.note_ $ show tripwire <> " was not tripped" + H.failure + + diff --git a/test/Hedgehog/Extras/Stock/IO/Network/PortSpec.hs b/test/Hedgehog/Extras/Stock/IO/Network/PortSpec.hs index e918b474..cb750da6 100644 --- a/test/Hedgehog/Extras/Stock/IO/Network/PortSpec.hs +++ b/test/Hedgehog/Extras/Stock/IO/Network/PortSpec.hs @@ -10,6 +10,9 @@ import qualified Hedgehog.Extras as H import qualified Hedgehog.Extras.Stock.IO.Network.Port as IO import qualified Network.Socket as N import Text.Show +import qualified Data.Time as D +import Control.Monad.IO.Class +import Control.Applicative hprop_randomPort :: Property hprop_randomPort = @@ -20,7 +23,11 @@ hprop_randomPort = H.note_ $ "Allocated port: " <> show pn - -- Check that the port is available and can be bound to a socket. - sock <- H.evalIO $ N.socket N.AF_INET N.Stream N.defaultProtocol - H.evalIO $ N.bind sock $ N.SockAddrInet pn hostAddress - H.evalIO $ N.close sock + -- retry binding for 5 seconds - seems that sometimes OS still marks port as unavailable for a while + -- after 'randomPort' call + deadline <- D.addUTCTime 5 <$> liftIO D.getCurrentTime + H.byDeadlineM 0.2 deadline "try binding to allocated port" $ do + -- Check that the port is available and can be bound to a socket. + sock <- H.evalIO $ N.socket N.AF_INET N.Stream N.defaultProtocol + H.evalIO $ N.bind sock $ N.SockAddrInet pn hostAddress + H.evalIO $ N.close sock diff --git a/test/Hedgehog/Extras/Test/TestWatchdogSpec.hs b/test/Hedgehog/Extras/Test/TestWatchdogSpec.hs new file mode 100644 index 00000000..ab303b7b --- /dev/null +++ b/test/Hedgehog/Extras/Test/TestWatchdogSpec.hs @@ -0,0 +1,187 @@ +{-# LANGUAGE NumericUnderscores #-} +{-# LANGUAGE LambdaCase #-} + +module Hedgehog.Extras.Test.TestWatchdogSpec where + +import Control.Concurrent +import Control.Monad +import Control.Monad.IO.Class +import Control.Monad.Trans.Except (runExceptT) +import Control.Monad.Trans.Resource (ResourceT, runResourceT) +import Control.Monad.Trans.Writer.Lazy (runWriterT) +import Data.Function +import Data.List (isPrefixOf) +import Data.Time.Clock as D +import GHC.Conc +import GHC.Stack +import Hedgehog (Property, (===)) +import qualified Hedgehog as H +import qualified Hedgehog.Extras as H +import qualified Hedgehog.Extras.Stock as H +import Hedgehog.Extras.Test.TestWatchdog +import qualified Hedgehog.Internal.Property as H +import Prelude +import qualified System.Process as P + +-- | Check that watchdog kills test case which waits without an end +hprop_check_watchdog_kills_hanged_thread :: Property +hprop_check_watchdog_kills_hanged_thread = H.propertyOnce $ do + let watchdogCfg = WatchdogConfig 1 + childTid <- H.newEmptyMVar + tripwire <- H.makeTripwire + + (result, _) <- spawnTestT $ runWithWatchdog_ watchdogCfg $ do + liftIO $ myThreadId >>= H.putMVar childTid + -- simulate thread hang + void $ H.threadDelay 3_000_000 + H.trip tripwire + + assertWatchdogExceptionWasRaised result + + -- make sure that we didn't trigger the tripwire + H.assertNotTripped tripwire + childStatus <- liftIO $ H.readMVar childTid >>= threadStatus + childStatus === ThreadFinished + +-- | Check that watchdog kills test case which spawns: +-- - a process waiting forever +-- - a child threads waiting +hprop_check_watchdog_kills_hanged_thread_with_its_children :: Property +hprop_check_watchdog_kills_hanged_thread_with_its_children = H.propertyOnce $ do + let watchdogCfg = WatchdogConfig 1 + childTid <- H.newEmptyMVar + grandChildTid1 <- H.newEmptyMVar + grandChildTid2 <- H.newEmptyMVar + procHandle <- H.newEmptyMVar + childTripwire <- H.makeTripwire + grandChildTripwire1 <- H.makeTripwire + grandChildTripwire2 <- H.makeTripwire + + (result, _) <- spawnTestT $ runWithWatchdog_ watchdogCfg $ do + liftIO $ myThreadId >>= H.putMVar childTid + + unless H.isWin32 $ do + (_, _, _, h, _) <- H.createProcess $ P.shell "tail -f /dev/null" + H.putMVar procHandle h + + H.asyncRegister_ $ do + liftIO $ myThreadId >>= H.putMVar grandChildTid1 + threadDelay 3_000_000 + H.trip_ grandChildTripwire1 + + H.asyncRegister_ $ do + liftIO $ myThreadId >>= H.putMVar grandChildTid2 + threadDelay 3_000_000 + H.trip_ grandChildTripwire2 + + void $ H.threadDelay 3_000_000 + H.trip childTripwire + + -- make sure that we didn't trigger the tripwire + H.assertNotTripped childTripwire + H.assertNotTripped grandChildTripwire1 + H.assertNotTripped grandChildTripwire2 + + assertWatchdogExceptionWasRaised result + + -- Give OS 5 seconds to do the process cleanup + deadline <- D.addUTCTime 5 <$> liftIO D.getCurrentTime + + H.byDeadlineM 0.2 deadline "childStatus" $ do + childStatus <- liftIO $ H.readMVar childTid >>= threadStatus + childStatus === ThreadFinished + + H.byDeadlineM 0.2 deadline "grandChildStatus1" $ do + grandChildStatus1 <- liftIO $ H.readMVar grandChildTid1 >>= threadStatus + grandChildStatus1 === ThreadFinished + + H.byDeadlineM 0.2 deadline "grandChildStatus2" $ do + grandChildStatus2 <- liftIO $ H.readMVar grandChildTid2 >>= threadStatus + grandChildStatus2 === ThreadFinished + + -- check that tail process got killed + unless H.isWin32 $ + H.byDeadlineM 0.2 deadline "tailPid" $ do + tailPid <- liftIO $ H.readMVar procHandle >>= P.getPid + tailPid === Nothing + +hprop_check_asyncRegister_finishes_with_test :: Property +hprop_check_asyncRegister_finishes_with_test = H.propertyOnce $ do + childTid <- H.newEmptyMVar + grandChildTid1 <- H.newEmptyMVar + grandChildTid2 <- H.newEmptyMVar + childTripwire <- H.makeTripwire + grandChildTripwire1 <- H.makeTripwire + grandChildTripwire2 <- H.makeTripwire + + -- test that asyncRegister_ gets killed when the main thread finishes + (result, _) <- spawnTestT $ do + liftIO $ myThreadId >>= H.putMVar childTid + + H.asyncRegister_ $ do + liftIO $ myThreadId >>= H.putMVar grandChildTid1 + threadDelay 10_000_000 + H.trip_ grandChildTripwire1 + + H.asyncRegister_ $ do + liftIO $ myThreadId >>= H.putMVar grandChildTid2 + threadDelay 10_000_000 + H.trip_ grandChildTripwire2 + + H.trip_ childTripwire + + result === Right () + -- double check that main thread finished successfully + H.assertTripped childTripwire + H.assertNotTripped grandChildTripwire1 + H.assertNotTripped grandChildTripwire2 + + -- Give OS 5 seconds to do the process cleanup + deadline <- D.addUTCTime 5 <$> liftIO D.getCurrentTime + + H.byDeadlineM 0.2 deadline "childStatus" $ do + childStatus <- liftIO $ H.readMVar childTid >>= threadStatus + childStatus === ThreadFinished + + H.byDeadlineM 0.2 deadline "grandChildStatus1" $ do + grandChildStatus1 <- liftIO $ H.readMVar grandChildTid1 >>= threadStatus + grandChildStatus1 === ThreadFinished + + H.byDeadlineM 0.2 deadline "grandChildStatus2" $ do + grandChildStatus2 <- liftIO $ H.readMVar grandChildTid2 >>= threadStatus + grandChildStatus2 === ThreadFinished + + +assertWatchdogExceptionWasRaised :: HasCallStack + => H.MonadTest m + => MonadFail m + => Either H.Failure a + -> m () +assertWatchdogExceptionWasRaised = withFrozenCallStack $ \case + Right _ -> do + H.note_ "Expected failure instead of Right" + H.failure + Left (H.Failure _ msg _) -> do + -- check we've failed because of watchdog + _header:exception:_ <- pure $ lines msg + H.note_ $ "Received exception:" + H.assertWith exception $ + isPrefixOf "WatchdogException: " + + +-- | Spawn TestT in an async. Waits for the async and logs the result as well as errors journal on failure +spawnTestT :: HasCallStack + => H.MonadTest m + => MonadIO m + => Show a + => H.TestT (ResourceT IO) a + -> m (Either H.Failure a, H.Journal) +spawnTestT testt = withFrozenCallStack $ do + (res, log') <- H.evalIO $ + H.withAsync + (runResourceT . runWriterT . runExceptT $ H.unTest testt) + H.wait + H.noteShow_ res + H.noteShow_ log' + pure (res, log') + diff --git a/test/hedgehog-extras-test.hs b/test/hedgehog-extras-test.hs index e1cee342..70c55f52 100644 --- a/test/hedgehog-extras-test.hs +++ b/test/hedgehog-extras-test.hs @@ -1 +1 @@ -{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --hide-successes #-} +{-# OPTIONS_GHC -F -pgmF tasty-discover #-}