packages feed

sandwich-0.3.1.0: src/Test/Sandwich/Logging/ProcessFileLogging.hs

{-# LANGUAGE CPP #-}

-- | Functions for launching processes while capturing their output to files in the test tree.

module Test.Sandwich.Logging.ProcessFileLogging (
  createProcessWithFileLogging
  , readCreateProcessWithFileLogging
  , createProcessWithFileLoggingAndStdin
  , callCommandWithFileLogging

  , createProcessWithFileLogging'
  , readCreateProcessWithFileLogging'
  , createProcessWithFileLoggingAndStdin'
  , callCommandWithFileLogging'
  ) where

import Control.Concurrent
import Control.DeepSeq (rnf)
import qualified Control.Exception as C
import Control.Monad
import Control.Monad.IO.Class
import Control.Monad.IO.Unlift
import Control.Monad.Logger
import Data.String.Interpolate
import Foreign.C.Error
import GHC.IO.Exception
import GHC.Stack
import System.FilePath
import System.IO
import System.Process
import Test.Sandwich.Contexts
import Test.Sandwich.Expectations
import Test.Sandwich.Types.RunTree
import UnliftIO.Exception

#if !MIN_VERSION_base(4,13,0)
import Control.Monad.Fail
#endif


-- | Derive a short name from a 'CreateProcess' for use in log file names.
-- For 'RawCommand', takes the base filename of the executable.
-- For 'ShellCommand', takes the first word of the command string.
processName :: CreateProcess -> String
processName cp = case cmdspec cp of
  RawCommand path _ -> takeFileName path
  ShellCommand cmd -> case words cmd of
    (w:_) -> takeFileName w
    [] -> "shell"

-- | Spawn a process with its stdout and stderr logged to files in the test tree.
createProcessWithFileLogging :: (
  HasCallStack, MonadUnliftIO m, MonadLogger m, HasBaseContextMonad context m
  ) => CreateProcess -> m ProcessHandle
createProcessWithFileLogging cp = withFrozenCallStack $ createProcessWithFileLogging' (processName cp) cp

-- | Spawn a process with its stdout and stderr logged to files in the test tree.
createProcessWithFileLogging' :: (
  HasCallStack, MonadUnliftIO m, MonadLogger m, HasBaseContextMonad context m
  ) => FilePath -> CreateProcess -> m ProcessHandle
createProcessWithFileLogging' name cp = withFrozenCallStack $ do
  getCurrentFolder >>= \case
    Nothing -> expectationFailure [i|createProcessWithFileLogging: no current folder, so unable to log for '#{name}'.|]
    Just dir ->
      bracket (liftIO $ openTempFile dir (name <.> "out")) (\(_outfile, hOut) -> liftIO $ hClose hOut) $ \(_outfile, hOut) ->
      bracket (liftIO $ openTempFile dir (name <.> "err")) (\(_errfile, hErr) -> liftIO $ hClose hErr) $ \(_errfile, hErr) -> do
        (_, _, _, p) <- liftIO $ createProcess (cp { std_out = UseHandle hOut, std_err = UseHandle hErr })
        return p

-- | Like 'readCreateProcess', but capture the stderr output to a file in the test tree.
-- Returns the stdout output as a 'String'.
readCreateProcessWithFileLogging :: (
  HasCallStack, MonadUnliftIO m, MonadLogger m, HasBaseContextMonad context m
  ) => CreateProcess -> String -> m String
readCreateProcessWithFileLogging cp = withFrozenCallStack $ readCreateProcessWithFileLogging' (processName cp) cp

-- | Like 'readCreateProcessWithFileLogging', but accepting a name to use for the log files.
readCreateProcessWithFileLogging' :: (
  HasCallStack, MonadUnliftIO m, MonadLogger m, HasBaseContextMonad context m
  ) => FilePath -> CreateProcess -> String -> m String
