packages feed

directory-1.3.11.0: tests/Util.hs

{-# LANGUAGE BangPatterns #-}
-- | A rudimentary testing framework
module Util where
import Prelude ()
import System.Directory.Internal.Prelude
import System.Directory.Internal
import System.Directory.OsPath
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)
import GHC.Stack (CallStack, HasCallStack, callStack, getCallStack, srcLocFile,
                  srcLocStartLine)
import System.Environment (getEnvironment, setEnv, unsetEnv)
import System.OsPath ((</>), decodeFS, encodeFS, normalise)
import qualified Data.List as List

modifyIORef' :: IORef a -> (a -> a) -> IO ()
modifyIORef' r f = do
  x <- readIORef r
  let !x' = f x in writeIORef r x'

tryAny :: IO a -> IO (Either SomeException a)
tryAny action = do
  result <- newEmptyMVar
  mask $ \ unmask -> do
    thread <- forkIO (try (unmask action) >>= putMVar result)
    unmask (readMVar result) `onException` killThread thread

timeLimit :: Double -> IO a -> IO a
timeLimit time action = do
  result <- timeout (round (1000000 * time)) action
  case result of
    Nothing -> throwIO (userError "timed out")
    Just x  -> pure x

data TestEnv =
  TestEnv
  { testCounter  :: IORef Int
  , testSilent   :: Bool
  , testKeepDirs :: Bool
  , testArgs     :: [(String, String)]
  }

printInfo :: TestEnv -> [String] -> IO ()
printInfo TestEnv{testSilent = True}  _   = pure ()
printInfo TestEnv{testSilent = False} msg = do
  putStrLn (List.intercalate ": " msg)
  hFlush stdout

printErr :: [String] -> IO ()
printErr msg = do
  hPutStrLn stderr ("*** " <> List.intercalate ": " msg)
  hFlush stderr

printFailure :: TestEnv -> [String] -> IO ()
printFailure TestEnv{testCounter = n} msg = do
  modifyIORef' n (+ 1)
  printErr msg

check :: TestEnv -> Bool -> [String] -> [String] -> [String] -> IO ()
check t True  prefix msg _   = printInfo t (prefix <> msg)
check t False prefix _   msg = printFailure t (prefix <> msg)

checkEither :: TestEnv -> [String] -> Either [String] [String] -> IO ()
checkEither t prefix (Right msg) = printInfo t (prefix <> msg)
checkEither t prefix (Left  msg) = printFailure t (prefix <> msg)

showContext :: Show a => CallStack -> a -> String
showContext stack context =
  case getCallStack stack of
    (_, l) : _ -> srcLocFile l <> ":" <> show (srcLocStartLine l)
    [] -> "<unknown caller>"
  <>
  case show context of
    "()" -> ""
    s    -> ":" <> s

inform :: HasCallStack => TestEnv -> String -> IO ()
inform t msg =
  printInfo t [showContext callStack (), msg]


expect :: (HasCallStack, Show a) => TestEnv -> a -> Bool -> IO ()
expect t context x =
  check t x
  [showContext callStack context]
  ["True"]
  ["False, but True was expected"]

expectEq :: (HasCallStack, Eq a, Show a, Show b) =>
            TestEnv -> b -> a -> a -> IO ()
expectEq t context x y =
  check t (x == y)
  [showContext callStack context]
  [show x <> " equals "     <> show y]
  [show x <> " is not equal to " <> show y]

expectNe :: (HasCallStack, Eq a, Show a, Show b) =>
            TestEnv -> b -> a -> a -> IO ()
expectNe t context x y =
  check t (x /= y)
  [showContext callStack context]
  [show x <> " is not equal to " <> show y]
  [show x <> " equals "     <> show y]

expectNear :: (HasCallStack, Num a, Ord a, Show a, Show b) =>
              TestEnv -> b -> a -> a -> a -> IO ()
expectNear t context x y diff =
  check t (abs (x - y) <= diff)
  [showContext callStack context]
  [show x <> " is near "     <> show y]
  [show x <> " is not near " <> show y]

expectNearTime :: (HasCallStack, Show a) =>
                  TestEnv -> a ->
                  UTCTime -> UTCTime -> NominalDiffTime -> IO ()
expectNearTime t context x y diff =
  check t (abs (diffUTCTime x y) <= diff)
  [showContext callStack context]
  [show x <> " is near "     <> show y]
  [show x <> " is not near " <> show y]

expectIOErrorType :: (HasCallStack, Show a) =>
                     TestEnv -> a -> (IOError -> Bool) -> IO b -> IO ()
expectIOErrorType t context which action = do
  result <- try action
  checkEither t [showContext callStack context] $ case result of
    Left  e | which e   -> Right ["got expected exception (" <> show e <> ")"]
            | otherwise -> Left  ["got wrong exception: ", show e]
    Right _             -> Left  ["did not throw an exception"]

-- | Traverse the directory tree in preorder.
preprocessPathRecursive :: (OsPath -> IO ()) -> OsPath -> IO ()
preprocessPathRecursive f path = do
  dirExists <- doesDirectoryExist path
  if dirExists
    then do
      isLink <- pathIsSymbolicLink path
      f path
      when (not isLink) $ do
        names <- listDirectory path
        for_ ((path </>) <$> names) (preprocessPathRecursive f)
    else do
      f path

