packages feed

test-lib-0.5: src/TestLib.hs

module TestLib (Config(..), mainWith, mainWithOpts, main, Options(..)) where

import Data.Char(isSpace)
import SimpleGetOpt
import Control.Monad (foldM,when)
import System.Directory ( getDirectoryContents,doesDirectoryExist
                        , doesFileExist
                        , createDirectoryIfMissing,canonicalizePath )
import System.Environment (withArgs, getEnvironment)
import System.Info(os)
import System.FilePath((</>),(<.>),splitFileName,splitDirectories,takeFileName
                      , isRelative, pathSeparator, takeExtension, replaceExtension )
import System.Process ( createProcess,CreateProcess(..), StdStream(..)
                      , proc, waitForProcess, readProcessWithExitCode
                       )
import System.IO(IOMode(..),withFile,Handle,hSetBuffering,BufferMode(..))
import System.Exit(exitSuccess)
import Paths_test_lib (version)
import Data.Version (showVersion)
import Test.Framework (defaultMain,Test,testGroup)
import Test.Framework.Providers.HUnit (testCase)
import Test.HUnit (assertFailure)
import qualified Control.Exception as X
import           Data.Map (Map)
import qualified Data.Map as Map
import           Data.Set (Set)
import qualified Data.Set as Set


-- | Specifies how the test runner should behave.
data Config = Config
  { cfgDefaultBinary :: String
    -- ^ Use this binary unless one is explicitly provided.

  , cfgBinOpts       :: String -> [String]
    -- ^ Given a test, produce a set of parameters for the binary.

  , cfgIsTestCase    :: String -> Bool
    -- ^ Examine a file name to determine if it is a test.
  }

main :: IO ()
main =
  do opts <- getOpts options
     mainWithOpts opts

-- | Define a @main@ function for an executable.
mainWith :: Config -> IO ()
mainWith cfg =
  do opts0 <- getOpts options
     let opts = opts0 { optCfg = Just cfg }
     mainWithOpts $ case optBinary opts of
                      "" -> opts { optBinary = cfgDefaultBinary cfg }
                      _  -> opts

-- | Run with the given options
mainWithOpts :: Options -> IO ()
mainWithOpts opts =
  do when (optHelp opts) $
        do dumpUsage options
           exitSuccess

     when (optVersion opts) $
        do putStrLn (showVersion version)
           exitSuccess

     -- Normalize paths
     bin' <- if pathSeparator `elem` optBinary opts
                     && isRelative (optBinary opts)
                then canonicalizePath (optBinary opts)
                else return (optBinary opts)
     resultsDir <- canonicalizePath (optResultDir opts)
     let opts' = opts { optResultDir = resultsDir, optBinary = bin' }

     createDirectoryIfMissing True resultsDir
     testFiles  <- findTests opts'
     withArgs (optOther opts') (defaultMain (generateTests opts' testFiles))





-- Command Line Options --------------------------------------------------------

data Options = Options
  { optBinary         :: String
  , optOther          :: [String]
  , optHelp           :: Bool
  , optVersion        :: Bool
  , optResultDir      :: FilePath
  , optTests          :: [FilePath]
  , optDiffTool       :: Maybe String
  , optIgnoreExpected :: Bool
  , optTestFileExts   :: [String]
  , optTestFileEnvExts :: [String]
  , optBinFlags       :: [String]
    -- ^ Add this flags to the binary, followed by the test file
  , optCfg            :: Maybe Config
  }


