{-# LANGUAGE ScopedTypeVariables #-} -- | The test monad module Test.Cabal.Monad ( -- * High-level runners setupAndCabalTest, setupTest, cabalTest, -- * The monad TestM, runTestM, -- * Helper functions programPathM, requireProgramM, isAvailableProgram, hackageRepoToolProgram, gitProgram, cabalProgram, diffProgram, -- * The test environment TestEnv(..), getTestEnv, -- * Recording mode RecordMode(..), testRecordMode, -- * Derived values from 'TestEnv' testCurrentDir, testWorkDir, testPrefixDir, testDistDir, testPackageDbDir, testRepoDir, testKeysDir, testSourceCopyDir, testCabalDir, testUserCabalConfigFile, testActualFile, -- * Skipping tests skip, skipIf, skipUnless, skipExitCode, -- * Known broken tests expectedBroken, unexpectedSuccess, expectedBrokenExitCode, unexpectedSuccessExitCode, -- whenHasSharedLibraries, -- * Arguments (TODO: move me) CommonArgs(..), renderCommonArgs, commonArgParser, ) where import Test.Cabal.Script import Test.Cabal.Plan import Test.Cabal.OutputNormalizer import Distribution.Simple.Compiler ( PackageDBStack, PackageDB(..), compilerFlavor , Compiler, compilerVersion ) import Distribution.System import Distribution.Simple.Program.Db import Distribution.Simple.Program import Distribution.Simple.Configure ( configCompilerEx ) import Distribution.Text import Distribution.Verbosity import Data.Monoid ((<>), mempty) import qualified Control.Exception as E import Control.Monad import Control.Monad.Trans.Reader import Control.Monad.IO.Class import Data.Maybe import Control.Applicative import System.Directory import System.Exit import System.FilePath import System.IO import System.IO.Error (isDoesNotExistError) import System.IO.Temp (withSystemTempDirectory) import System.Process hiding (env) import Options.Applicative data CommonArgs = CommonArgs { argCabalInstallPath :: Maybe FilePath, argGhcPath :: Maybe FilePath, argHackageRepoToolPath :: Maybe FilePath, argHaddockPath :: Maybe FilePath, argAccept :: Bool, argSkipSetupTests :: Bool } commonArgParser :: Parser CommonArgs commonArgParser = CommonArgs <$> optional (option str ( help "Path to cabal-install executable to test" Data.Monoid.<> long "with-cabal" <> metavar "PATH" )) <*> optional (option str ( help "GHC to ask Cabal to use via --with-ghc flag" <> short 'w' <> long "with-ghc" <> metavar "PATH" )) <*> optional (option str ( help "Path to hackage-repo-tool to use for repository manipulation" <> long "with-hackage-repo-tool" <> metavar "PATH" )) <*> optional (option str ( help "Path to haddock to use for --with-haddock flag" <> long "with-haddock" <> metavar "PATH" )) <*> switch ( long "accept" <> help "Accept output" ) <*> switch (long "skip-setup-tests" <> help "Skip setup tests") renderCommonArgs :: CommonArgs -> [String] renderCommonArgs args = maybe [] (\x -> ["--with-cabal", x]) (argCabalInstallPath args) ++ maybe [] (\x -> ["--with-ghc", x]) (argGhcPath args) ++ maybe [] (\x -> ["--with-haddock", x]) (argHaddockPath args) ++ maybe [] (\x -> ["--with-hackage-repo-tool", x]) (argHackageRepoToolPath args) ++ (if argAccept args then ["--accept"] else []) ++ (if argSkipSetupTests args then ["--skip-setup-tests"] else []) data TestArgs = TestArgs { testArgDistDir :: FilePath, testArgScriptPath :: FilePath, testCommonArgs :: CommonArgs } testArgParser :: Parser TestArgs testArgParser = TestArgs <$> option str ( help "Build directory of cabal-testsuite" <> long "builddir" <> metavar "DIR") <*> argument str ( metavar "FILE") <*> commonArgParser skip :: TestM () skip = liftIO $ do putStrLn "SKIP" exitWith (ExitFailure skipExitCode) skipIf :: Bool -> TestM () skipIf b = when b skip skipUnless :: Bool -> TestM () skipUnless b = unless b skip expectedBroken :: TestM () expectedBroken = liftIO $ do putStrLn "EXPECTED FAIL" exitWith (ExitFailure expectedBrokenExitCode) unexpectedSuccess :: TestM () unexpectedSuccess = liftIO $ do putStrLn "UNEXPECTED OK" exitWith (ExitFailure unexpectedSuccessExitCode) skipExitCode :: Int skipExitCode = 64 expectedBrokenExitCode :: Int expectedBrokenExitCode = 65 unexpectedSuccessExitCode :: Int unexpectedSuccessExitCode = 66 catchSkip :: IO a -> IO a -> IO a catchSkip m r = m `E.catch` \e -> case e of ExitFailure c | c == skipExitCode -> r _ -> E.throwIO e setupAndCabalTest :: TestM () -> IO () setupAndCabalTest m = do r1 <- (setupTest m >> return False) `catchSkip` return True r2 <- (cabalTest' "cabal" m >> return False) `catchSkip` return True when (r1 && r2) $ do putStrLn "SKIP" exitWith (ExitFailure skipExitCode) setupTest :: TestM () -> IO () setupTest m = runTestM "" $ do env <- getTestEnv skipIf (testSkipSetupTests env) m cabalTest :: TestM () -> IO () cabalTest = cabalTest' "" cabalTest' :: String -> TestM () -> IO () cabalTest' mode m = runTestM mode $ do skipUnless =<< isAvailableProgram cabalProgram withReaderT (\nenv -> nenv { testCabalInstallAsSetup = True }) m type TestM = ReaderT TestEnv IO gitProgram :: Program gitProgram = simpleProgram "git" hackageRepoToolProgram :: Program hackageRepoToolProgram = simpleProgram "hackage-repo-tool" cabalProgram :: Program cabalProgram = (simpleProgram "cabal") { -- Do NOT search for executable named cabal, it's probably -- not the one you were intending to test programFindLocation = \_ _ -> return Nothing } diffProgram :: Program diffProgram = simpleProgram "diff" -- | Run a test in the test monad according to program's arguments. runTestM :: String -> TestM a -> IO a runTestM mode m = withSystemTempDirectory "cabal-testsuite" $ \tmp_dir -> do args <- execParser (info testArgParser Data.Monoid.mempty) let dist_dir = testArgDistDir args (script_dir0, script_filename) = splitFileName (testArgScriptPath args) script_base = dropExtensions script_filename -- Canonicalize this so that it is stable across working directory changes script_dir <- canonicalizePath script_dir0 let verbosity = normal -- TODO: configurable senv <- mkScriptEnv verbosity -- Add test suite specific programs let program_db0 = addKnownPrograms ([gitProgram, hackageRepoToolProgram, cabalProgram, diffProgram] ++ builtinPrograms) (runnerProgramDb senv) -- Reconfigure according to user flags let cargs = testCommonArgs args -- Reconfigure GHC (comp, platform, program_db2) <- case argGhcPath cargs of Nothing -> return (runnerCompiler senv, runnerPlatform senv, program_db0) Just ghc_path -> do -- All the things that get updated paths from -- configCompilerEx. The point is to make sure -- we reconfigure these when we need them. let program_db1 = unconfigureProgram "ghc" . unconfigureProgram "ghc-pkg" . unconfigureProgram "hsc2hs" . unconfigureProgram "haddock" . unconfigureProgram "hpc" . unconfigureProgram "runghc" . unconfigureProgram "gcc" . unconfigureProgram "ld" . unconfigureProgram "ar" . unconfigureProgram "strip" $ program_db0 -- TODO: this actually leaves a pile of things unconfigured. -- Optimal strategy for us is to lazily configure them, so -- we don't pay for things we don't need. A bit difficult -- to do in the current design. configCompilerEx (Just (compilerFlavor (runnerCompiler senv))) (Just ghc_path) Nothing program_db1 verbosity program_db3 <- reconfigurePrograms verbosity ([("cabal", p) | p <- maybeToList (argCabalInstallPath cargs)] ++ [("hackage-repo-tool", p) | p <- maybeToList (argHackageRepoToolPath cargs)] ++ [("haddock", p) | p <- maybeToList (argHaddockPath cargs)]) [] -- --prog-options not supported ATM program_db2 -- configCompilerEx only marks some programs as known, so to pick -- them up we must configure them program_db <- configureAllKnownPrograms verbosity program_db3 let ghcAndRunnedGhcAreTheSame :: Bool ghcAndRunnedGhcAreTheSame = fromMaybe False $ do ghc_program <- lookupProgram ghcProgram program_db runner_ghc_program <- lookupProgram ghcProgram (runnerProgramDb senv) return $ programPath ghc_program == programPath runner_ghc_program let db_stack = case argGhcPath (testCommonArgs args) of Nothing -> runnerPackageDbStack senv -- NB: canonicalized -- Can't use the build package db stack since they -- are all for the wrong versions! TODO: Make -- this configurable -- -- Oleg: if runner ghc and provided ghc are the same, -- use runnerPackageDbStack. See 'hasCabalForGhc' check. Just _ | ghcAndRunnedGhcAreTheSame -> runnerPackageDbStack senv | otherwise -> [GlobalPackageDB] env = TestEnv { testSourceDir = script_dir, testTmpDir = tmp_dir, testSubName = script_base, testMode = mode, testProgramDb = program_db, testPlatform = platform, testCompiler = comp, testPackageDBStack = db_stack, testVerbosity = verbosity, testMtimeChangeDelay = Nothing, testScriptEnv = senv, testSetupPath = dist_dir "build" "setup" "setup", testSkipSetupTests = argSkipSetupTests (testCommonArgs args), testHaveCabalShared = runnerWithSharedLib senv, testEnvironment = -- Try to avoid Unicode output [ ("LC_ALL", Just "C") -- Hermetic builds (knot-tied) , ("HOME", Just (testHomeDir env)) -- Set CABAL_DIR in addition to HOME, since HOME has no -- effect on Windows. , ("CABAL_DIR", Just (testCabalDir env)) , ("CABAL_CONFIG", Just $ testCabalDir env "config") ], testShouldFail = False, testRelativeCurrentDir = ".", testHavePackageDb = False, testHaveRepo = False, testHaveSourceCopy = False, testCabalInstallAsSetup = False, testCabalProjectFile = "cabal.project", testPlan = Nothing, testRecordDefaultMode = DoNotRecord, testRecordUserMode = Nothing, testRecordNormalizer = id, testSourceCopyRelativeDir = "source" } let go = do cleanup r <- m check_expect (argAccept (testCommonArgs args)) return r runReaderT go env where cleanup = do env <- getTestEnv onlyIfExists . removeDirectoryRecursive $ testWorkDir env -- NB: it's important to initialize this ourselves, as -- the default configuration hardcodes Hackage, which we do -- NOT want to assume for these tests (no test should -- hit Hackage.) liftIO $ createDirectoryIfMissing True (testCabalDir env) ghc_path <- programPathM ghcProgram liftIO $ writeFile (testUserCabalConfigFile env) $ unlines [ "with-compiler: " ++ ghc_path ] check_expect accept = do env <- getTestEnv actual_raw <- liftIO $ readFileOrEmpty (testActualFile env) expect <- liftIO $ readFileOrEmpty (testExpectFile env) norm_env <- mkNormalizerEnv let actual = normalizeOutput norm_env actual_raw when (words actual /= words expect) $ do -- First try whitespace insensitive diff let actual_fp = testNormalizedActualFile env expect_fp = testNormalizedExpectFile env liftIO $ writeFile actual_fp actual liftIO $ writeFile expect_fp expect liftIO $ putStrLn "Actual output differs from expected:" b <- diff ["-uw"] expect_fp actual_fp unless b . void $ diff ["-u"] expect_fp actual_fp if accept then do liftIO $ putStrLn "Accepting new output." liftIO $ writeFileNoCR (testExpectFile env) actual else liftIO $ exitWith (ExitFailure 1) readFileOrEmpty :: FilePath -> IO String readFileOrEmpty f = readFile f `E.catch` \e -> if isDoesNotExistError e then return "" else E.throwIO e -- | Runs 'diff' with some arguments on two files, outputting the -- diff to stderr, and returning true if the two files differ diff :: [String] -> FilePath -> FilePath -> TestM Bool diff args path1 path2 = do diff_path <- programPathM diffProgram (_,_,_,h) <- liftIO $ createProcess (proc diff_path (args ++ [path1, path2])) { std_out = UseHandle stderr } r <- liftIO $ waitForProcess h return (r /= ExitSuccess) -- | Write a file with no CRs, always. writeFileNoCR :: FilePath -> String -> IO () writeFileNoCR f s = withFile f WriteMode $ \h -> do hSetNewlineMode h noNewlineTranslation hPutStr h s mkNormalizerEnv :: TestM NormalizerEnv mkNormalizerEnv = do env <- getTestEnv ghc_pkg_program <- requireProgramM ghcPkgProgram -- Arguably we should use Cabal's APIs but I am too lazy -- to remember what it is list_out <- liftIO $ readProcess (programPath ghc_pkg_program) ["list", "--global", "--simple-output"] "" tmpDir <- liftIO $ getTemporaryDirectory return NormalizerEnv { normalizerRoot = addTrailingPathSeparator (testSourceDir env), normalizerTmpDir = addTrailingPathSeparator (testTmpDir env), normalizerGblTmpDir = addTrailingPathSeparator tmpDir, normalizerGhcVersion = compilerVersion (testCompiler env), normalizerKnownPackages = mapMaybe simpleParse (words list_out), normalizerPlatform = testPlatform env } requireProgramM :: Program -> TestM ConfiguredProgram requireProgramM program = do env <- getTestEnv (configured_program, _) <- liftIO $ requireProgram (testVerbosity env) program (testProgramDb env) return configured_program programPathM :: Program -> TestM FilePath programPathM program = do fmap programPath (requireProgramM program) isAvailableProgram :: Program -> TestM Bool isAvailableProgram program = do env <- getTestEnv case lookupProgram program (testProgramDb env) of Just _ -> return True Nothing -> do -- It might not have been configured. Try to configure. progdb <- liftIO $ configureProgram (testVerbosity env) program (testProgramDb env) case lookupProgram program progdb of Just _ -> return True Nothing -> return False -- | Run an IO action, and suppress a "does not exist" error. onlyIfExists :: MonadIO m => IO () -> m () onlyIfExists m = liftIO $ E.catch m $ \(e :: IOError) -> unless (isDoesNotExistError e) $ E.throwIO e data TestEnv = TestEnv -- UNCHANGING: { -- | Path to the test directory, as specified by path to test -- script. testSourceDir :: FilePath -- | Somewhere to stow temporary files needed by the test. , testTmpDir :: FilePath -- | Test sub-name, used to qualify dist/database directory to avoid -- conflicts. , testSubName :: String -- | Test mode, further qualifies multiple invocations of the -- same test source code. , testMode :: String -- | Program database to use when we want ghc, ghc-pkg, etc. , testProgramDb :: ProgramDb -- | Compiler we are running tests for , testCompiler :: Compiler -- | Platform we are running tests on , testPlatform :: Platform -- | Package database stack (actually this changes lol) , testPackageDBStack :: PackageDBStack -- | How verbose to be , testVerbosity :: Verbosity -- | How long we should 'threadDelay' to make sure the file timestamp is -- updated correctly for recompilation tests. Nothing if we haven't -- calibrated yet. , testMtimeChangeDelay :: Maybe Int -- | Script environment for runghc , testScriptEnv :: ScriptEnv -- | Setup script path , testSetupPath :: FilePath -- | Skip Setup tests? , testSkipSetupTests :: Bool -- | Do we have shared libraries for the Cabal-under-tests? -- This is used for example to determine whether we can build -- detailed-0.9 tests dynamically, since they link against Cabal-under-test. , testHaveCabalShared :: Bool -- CHANGING: -- | Environment override , testEnvironment :: [(String, Maybe String)] -- | When true, we invert the meaning of command execution failure , testShouldFail :: Bool -- | The current working directory, relative to 'testSourceDir' , testRelativeCurrentDir :: FilePath -- | Says if we've initialized the per-test package DB , testHavePackageDb :: Bool -- | Says if we've setup a repository , testHaveRepo :: Bool -- | Says if we've copied the source to a hermetic directory , testHaveSourceCopy :: Bool -- | Says if we're testing cabal-install as setup , testCabalInstallAsSetup :: Bool -- | Says what cabal.project file to use (probed) , testCabalProjectFile :: FilePath -- | Cached record of the plan metadata from a new-build -- invocation; controlled by 'withPlan'. , testPlan :: Maybe Plan -- | If user mode is not set, this is the record mode we default to. , testRecordDefaultMode :: RecordMode -- | User explicitly set record mode. Not implemented ATM. , testRecordUserMode :: Maybe RecordMode -- | Function to normalize recorded output , testRecordNormalizer :: String -> String -- | Name of the subdirectory we copied the test's sources to, -- relative to 'testSourceDir' , testSourceCopyRelativeDir :: FilePath } testRecordMode :: TestEnv -> RecordMode testRecordMode env = fromMaybe (testRecordDefaultMode env) (testRecordUserMode env) data RecordMode = DoNotRecord | RecordMarked | RecordAll deriving (Show, Eq, Ord) getTestEnv :: TestM TestEnv getTestEnv = ask ------------------------------------------------------------------------ -- * Directories -- | The absolute path to the root of the package directory; it's -- where the Cabal file lives. This is what you want the CWD of cabal -- calls to be. testCurrentDir :: TestEnv -> FilePath testCurrentDir env = (if testHaveSourceCopy env then testSourceCopyDir env else testSourceDir env) testRelativeCurrentDir env testName :: TestEnv -> String testName env = testSubName env <.> testMode env -- | The absolute path to the directory containing all the -- files for ALL tests associated with a test (respecting -- subtests.) To clean, you ONLY need to delete this directory. testWorkDir :: TestEnv -> FilePath testWorkDir env = testSourceDir env (testName env <.> "dist") -- | The absolute prefix where installs go. testPrefixDir :: TestEnv -> FilePath testPrefixDir env = testWorkDir env "usr" -- | The absolute path to the build directory that should be used -- for the current package in a test. testDistDir :: TestEnv -> FilePath testDistDir env = testWorkDir env "work" testRelativeCurrentDir env "dist" -- | The absolute path to the shared package database that should -- be used by all packages in this test. testPackageDbDir :: TestEnv -> FilePath testPackageDbDir env = testWorkDir env "packagedb" -- | The absolute prefix where our simulated HOME directory is. testHomeDir :: TestEnv -> FilePath testHomeDir env = testWorkDir env "home" -- | The absolute prefix of our local secure repository, which we -- use to simulate "external" packages testRepoDir :: TestEnv -> FilePath testRepoDir env = testWorkDir env "repo" -- | The absolute prefix of keys for the test. testKeysDir :: TestEnv -> FilePath testKeysDir env = testWorkDir env "keys" -- | If 'withSourceCopy' is used, where the source files go. testSourceCopyDir :: TestEnv -> FilePath testSourceCopyDir env = testWorkDir env testSourceCopyRelativeDir env -- | The user cabal directory testCabalDir :: TestEnv -> FilePath testCabalDir env = testHomeDir env ".cabal" -- | The user cabal config file testUserCabalConfigFile :: TestEnv -> FilePath testUserCabalConfigFile env = testCabalDir env "config" -- | The file where the expected output of the test lives testExpectFile :: TestEnv -> FilePath testExpectFile env = testSourceDir env testName env <.> "out" -- | Where we store the actual output testActualFile :: TestEnv -> FilePath testActualFile env = testWorkDir env testName env <.> "comp.out" -- | Where we will write the normalized actual file (for diffing) testNormalizedActualFile :: TestEnv -> FilePath testNormalizedActualFile env = testActualFile env <.> "normalized" -- | Where we will write the normalized expected file (for diffing) testNormalizedExpectFile :: TestEnv -> FilePath testNormalizedExpectFile env = testWorkDir env testName env <.> "out.normalized"