skeletest-0.3.4: src/Skeletest/Internal/Fixtures.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE NoFieldSelectors #-}
module Skeletest.Internal.Fixtures (
Fixture (..),
FixtureScope (..),
FixtureScopeKey (..),
getFixture,
-- * Cleanup
FixtureCleanup (..),
noCleanup,
withCleanup,
cleanupFixtures,
-- * Built-in fixtures
FixtureSkeletestTmpDir (..),
FixtureTmpDir (..),
) where
import Control.Concurrent (ThreadId, myThreadId)
import Control.Monad (forM)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.IORef (IORef, atomicModifyIORef, newIORef)
import Data.Map (Map)
import Data.Map qualified as Map
import Data.Map.Ordered (OMap)
import Data.Map.Ordered qualified as OMap
import Data.Maybe (catMaybes)
import Data.Proxy (Proxy (..))
import Data.Text qualified as Text
import Data.Typeable (TypeRep, Typeable, eqT, typeOf, typeRep, (:~:) (Refl))
import Skeletest.Internal.Error (SkeletestError (..), invariantViolation)
import Skeletest.Internal.TestInfo (getTestInfo)
import Skeletest.Internal.TestInfo qualified as TestInfo
import Skeletest.Internal.Utils.Map qualified as Map.Utils
import System.Directory (
createDirectory,
createDirectoryIfMissing,
getTemporaryDirectory,
removePathForcibly,
)
import System.FilePath ((</>))
import System.IO.Unsafe (unsafePerformIO)
import System.Process (getCurrentPid)
import UnliftIO.Exception (throwIO, tryAny)
class (Typeable a) => Fixture a where
-- | The scope of the fixture, defaults to per-test
fixtureScope :: FixtureScope
fixtureScope = PerTestFixture
fixtureAction :: IO (a, FixtureCleanup)
data FixtureScope
= PerTestFixture
| PerFileFixture
| PerSessionFixture
deriving (Show)
data FixtureCleanup
= NoCleanup
| CleanupFunc (IO ())
data FixtureScopeKey
= PerTestFixtureKey ThreadId
| PerFileFixtureKey FilePath
| PerSessionFixtureKey
deriving (Show)
-- | A helper for specifying no cleanup.
noCleanup :: a -> (a, FixtureCleanup)
noCleanup a = (a, NoCleanup)
-- | A helper for defining the cleanup function in-line.
withCleanup :: a -> IO () -> (a, FixtureCleanup)
withCleanup a cleanup = (a, CleanupFunc cleanup)
-- | Load a fixture, initializing it if it hasn't been cached already.
getFixture :: forall a m. (Fixture a, MonadIO m) => m a
getFixture = liftIO $ do
(getScopedFixtures, updateScopedFixtures) <-
fmap getScopedAccessors $
case fixtureScope @a of
PerTestFixture -> PerTestFixtureKey <$> myThreadId
PerFileFixture -> PerFileFixtureKey . (.file) <$> getTestInfo
PerSessionFixture -> pure PerSessionFixtureKey
let insertFixture state = updateScopedFixtures (OMap.>| (rep, state))
cachedFixture <-
modifyFixtureRegistry $ \registry ->
case OMap.lookup rep $ getScopedFixtures registry of
-- fixture has not been requested yet
Nothing -> (insertFixture FixtureInProgress registry, Right Nothing)
-- fixture has already been requested
Just (FixtureLoaded (fixture :: ty, _)) ->
case eqT @a @ty of
Just Refl -> (registry, Right $ Just fixture)
Nothing ->
invariantViolation . unwords $
[ "fixture registry contained incorrect types."
, "Expected: " <> show rep <> "."
, "Got: " <> show (typeOf fixture)
]
Just FixtureInProgress ->
-- get list of fixtures causing a circular dependency
let fixtures = map fst . filter (isInProgress . snd) . OMap.assocs $ getScopedFixtures registry
in (registry, Left $ FixtureCircularDependency $ map (Text.pack . show) (fixtures <> [rep]))
case cachedFixture of
-- error when getting fixture
Left e -> throwIO e
-- fixture was cached, return it
Right (Just fixture) -> pure fixture
-- otherwise, execute it (allowing it to request other fixtures) and cache the result.
Right Nothing -> do
result@(fixture, _) <- fixtureAction @a
modifyFixtureRegistry $ \registry -> (insertFixture (FixtureLoaded result) registry, ())
pure fixture
where
rep = typeRep (Proxy @a)
isInProgress = \case
FixtureInProgress -> True
_ -> False
-- | Clean up fixtures in the given scope.
--
-- Clean up functions are run in the reverse order the fixtures finished in.
-- For example, if a test asks for fixtures A and C, A asks for B, and C asks
-- for D, the fixtures should finish loading in order: B, A, D, C.
-- Cleanup should then go in order: C, D, A, B.
cleanupFixtures :: FixtureScopeKey -> IO ()
cleanupFixtures scopeKey = do
-- get fixtures in the given scope and clear
fixtures <-
modifyFixtureRegistry $ \registry ->
(updateScopedFixtures (const OMap.empty) registry, getScopedFixtures registry)
errors <-
forM (reverse . map snd . OMap.assocs $ fixtures) $ \case
FixtureInProgress -> pure Nothing
FixtureLoaded (_, NoCleanup) -> pure Nothing
FixtureLoaded (_, CleanupFunc io) -> fromLeft <$> tryAny io
-- throw the first error we encountered
case catMaybes errors of
e : _ -> throwIO e
[] -> pure ()
where
(getScopedFixtures, updateScopedFixtures) = getScopedAccessors scopeKey
fromLeft = \case
Left x -> Just x
Right _ -> Nothing
{----- Fixtures registry -----}
-- | The registry of active fixtures, in order of activation.
data FixtureRegistry = FixtureRegistry
{ sessionFixtures :: FixtureMap
, fileFixtures :: Map FilePath FixtureMap
, testFixtures :: Map ThreadId FixtureMap
}
type FixtureMap = OMap TypeRep FixtureStatus
data FixtureStatus
= FixtureInProgress
| forall a. (Typeable a) => FixtureLoaded (a, FixtureCleanup)
fixtureRegistryRef :: IORef FixtureRegistry
fixtureRegistryRef = unsafePerformIO $ newIORef emptyFixtureRegistry
where
emptyFixtureRegistry =
FixtureRegistry
{ sessionFixtures = OMap.empty
, fileFixtures = Map.empty
, testFixtures = Map.empty
}
{-# NOINLINE fixtureRegistryRef #-}
modifyFixtureRegistry :: (FixtureRegistry -> (FixtureRegistry, a)) -> IO a
modifyFixtureRegistry = atomicModifyIORef fixtureRegistryRef
getScopedAccessors ::
FixtureScopeKey ->
( FixtureRegistry -> FixtureMap
, (FixtureMap -> FixtureMap) -> FixtureRegistry -> FixtureRegistry
)
getScopedAccessors scopeKey =
case scopeKey of
PerTestFixtureKey tid ->
( Map.Utils.findOrEmpty tid . (.testFixtures)
, \f registry -> registry{testFixtures = Map.Utils.adjustNested f tid registry.testFixtures}
)
PerFileFixtureKey fp ->
( Map.Utils.findOrEmpty fp . (.fileFixtures)
, \f registry -> registry{fileFixtures = Map.Utils.adjustNested f fp registry.fileFixtures}
)
PerSessionFixtureKey ->
( (.sessionFixtures)
, \f registry -> registry{sessionFixtures = f registry.sessionFixtures}
)
{----- Built-in fixtures -----}
-- | A fixture that provides a global temporary directory for internal Skeletest use.
newtype FixtureSkeletestTmpDir = FixtureSkeletestTmpDir FilePath
instance Fixture FixtureSkeletestTmpDir where
fixtureScope = PerSessionFixture
fixtureAction = do
tmpdir <- getTemporaryDirectory
pid <- getCurrentPid
let dir = tmpdir </> ("skeletest-tmp-dir." <> show pid)
removePathForcibly dir
createDirectoryIfMissing True dir
pure . withCleanup (FixtureSkeletestTmpDir dir) $
removePathForcibly dir
-- | A fixture that provides a temporary directory that can be used in a test.
newtype FixtureTmpDir = FixtureTmpDir FilePath
instance Fixture FixtureTmpDir where
fixtureAction = do
FixtureSkeletestTmpDir tmpdir <- getFixture
let dir = tmpdir </> "test-tmp-dir"
removePathForcibly dir
createDirectory dir
pure . withCleanup (FixtureTmpDir dir) $
removePathForcibly dir