packages feed

skeletest-0.4.0: src/Skeletest/Internal/Spec.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoFieldSelectors #-}

module Skeletest.Internal.Spec (
  -- * Spec interface
  X.Spec,
  X.SpecM,
  X.SpecTree (..),

  -- ** Execution
  SpecRunner,
  newSpecRunner,

  -- ** Entrypoint
  X.SpecRegistry,
  X.SpecInfo (..),

  -- ** Defining a Spec
  X.describe,
  X.Testable (..),
  X.test,
  X.it,

  -- ** Modifiers
  X.xfail,
  X.skip,
  X.focus,
  X.markManual,

  -- ** Markers
  X.IsMarker (..),
  X.withMarkers,
  X.withMarker,

  -- ** Runtime functionality
  skipTest,

  -- ** Plugin
  specTreePlugin,
) where

import Control.Concurrent (myThreadId)
import Control.Monad.IO.Class (MonadIO)
import Control.Monad.Trans.State.Strict qualified as State
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (NominalDiffTime)
import GHC.Records (HasField (..))
import Skeletest.Internal.Error (SkeletestError (..))
import Skeletest.Internal.Exit (TestExitCode (..))
import Skeletest.Internal.Fixtures (FixtureScopeKey (..), cleanupFixtures)
import Skeletest.Internal.Hooks (
  ModifyTestSummaryHookContext (..),
  OnTestFailureHookContext (..),
  RunTestHookContext (..),
  userHooks,
 )
import Skeletest.Internal.Hooks qualified as Hooks
import Skeletest.Internal.Markers (
  findMarker,
 )
import Skeletest.Internal.Spec.TestReporter (
  TestReporter,
  newTestReporter,
 )
import Skeletest.Internal.Spec.Tree (
  MarkerFocus (..),
  MarkerManual (..),
  MarkerSkip (..),
  MarkerXFail (..),
  SpecInfo (..),
  SpecRegistry,
  SpecTest (..),
  SpecTree (..),
  applyTestSelections,
  getSpecTrees,
  mapSpecs,
  pruneSpec,
  traverseSpecTests,
  traverseSpecs,
 )
import Skeletest.Internal.Spec.Tree qualified as X
import Skeletest.Internal.TestInfo (TestInfo (TestInfo), withTestInfo)
import Skeletest.Internal.TestInfo qualified as TestInfo
import Skeletest.Internal.TestRunner (
  TestResult (..),
  TestResultMessage (..),
  TestResultStatus (..),
  testResultFromAssertionFail,
  testResultFromError,
 )
import Skeletest.Internal.Utils.Color qualified as Color
import Skeletest.Internal.Utils.Term qualified as Term
import Skeletest.Internal.Utils.Text (pluralize)
import Skeletest.Internal.Utils.Timer (renderDuration, withTimer)
import Skeletest.Plugin (Hooks (..), Plugin (..), defaultHooks, defaultPlugin, filterSpecTests, hasMarker)
import Skeletest.Plugin qualified as Plugin
import UnliftIO.Exception (
  catch,
  finally,
  fromException,
  throwIO,
 )

{----- Execute spec -----}

data SpecRunner = SpecRunner
  { testSummary :: TestSummary
  , testReporter :: TestReporter
  }

newSpecRunner :: SpecRegistry -> IO SpecRunner
newSpecRunner initialSpecs = do
  testSummary <- newTestSummary initialSpecs
  testReporter <- newTestReporter
  pure
    SpecRunner
      { testSummary
      , testReporter
      }

instance HasField "run" SpecRunner (SpecRegistry -> IO TestExitCode) where
  getField runner specs = withTestSummary $ do
    (`finally` cleanupFixtures PerSessionFixtureKey) $
      resolveExitCode <$> mapM runner.runFile (pruneSpec specs)
   where
    withTestSummary action = do
      runner.testSummary.update $ \d -> d{testsSelected = getTotalTests specs}
      recordDuration runner.testSummary action

instance HasField "runFile" SpecRunner (SpecInfo -> IO TestExitCode) where
  getField runner info = do
    (`finally` cleanupFixtures (PerFileFixtureKey info.specPath)) $ do
      runner.testReporter.reportFilePre info.specPath
      (code, duration) <- withTimer $ runner.runTrees emptyTestInfo trees
      runner.testReporter.reportFilePost info.specPath (code, duration)
      pure code
   where
    trees = getSpecTrees info.spec
    emptyTestInfo =
      TestInfo
        { contexts = []
        , name = ""
        , markers = []
        , file = info.specPath
        }

