packages feed

tasty-silver-3.0: Test/Tasty/Silver/Manage.hs

{-# LANGUAGE GeneralizedNewtypeDeriving, PatternGuards, DeriveDataTypeable #-}
-- | Golden test management
module Test.Tasty.Silver.Manage
  (
  -- * Command line helpers
    defaultMain

  -- * The ingredient
  , acceptingTests
  , AcceptTests(..)

  -- * Programmatic API
  , acceptGoldenTests
  )
  where

import Test.Tasty hiding (defaultMain)
import Test.Tasty.Runners
import Test.Tasty.Options
import Test.Tasty.Silver.Internal
import Data.Typeable
import Data.Tagged
import Data.Proxy
import Data.Maybe
import Control.Monad.Cont
import Control.Monad.State
import Control.Exception
import Control.Concurrent.Async
import Text.Printf
import qualified Data.Text as T
import Data.Text.Encoding
import Options.Applicative
import System.Process.ByteString.Lazy as PL
import System.Process
import qualified Data.ByteString.Lazy as B
import qualified Data.ByteString as BS
import System.IO
import System.IO.Temp
import System.FilePath

-- | Like @defaultMain@ from the main tasty package, but also includes the
-- golden test management capabilities.
defaultMain :: TestTree -> IO ()
defaultMain = defaultMainWithIngredients [interactiveTests, acceptingTests, listingTests, consoleTestReporter]

-- | This option, when set to 'True', specifies that we should run in the
-- «accept tests» mode
newtype AcceptTests = AcceptTests Bool
  deriving (Eq, Ord, Typeable)
instance IsOption AcceptTests where
  defaultValue = AcceptTests False
  parseValue = fmap AcceptTests . safeRead
  optionName = return "accept"
  optionHelp = return "Accept current results of golden tests"
  optionCLParser =
    fmap AcceptTests $
    switch
      (  long (untag (optionName :: Tagged AcceptTests String))
      <> help (untag (optionHelp :: Tagged AcceptTests String))
      )

newtype Interactive = Interactive Bool
  deriving (Eq, Ord, Typeable)
instance IsOption Interactive where
  defaultValue = Interactive False
  parseValue = fmap Interactive . safeRead
  optionName = return "interactive"
  optionHelp = return "Run tests in interactive mode."
  optionCLParser =
    fmap Interactive $
    switch
      (  long (untag (optionName :: Tagged Interactive String))
      <> help (untag (optionHelp :: Tagged Interactive String))
      )


acceptingTests :: Ingredient
acceptingTests = TestManager [Option (Proxy :: Proxy AcceptTests)] $
  \opts tree ->
    case lookupOption opts of
      AcceptTests False -> Nothing
      AcceptTests True -> Just $
        acceptGoldenTests opts tree

interactiveTests :: Ingredient
interactiveTests = TestManager [Option (Proxy :: Proxy Interactive)] $
  \opts tree ->
    case lookupOption opts of
      Interactive False -> Nothing
      Interactive True -> Just $
        runTestsInteractive opts tree

-- | Get the list of all golden tests in a given test tree
getGoldenTests :: OptionSet -> TestTree -> [(TestName, Golden)]
getGoldenTests =
  foldTestTree
    trivialFold { foldSingle = \_ name t -> fmap ((,) name) $ maybeToList $ cast t }

-- | «Accept» a golden test, i.e. reset the golden value to the currently
-- produced value
acceptGoldenTest :: Golden -> IO ()
acceptGoldenTest (Golden _ getTested _  _ update) =
  vgRun $ liftIO . update =<< getTested

-- | Accept all golden tests in the test tree
acceptGoldenTests :: OptionSet -> TestTree -> IO Bool
acceptGoldenTests opts tests = do
  let gs = getGoldenTests opts tests
  numExns <- flip execStateT (0 :: Int) $ forM_ gs $ \(n,g) -> do
    mbExn <- liftIO $ withAsync (acceptGoldenTest g) waitCatch
    case mbExn of
      Right {} -> liftIO $ printf "Accepted %s\n" n
      Left e -> do
        _ <- liftIO $ printf "Error when trying to accept %s: %s\n" n (show (e :: SomeException))
        ne <- get
        put $! ne+1

  -- warn when there were problems
  when (numExns > 0) $
    printf "NOTE: %d tests threw exceptions!\n" numExns
  -- is everything ok?
  return (numExns == 0)

-- | Run in interactive mode (only tested on linux)
runTestsInteractive :: OptionSet -> TestTree -> IO Bool
runTestsInteractive opts tests = do
  let gs = getGoldenTests opts tests

  liftIO $ hSetBuffering stdout NoBuffering

  (nFail, nReject) <- flip execStateT (0, 0) $ forM_ gs runTest

  -- warn when there were problems
  when (nFail > 0 || nReject > 0) (do
    _ <- printf "NOTE: %d tests threw exceptions!\n" nFail
    printf "NOTE: %d tests were rejected!\n" nReject
    )

  -- is everything ok?
  return (nFail == 0 && nReject == 0)
  where runTest :: (TestName, Golden) -> StateT (Integer, Integer) IO ()
        runTest (n, (Golden getGolden getActual cmp shw upd)) = do
            -- execute test
            liftIO $ putStrLn "Executing test"

            -- we can't run any update inside the vg monad,
            -- as the golden file might still be open
            (pFail, pReject, act) <- liftIO $ vgRun $ do
              tested <- getActual

              -- read golden
              liftIO $ putStrLn "Getting golden"
              golden <- getGolden
              case golden of
                Nothing -> do
                  _ <- liftIO $ printf "%s: No golden value. Press <enter> to see actual value.\n" n
                  _ <- liftIO getLine
                  _ <- liftIO $ shw tested >>= showValue n
                  liftIO $ tryAccept n upd tested
                Just golden' -> do
                  cmp' <- liftIO $ cmp golden' tested
                  case cmp' of
                    Equal -> do
                      _ <- liftIO $ printf "%s: Golden value matches output.\n" n
                      return (0, 0, return ())
                    diff' -> do
                      _ <- liftIO $ printf "%s: Output does not match golden value. Press <enter> to see diff.\n" n
                      _ <- liftIO getLine
                      _ <- liftIO $ showDiff n diff'
                      liftIO $ tryAccept n upd tested
            -- now execute update
            liftIO act
            modify (\(nFail, nReject) -> (nFail + pFail, nReject + pReject))

        tryAccept :: TestName -> (a -> IO ()) -> a -> IO (Integer, Integer, IO ())
        tryAccept n upd new = do
            _ <- printf "%s: Accept actual value as new golden value? [yn]" n
            ans <- getLine
            case ans of
              "y" -> do
                    return (0, 0, upd new)
              "n" -> return (0, 1, return ())
              _   -> do
                    putStrLn "Invalid answer."
                    tryAccept n upd new

showDiff :: TestName -> GDiff -> IO ()
showDiff n (DiffText tGold tAct) = do
  withSystemTempFile (n <.> "golden") (\fGold hGold -> do
    withSystemTempFile (n <.> "actual") (\fAct hAct -> do
      hSetBinaryMode hGold True
      hSetBinaryMode hAct True
      BS.hPut hGold (encodeUtf8 tGold)
      BS.hPut hAct (encodeUtf8 tAct)
      hClose hGold
      hClose hAct
      callProcess "sh"
        ["-c", "git diff --color=always --no-index --text " ++ fGold ++ " " ++ fAct ++ " | less -r > /dev/tty"]
      )
    )
showDiff n (ShowDiffed t) = showInLess n t
showDiff _ Equal = error "Can't show diff for equal values..."

showValue :: TestName -> GShow -> IO ()
showValue n (ShowText t) = showInLess n t

showInLess :: String -> T.Text -> IO ()
showInLess _ t = do
  -- TODO error handling...
  _ <- PL.readProcessWithExitCode "sh" ["-c", "less > /dev/tty"] inp
  return ()
  where inp = B.fromStrict $ encodeUtf8 t