options :: OptSpec Options
options = OptSpec
  { progDefaults = Options { optBinary         = ""
                           , optOther          = []
                           , optHelp           = False
                           , optVersion        = False
                           , optResultDir      = "output"
                           , optTests          = []
                           , optDiffTool       = Nothing
                           , optBinFlags       = []
                           , optTestFileExts   = []
                           , optTestFileEnvExts = []
                           , optIgnoreExpected = False
                           , optCfg            = Nothing
                           }

  , progOptions =
      [ Option "c" ["exe"]
        "the binary executable to use"
        $ ReqArg "PATH" $ \s o -> Right o { optBinary = s }

     , Option "F" ["flag"]
        "add a flag to the test binary"
        $ ReqArg "STRING" $ \s o -> Right o { optBinFlags = optBinFlags o ++[s]}

      , Option "r" ["result-dir"]
        "the result directory for test runs"
        $ ReqArg "PATH" $ \s o -> Right o { optResultDir = s }

      , Option "p" ["diff-prog"]
        "use this diffing program on failures"
        $ ReqArg "PROG" $ \s o -> Right o { optDiffTool = Just s }

      , Option "T" []
        "add an argument to pass to the test-runner main"
        $ ReqArg "STRING" $ \s o -> Right o { optOther = s : optOther o }

      , Option "i" ["ignore-expected"]
        "ignore expected failures"
        $ NoArg $ \o -> Right o { optIgnoreExpected = True }

      , Option "" ["ext"]
        "files with this extension are tests"
        $ ReqArg "STRING" $ \s o ->
            let e = case s of
                      '.' : _ -> s
                      _ -> '.' : s
            in Right o { optTestFileExts = e : optTestFileExts o }

      , Option "" ["env-ext"]
        "files with this extension set environmental variables"
        $ ReqArg "STRING" $ \s o ->
            let e = case s of
                      '.' : _ -> s
                      _ -> '.' : s
            in Right o { optTestFileEnvExts = e : optTestFileEnvExts o }

      , Option "" ["version"]
        "display current version"
        $ NoArg $ \o -> Right o { optVersion = True }

      , Option "h" ["help"]
        "display this message"
        $ NoArg $ \o -> Right o { optHelp = True }
      ]

  , progParamDocs =
      [ ("FILES/DIRS",   "The tests to run.") ]

  , progParams = \p o -> Right o { optTests = p : optTests o }
  }

-- Test Generation -------------------------------------------------------------

-- | Turn a directory tree of tests into a collection of tests.
-- Tests in the same directory share a test-group.
generateTests :: Options -> TestFiles -> [Test]
generateTests opts = loop ""
  where
  loop dir tests = as ++ grouped
    where
    as      = map (generateAssertion opts dir) (Set.toList (files tests))
    grouped = [ testGroup path (loop (dir </> path) t)
              | (path,t) <- Map.toList (subDirs tests) ]