instance HasField "runTrees" SpecRunner (TestInfo -> [SpecTree] -> IO TestExitCode) where
  getField runner testInfo = fmap resolveExitCode . mapM runTree
   where
    runTree = \case
      SpecTree_Group{label, trees} -> runner.runGroup testInfo label trees
      SpecTree_Test test ->
        runner.runTest
          testInfo
            { TestInfo.name = test.name
            , TestInfo.markers = test.markers
            }
          test

instance HasField "runGroup" SpecRunner (TestInfo -> Text -> [SpecTree] -> IO TestExitCode) where
  getField runner testInfo label trees = do
    runner.testReporter.reportGroupPre testInfo label
    (code, duration) <- withTimer $ runner.runTrees testInfo' trees
    runner.testReporter.reportGroupPost testInfo label (code, duration)
    pure code
   where
    testInfo' = testInfo{TestInfo.contexts = testInfo.contexts <> [label]}

instance HasField "runTest" SpecRunner (TestInfo -> SpecTest -> IO TestExitCode) where
  getField runner testInfo test = withTestInfo testInfo $ do
    runner.testReporter.reportTestPre testInfo
    tid <- myThreadId
    (result, duration) <-
      (`finally` cleanupFixtures (PerTestFixtureKey tid)) $ do
        withTimer . runTestHook $ test.action `catch` onTestFailureHook mkTestResultError
    runner.testReporter.reportTestPost testInfo (result, duration)

    runner.testSummary.update $ \d ->
      d
        { testCategories =
            Map.alter
              (Just . (+ 1) . fromMaybe 0)
              result.status
              d.testCategories
        }

    pure $ if result.status.success then ExitSuccess else ExitTestFailure
   where
    runTestHook action =
      let ctx =
            RunTestHookContext
              { testInfo
              }
       in userHooks.runTest ctx () $ \() -> action
    onTestFailureHook action e =
      let ctx =
            OnTestFailureHookContext
              { testInfo
              }
       in userHooks.onTestFailure ctx e action
    mkTestResultError e =
      case fromException e of
        Just e' -> testResultFromAssertionFail e'
        Nothing -> testResultFromError e

instance HasField "printSummary" SpecRunner (IO ()) where
  getField runner = do
    summary0 <- runner.testSummary.render
    summary <-
      userHooks.modifyTestSummary
        ModifyTestSummaryHookContext
        summary0
        pure
    Term.output ""
    Term.outputN . colorize . Text.strip $ summary
   where
    colorize = Text.unlines . map Color.yellow . Text.lines

-- | Resolve the given exit codes, returning the first non-success code.
resolveExitCode :: [TestExitCode] -> TestExitCode
resolveExitCode = go
 where
  go = \case
    [] -> ExitSuccess
    ExitSuccess : rest -> go rest
    code : _ -> code

{----- Test summary -----}

newtype TestSummary = TestSummary (IORef TestSummaryData)

data TestSummaryData = TestSummaryData
  { totalTests :: !Int
  , testsSelected :: !Int
  , testCategories :: !(Map TestResultStatus Int)
  , snapshotsUpdated :: !Int
  , totalDuration :: !NominalDiffTime
  }

newTestSummary :: SpecRegistry -> IO TestSummary
newTestSummary specs = do
  fmap TestSummary . newIORef $
    TestSummaryData
      { totalTests = getTotalTests specs
      , testsSelected = 0
      , testCategories = Map.empty
      , snapshotsUpdated = 0
      , totalDuration = 0
      }

getTotalTests :: SpecRegistry -> Int
getTotalTests specs = State.execState (count specs) 0
 where
  count = traverseSpecs . traverseSpecTests $ \x -> State.modify (+ 1) *> pure x

recordDuration :: TestSummary -> IO a -> IO a
recordDuration (TestSummary ref) m = do
  (a, duration) <- withTimer m
  modifyIORef' ref $ \d -> d{totalDuration = duration}
  pure a

instance HasField "update" TestSummary ((TestSummaryData -> TestSummaryData) -> IO ()) where
  getField (TestSummary ref) = modifyIORef' ref
