module EasyTest.Porcelain
(
Test
, expect
, expectJust
, expectRight
, expectEq
, tests
, using
, runOnly
, rerunOnly
, run
, rerun
, scope
, note'
, ok
, skip
, fork
, fork'
, crash
, note
, io
) where
import Control.Applicative
import Control.Concurrent
import Control.Concurrent.STM
import Control.Exception
import Control.Monad
import Control.Monad.IO.Class
import Control.Monad.Reader
import Data.Semigroup
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import GHC.Stack
import System.Exit
import qualified Control.Concurrent.Async as A
import qualified Data.Map as Map
import qualified System.Random as Random
import EasyTest.Internal
io :: IO a -> Test a
io = liftIO
expect :: HasCallStack => Bool -> Test ()
expect False = crash "unexpected"
expect True = ok
expectJust :: HasCallStack => Maybe a -> Test a
expectJust Nothing = crash "expected Just, got Nothing"
expectJust (Just a) = ok >> pure a
expectRight :: HasCallStack => Either e a -> Test a
expectRight (Left _) = crash "expected Right, got Left"
expectRight (Right a) = ok >> pure a
expectEq :: (Eq a, Show a, HasCallStack) => a -> a -> Test ()
expectEq x y = if x == y then ok else crash $
"expected to be equal: (" <> show' x <> "), (" <> show' y <> ")"
tests :: [Test ()] -> Test ()
tests = msum
atomicLogger :: IO (Text -> IO ())
atomicLogger = do
lock <- newMVar ()
pure $ \msg ->
let dummy = T.foldl' (\_ ch -> ch == 'a') True msg
in dummy `seq` bracket (takeMVar lock) (\_ -> putMVar lock ()) (\_ -> T.putStrLn msg)
using :: IO r -> (r -> IO ()) -> (r -> Test a) -> Test a
using r cleanup use = Test $ do
r' <- liftIO r
env <- ask
let Test t = use r'
a <- liftIO (runWrap env t)
liftIO (cleanup r')
pure a
runOnly :: Text -> Test a -> IO ()
runOnly prefix t = do
logger <- atomicLogger
seed <- abs <$> Random.randomIO :: IO Int
let allowed = filter (not . T.null) $ T.splitOn "." prefix
run' seed logger allowed t
rerunOnly :: Int -> Text -> Test a -> IO ()
rerunOnly seed prefix t = do
logger <- atomicLogger
let allowed = filter (not . T.null) $ T.splitOn "." prefix
run' seed logger allowed t
run :: Test a -> IO ()
run = runOnly ""
rerun :: Int -> Test a -> IO ()
rerun seed = rerunOnly seed ""
run' :: Int -> (Text -> IO ()) -> [Text] -> Test a -> IO ()
run' seed note_ allowed (Test t) = do
let !rng_ = Random.mkStdGen seed
resultsQ <- atomically (newTBQueue 50)
rngVar <- newTVarIO rng_
note_ $ "Randomness seed for this run is " <> show' seed <> ""
results <- atomically $ newTVar Map.empty
rs <- A.async . forever $ do
Just result <- atomically $ readTBQueue resultsQ
(msgs, passed) <- atomically $ takeTMVar result
let msgs' = T.intercalate "." msgs
atomically $ modifyTVar results (Map.insertWith combineStatus msgs' passed)
resultsMap <- readTVarIO results
case Map.findWithDefault Skipped msgs' resultsMap of
Skipped -> pure ()
Passed n -> note_ $ "OK " <> (if n <= 1 then msgs' else "(" <> show' n <> ") " <> msgs')
Failed -> note_ $ "FAILED " <> msgs'
let line = "------------------------------------------------------------"
note_ "Raw test output to follow ... "
note_ line
result <- try (runReaderT (void t) (Env rngVar [] resultsQ note_ allowed))
:: IO (Either SomeException ())
case result of
Left e -> note_ $ "Exception while running tests: " <> show' e
Right () -> pure ()
atomically $ writeTBQueue resultsQ Nothing
_ <- A.waitCatch rs
resultsMap <- readTVarIO results
let
resultsList = Map.toList resultsMap
succeededList = [ n | (_, Passed n) <- resultsList ]
succeeded = length succeededList
failures = [ a | (a, Failed) <- resultsList ]
failed = length failures
case failures of
[] -> do
note_ line
case succeeded of
0 -> do
note_ $ T.unlines
[ "😶 hmm ... no test results recorded"
, "Tip: use `ok`, `expect`, or `crash` to record results"
, "Tip: if running via `runOnly` or `rerunOnly`, check for typos"
]
1 -> note_ "✅ 1 test passed, no failures! 👍 🎉"
_ -> note_ $ "✅ " <> show' succeeded <> " tests passed, no failures! 👍 🎉"
hd:_ -> do
note_ $ T.unlines
[ line
, "\n"
, " " <> show' succeeded <> (if failed == 0 then " PASSED" else " passed")
, " " <> show' (length failures) <> (if failed == 0 then " failed" else " FAILED (failed scopes below)")
, " " <> T.intercalate "\n " (map show' failures)
, ""
, " To rerun with same random seed:\n"
, " EasyTest.rerun " <> show' seed
, " EasyTest.rerunOnly " <> show' seed <> " " <> "\"" <> hd <> "\""
, "\n"
, line
, "❌"
]
exitWith (ExitFailure 1)
show' :: Show a => a -> Text
show' = T.pack . show
note' :: Show s => s -> Test ()
note' = note . show'
ok :: Test ()
ok = Test (Just <$> putResult (Passed 1))
skip :: Test ()
skip = Test (Nothing <$ putResult Skipped)
fork :: Test a -> Test ()
fork t = void (fork' t)
fork' :: Test a -> Test (Test a)
fork' (Test t) = do
env <- ask
tmvar <- liftIO newEmptyTMVarIO
liftIO . atomically $ writeTBQueue (envResults env) (Just tmvar)
r <- liftIO . A.async $ runWrap env t
waiter <- liftIO . A.async $ do
e <- A.waitCatch r
_ <- atomically $ tryPutTMVar tmvar (envMessages env, Skipped)
case e of
Left _ -> pure Nothing
Right a -> pure a
pure $ do
a <- liftIO (A.wait waiter)
case a of Nothing -> empty
Just a' -> pure a'