{- Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. This source code is licensed under the BSD-style license found in the LICENSE file in the root directory of this source tree. -} {-# LANGUAGE ApplicativeDo #-} -- | Make a snapshot regression test out of a 'Driver'. -- -- A snapshot regression test runs queries specified in @.query@ files -- against some indexed sample source code and compares the output -- against the snapshot saved in the correspond @.out@ file. module Glean.Regression.Snapshot ( testMain ) where import Control.Exception import Control.Monad import qualified Data.ByteString as BS import qualified Data.Map.Strict as Map import Data.List import Data.Maybe import qualified Data.Text as Text import System.Directory import System.Exit import System.FilePath import System.IO.Temp import System.Process import qualified Test.HUnit as HUnit import TestRunner import Util.JSON.Pretty () import Util.Log import Glean (Backend) import Glean.Indexer import Glean.Init (withUnitTestOptions) import Glean.Regression.Config import Glean.Regression.Indexer import Glean.Regression.Snapshot.Driver import Glean.Regression.Snapshot.Options import Glean.Regression.Snapshot.Query import Glean.Regression.Snapshot.Result import Glean.Regression.Snapshot.Transform import Glean.Types -- | From 'testRoot' this locates all subdirectories below the root -- that contain at least one ".out" file. discoverTests :: FilePath -> IO [FilePath] discoverTests root = go "" where go dir = do xs <- listDirectory (root dir) dirs <- filterM (doesDirectoryExist . ((root dir) )) xs subdirTests <- concat <$> mapM (go . (dir )) dirs return $ if any (".out" `isSuffixOf`) xs then dir : subdirTests else subdirTests -- | Run one test and its *.query files, return (*.out, *.perf) 'FilePath'. runTest :: Driver opts -> opts -> FilePath -- ^ test root, canonicalized -> TestConfig -> IO [FilePath] runTest driver@Driver{..} driverOpts root testIn = withTestBackend testIn $ \backend -> do let index = indexerRun driverIndexer driverCreateDatabase driverOpts backend index testIn runQueries backend driver root testIn -- | Run the queries runQueries :: Backend b => b -> Driver opts -> FilePath -- ^ test root, canonicalized -> TestConfig -> IO [FilePath] runQueries backend Driver{..} root test = do queries <- get_queries root mempty (testRoot test) when (null queries) $ throwIO $ ErrorCall $ "no .query files found; root=" <> show root fmap concat $ forM (Map.elems queries) $ \query -> do (result, perf) <- runQuery backend (testRepo test) (defaultTransforms <> driverTransforms) query let base = testOutput test dropExtension (takeFileName query) out = base <.> "out" perfOut = base <.> "perf" writeFile out result mapM_ (writeFile perfOut) perf return $ if isJust perf then [out,perfOut] else [out] where get_queries root qs path = do files <- listDirectory path let qs' = Map.union qs $ Map.fromList [ (file, path file) | file <- files, ".query" `isExtensionOf` file ] if equalFilePath path root then return qs' else get_queries root qs' $ takeDirectory path -- | Outputs to compare/regenerate. -- -- When (re)generating the golden outputs, we designate one group (the first -- in the Driver's list) as the base group and generate the base golden output -- from it. For any other group, if the output differs from the base one we -- generate a group-specific output file (`xxx..out`). During testing -- we prefer those group-specific outputs to the base ones. data Outputs = Outputs { outGenerated :: FilePath -- ^ generated output , outGoldenBase :: FilePath -- ^ base golden output , outGoldenGroup :: FilePath -- ^ golden output for this group (can be same as base) } -- | Run one test and check the *.out files against the golden *.out files. executeTest :: Config -> Driver opts -> opts -> String -- ^ group which produces the base golden output ('outGoldenBase') -> String -- ^ current group -> (Outputs -> IO Result) -- ^ compare or overwrite golden outputs -> FilePath -> IO Result executeTest cfg driver driverOpts base_group group diff subdir = with_outdir $ \outdir -> do let test = TestConfig { testRepo = let hash = map (\c -> if c == '/' then '_' else c) subdir in Repo "test" (if null hash then "0" else Text.pack hash) , testOutput = outdir (if null group then id else (group )) subdir , testRoot = cfgRoot cfg subdir , testProjectRoot = cfgProjectRoot cfg , testGroup = group , testSchema = cfgSchema cfg } createDirectoryIfMissing True $ testOutput test outputs <- runTest driver driverOpts (cfgRoot cfg) test compareOutputs test diff base_group group outputs where with_outdir f = case cfgOutput cfg of Just dir -> f dir Nothing -> withSystemTempDirectory "glean-regression" f compareOutputs :: TestConfig -> (Outputs -> IO Result) -- ^ compare or overwrite golden outputs -> String -> String -> [FilePath] -> IO Result compareOutputs test diff base_group group outputs = do fmap mconcat $ forM outputs $ \output -> do let base = testRoot test takeFileName output specific | group == base_group = base | otherwise = outputFileForGroup base group diff Outputs { outGenerated = output , outGoldenBase = base , outGoldenGroup = specific } outputFileForGroup :: FilePath -> String -> FilePath outputFileForGroup base group = addExtension (stem <.> group) ext where (stem,ext) = splitExtension base -- | Regenerate golden outputs. Do nothing if 'outGoldenBase' exists and is the -- same as 'outGenerated'. Otherwise, copy 'outGenerated' to 'outGoldenGroup' -- (which might be the same as 'outGoldenBase'). regenerate :: Outputs -> IO Result regenerate Outputs{..} = do base <- do ex <- doesFileExist outGoldenBase if ex then Just <$> BS.readFile outGoldenBase else return Nothing generated <- BS.readFile outGenerated -- this will either overwrite base or generate a group-specific output when (base /= Just generated) $ BS.writeFile outGoldenGroup generated return (Success [outGoldenGroup]) -- | Compare the generated output with the appropriate golden output via `diff`. -- This uses 'outGoldenGroup' if it exists and 'outGoldenBase' otherwise. diff :: Outputs -> IO Result diff Outputs{..} = do spec <- doesFileExist outGoldenGroup (e, sout, serr) <- readProcessWithExitCode "diff" [outGenerated, if spec then outGoldenGroup else outGoldenBase] "" return $ case e of ExitSuccess -> Success [] ExitFailure n -> failure $ takeFileName outGenerated ++ if n == 1 then ": unexpected result\n" ++ sout else ": fatal error\n" ++ serr -- | Convert a 'Driver' into a regression test over --root parameter. -- -- Normal mode: find all /testRoot/*/*/ directories and run all tests. -- -- With --replace : find all /testRoot/*/*/ directories and update all golden -- *.out files. testMain :: Driver opts -> IO () testMain driver = do let parse = indexerOptParser (driverIndexer driver) withUnitTestOptions (optionsWith parse) $ \act (mk_cfg, indexerOpts) -> do cfg <- mk_cfg testAll act cfg driver indexerOpts testAll :: TestAction -> Config -> Driver opts -> opts -> IO () testAll act cfg driver opts = do tests' <- if null $ cfgTests cfg then discoverTests $ cfgRoot cfg else return $ cfgTests cfg when (null tests') $ die $ "No .out files found under " <> cfgRoot cfg let tests = filter (`notElem` cfgOmitTests cfg) tests' let groups | null fromDriver = [""] | otherwise = fromDriver where fromDriver = driverGroups driver opts case cfgReplace cfg of Just root -> let cfg' = cfg { cfgRoot = root } in testRunnerAction act $ HUnit.TestList $ flip map tests $ \subdir -> HUnit.TestLabel subdir $ HUnit.TestCase $ do -- With --replace, we have to run all groups serially, -- because if we run them in parallel then it would be -- non-deterministic whether we overwrite the output file -- for the base group or a specific group. Also we -- wouldn't know which files we can remove in -- removeNonRegenerated below. result <- mconcat $ flip map groups $ \g -> executeTest cfg' driver opts (head groups) g regenerate subdir removeNonRegenerated root subdir result toHUnit result Nothing -> testRunnerAction act $ HUnit.TestList $ flip map groups $ \g -> (if null g then id else HUnit.TestLabel g) $ HUnit.TestList $ flip map tests $ \subdir -> HUnit.TestLabel subdir $ HUnit.TestCase $ executeTest cfg driver opts (head groups) g diff subdir >>= toHUnit where -- clean-up .out or .perf files which weren't regenerated -- for instance, if a .query file was removed. removeNonRegenerated _ _ Failure{} = return () removeNonRegenerated root test (Success regenerated) = do let path = root test allFiles <- listDirectory path let allOutFiles = filter (\x -> takeExtension x == ".out" || takeExtension x == ".perf") ((path ) <$> allFiles) let toDelete = filter (`notElem` regenerated) allOutFiles when (not (null toDelete)) $ logInfo $ "Removing output files that were not regenerated: " <> intercalate "," toDelete mapM_ removePathForcibly toDelete