{-# LANGUAGE OverloadedStrings #-} module Test.Sunlight ( Description , Compiler , GhcPkg , Cabal , TestInputs(..) , runTests ) where import Distribution.Package import Distribution.Text import Test.Sunlight.Shell import System.Directory import Distribution.PackageDescription import Distribution.PackageDescription.Parse import Distribution.Simple.Utils import Distribution.Verbosity import Distribution.Version import Data.Tuple.Select import qualified Data.ByteString.Char8 as BS8 import Data.Monoid import Data.Time import System.Locale (defaultTimeLocale) import Data.List (intersperse) import System.Random import Control.Monad import System.Exit -- | Result from installing the package's dependencies. data InstallResult = InstallResult { drOutput :: CmdResult , drGhcPkg :: CmdResult } deriving Show instance CheckOk InstallResult where isOk r = all isOk . map ($ r) $ [drOutput, drGhcPkg] -- | Install a package's dependencies. -- Preconditions: -- -- * current directory has the unpacked tarball. -- -- Side effects: -- -- * dependencies are fully or partially installed. If partially -- installed, the ExitCode should be non-zero. installDeps :: [PackageIdentifier] -- ^ Optional constraints. Currently constraints may only be tied -- to specific versions (for instance, flag constraints or -- constraints tied to a range of versions are not allowed.) -> FilePath -- ^ Install using this compiler (full path to compiler). -> FilePath -- ^ Full path to ghc-pkg. -> FilePath -- ^ Path to cabal executable -> FilePath -- ^ Path to directory to use for user package DB. -> FilePath -- ^ Directory to use for installation prefix -> IO InstallResult installDeps cs ghc pkg cabal db dir = do let opts = [ "install" , "--verbose=2" , "--with-compiler=" ++ ghc , "--with-hc-pkg=" ++ pkg , "--prefix=" ++ dir , "--disable-library-profiling" , "--disable-executable-profiling" , "--package-db=clear" , "--package-db=global" , "--package-db=" ++ db , "--enable-tests" , "--disable-documentation" , "--only-dependencies" ] ++ map constraint cs out <- tee cabal opts let pkgOpts = [ "list", "--global", "--package-conf=" ++ db ] pkgOut <- tee pkg pkgOpts return $ InstallResult out pkgOut -- | Makes a PackageIdentifier into a version constraint option. constraint :: PackageIdentifier -> String constraint i = "--constraint=" ++ name ++ "==" ++ ver where name = display . pkgName $ i ver = display . pkgVersion $ i data PackageInstResult = PackageInstResult { piSetup :: CmdResult , piConfigure :: CmdResult , piBuild :: CmdResult , piInst :: CmdResult , piGhcPkg :: CmdResult } deriving Show instance CheckOk PackageInstResult where isOk r = all isOk . map ($ r) $ [ piSetup, piConfigure, piBuild, piInst, piGhcPkg ] -- | Install a package. -- -- Preconditions: -- -- * dependencies have already been installed at the given prefix -- and registered in the given db -- -- * current directory has the unpacked tarball. -- -- Side effects: -- -- * package should be installed, or exit code is non-zero installPackage :: FilePath -- ^ Install using this compiler (full path to compiler) -> FilePath -- ^ Full path to ghc-pkg -> FilePath -- ^ User DB -> FilePath -- ^ Installation prefix -> IO PackageInstResult installPackage ghc pkg db pfx = do let opts = [ "configure" , "--verbose=2" , "--with-compiler=" ++ ghc , "--with-hc-pkg=" ++ pkg , "--prefix=" ++ pfx , "--package-db=clear" , "--package-db=global" , "--package-db=" ++ db , "--enable-tests" ] rBuildSetup <- tee "ghc" ["--make", "Setup.hs"] rConf <- tee "./Setup" opts let bOpts = [ "build", "--verbose=2" ] rBuild <- tee "./Setup" bOpts rInst <- tee "./Setup" ["install", "--verbose=2"] rPkg <- tee pkg [ "list" , "--global" , "--package-conf=" ++ db ] return $ PackageInstResult rBuildSetup rConf rBuild rInst rPkg -- | Test a package. -- -- Preconditions: -- -- * Package has already been built. testPackage :: [(String, [String])] -- ^ From the package root directory, these are the commands and -- arguments to run to test the package. -> IO [CmdResult] testPackage = mapM (uncurry tee) data InstallAndTestResult = InstallAndTestResult { itDate :: UTCTime -- ^ Current time , itGhcVersion :: CmdResult -- ^ Result of ghc --version , itPkgVersion :: CmdResult -- ^ Result of ghc-pkg --version , itCabalVersion :: CmdResult -- ^ Result of cabal --version , itCompileSetup :: CmdResult -- ^ Result from compiling Setup.hs , itSdistDeps :: CmdResult -- ^ Result from running sdist to create tree from which to -- install dependencies , itSdistPkg :: CmdResult -- ^ Result from running sdist to create tree from which to -- install package , itInit :: CmdResult -- ^ Result from initializing user package DB , itDeps :: InstallResult , itPackage :: PackageInstResult , itTest :: [CmdResult] } deriving Show instance CheckOk InstallAndTestResult where isOk r = and [ rsltsOk, testsOk, isOk (itDeps r), isOk (itPackage r) ] where rsltsOk = all isOk . map ($ r) $ [ itGhcVersion, itPkgVersion, itCabalVersion, itCompileSetup, itSdistDeps, itSdistPkg, itInit ] testsOk = all isOk . itTest $ r -- | Performs test installation. -- -- Preconditions: -- -- * is run from root package directory. A temporary directory is -- created inside this directory. All work is done within the -- temporary directory. -- -- Postconditions: -- -- * cleans up after itself; no temporary files should remain. installAndTest :: [PackageIdentifier] -- ^ Constraints -> UTCTime -> FilePath -- ^ Path to compiler -> FilePath -- ^ Path to ghc-pkg -> FilePath -- ^ Path to cabal executable -> [(String, [String])] -- ^ How to test the package -> IO InstallAndTestResult installAndTest cs date ghc pkg cabal test = withTempDirectory verbose "." "sunlight" $ \relDir -> do dir <- canonicalizePath relDir let setup = dir ++ "/Setup" distDeps = dir ++ "/distDeps" distPkg = dir ++ "/distPkg" db = dir ++ "/db" pfx = dir ++ "/prefix" ghcVer <- tee ghc ["--version"] pkgVer <- tee pkg ["--version"] cblVer <- tee "cabal" ["--version"] rSetup <- tee ghc ["--make", "-outputdir", dir, "-o", setup, "Setup.hs"] rDistDeps <- tee setup ["sdist", "--output-directory=" ++ distDeps ] rDistPkg <- tee setup ["sdist", "--output-directory=" ++ distPkg ] createDirectory pfx rInit <- tee pkg ["init", db] rDeps <- inDirectory distDeps $ installDeps cs ghc pkg cabal db pfx rInst <- inDirectory distPkg $ installPackage ghc pkg db pfx rTest <- inDirectory distPkg $ testPackage test return $ InstallAndTestResult date ghcVer pkgVer cblVer rSetup rDistDeps rDistPkg rInit rDeps rInst rTest -- | Gets a list of PackageIdentifier with the lowest possible -- versions. Fails if a package has a dependency range with no -- minimum. lowestVersions :: GenericPackageDescription -> Either Dependency [PackageIdentifier] -- ^ Left with the bad dependency if there is one; Right -- otherwise. lowestVersions pd = mapM lowestVersion ls where ls = depsLib ++ depsExe ++ depsTest ++ depsBench depsLib = case condLibrary pd of Nothing -> [] Just deps -> getDependencies deps getDeps = getDepsList pd depsExe = getDeps condExecutables depsTest = getDeps condTestSuites depsBench = getDeps condBenchmarks getDepsList :: GenericPackageDescription -> (GenericPackageDescription -> [(a, CondTree b [Dependency] c)]) -> [Dependency] getDepsList d f = concatMap getDependencies . map snd . f $ d getDependencies :: CondTree v [Dependency] a -> [Dependency] getDependencies t = let this = condTreeConstraints t rest = concatMap getDependencies . map sel2 . condTreeComponents $ t in this ++ rest lowestVersion :: Dependency -> Either Dependency PackageIdentifier lowestVersion d@(Dependency n r) = case asVersionIntervals r of [] -> Left d (LowerBound v b, _):_ | b == ExclusiveBound -> Left d | otherwise -> Right $ PackageIdentifier n v testLowestVersions :: UTCTime -> FilePath -- ^ Path to compiler -> FilePath -- ^ Path to ghc-pkg -> FilePath -- ^ Path to cabal executable -> [(String, [String])] -- ^ How to test the package -> GenericPackageDescription -> Either Dependency (IO InstallAndTestResult) testLowestVersions date ghc pkg cabal test d = case lowestVersions d of Left e -> Left e Right ps -> Right $ installAndTest ps date ghc pkg cabal test testDefaultVersions :: UTCTime -> FilePath -- ^ Path to compiler -> FilePath -- ^ Path to ghc-pkg -> FilePath -- ^ Path to cabal executable -> [(String, [String])] -- ^ How to test the package -> IO InstallAndTestResult testDefaultVersions date ghc pkg cabal test = installAndTest [] date ghc pkg cabal test testMultipleVersions :: UTCTime -> (FilePath, FilePath) -- ^ Compiler and ghc-pkg to use when testing lowest version -> [(FilePath, FilePath)] -- ^ Compilers and ghc-pkg to use to test default versions -> FilePath -- ^ Path to cabal executable -> [(String, [String])] -- ^ How to test package -> GenericPackageDescription -> Either Dependency (IO (InstallAndTestResult, [InstallAndTestResult])) testMultipleVersions date (lowestGhc, lowestPkg) rest cabal test pd = case testLowestVersions date lowestGhc lowestPkg cabal test pd of Left d -> Left d Right g -> Right $ do r1 <- g let testRest (c, p) = testDefaultVersions date c p cabal test rs <- mapM testRest rest return (r1, rs) -- Sample test directory structure: -- minimum-versions.txt -- current-versions.txt -- sunlight -- - 2014 -- - 02 -- - 22 -- - UTCtime -- - [random string] -- - lowest-7.4.2-passed.txt -- - current-7.4.2-passed.txt -- - current-7.6.1-passed.txt versionsReport :: BS8.ByteString -- ^ Description of dependencies -> Compiler -> Description -> UTCTime -> InstallAndTestResult -> BS8.ByteString versionsReport desc c d t r = BS8.concat [ "This package was tested to work with these dependency\n" , "versions and compiler version.\n" , desc , "Tested as of: " <> (BS8.pack . show $ t) <> "\n" , "Path to compiler: " <> BS8.pack c <> "\n" , "Compiler description: " <> BS8.pack d <> "\n" , "\n" ] <> (crStdOut . piGhcPkg . itPackage $ r) minimumVersionsReport :: Compiler -> Description -> UTCTime -> InstallAndTestResult -> BS8.ByteString minimumVersionsReport = versionsReport "These are the minimum versions given in the .cabal file.\n" writeMinimumVersionsReport :: BS8.ByteString -> IO () writeMinimumVersionsReport = BS8.writeFile "minimum-versions.txt" currentVersionsReport :: UTCTime -> [(Description, Compiler, a)] -> [InstallAndTestResult] -> Maybe BS8.ByteString currentVersionsReport t ds ts | null ds || null ts = Nothing | otherwise = Just $ versionsReport dep comp desc t r where dep = "These are the default versions fetched by cabal install.\n" (desc, comp, _) = last ds r = last ts writeCurrentVersionsReport :: BS8.ByteString -> IO () writeCurrentVersionsReport = BS8.writeFile "current-versions.txt" instance ShowBS InstallResult where showBS r = showBS (drOutput r) <> showBS (drGhcPkg r) instance ShowBS PackageInstResult where showBS r = showBS (piSetup r) <> showBS (piConfigure r) <> showBS (piBuild r) <> showBS (piInst r) <> showBS (piGhcPkg r) instance ShowBS InstallAndTestResult where showBS i = "Current time: " <> showBS (itDate i) <> "\n" <> showBS (itGhcVersion i) <> showBS (itPkgVersion i) <> showBS (itCabalVersion i) <> showBS (itCompileSetup i) <> showBS (itSdistDeps i) <> showBS (itSdistPkg i) <> showBS (itInit i) <> showBS (itDeps i) <> showBS (itPackage i) <> (BS8.concat . map showBS . itTest $ i) -- | Gets four-character random string. randomString :: IO String randomString = fmap (map toEnum) $ replicateM 4 (getStdRandom (randomR (fromEnum 'a', fromEnum 'z'))) makeResultFile :: UTCTime -> String -- ^ Random string -> String -- ^ Description string, e.g. "lowest" or "current" -> String -- ^ Compiler version description -> InstallAndTestResult -> IO () makeResultFile utct rand desc comp res = do let dir = resultDirectory utct rand createDirectoryIfMissing True dir let fn = dir ++ "/" ++ desc ++ "-" ++ comp ++ "-" ++ passFail ++ ".txt" passFail | isOk res = "passed" | otherwise = "FAILED" BS8.writeFile fn . showBS $ res resultDirectory :: UTCTime -> String -- ^ Random string -> String resultDirectory t s = concat . intersperse "/" $ ["sunlight", yr, mo, dy, ti, s ] where (y, m, d) = toGregorian . utctDay $ t yr = show y mo = pad . show $ m dy = pad . show $ d pad str | length str > 1 = str | otherwise = '0':str ti = formatTime defaultTimeLocale "%H%M%S" t -- | A description for this compiler version. This will be used in -- the directory name in the tree that is written to disk. I simply -- use a compiler version, such as @7.4@. type Description = String -- | Path to GHC compiler. You can use a full path name or, if you use -- just an executable name, the PATH will be searched. type Compiler = String -- | Path to ghc-pkg. If you use just an executable name, the PATH -- will be searched. type GhcPkg = String -- | Path to cabal executable. Used to install package -- dependencies. If you use just an executable name, the PATH will -- be searched. type Cabal = String data TestInputs = TestInputs { tiDescription :: Maybe GenericPackageDescription -- ^ If Just, use this package description. Handy if you write -- your own package description in Haskell rather than in the -- cabal format. Otherwise, use Nothing and sunlight will look -- for the default cabal file and parse that. , tiCabal :: Cabal -- ^ Which @cabal@ executable to use. , tiLowest :: (Description, Compiler, GhcPkg) -- ^ Test the minimum dependency bounds using this compiler. A -- report is left in the main package directory showing which -- package versions worked with this compiler and with the minimum -- bounds. , tiDefault :: [(Description, Compiler, GhcPkg)] -- ^ Test the default dependencies using these compilers. Since -- cabal-install will eagerly get the most recent dependencies it -- can find, this will test the highest possible versions. The -- compiler specified in 'tiLowest' is not automatically retried -- here, so if you want to use that compiler specify it as well. -- -- The last compiler in this list is assumed to be the most recent -- compiler. A report is left in the main package directory -- showing the dependencies that worked with this compiler -- version. , tiTest :: [(String, [String])] -- ^ How to test the package. Each pair is a command to run, -- and the arguments to that command. The command is run from -- the resulting package root directory after the package is -- unpacked and compiled. So, if your test suite is an -- executable called @myTest@ and it requires the argument -- @--verbose@, you would use something like this: -- -- > [("dist/build/myTest/myTest", ["--verbose"])] -- -- The tests are considered to pass if all these commands exit -- with a zero exit status. -- -- You can also abuse this option to get output in the record of -- the test; for instance, maybe you want to record the current -- git HEAD: -- -- > [("git", ["rev-parse", "HEAD"])] } deriving Show runTests :: TestInputs -> IO () runTests i = do desc <- case tiDescription i of Just pd -> return pd Nothing -> do path <- defaultPackageDesc verbose readPackageDescription verbose path date <- getCurrentTime randStr <- randomString let last2 (_, b, c) = (b, c) eiRes = testMultipleVersions date (last2 . tiLowest $ i) (map last2 . tiDefault $ i) (tiCabal i) (tiTest i) desc (r1, rs) <- case eiRes of Left e -> dependencyError e Right g -> g makeResultFile date randStr "lowest" (sel1 . tiLowest $ i) r1 let makeRest r (d, _, _) = makeResultFile date randStr "current" d r _ <- zipWithM makeRest rs (tiDefault i) when (not $ isOk r1 && all isOk rs) exitFailure writeMinimumVersionsReport $ minimumVersionsReport (sel2 . tiLowest $ i) (sel1 . tiLowest $ i) date r1 maybe (return ()) writeCurrentVersionsReport $ currentVersionsReport date (tiDefault i) rs exitSuccess dependencyError :: Dependency -> IO a dependencyError d = putStrLn s >> exitFailure where s = "dependency invalid: " ++ show d ++ " sunlight requires " ++ "that you specify a fixed lower bound for each dependency."