instance HasField "render" TestSummary (IO Text) where
  getField (TestSummary ref) = do
    TestSummaryData{..} <- readIORef ref
    let testsDeselected = totalTests - testsSelected
    pure . Text.unlines . concat $
      [ ["═════ Test report ═════"]
      , ["➤ " <> pluralize testsSelected "test" <> " ran in " <> renderDuration totalDuration]
      , [ "  • " <> pluralize count "test" <> " " <> status.name <> " " <> icon
        | (status, count) <- Map.toAscList testCategories
        , let icon =
                case status of
                  TestPassed -> Color.green "✔"
                  TestFailed -> Color.red "✘"
                  TestSkipped -> Color.yellow "≫"
                  TestStatus{success_}
                    | success_ -> Color.green "✔"
                    | otherwise -> Color.red "✘"
        ]
      , when_ (testsDeselected > 0) $
          "  • " <> pluralize testsDeselected "test" <> " deselected"
      ]
   where
    when_ p x = if p then [x] else []

{----- Built-in hooks -----}

specTreePlugin :: Plugin
specTreePlugin =
  defaultPlugin
    { Plugin.hooks =
        mconcat
          [ xfailHook
          , skipHook
          , focusHook
          , applyTestSelectionsHook
          , manualTestsHook
          ]
    }

applyTestSelectionsHook :: Hooks
applyTestSelectionsHook =
  defaultHooks
    { modifySpecRegistry = Hooks.runLate . Hooks.mkPreHook $ \ctx inp ->
        pure $
          case ctx.testTargets of
            Just selections -> map (applyTestSelections selections) inp
            Nothing -> inp
    }

manualTestsHook :: Hooks
manualTestsHook =
  defaultHooks
    { modifySpecRegistry = Hooks.mkPreHook $ \ctx inp ->
        pure $
          case ctx.testTargets of
            -- only hide manual tests when no selections are specified
            Just _ -> inp
            Nothing -> mapSpecs hideManual inp
    }
 where
  hideManual = filterSpecTests (not . hasMarker @MarkerManual . (.markers))

xfailHook :: Hooks
xfailHook =
  defaultHooks
    { runTest = Hooks.mkPostHook $ \ctx _ ->
        case findMarker ctx.testInfo.markers of
          Just (MarkerXFail reason) -> pure . modify reason
          Nothing -> pure
    }
 where
  modify reason result =
    if result.status.success
      then
        TestResult
          { status =
              TestStatus
                { name_ = "xpassed"
                , success_ = False
                }
          , label = Color.red "XPASS"
          , message = TestResultMessageInline reason
          }
      else
        TestResult
          { status = TestPassed
          , label = Color.yellow "XFAIL"
          , message = TestResultMessageInline reason
          }

skipHook :: Hooks
skipHook =
  defaultHooks
    { runTest = Hooks.mkHook $ \ctx run ->
        case findMarker (ctx.testInfo.markers) of
          Just (MarkerSkip reason) -> \_ -> pure $ skipResult reason
          Nothing -> run
    , onTestFailure = Hooks.mkHook $ \_ run e ->
        case fromException e of
          Just (SkipTest reason) -> pure $ skipResult reason
          _ -> run e
    }
 where
  skipResult reason =
    TestResult
      { status = TestSkipped
      , label = Color.yellow "SKIP"
      , message = TestResultMessageInline reason
      }

-- | Like 'X.skip', except allows skipping tests at runtime.
--
-- @since 0.4.0
skipTest :: (MonadIO m) => String -> m a
skipTest reason = throwIO $ SkipTest (Text.pack reason)

focusHook :: Hooks
focusHook =
  defaultHooks
    { modifySpecRegistry = Hooks.runEarly . Hooks.mkPreHook $ \_ specs ->
        pure $
          if hasFocus specs
            then mapSpecs hideNotFocused specs
            else specs
    }
 where
  hasFocus = any (anySpecTests isFocused . (.spec))
  anySpecTests f spec =
    let go = \case
          SpecTree_Group{trees} -> concatMap go trees
          SpecTree_Test test -> [test]
     in any f $ concatMap go (getSpecTrees spec)
  isFocused test = hasMarker @MarkerFocus test.markers
  hideNotFocused = filterSpecTests isFocused