readCreateProcessWithFileLogging' name cp input = withFrozenCallStack $ do
  getCurrentFolder >>= \case
    Nothing -> expectationFailure [i|readCreateProcessWithFileLogging: no current folder, so unable to log for '#{name}'.|]
    Just dir ->
      bracket (liftIO $ openTempFile dir (name <.> "err")) (\(_errfile, hErr) -> liftIO $ hClose hErr) $ \(_errfile, hErr) -> do
        (ex, output) <- liftIO $ withCreateProcess (cp { std_in = CreatePipe, std_out = CreatePipe, std_err = UseHandle hErr }) $ \sin' sout _ p -> do
          case (sin', sout) of
            (Just hIn, Just hOut) -> do
              output <- hGetContents hOut
              withForkWait (C.evaluate $ rnf output) $ \waitOut -> do
                unless (Prelude.null input) $
                  ignoreSigPipe $ hPutStr hIn input
                ignoreSigPipe $ hClose hIn
                waitOut
                hClose hOut
              ex <- waitForProcess p
              return (ex, output)
            (Nothing, _) -> throwIO $ userError "readCreateProcessWithFileLogging: Failed to get a stdin handle."
            (_, Nothing) -> throwIO $ userError "readCreateProcessWithFileLogging: Failed to get a stdout handle."
        case ex of
          ExitSuccess -> return output
          ExitFailure r -> liftIO $ throwIO $ userError [i|readCreateProcessWithFileLogging failed for '#{name}': exit code #{r}|]

-- | Spawn a process with its stdout and stderr logged to files in the test tree,
-- passing the given string as stdin.
createProcessWithFileLoggingAndStdin :: (HasCallStack, MonadUnliftIO m, MonadFail m, MonadLogger m, HasBaseContextMonad context m) => CreateProcess -> String -> m ProcessHandle
createProcessWithFileLoggingAndStdin cp = withFrozenCallStack $ createProcessWithFileLoggingAndStdin' (processName cp) cp

-- | Like 'createProcessWithFileLoggingAndStdin', but accepting a name to use for the log files.
createProcessWithFileLoggingAndStdin' :: (HasCallStack, MonadUnliftIO m, MonadFail m, MonadLogger m, HasBaseContextMonad context m) => FilePath -> CreateProcess -> String -> m ProcessHandle
createProcessWithFileLoggingAndStdin' name cp input = withFrozenCallStack $ do
  getCurrentFolder >>= \case
    Nothing -> expectationFailure [i|createProcessWithFileLoggingAndStdin: no current folder, so unable to log for '#{name}'.|]
    Just dir ->
      bracket (liftIO $ openTempFile dir (name <.> "out")) (\(_outfile, hOut) -> liftIO $ hClose hOut) $ \(_outfile, hOut) ->
      bracket (liftIO $ openTempFile dir (name <.> "err")) (\(_errfile, hErr) -> liftIO $ hClose hErr) $ \(_errfile, hErr) -> do
        (Just inh, _, _, p) <- liftIO $ createProcess (cp { std_out = UseHandle hOut
                                                           , std_err = UseHandle hErr
                                                           , std_in = CreatePipe })
        unless (Prelude.null input) $
          liftIO $ ignoreSigPipe $ hPutStr inh input
        liftIO $ ignoreSigPipe $ hClose inh
        return p

-- | Higher level version of 'createProcessWithFileLogging', accepting a shell command.
callCommandWithFileLogging :: (HasCallStack, MonadUnliftIO m, MonadLogger m, HasBaseContextMonad context m) => String -> m ()
callCommandWithFileLogging cmd = withFrozenCallStack $ callCommandWithFileLogging' (processName (shell cmd)) cmd

-- | Like 'callCommandWithFileLogging', but accepting a name to use for the log files.
callCommandWithFileLogging' :: (HasCallStack, MonadUnliftIO m, MonadLogger m, HasBaseContextMonad context m) => FilePath -> String -> m ()
callCommandWithFileLogging' name cmd = withFrozenCallStack $ do
  getCurrentFolder >>= \case
    Nothing -> expectationFailure [i|callCommandWithFileLogging: no current folder, so unable to log for '#{name}'.|]
    Just dir ->
      bracket (liftIO $ openTempFile dir (name <.> "out")) (\(_outfile, hOut) -> liftIO $ hClose hOut) $ \(_outfile, hOut) ->
      bracket (liftIO $ openTempFile dir (name <.> "err")) (\(_errfile, hErr) -> liftIO $ hClose hErr) $ \(_errfile, hErr) -> do
        (_, _, _, p) <- liftIO $ createProcess (shell cmd) {
          delegate_ctlc = True
          , std_out = UseHandle hOut
          , std_err = UseHandle hErr
          }
        liftIO (waitForProcess p) >>= \case
          ExitSuccess -> return ()
          ExitFailure r -> liftIO $ throwIO $ userError [i|callCommandWithFileLogging failed for '#{cmd}': '#{r}'|]


-- * Util

-- Copied from System.Process
withForkWait :: IO () -> (IO () ->  IO a) -> IO a
withForkWait asy body = do
  waitVar <- newEmptyMVar :: IO (MVar (Either SomeException ()))
  mask $ \restore -> do
    tid <- forkIO $ try (restore asy) >>= putMVar waitVar
    let wait = takeMVar waitVar >>= either throwIO return
    restore (body wait) `C.onException` killThread tid

-- Copied from System.Process
ignoreSigPipe :: IO () -> IO ()
ignoreSigPipe = C.handle $ \case
  IOError { ioe_type  = ResourceVanished, ioe_errno = Just ioe } | Errno ioe == ePIPE -> return ()
  e -> throwIO e