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}