futhark-0.19.3: src/Futhark/CLI/Test.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | @futhark test@
module Futhark.CLI.Test (main) where
import Control.Applicative.Lift (Errors, Lift (..), failure, runErrors)
import Control.Concurrent
import Control.Exception
import Control.Monad
import Control.Monad.Except hiding (throwError)
import qualified Control.Monad.Except as E
import qualified Data.ByteString as SBS
import qualified Data.ByteString.Lazy as LBS
import Data.List (delete, partition)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Futhark.Analysis.Metrics.Type
import Futhark.Server
import Futhark.Test
import Futhark.Util (fancyTerminal)
import Futhark.Util.Console
import Futhark.Util.Options
import Futhark.Util.Pretty (prettyText)
import Futhark.Util.Table
import System.Console.ANSI
import qualified System.Console.Terminal.Size as Terminal
import System.Environment
import System.Exit
import System.FilePath
import System.IO
import System.Process.ByteString (readProcessWithExitCode)
import Text.Regex.TDFA
--- Test execution
type TestM = ExceptT [T.Text] IO
-- Taken from transformers-0.5.5.0.
eitherToErrors :: Either e a -> Errors e a
eitherToErrors = either failure Pure
throwError :: MonadError [e] m => e -> m a
throwError e = E.throwError [e]
runTestM :: TestM () -> IO TestResult
runTestM = fmap (either Failure $ const Success) . runExceptT
liftExcept :: ExceptT T.Text IO a -> TestM a
liftExcept = either (E.throwError . pure) pure <=< liftIO . runExceptT
context :: T.Text -> TestM a -> TestM a
context s = withExceptT $
\case
[] -> []
(e : es') -> (s <> ":\n" <> e) : es'
context1 :: Monad m => T.Text -> ExceptT T.Text m a -> ExceptT T.Text m a
context1 s = withExceptT $ \e -> s <> ":\n" <> e
accErrors :: [TestM a] -> TestM [a]
accErrors tests = do
eithers <- lift $ mapM runExceptT tests
let errors = traverse eitherToErrors eithers
ExceptT $ return $ runErrors errors
accErrors_ :: [TestM a] -> TestM ()
accErrors_ = void . accErrors
data TestResult
= Success
| Failure [T.Text]
deriving (Eq, Show)
pureTestResults :: IO [TestResult] -> TestM ()
pureTestResults m =
mapM_ check =<< liftIO m
where
check Success = pure ()
check (Failure err) = E.throwError err
withProgramServer :: FilePath -> FilePath -> [String] -> (Server -> IO [TestResult]) -> TestM ()
withProgramServer program runner extra_options f = do
-- Explicitly prefixing the current directory is necessary for
-- readProcessWithExitCode to find the binary when binOutputf has
-- no path component.
let binOutputf = dropExtension program
binpath = "." </> binOutputf
(to_run, to_run_args)
| null runner = (binpath, extra_options)
| otherwise = (runner, binpath : extra_options)
prog_ctx =
"Running " <> T.pack (unwords $ binpath : extra_options)
context prog_ctx $
pureTestResults $ liftIO $ withServer to_run to_run_args f
data TestCase = TestCase
{ _testCaseMode :: TestMode,
testCaseProgram :: FilePath,
testCaseTest :: ProgramTest,
_testCasePrograms :: ProgConfig
}
deriving (Show)
instance Eq TestCase where
x == y = testCaseProgram x == testCaseProgram y
instance Ord TestCase where
x `compare` y = testCaseProgram x `compare` testCaseProgram y
data RunResult
= ErrorResult T.Text
| SuccessResult [Value]
progNotFound :: T.Text -> T.Text
progNotFound s = s <> ": command not found"
optimisedProgramMetrics :: ProgConfig -> StructurePipeline -> FilePath -> TestM AstMetrics
optimisedProgramMetrics programs pipeline program =
case pipeline of
SOACSPipeline ->
check ["-s"]
KernelsPipeline ->
check ["--kernels"]
SequentialCpuPipeline ->
check ["--cpu"]
GpuPipeline ->
check ["--gpu"]
NoPipeline ->
check []
where
check opt = do
futhark <- liftIO $ maybe getExecutablePath return $ configFuthark programs
let opts = ["dev"] ++ opt ++ ["--metrics", program]
(code, output, err) <- liftIO $ readProcessWithExitCode futhark opts ""
let output' = T.decodeUtf8 output
case code of
ExitSuccess
| [(m, [])] <- reads $ T.unpack output' -> return m
| otherwise -> throwError $ "Could not read metrics output:\n" <> output'
ExitFailure 127 -> throwError $ progNotFound $ T.pack futhark
ExitFailure _ -> throwError $ T.decodeUtf8 err
testMetrics :: ProgConfig -> FilePath -> StructureTest -> TestM ()
testMetrics programs program (StructureTest pipeline (AstMetrics expected)) =
context "Checking metrics" $ do
actual <- optimisedProgramMetrics programs pipeline program
accErrors_ $ map (ok actual) $ M.toList expected
where
ok (AstMetrics metrics) (name, expected_occurences) =
case M.lookup name metrics of
Nothing
| expected_occurences > 0 ->
throwError $
name <> " should have occurred " <> T.pack (show expected_occurences)
<> " times, but did not occur at all in optimised program."
Just actual_occurences
| expected_occurences /= actual_occurences ->
throwError $
name <> " should have occurred " <> T.pack (show expected_occurences)
<> " times, but occurred "
<> T.pack (show actual_occurences)
<> " times."
_ -> return ()
testWarnings :: [WarningTest] -> SBS.ByteString -> TestM ()
testWarnings warnings futerr = accErrors_ $ map testWarning warnings
where
testWarning (ExpectedWarning regex_s regex)
| not (match regex $ T.unpack $ T.decodeUtf8 futerr) =
throwError $
"Expected warning:\n " <> regex_s
<> "\nGot warnings:\n "
<> T.decodeUtf8 futerr
| otherwise = return ()
runInterpretedEntry :: FutharkExe -> FilePath -> InputOutputs -> TestM ()
runInterpretedEntry (FutharkExe futhark) program (InputOutputs entry run_cases) =
let dir = takeDirectory program
runInterpretedCase run@(TestRun _ inputValues _ index _) =
unless ("compiled" `elem` runTags run) $
context
( "Entry point: " <> entry
<> "; dataset: "
<> T.pack (runDescription run)
)
$ do
input <- T.unlines . map prettyText <$> getValues (FutharkExe futhark) dir inputValues
expectedResult' <- getExpectedResult (FutharkExe futhark) program entry run
(code, output, err) <-
liftIO $
readProcessWithExitCode futhark ["run", "-e", T.unpack entry, program] $
T.encodeUtf8 input
case code of
ExitFailure 127 ->
throwError $ progNotFound $ T.pack futhark
_ ->
liftExcept $
compareResult entry index program expectedResult'
=<< runResult program code output err
in accErrors_ $ map runInterpretedCase run_cases
runTestCase :: TestCase -> TestM ()
runTestCase (TestCase mode program testcase progs) = do
futhark <- liftIO $ maybe getExecutablePath return $ configFuthark progs
let checkctx =
mconcat
[ "Type-checking with '",
T.pack futhark,
" check ",
T.pack program,
"'"
]
case testAction testcase of
CompileTimeFailure expected_error ->
context checkctx $ do
(code, _, err) <-
liftIO $ readProcessWithExitCode futhark ["check", program] ""
case code of
ExitSuccess -> throwError "Expected failure\n"
ExitFailure 127 -> throwError $ progNotFound $ T.pack futhark
ExitFailure 1 -> throwError $ T.decodeUtf8 err
ExitFailure _ -> liftExcept $ checkError expected_error $ T.decodeUtf8 err
RunCases {} | mode == TypeCheck -> do
let options = ["check", program] ++ configExtraCompilerOptions progs
context checkctx $ do
(code, _, err) <- liftIO $ readProcessWithExitCode futhark options ""
case code of
ExitSuccess -> return ()
ExitFailure 127 -> throwError $ progNotFound $ T.pack futhark
ExitFailure _ -> throwError $ T.decodeUtf8 err
RunCases ios structures warnings -> do
-- Compile up-front and reuse same executable for several entry points.
let backend = configBackend progs
extra_compiler_options = configExtraCompilerOptions progs
unless (mode == Compile) $
context "Generating reference outputs" $
-- We probably get the concurrency at the test program level,
-- so force just one data set at a time here.
ensureReferenceOutput (Just 1) (FutharkExe futhark) "c" program ios
unless (mode == Interpreted) $
context ("Compiling with --backend=" <> T.pack backend) $ do
compileTestProgram extra_compiler_options (FutharkExe futhark) backend program warnings
mapM_ (testMetrics progs program) structures
unless (mode == Compile) $ do
(tuning_opts, _) <-
liftIO $ determineTuning (configTuning progs) program
let extra_options = tuning_opts ++ configExtraOptions progs
runner = configRunner progs
context "Running compiled program" $
withProgramServer program runner extra_options $ \server -> do
let run = runCompiledEntry (FutharkExe futhark) server program
concat <$> mapM run ios
unless (mode == Compile || mode == Compiled) $
context "Interpreting" $
accErrors_ $ map (runInterpretedEntry (FutharkExe futhark) program) ios
liftCommand ::
(MonadError T.Text m, MonadIO m) =>
IO (Maybe CmdFailure) ->
m ()
liftCommand m = do
r <- liftIO m
case r of
Just (CmdFailure _ err) -> E.throwError $ T.unlines err
Nothing -> pure ()
runCompiledEntry :: FutharkExe -> Server -> FilePath -> InputOutputs -> IO [TestResult]
runCompiledEntry futhark server program (InputOutputs entry run_cases) = do
Right output_types <- cmdOutputs server entry
Right input_types <- cmdInputs server entry
let outs = ["out" <> T.pack (show i) | i <- [0 .. length output_types -1]]
ins = ["in" <> T.pack (show i) | i <- [0 .. length input_types -1]]
onRes = either (Failure . pure) (const Success)
mapM (fmap onRes . runCompiledCase input_types outs ins) run_cases
where
dir = takeDirectory program
runCompiledCase input_types outs ins run = runExceptT $ do
let TestRun _ inputValues _ index _ = run
case_ctx =
"Entry point: " <> entry <> "; dataset: "
<> T.pack (runDescription run)
context1 case_ctx $ do
expected <- getExpectedResult futhark program entry run
either E.throwError pure
<=< withValuesFile futhark dir inputValues
$ \values_f -> runExceptT $ do
checkValueTypes values_f input_types
liftCommand $ cmdRestore server values_f (zip ins input_types)
call_r <- liftIO $ cmdCall server entry outs ins
liftCommand $ cmdFree server ins
res <- case call_r of
Left (CmdFailure _ err) ->
pure $ ErrorResult $ T.unlines err
Right _ ->
SuccessResult
<$> readResults server outs program
<* liftCommand (cmdFree server outs)
compareResult entry index program expected res
checkError :: MonadError T.Text m => ExpectedError -> T.Text -> m ()
checkError (ThisError regex_s regex) err
| not (match regex $ T.unpack err) =
E.throwError $
"Expected error:\n " <> regex_s
<> "\nGot error:\n "
<> err
checkError _ _ =
return ()
runResult ::
(MonadIO m, MonadError T.Text m) =>
FilePath ->
ExitCode ->
SBS.ByteString ->
SBS.ByteString ->
m RunResult
runResult program ExitSuccess stdout_s _ =
case valuesFromByteString "stdout" $ LBS.fromStrict stdout_s of
Left e -> do
let actualf = program `addExtension` "actual"
liftIO $ SBS.writeFile actualf stdout_s
E.throwError $ T.pack e <> "\n(See " <> T.pack actualf <> ")"
Right vs -> return $ SuccessResult vs
runResult _ (ExitFailure _) _ stderr_s =
return $ ErrorResult $ T.decodeUtf8 stderr_s
compileTestProgram :: [String] -> FutharkExe -> String -> FilePath -> [WarningTest] -> TestM ()
compileTestProgram extra_options futhark backend program warnings = do
(_, futerr) <- compileProgram ("--server" : extra_options) futhark backend program
testWarnings warnings futerr
compareResult ::
(MonadIO m, MonadError T.Text m) =>
T.Text ->
Int ->
FilePath ->
ExpectedResult [Value] ->
RunResult ->
m ()
compareResult _ _ _ (Succeeds Nothing) SuccessResult {} =
return ()
compareResult entry index program (Succeeds (Just expected_vs)) (SuccessResult actual_vs) =
checkResult
(program <.> T.unpack entry <.> show index)
expected_vs
actual_vs
compareResult _ _ _ (RunTimeFailure expectedError) (ErrorResult actualError) =
checkError expectedError actualError
compareResult _ _ _ (Succeeds _) (ErrorResult err) =
E.throwError $ "Function failed with error:\n" <> err
compareResult _ _ _ (RunTimeFailure f) (SuccessResult _) =
E.throwError $ "Program succeeded, but expected failure:\n " <> T.pack (show f)
---
--- Test manager
---
data TestStatus = TestStatus
{ testStatusRemain :: [TestCase],
testStatusRun :: [TestCase],
testStatusTotal :: Int,
testStatusFail :: Int,
testStatusPass :: Int,
testStatusRuns :: Int,
testStatusRunsRemain :: Int,
testStatusRunPass :: Int,
testStatusRunFail :: Int
}
catching :: IO TestResult -> IO TestResult
catching m = m `catch` save
where
save :: SomeException -> IO TestResult
save e = return $ Failure [T.pack $ show e]
doTest :: TestCase -> IO TestResult
doTest = catching . runTestM . runTestCase
makeTestCase :: TestConfig -> TestMode -> (FilePath, ProgramTest) -> TestCase
makeTestCase config mode (file, spec) =
TestCase mode file spec $ configPrograms config
data ReportMsg
= TestStarted TestCase
| TestDone TestCase TestResult
runTest :: MVar TestCase -> MVar ReportMsg -> IO ()
runTest testmvar resmvar = forever $ do
test <- takeMVar testmvar
putMVar resmvar $ TestStarted test
res <- doTest test
putMVar resmvar $ TestDone test res
excludedTest :: TestConfig -> TestCase -> Bool
excludedTest config =
any (`elem` configExclude config) . testTags . testCaseTest
-- | Exclude those test cases that have tags we do not wish to run.
excludeCases :: TestConfig -> TestCase -> TestCase
excludeCases config tcase =
tcase {testCaseTest = onTest $ testCaseTest tcase}
where
onTest (ProgramTest desc tags action) =
ProgramTest desc tags $ onAction action
onAction (RunCases ios stest wtest) =
RunCases (map onIOs ios) stest wtest
onAction action = action
onIOs (InputOutputs entry runs) =
InputOutputs entry $ filter (not . any excluded . runTags) runs
excluded = (`elem` configExclude config) . T.pack
statusTable :: TestStatus -> String
statusTable ts = buildTable rows 1
where
rows =
[ [mkEntry "", passed, failed, mkEntry "remaining"],
map mkEntry ["programs", passedProgs, failedProgs, remainProgs'],
map mkEntry ["runs", passedRuns, failedRuns, remainRuns']
]
passed = ("passed", [SetColor Foreground Vivid Green])
failed = ("failed", [SetColor Foreground Vivid Red])
passedProgs = show $ testStatusPass ts
failedProgs = show $ testStatusFail ts
totalProgs = show $ testStatusTotal ts
totalRuns = show $ testStatusRuns ts
passedRuns = show $ testStatusRunPass ts
failedRuns = show $ testStatusRunFail ts
remainProgs = show . length $ testStatusRemain ts
remainProgs' = remainProgs ++ "/" ++ totalProgs
remainRuns = show $ testStatusRunsRemain ts
remainRuns' = remainRuns ++ "/" ++ totalRuns
tableLines :: Int
tableLines = 1 + (length . lines $ blankTable)
where
blankTable = statusTable $ TestStatus [] [] 0 0 0 0 0 0 0
spaceTable :: IO ()
spaceTable = putStr $ replicate tableLines '\n'
reportTable :: TestStatus -> IO ()
reportTable ts = do
moveCursorToTableTop
putStrLn $ statusTable ts
clearLine
w <- maybe 80 Terminal.width <$> Terminal.size
putStrLn $ atMostChars (w - length labelstr) running
where
running = labelstr ++ (unwords . reverse . map testCaseProgram . testStatusRun) ts
labelstr = "Now testing: "
moveCursorToTableTop :: IO ()
moveCursorToTableTop = cursorUpLine tableLines
atMostChars :: Int -> String -> String
atMostChars n s
| length s > n = take (n -3) s ++ "..."
| otherwise = s
reportText :: TestStatus -> IO ()
reportText ts =
putStr $
"(" ++ show (testStatusFail ts) ++ " failed, "
++ show (testStatusPass ts)
++ " passed, "
++ show num_remain
++ " to go).\n"
where
num_remain = length $ testStatusRemain ts
runTests :: TestConfig -> [FilePath] -> IO ()
runTests config paths = do
-- We force line buffering to ensure that we produce running output.
-- Otherwise, CI tools and the like may believe we are hung and kill
-- us.
hSetBuffering stdout LineBuffering
let mode = configTestMode config
all_tests <-
map (makeTestCase config mode)
<$> testSpecsFromPathsOrDie paths
testmvar <- newEmptyMVar
reportmvar <- newEmptyMVar
concurrency <- maybe getNumCapabilities pure $ configConcurrency config
replicateM_ concurrency $ forkIO $ runTest testmvar reportmvar
let (excluded, included) = partition (excludedTest config) all_tests
_ <- forkIO $ mapM_ (putMVar testmvar . excludeCases config) included
let fancy = not (configLineOutput config) && fancyTerminal
report
| fancy = reportTable
| otherwise = reportText
clear
| fancy = clearFromCursorToScreenEnd
| otherwise = putStr "\n"
numTestCases tc =
case testAction $ testCaseTest tc of
CompileTimeFailure _ -> 1
RunCases ios sts wts ->
(length . concat) (iosTestRuns <$> ios)
+ length sts
+ length wts
getResults ts
| null (testStatusRemain ts) = report ts >> return ts
| otherwise = do
report ts
msg <- takeMVar reportmvar
case msg of
TestStarted test -> do
unless fancy $
putStr $ "Started testing " <> testCaseProgram test <> " "
getResults $ ts {testStatusRun = test : testStatusRun ts}
TestDone test res -> do
let ts' =
ts
{ testStatusRemain = test `delete` testStatusRemain ts,
testStatusRun = test `delete` testStatusRun ts,
testStatusRunsRemain =
testStatusRunsRemain ts
- numTestCases test
}
case res of
Success -> do
let ts'' =
ts'
{ testStatusRunPass =
testStatusRunPass ts' + numTestCases test
}
unless fancy $
putStr $ "Finished testing " <> testCaseProgram test <> " "
getResults $ ts'' {testStatusPass = testStatusPass ts + 1}
Failure s -> do
when fancy moveCursorToTableTop
clear
putStr $ inBold (testCaseProgram test <> ":\n") <> T.unpack (T.unlines s)
when fancy spaceTable
getResults $
ts'
{ testStatusFail = testStatusFail ts' + 1,
testStatusRunPass =
testStatusRunPass ts'
+ numTestCases test - length s,
testStatusRunFail =
testStatusRunFail ts'
+ length s
}
when fancy spaceTable
ts <-
getResults
TestStatus
{ testStatusRemain = included,
testStatusRun = [],
testStatusTotal = length included,
testStatusFail = 0,
testStatusPass = 0,
testStatusRuns = sum $ map numTestCases included,
testStatusRunsRemain = sum $ map numTestCases included,
testStatusRunPass = 0,
testStatusRunFail = 0
}
-- Removes "Now testing" output.
when fancy $ cursorUpLine 1 >> clearLine
let excluded_str
| null excluded = ""
| otherwise = " (" ++ show (length excluded) ++ " program(s) excluded).\n"
putStr excluded_str
exitWith $ case testStatusFail ts of
0 -> ExitSuccess
_ -> ExitFailure 1
---
--- Configuration and command line parsing
---
data TestConfig = TestConfig
{ configTestMode :: TestMode,
configPrograms :: ProgConfig,
configExclude :: [T.Text],
configLineOutput :: Bool,
configConcurrency :: Maybe Int
}
defaultConfig :: TestConfig
defaultConfig =
TestConfig
{ configTestMode = Everything,
configExclude = ["disable"],
configPrograms =
ProgConfig
{ configBackend = "c",
configFuthark = Nothing,
configRunner = "",
configExtraOptions = [],
configExtraCompilerOptions = [],
configTuning = Just "tuning"
},
configLineOutput = False,
configConcurrency = Nothing
}
data ProgConfig = ProgConfig
{ configBackend :: String,
configFuthark :: Maybe FilePath,
configRunner :: FilePath,
configExtraCompilerOptions :: [String],
configTuning :: Maybe String,
-- | Extra options passed to the programs being run.
configExtraOptions :: [String]
}
deriving (Show)
changeProgConfig :: (ProgConfig -> ProgConfig) -> TestConfig -> TestConfig
changeProgConfig f config = config {configPrograms = f $ configPrograms config}
setBackend :: FilePath -> ProgConfig -> ProgConfig
setBackend backend config =
config {configBackend = backend}
setFuthark :: FilePath -> ProgConfig -> ProgConfig
setFuthark futhark config =
config {configFuthark = Just futhark}
setRunner :: FilePath -> ProgConfig -> ProgConfig
setRunner runner config =
config {configRunner = runner}
addCompilerOption :: String -> ProgConfig -> ProgConfig
addCompilerOption option config =
config {configExtraCompilerOptions = configExtraCompilerOptions config ++ [option]}
addOption :: String -> ProgConfig -> ProgConfig
addOption option config =
config {configExtraOptions = configExtraOptions config ++ [option]}
data TestMode
= TypeCheck
| Compile
| Compiled
| Interpreted
| Everything
deriving (Eq, Show)
commandLineOptions :: [FunOptDescr TestConfig]
commandLineOptions =
[ Option
"t"
["typecheck"]
(NoArg $ Right $ \config -> config {configTestMode = TypeCheck})
"Only perform type-checking",
Option
"i"
["interpreted"]
(NoArg $ Right $ \config -> config {configTestMode = Interpreted})
"Only interpret",
Option
"c"
["compiled"]
(NoArg $ Right $ \config -> config {configTestMode = Compiled})
"Only run compiled code",
Option
"C"
["compile"]
(NoArg $ Right $ \config -> config {configTestMode = Compile})
"Only compile, do not run.",
Option
[]
["no-terminal", "notty"]
(NoArg $ Right $ \config -> config {configLineOutput = True})
"Provide simpler line-based output.",
Option
[]
["backend"]
(ReqArg (Right . changeProgConfig . setBackend) "BACKEND")
"Backend used for compilation (defaults to 'c').",
Option
[]
["futhark"]
(ReqArg (Right . changeProgConfig . setFuthark) "PROGRAM")
"Program to run for subcommands (defaults to same binary as 'futhark test').",
Option
[]
["runner"]
(ReqArg (Right . changeProgConfig . setRunner) "PROGRAM")
"The program used to run the Futhark-generated programs (defaults to nothing).",
Option
[]
["exclude"]
( ReqArg
( \tag ->
Right $ \config ->
config {configExclude = T.pack tag : configExclude config}
)
"TAG"
)
"Exclude test programs that define this tag.",
Option
"p"
["pass-option"]
(ReqArg (Right . changeProgConfig . addOption) "OPT")
"Pass this option to programs being run.",
Option
[]
["pass-compiler-option"]
(ReqArg (Right . changeProgConfig . addCompilerOption) "OPT")
"Pass this option to the compiler (or typechecker if in -t mode).",
Option
[]
["no-tuning"]
(NoArg $ Right $ changeProgConfig $ \config -> config {configTuning = Nothing})
"Do not load tuning files.",
Option
[]
["concurrency"]
( ReqArg
( \n ->
case reads n of
[(n', "")]
| n' > 0 ->
Right $ \config -> config {configConcurrency = Just n'}
_ ->
Left $ error $ "'" ++ n ++ "' is not a positive integer."
)
"NUM"
)
"Number of tests to run concurrently."
]
-- | Run @futhark test@.
main :: String -> [String] -> IO ()
main = mainWithOptions defaultConfig commandLineOptions "options... programs..." $ \progs config ->
case progs of
[] -> Nothing
_ -> Just $ runTests config progs