{-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} module Stackage.Test ( runTestSuites ) where import qualified Control.Concurrent as C import Control.Exception (Exception, SomeException, handle, throwIO, IOException, try) import Control.Monad (replicateM, unless, when, forM_) import qualified Data.Map as Map import qualified Data.Set as Set import qualified Control.Monad.Trans.Writer as W import Distribution.Package (Dependency (Dependency)) import Data.Version (parseVersion, Version (Version)) import Data.Typeable (Typeable) import Stackage.Types import Stackage.Util import System.Directory (copyFile, createDirectory, createDirectoryIfMissing, doesFileExist, findExecutable, getDirectoryContents, removeFile, renameDirectory, canonicalizePath) import System.Exit (ExitCode (ExitSuccess)) import System.FilePath ((<.>), (), takeDirectory) import System.IO (IOMode (WriteMode, AppendMode), withBinaryFile) import System.Process (readProcess, runProcess, waitForProcess, createProcess, proc, cwd) import Text.ParserCombinators.ReadP (readP_to_S) import Data.IORef (IORef, readIORef, atomicModifyIORef, newIORef) import qualified Data.ByteString.Lazy as L import qualified Data.ByteString.Lazy.Char8 as L8 import qualified Distribution.PackageDescription as PD import Distribution.PackageDescription.Parse (ParseResult (ParseOk), parsePackageDescription) runTestSuites :: BuildSettings -> BuildPlan -> IO () runTestSuites settings' bp = do settings <- fixBuildSettings settings' let selected' = Map.filterWithKey notSkipped $ bpPackages bp let testdir = "runtests" docdir = "haddock" rm_r testdir rm_r docdir createDirectory testdir createDirectory docdir putStrLn "Determining package dependencies" selected <- mapM (addDependencies settings (Map.keysSet selected') testdir) $ Map.toList selected' putStrLn "Running test suites" copyBuiltInHaddocks docdir cabalVersion <- getCabalVersion haddockFilesRef <- newIORef [] allPass <- parFoldM (testWorkerThreads settings) (runTestSuite cabalVersion settings testdir docdir bp haddockFilesRef) (&&) True selected unless allPass $ error $ "There were failures, please see the logs in " ++ testdir where notSkipped p _ = p `Set.notMember` bpSkippedTests bp addDependencies :: BuildSettings -> Set PackageName -- ^ all packages to be installed -> FilePath -- ^ testdir -> (PackageName, SelectedPackageInfo) -> IO (PackageName, Set PackageName, SelectedPackageInfo) addDependencies settings allPackages testdir (packageName, spi) = do package' <- replaceTarball (tarballDir settings) package deps <- handle (\e -> print (e :: IOException) >> return Set.empty) $ getDeps allPackages testdir packageName package package' return (packageName, deps, spi) where package = packageVersionString (packageName, spiVersion spi) getDeps :: Set PackageName -- ^ all packages to be installed -> FilePath -> PackageName -> String -> String -> IO (Set PackageName) getDeps allPackages testdir (PackageName name) nameVer loc = do (Nothing, Nothing, Nothing, ph) <- createProcess (proc "cabal" ["unpack", loc, "--verbose=0"]) { cwd = Just testdir } ec <- waitForProcess ph unless (ec == ExitSuccess) $ error $ "Unable to unpack: " ++ loc lbs <- L.readFile $ testdir nameVer name <.> "cabal" case parsePackageDescription $ L8.unpack lbs of ParseOk _ gpd -> return $ Set.intersection allPackages $ allLibraryDeps gpd _ -> return Set.empty allLibraryDeps :: PD.GenericPackageDescription -> Set PackageName allLibraryDeps = maybe Set.empty (W.execWriter . goTree) . PD.condLibrary where goTree tree = do mapM_ goDep $ PD.condTreeConstraints tree forM_ (PD.condTreeComponents tree) $ \(_, y, z) -> do goTree y maybe (return ()) goTree z goDep (Dependency pn _) = W.tell $ Set.singleton pn getCabalVersion :: IO CabalVersion getCabalVersion = do output <- readProcess "cabal" ["--numeric-version"] "" case filter (null . snd) $ readP_to_S parseVersion $ filter notCRLF output of (Version (x:y:_) _, _):_ -> return $ CabalVersion x y _ -> error $ "Invalid cabal version: " ++ show output where notCRLF '\n' = False notCRLF '\r' = False notCRLF _ = True parFoldM :: Int -- ^ number of threads -> ((PackageName, payload) -> IO c) -> (a -> c -> a) -> a -> [(PackageName, Set PackageName, payload)] -> IO a parFoldM threadCount0 f g a0 bs0 = do ma <- C.newMVar a0 mbs <- C.newMVar bs0 signal <- C.newEmptyMVar completed <- newIORef Set.empty tids <- replicateM threadCount0 $ C.forkIO $ worker completed ma mbs signal wait threadCount0 signal tids unrun <- C.takeMVar mbs when (not $ null unrun) $ error $ "The following tests were not run: " ++ unwords [x | (PackageName x, _, _) <- unrun] C.takeMVar ma where worker completedRef ma mbs signal = handle (C.putMVar signal . Just) (loop >> C.putMVar signal Nothing) where loop = do mb <- C.modifyMVar mbs $ \bs -> do completed <- readIORef completedRef return $ case findReady completed bs of -- There's a workload ready with no deps Just (b, bs') -> (bs', Just b) -- No workload with no deps Nothing -> (bs, Nothing) case mb of Nothing -> return () Just (name, _, payload) -> do c <- f (name, payload) C.modifyMVar_ ma $ \a -> return $! g a c atomicModifyIORef completedRef $ \s -> (Set.insert name s, ()) loop wait threadCount signal tids | threadCount == 0 = return () | otherwise = do me <- C.takeMVar signal case me of Nothing -> wait (threadCount - 1) signal tids Just e -> do mapM_ C.killThread tids throwIO (e :: SomeException) -- | Find a workload whose dependencies have been met. findReady :: Ord key => Set key -- ^ workloads already complete -> [(key, Set key, value)] -> Maybe ((key, Set key, value), [(key, Set key, value)]) findReady completed = loop id where loop _ [] = Nothing loop front (x@(_, deps, _):xs) | Set.null $ Set.difference deps completed = Just (x, front xs) | otherwise = loop (front . (x:)) xs data TestException = TestException deriving (Show, Typeable) instance Exception TestException data CabalVersion = CabalVersion Int Int deriving (Eq, Ord, Show) runTestSuite :: CabalVersion -> BuildSettings -> FilePath -- ^ testdir -> FilePath -- ^ docdir -> BuildPlan -> IORef [(String, FilePath)] -- ^ .haddock files -> (PackageName, SelectedPackageInfo) -> IO Bool runTestSuite cabalVersion settings testdir docdir bp haddockFilesRef (packageName, SelectedPackageInfo {..}) = do -- Set up a new environment that includes the sandboxed bin folder in PATH. env' <- getModifiedEnv settings let menv = Just $ addSandbox env' addSandbox = (("HASKELL_PACKAGE_SANDBOX", packageDir settings):) let run cmd args wdir handle' = do ph <- runProcess cmd args (Just wdir) menv Nothing (Just handle') (Just handle') ec <- waitForProcess ph unless (ec == ExitSuccess) $ throwIO TestException passed <- handle (\TestException -> return False) $ do case cabalFileDir settings of Nothing -> return () Just cfd -> do let PackageName name = packageName basename = name ++ ".cabal" src = dir basename dst = cfd basename createDirectoryIfMissing True cfd copyFile src dst getHandle WriteMode $ run "cabal" (addCabalArgs settings BSTest ["configure", "--enable-tests"]) dir -- Try building docs first in case tests have an expected failure. when (buildDocs settings) $ do hfs <- readIORef haddockFilesRef let hfsOpts = flip map hfs $ \(pkgVer, hf) -> concat [ "--haddock-options=--read-interface=" , "../" , pkgVer , "/," , hf ] getHandle AppendMode $ run "cabal" ( "haddock" : "--hyperlink-source" : "--html" : "--hoogle" : "--html-location=../$pkg-$version/" : hfsOpts) dir let PackageName packageName' = packageName handle (\(_ :: IOException) -> return ()) $ renameDirectory (dir "dist" "doc" "html" packageName') (docdir package) enewPath <- try $ canonicalizePath $ docdir package packageName' <.> "haddock" case enewPath :: Either IOException FilePath of Left e -> print e Right newPath -> atomicModifyIORef haddockFilesRef $ \hfs' -> ((package, newPath) : hfs', ()) when spiHasTests $ do getHandle AppendMode $ run "cabal" ["build"] dir getHandle AppendMode $ run "cabal" (concat [ ["test"] , if cabalVersion >= CabalVersion 1 20 then ["--show-details=streaming"] -- FIXME temporary workaround for https://github.com/haskell/cabal/issues/1810 else [] ]) dir return True let expectedFailure = packageName `Set.member` bpExpectedFailures bp if passed then do removeFile logfile when expectedFailure $ putStrLn $ " " ++ package ++ " passed, but I didn't think it would." else unless expectedFailure $ putStrLn $ concat [ "Test suite failed: " , package , "(" , unMaintainer spiMaintainer , githubMentions spiGithubUser , ")" ] rm_r dir return $! passed || expectedFailure where logfile = testdir package <.> "log" dir = testdir package getHandle mode = withBinaryFile logfile mode package = packageVersionString (packageName, spiVersion) copyBuiltInHaddocks docdir = do Just ghc <- findExecutable "ghc" copyTree (takeDirectory ghc "../share/doc/ghc/html/libraries") docdir where copyTree src dest = do entries <- fmap (filter (\s -> s /= "." && s /= "..")) $ getDirectoryContents src forM_ entries $ \entry -> do let src' = src entry dest' = dest entry isFile <- doesFileExist src' if isFile then copyFile src' dest' else do createDirectory dest' copyTree src' dest'