-- | This is how we run a test.
generateAssertion :: Options -> FilePath -> FilePath -> Test
generateAssertion opts dir file = testCase file runTest
  where
  -- file locations:
  resultDir        = optResultDir opts </> dir        -- test output goes here
  goldFiles        = [ dir </> file <.> "stdout" <.> os -- what we expect to see
                     , dir </> file <.> "stdout"        -- what we expect to see
                     ]
  knownFailureFile = dir </> file <.> "fails"         -- expected failur
  resultOut        = resultDir </> file <.> "stdout"  -- outputfile

  getGoldFile gfs =
    case gfs of
      [] -> error ("Missing gold file for " ++ show (dir </> file))
      f : fs -> do yes <- doesFileExist f
                   if yes then pure f else getGoldFile fs

  runTest =
    do createDirectoryIfMissing True resultDir
       withFile resultOut WriteMode $ \ hout ->
         do hSetBuffering hout NoBuffering
            runBinary opts hout dir file

       out      <- readFile resultOut
       gf       <- getGoldFile goldFiles
       expected <- readFile gf
       mbKnown  <- X.try (readFile knownFailureFile)
       checkOutput gf mbKnown expected out

  checkOutput goldFile mbKnown expected out
    | expected == out =
      case mbKnown of
        Left _  -> return ()
        -- Test passed, but we expected a failure.
        Right _ ->
          assertFailure $
            "Test completed successfully.  Please remove " ++ knownFailureFile


      -- Gold and output differ
    | otherwise =
      case mbKnown of

        -- No expected errors.
        Left (X.SomeException {})

          -- A custom diff tool was lister.  We don't run it,
          -- we just write it on the terminal for easy copy and paste.
          | Just prog <- optDiffTool opts ->
            do goldFile' <- canonicalizePath goldFile
               assertFailure $ unlines
                  [ unwords [ prog, goldFile', "\\\n    ", resultOut ]
                  , makeGold resultOut goldFile'
                  ]

          -- Just use "diff"
          | otherwise ->
            do goldFile' <- canonicalizePath goldFile
               (_,diffOut,_) <-
                  readProcessWithExitCode "diff" [ goldFile', resultOut ] ""
               assertFailure $ unlines [ diffOut, makeGold resultOut goldFile' ]

        Right fail_msg
          -- Expected error.
          | optIgnoreExpected opts -> return ()
          -- Different expected error.
          | otherwise              -> assertFailure fail_msg

  makeGold out gold =
    unlines [ "# If output is OK:"
            , unwords [ "cp", out, "\\\n    ", gold ]
            ]



-- | Write the output of stdout and stderr for a run of the binary to
-- the given handle.
runBinary :: Options -> Handle -> FilePath -> String -> IO ()
runBinary opts hout path file =
  do let bin  = optBinary opts
         args = case optCfg opts of
                          Just x -> optBinFlags opts ++ cfgBinOpts x file
                          Nothing -> optBinFlags opts ++ [file]
     envVars <- getEnvVars path file (optTestFileEnvExts opts)
     (_, _, _, ph) <- createProcess (proc bin args)
                        { cwd     = Just path
                        , std_out = UseHandle hout
                        , std_in  = Inherit
                        , std_err = UseHandle hout
                        , env     = envVars
                        }
     _ <- waitForProcess ph
     return ()

getEnvVars :: FilePath -> String -> [String] -> IO (Maybe [(String,String)])
getEnvVars path p es =
  case es of
    [] -> pure Nothing
    e : more ->
      do mb <- getEnvVarsFrom (path </> replaceExtension p e)
         case mb of
           Just ev -> pure (Just ev)
           Nothing -> getEnvVars path p more


-- | Parse the environment variables from a file.
getEnvVarsFrom :: FilePath -> IO (Maybe [(String,String)])
getEnvVarsFrom file =
  do mbTxt <- X.try (readFile file)
     case mbTxt of
       Left X.SomeException {} -> pure Nothing
       Right txt ->
         case parse txt of
           Right as ->
             do es <- getEnvironment
                let new = map fst as
                pure (Just (as ++ [ (x,v) | (x,v) <- es, x `notElem` new ]))
           Left err -> error err 
  where
  parse = mapM parseLine . filter (not . isBlank) . zip [1 :: Int .. ] . lines

  isBlank (_, xs) =
    case dropWhile isSpace xs of
      [] -> True
      '#' : _ -> True
      _ -> False

  trim = reverse . dropWhile isSpace . reverse . dropWhile isSpace
  
  parseLine (n,l) =
    case break (== '=') l of
      (as,_:bs) -> pure (trim as, trim bs)
      _ -> Left (unlines [ file ++ ":" ++ show n ++
                                   " Syntax error, expected KEY=VALUE" ])


-- Test Discovery --------------------------------------------------------------

-- | Directory structure of the discovered tests.  Each entry in the map
-- represents a single folder, with the top-level list representing tests
-- inside the base directory.
data TestFiles = TestFiles
  { subDirs :: Map String TestFiles
  , files   :: Set String
  }

-- | An empty collection of tests.
noTests :: TestFiles
noTests = TestFiles { subDirs = Map.empty, files = Set.empty }

-- | Join two collections of tests, removing duplicates.
joinTests :: TestFiles -> TestFiles -> TestFiles
joinTests ts1 ts2 = TestFiles
  { files   = Set.union (files ts1) (files ts2)
  , subDirs = Map.unionWith joinTests (subDirs ts1) (subDirs ts2)
  }

-- | Create a test collection with a single file.
testFile :: FilePath -> TestFiles
testFile path = foldr addDir baseTest dirs
  where
  baseTest   = noTests { files = Set.singleton file }
  (dir,file) = splitFileName path
  dirs       = splitDirectories dir
  addDir d t = TestFiles (Map.singleton d t) Set.empty


-- | Find a bunch of tests.
findTests :: Options -> IO TestFiles
findTests opts = searchMany noTests (optTests opts)
  where
  searchMany = foldM step

  step tests path =
    do isDir <- doesDirectoryExist path
       if isDir
          then do fs <- getDirectoryContents path
                  searchMany tests [ path </> f | f <- fs, not (isDotFile f) ]
          else if isTestFile path
                    then return $! joinTests (testFile path) tests
                    else return tests

  isDotFile path = case path of
                     '.' : _ -> True
                     _       -> False

  isTestFile f = case optCfg opts of
                   Nothing -> byExt
                   Just cfg -> byExt || cfgIsTestCase cfg file
    where
    file  = takeFileName f
    byExt = takeExtension file `elem` optTestFileExts opts