withNewDirectory :: Bool -> OsPath -> IO a -> IO a
withNewDirectory keep dir action = do
  dir' <- makeAbsolute dir
  bracket_ (createDirectoryIfMissing True dir') (cleanup dir') action
  where cleanup dir' | keep      = pure ()
                     | otherwise = removePathForcibly dir'

diffAsc' :: (j -> k -> Ordering)
         -> (u -> v -> Bool)
         -> [(j, u)]
         -> [(k, v)]
         -> ([(j, u)], [(k, v)])
diffAsc' cmp eq = go id id
  where
    go a b [] [] = (a [], b [])
    go a b jus [] = go (a . (jus <>)) b [] []
    go a b [] kvs = go a (b . (kvs <>)) [] []
    go a b jus@((j, u) : jus') kvs@((k, v) : kvs') =
      case cmp j k of
        LT -> go (a . ((j, u) :)) b jus' kvs
        GT -> go a (b . ((k, v) :)) jus kvs'
        EQ | eq u v -> go a b jus' kvs'
           | otherwise -> go (a . ((j, u) :)) (b . ((k, v) :)) jus' kvs'

diffAsc :: (Ord k, Eq v) => [(k, v)] -> [(k, v)] -> ([(k, v)], [(k, v)])
diffAsc = diffAsc' compare (==)

-- Environment variables may be sensitive, so don't log their values.
scrubEnv :: (String, String) -> (String, String)
scrubEnv (k, v)
  -- Allowlist for nonsensitive variables.
  | k `elem` ["XDG_CONFIG_HOME"] = (k, v)
  | otherwise = (k, "<" <> show (length v) <> " chars>")

isolateEnvironment :: IO a -> IO a
isolateEnvironment = bracket getEnvs setEnvs . const
  where
    -- Duplicate environment variables will cause problems for this code.
    -- https://github.com/haskell/cabal/issues/10718
    getEnvs = List.sort . filter (\(k, _) -> not (null k)) <$> getEnvironment
    setEnvs target = do
      current <- getEnvs
      let (deletions, insertions) = diffAsc current target
      updateEnvs deletions insertions
      new <- getEnvs
      when (target /= new) $ do
        let (missing, extraneous) = diffAsc target new
        throwIO (userError ("isolateEnvironment.setEnvs failed:" <>
                            " deletions=" <> show (scrubEnv <$> deletions) <>
                            " insertions=" <> show (scrubEnv <$> insertions) <>
                            " missing=" <> show (scrubEnv <$> missing) <>
                            " extraneous=" <> show (scrubEnv <$> extraneous)))
    updateEnvs deletions insertions = do
      for_ deletions (unsetEnv . fst)
      for_ insertions (uncurry setEnv)

isolateWorkingDirectory :: Bool -> OsPath -> IO a -> IO a
isolateWorkingDirectory keep dir action = do
  normalisedDir <- decodeFS (normalise dir)
  when (normalisedDir `List.elem` [".", "./"]) $
    throwIO (userError ("isolateWorkingDirectory cannot be used " <>
                        "with current directory"))
  dir' <- makeAbsolute dir
  removePathForcibly dir'
  withNewDirectory keep dir' $ do
    withCurrentDirectory dir' $ do
      action

run :: TestEnv -> String -> (TestEnv -> IO ()) -> IO ()
run t name action = do
  result <- tryAny (action t)
  case result of
    Left  e  -> check t False [name] [] ["exception", show e]
    Right () -> pure ()

isolatedRun :: TestEnv -> String -> (TestEnv -> IO ()) -> IO ()
isolatedRun t@TestEnv{testKeepDirs = keep} name action = do
  workDir <- encodeFS ("dist-newstyle/tmp/test-" <> name <> ".tmp")
  run t name (isolate workDir . action)
  where
    isolate workDir = isolateEnvironment . isolateWorkingDirectory keep workDir

tryRead :: Read a => String -> Maybe a
tryRead s =
  case reads s of
    [(x, "")] -> Just x
    _         -> Nothing

getArg :: (String -> Maybe a) -> TestEnv -> String -> String -> a -> a
getArg parse TestEnv{testArgs = args} testname name defaultValue =
  fromMaybe defaultValue (List.lookup (prefix <> name) args >>= parse)
  where prefix | testname == "" = ""
               | otherwise      = testname <> "."

readArg :: Read a => TestEnv -> String -> String -> a -> a
readArg = getArg tryRead

readBool :: String -> Maybe Bool
readBool s = Just $
  case toLower <$> s of
    'y' : _ -> True
    't' : _ -> True
    _       -> False

parseArgs :: [String] -> [(String, String)]
parseArgs = List.reverse . (second (List.drop 1) . List.span (/= '=') <$>)

testMain :: (TestEnv -> IO ()) -> IO ()
testMain action = do
  args <- parseArgs <$> getArgs
  counter <- newIORef 0
  let t = TestEnv
          { testCounter  = counter
          , testSilent   = getArg readBool t "" "silent" False
          , testKeepDirs = getArg readBool t "" "keep-dirs" False
          , testArgs     = args
          }
  action t
  n <- readIORef counter
  unless (n == 0) $ do
    putStrLn ("[" <> show n <> " failures]")
    hFlush stdout
    exitFailure