packages feed

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

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

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

  -- ** Entrypoint
  SpecRegistry,
  SpecInfo (..),
  pruneSpec,
  applyTestSelections,

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

  -- ** Modifiers
  xfail,
  skip,
  markManual,

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

import Control.Concurrent (myThreadId)
import Control.Monad (forM, guard)
import Control.Monad.Trans.Reader qualified as Trans
import Control.Monad.Trans.Writer (Writer, execWriter, tell)
import Data.Functor.Identity (runIdentity)
import Data.Maybe (catMaybes, isJust, mapMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text
import Skeletest.Assertions (Testable, runTestable)
import Skeletest.Internal.Capture (addCapturedOutput, withCaptureOutput)
import Skeletest.Internal.Fixtures (FixtureScopeKey (..), cleanupFixtures)
import Skeletest.Internal.Markers (
  AnonMarker (..),
  IsMarker (..),
  SomeMarker (..),
  findMarker,
 )
import Skeletest.Internal.Spec.Output (
  reportGroup,
  reportTestInProgress,
  reportTestResultWithBoxMessage,
  reportTestResultWithInlineMessage,
  reportTestResultWithoutMessage,
 )
import Skeletest.Internal.TestInfo (TestInfo (TestInfo), withTestInfo)
import Skeletest.Internal.TestInfo qualified as TestInfo
import Skeletest.Internal.TestRunner (
  TestResult (..),
  TestResultMessage (..),
  testResultFromAssertionFail,
  testResultFromError,
 )
import Skeletest.Internal.TestTargets (TestTarget, TestTargets, matchesTest)
import Skeletest.Internal.TestTargets qualified as TestTargets
import Skeletest.Internal.Utils.Color qualified as Color
import Skeletest.Plugin (Hooks (..), defaultHooks)
import Skeletest.Prop.Internal (Property)
import System.Console.Terminal.Size qualified as Term
import UnliftIO.Exception (
  finally,
  fromException,
  try,
 )

type Spec = Spec' ()

newtype Spec' a = Spec (Writer [SpecTree] a)
  deriving (Functor, Applicative, Monad)

getSpecTrees :: Spec -> [SpecTree]
getSpecTrees (Spec spec) = execWriter spec

withSpecTrees :: (Monad m) => ([SpecTree] -> m [SpecTree]) -> Spec -> m Spec
withSpecTrees f = fmap (Spec . tell) . f . getSpecTrees

data SpecTree
  = SpecGroup
      { groupLabel :: Text
      , groupTrees :: [SpecTree]
      }
  | SpecTest
      { testName :: Text
      , testMarkers :: [SomeMarker]
      -- ^ Markers, in order from least to most recently applied.
      --
      -- >>> withMarker MarkerA . withMarker MarkerB $ test ...
      --
      -- will contain
      --
      -- >>> SpecTest { testMarkers = [MarkerA, MarkerB] }
      , testAction :: IO TestResult
      }

-- | Traverse the tree with the given processing function.
--
-- To preprocess trees with @pre@ and postprocess with @post@:
--
-- >>> traverseSpecTrees (\go -> post <=< mapM go <=< pre) spec
traverseSpecTrees ::
  forall m.
  (Monad m) =>
  ( (SpecTree -> m SpecTree) ->
    [SpecTree] ->
    m [SpecTree]
  ) ->
  Spec ->
  m Spec
traverseSpecTrees f = withSpecTrees go
 where
  go :: [SpecTree] -> m [SpecTree]
  go = f recurseGroups

  recurseGroups = \case
    group@SpecGroup{} -> do
      trees' <- go $ groupTrees group
      pure group{groupTrees = trees'}
    stest@SpecTest{} -> pure stest

-- | Map the tree with the given processing function.
--
-- To preprocess trees with @pre@ and postprocess with @post@:
--
-- >>> mapSpecTrees (\go -> post . map go . pre) spec
mapSpecTrees ::
  ( (SpecTree -> SpecTree) ->
    [SpecTree] ->
    [SpecTree]
  ) ->
  Spec ->
  Spec
mapSpecTrees f = runIdentity . traverseSpecTrees (\go -> pure . f (runIdentity . go))

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

-- | Run the given Specs and return whether all of the tests passed.
runSpecs :: Hooks -> SpecRegistry -> IO Bool
runSpecs hooks0 specs =
  (`finally` cleanupFixtures PerSessionFixtureKey) $
    fmap and . forM specs $ \SpecInfo{..} ->
      (`finally` cleanupFixtures (PerFileFixtureKey specPath)) $ do
        let emptyTestInfo =
              TestInfo
                { testContexts = []
                , testName = ""
                , testMarkers = []
                , testFile = specPath
                }
        Text.putStrLn $ Text.pack specPath
        runTrees emptyTestInfo $ getSpecTrees specSpec
 where
  Hooks{..} = builtinHooks <> hooks0
  builtinHooks = xfailHook <> skipHook

  runTrees baseTestInfo = fmap and . mapM (runTree baseTestInfo)
  runTree baseTestInfo = \case
    SpecGroup{..} -> do
      let lvl = getIndentLevel baseTestInfo
      reportGroup lvl groupLabel
      runTrees baseTestInfo{TestInfo.testContexts = TestInfo.testContexts baseTestInfo <> [groupLabel]} groupTrees
    SpecTest{..} -> do
      let lvl = getIndentLevel baseTestInfo
      reportTestInProgress lvl testName

      let testInfo =
            baseTestInfo
              { TestInfo.testName = testName
              , TestInfo.testMarkers = testMarkers
              }
      TestResult{..} <-
        withTestInfo testInfo $ do
          tid <- myThreadId
          runTest testInfo testAction `finally` cleanupFixtures (PerTestFixtureKey tid)

      case testResultMessage of
        TestResultMessageNone -> do
          reportTestResultWithoutMessage testResultLabel
        TestResultMessageInline msg -> do
          reportTestResultWithInlineMessage lvl testResultLabel msg
        TestResultMessageBox box -> do
          termSize <- Term.size
          reportTestResultWithBoxMessage termSize lvl testName testResultLabel box
      pure testResultSuccess

  runTest info action =
    hookRunTest info $ do
      (mCapture, resultOrError) <- withCaptureOutput (try action)
      case resultOrError of
        Right result -> pure result
        Left e ->
          fmap (addCapturedOutput mCapture) $
            case fromException e of
              Just e' -> testResultFromAssertionFail e'
              Nothing -> testResultFromError e

  getIndentLevel testInfo = length (TestInfo.testContexts testInfo) + 1 -- +1 to include the module name

{----- Entrypoint -----}

type SpecRegistry = [SpecInfo]

data SpecInfo = SpecInfo
  { specPath :: FilePath
  , specSpec :: Spec
  }

pruneSpec :: SpecRegistry -> SpecRegistry
pruneSpec = mapMaybe $ \info -> do
  let spec = mapSpecTrees (\go -> filter (not . isEmptySpec) . map go) (specSpec info)
  guard $ (not . null . getSpecTrees) spec
  pure info{specSpec = spec}
 where
  isEmptySpec = \case
    SpecGroup _ [] -> True
    _ -> False

-- TODO: make hookable? implement manual tests with hook?
applyTestSelections :: TestTargets -> SpecRegistry -> SpecRegistry
applyTestSelections = \case
  Just selections -> map (applyTestSelections' selections)
  -- if no selections are specified, hide manual tests
  Nothing -> map (\info -> info{specSpec = hideManualTests $ specSpec info})
 where
  hideManualTests = mapSpecTrees (\go -> filter (not . isManualTest) . map go)
  isManualTest = \case
    SpecGroup{} -> False
    SpecTest{testMarkers} -> isJust $ findMarker @MarkerManual testMarkers

applyTestSelections' :: TestTarget -> SpecInfo -> SpecInfo
applyTestSelections' selections info = info{specSpec = applySelections $ specSpec info}
 where
  applySelections = (`Trans.runReader` []) . traverseSpecTrees apply

  apply go = mapMaybeM $ \case
    group@SpecGroup{groupLabel} -> Just <$> Trans.local (<> [groupLabel]) (go group)
    stest@SpecTest{testName, testMarkers} -> do
      groups <- Trans.ask
      let attrs =
            TestTargets.TestAttrs
              { testPath = specPath info
              , testIdentifier = groups <> [testName]
              , testMarkers = [Text.pack $ getMarkerName m | SomeMarker m <- testMarkers]
              }
      pure $
        if matchesTest selections attrs
          then Just stest
          else Nothing

  mapMaybeM f = fmap catMaybes . mapM f

{----- Defining a Spec -----}

-- | The entity or concept being tested.
describe :: String -> Spec -> Spec
describe name = runIdentity . withSpecTrees (pure . (: []) . mkGroup)
 where
  mkGroup trees =
    SpecGroup
      { groupLabel = Text.pack name
      , groupTrees = trees
      }

test :: (Testable m) => String -> m () -> Spec
test name t = Spec $ tell [mkTest]
 where
  mkTest =
    SpecTest
      { testName = Text.pack name
      , testMarkers = []
      , testAction = runTestable t
      }

-- | Define an IO-based test.
--
-- Should typically be written to be read as full sentences in traditional BDD style:
-- https://en.wikipedia.org/wiki/Behavior-driven_development.
--
-- @
-- describe \"User\" $ do
--   it "can be checked for equality" $ do
--     user1 `shouldBe` user1
-- @
it :: String -> IO () -> Spec
it = test

-- | Define a property test.
--
-- @
-- describe \"User\" $ do
--   prop "decode . encode === Just" $ do
--     let genUser = ...
--     (decode . encode) P.=== Just \`shouldSatisfy\` P.isoWith genUser
-- @
prop :: String -> Property -> Spec
prop = test

{----- Modifiers -----}

-- | Mark the given spec as expected to fail.
-- Fails tests if they unexpectedly pass.
--
-- Can be selected with the marker @@xfail@
xfail :: String -> Spec -> Spec
xfail = withMarker . MarkerXFail . Text.pack

xfailHook :: Hooks
xfailHook =
  defaultHooks
    { hookRunTest = \testInfo runTest ->
        case findMarker (TestInfo.testMarkers testInfo) of
          Just (MarkerXFail reason) -> modify reason <$> runTest
          Nothing -> runTest
    }
 where
  modify reason TestResult{..} =
    if testResultSuccess
      then
        TestResult
          { testResultSuccess = False
          , testResultLabel = Color.red "XPASS"
          , testResultMessage = TestResultMessageInline reason
          }
      else
        TestResult
          { testResultSuccess = True
          , testResultLabel = Color.yellow "XFAIL"
          , testResultMessage = TestResultMessageInline reason
          }

-- | Skip all tests in the given spec.
--
-- Can be selected with the marker @@skip@
skip :: String -> Spec -> Spec
skip = withMarker . MarkerSkip . Text.pack

skipHook :: Hooks
skipHook =
  defaultHooks
    { hookRunTest = \testInfo runTest ->
        case findMarker (TestInfo.testMarkers testInfo) of
          Just (MarkerSkip reason) ->
            pure
              TestResult
                { testResultSuccess = True
                , testResultLabel = Color.yellow "SKIP"
                , testResultMessage = TestResultMessageInline reason
                }
          Nothing -> runTest
    }

-- | Mark tests as tests that should only be run when explicitly specified on the command line.
markManual :: Spec -> Spec
markManual = withMarker MarkerManual

{----- Markers -----}

newtype MarkerXFail = MarkerXFail Text
  deriving (Show)

instance IsMarker MarkerXFail where
  getMarkerName _ = "xfail"

newtype MarkerSkip = MarkerSkip Text
  deriving (Show)

instance IsMarker MarkerSkip where
  getMarkerName _ = "skip"

data MarkerManual = MarkerManual
  deriving (Show)

instance IsMarker MarkerManual where
  getMarkerName _ = "manual"

-- | Adds the given marker to all the tests in the given spec.
--
-- Useful for selecting tests from the command line or identifying tests in hooks
withMarker :: (IsMarker a) => a -> Spec -> Spec
withMarker m = mapSpecTrees (\go -> map (addMarker . go))
 where
  marker = SomeMarker m
  addMarker = \case
    group@SpecGroup{} -> group
    tree@SpecTest{} -> tree{testMarkers = marker : testMarkers tree}

-- | Adds the given names as plain markers to all tests in the given spec.
--
-- See 'getMarkerName'.
withMarkers :: [String] -> Spec -> Spec
withMarkers = foldr (\name acc -> withMarker (AnonMarker name) . acc) id