packages feed

tasty-process-0.1.0.0: src/Test/Tasty/Process.hs

{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE UndecidableInstances #-}

module Test.Tasty.Process
  ( processTest
  , TestProcess (..)
  , proc
  , shell
  , defaultProcess
  , ExitCodeCheck
  , OutputCheck
  , equals
  , ignored
  , setTimeout
  )
where

import Control.DeepSeq (deepseq)
import Data.Foldable (for_)
import Data.Maybe (fromMaybe)
import GHC.IO.Handle (Handle, hClose, hFlush, hPutStr)
import System.Exit (ExitCode)
import System.IO (hGetContents)
import System.Process
  ( CmdSpec (..)
  , CreateProcess (..)
  , ProcessHandle
  , StdStream (..)
  , cleanupProcess
  , createProcess
  , waitForProcess
  )
import qualified System.Process as P (proc, shell)
import Test.Tasty (TestName, TestTree, localOption, mkTimeout, withResource)
import Test.Tasty.Providers
  ( IsTest (..)
  , Result
  , singleTest
  , testFailed
  , testPassed
  )

{- | Create a 'TestTree' from a 'TestProcess'. Here is an example of how to use
the function to create a test.

@
exampleTest :: TestTree
exampleTest =
  setTimeout (1000000) $
    processTest
      "Simple test"
      TestProcess
        { process =
          (proc "test-executable-simple" [])
        , input = Nothing
        , exitCodeCheck = equals ExitSuccess
        , stdoutCheck = equals "Hello, world!\n"
        , stderrCheck = equals ""
        }
@
-}
processTest
  :: TestName
  -> TestProcess
  -> TestTree
processTest
  testName
  tp@TestProcess {process} =
    withResource
      (createProcess process)
      cleanupProcess
      (\io -> singleTest testName (tp, io))

{- | 'ExitCodeCheck' represents a function that given the 'ExitCode' of a process,
returns '()' if the exit code is expected, or a reason otherwise.
-}
type ExitCodeCheck = ExitCode -> Either String ()

{- | 'OutputCheck' represents a function that given the output of a process,
returns '()' if the output is expected, or a reason otherwise.
-}
type OutputCheck = String -> Either String ()

-- | 'TestProcess' is a data type that represents a process to be tested.
data TestProcess = TestProcess
  { process :: CreateProcess
  -- ^ The process to be tested.
  , input :: Maybe String
  -- ^ The input to be sent to the process. If 'Nothing', no input will be sent.
  , exitCodeCheck :: ExitCodeCheck
  -- ^ The check to be performed on the exit code of the process.
  , stdoutCheck :: OutputCheck
  -- ^ The check to be performed on the @stdout@ of the process.
  , stderrCheck :: OutputCheck
  -- ^ The check to be performed on the @stderr@ of the process.
  }

-- | The template process configuration.
defaultProcess :: TestProcess
defaultProcess =
  TestProcess
    { process = undefined
    , input = Nothing
    , exitCodeCheck = ignored
    , stdoutCheck = ignored
    , stderrCheck = ignored
    }

instance
  IsTest
    (TestProcess, IO (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle))
  where
  run _ (tp, io) _ = runTestProcess tp io

  testOptions = return []

runTestProcess
  :: TestProcess
  -> IO (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle)
  -> IO Result
runTestProcess
  TestProcess
    { process
    , input
    , exitCodeCheck
    , stdoutCheck
    , stderrCheck
    }
  io = do
    (mbStdinH, mbStdoutH, mbStderrH, ph) <- io
    -- Send input to the process.
    for_ input $ \i -> do
      mapM_ (`hPutStr` i) mbStdinH
      mapM_ hFlush mbStdinH
      mapM_ hClose mbStdinH
    -- Wait for the process to finish and get the exit code, stdout and stderr.
    stdout :: String <- fromMaybe "" <$> mapM hGetContents mbStdoutH
    stderr :: String <- fromMaybe "" <$> mapM hGetContents mbStderrH
    exitCode :: ExitCode <- stderr `deepseq` stdout `deepseq` waitForProcess ph
    -- Check the exit code, stdout and stderr.
    let exitFailure' = exitFailure process exitCode stderr stdout
    let exitCodeCheckResult = exitCodeCheck exitCode
    let stderrCheckResult = stderrCheck stderr
    let stdoutCheckResult = stdoutCheck stdout
    let handleNotes =
          printHandleNote mbStdinH "stdin"
            ++ printHandleNote mbStdoutH "stdout"
            ++ printHandleNote mbStderrH "stderr"
    let res
          | Left reason <- exitCodeCheckResult =
              exitFailure' ("ExitCode check failed.\n" ++ reason ++ handleNotes)
          | Left reason <- stdoutCheckResult =
              exitFailure' ("Stdout check failed.\n" ++ reason ++ handleNotes)
          | Left reason <- stderrCheckResult =
              exitFailure' ("Stderr check failed.\n" ++ reason ++ handleNotes)
          | otherwise = testPassed ""
    return res

printHandleNote :: Maybe a -> String -> String
printHandleNote (Just _) _ = ""
printHandleNote Nothing hName =
  hName
    ++ " was not captured because it is not set to `CreatePipe` in `CreateProcess`.\n"

exitFailure
  :: CreateProcess -> ExitCode -> String -> String -> String -> Result
exitFailure CreateProcess {cmdspec} code stderr stdout reason =
  testFailed $
    unlines
      [ printCmdSpec cmdspec ++ " exited with code " ++ show code
      , ""
      , if null stdout
          then "Nothing was printed to stdout."
          else "stdout contained:\n" ++ stdout
      , ""
      , if null stderr
          then "Nothing was printed to stderr."
          else "stderr contained:\n" ++ stderr
      , ""
      , reason
      ]

printCmdSpec :: CmdSpec -> String
printCmdSpec (ShellCommand x) = x
printCmdSpec (RawCommand x y) = unwords (x : y)

-- | Set the timeout (in microseconds) for a 'TestTree'.
setTimeout :: Integer -> TestTree -> TestTree
setTimeout = localOption . mkTimeout

{- | A helper function for creating equality checks.

>>> equals "str" "str"
Right ()

>>> equals ExitSuccess ExitSuccess
Right ()

>>> equals "expected value" "actual value"
Left "expected : \"expected value\"\nactual   : \"actual value\"\n"
-}
equals :: (Show a, Eq a) => a -> a -> Either String ()
equals expected actual
  | expected == actual = Right ()
  | otherwise =
      Left $ "expected : " ++ show expected ++ "\nactual   : " ++ show actual ++ "\n"

{- | A helper function to ignore checks.

>>> ignored "any value"
Right ()
-}
ignored :: a -> Either String ()
ignored _ = Right ()

{- | Re-export of 'proc' from "System.Process" with correct default values.

Construct a `CreateProcess` record for passing to `createProcess`, representing a command to be passed to the shell.
-}
proc :: FilePath -> [String] -> CreateProcess
proc x y =
  (P.proc x y) {std_out = CreatePipe, std_err = CreatePipe, std_in = CreatePipe}

{- | Re-export of 'shell' from "System.Process" with correct default values.

Construct a `CreateProcess` record for passing to `createProcess`, representing a raw command with arguments.
See `RawCommand` for precise semantics of the specified `FilePath`.
-}
shell :: String -> CreateProcess
shell x = (P.shell x) {std_out = CreatePipe, std_err = CreatePipe, std_in = CreatePipe}