packages feed

skeletest-0.2.0: src/Skeletest/Internal/TestRunner.hs

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

module Skeletest.Internal.TestRunner (
  -- * Testable
  Testable (..),

  -- * TestResult
  TestResult (..),
  TestResultMessage (..),
  testResultPass,
  testResultFromAssertionFail,
  testResultFromError,

  -- * AssertionFail
  AssertionFail (..),
  FailContext,
) where

import Control.Monad.IO.Class (MonadIO)
import Data.Maybe (listToMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text
import GHC.Stack (CallStack)
import GHC.Stack qualified as GHC
import UnliftIO.Exception (
  Exception,
  SomeException,
  displayException,
  fromException,
  try,
 )

import Skeletest.Internal.Error (SkeletestError)
import Skeletest.Internal.TestInfo (TestInfo)
import Skeletest.Internal.Utils.Color qualified as Color

{----- Testable -----}

class (MonadIO m) => Testable m where
  runTestable :: m () -> IO TestResult
  context :: String -> m a -> m a
  throwFailure :: AssertionFail -> m a

{----- TestResult -----}

data TestResult = TestResult
  { testResultSuccess :: Bool
  , testResultLabel :: Text
  , testResultMessage :: TestResultMessage
  }

data TestResultMessage
  = TestResultMessageNone
  | TestResultMessageInline Text
  | TestResultMessageSection Text

testResultPass :: TestResult
testResultPass =
  TestResult
    { testResultSuccess = True
    , testResultLabel = Color.green "OK"
    , testResultMessage = TestResultMessageNone
    }

testResultFromAssertionFail :: AssertionFail -> IO TestResult
testResultFromAssertionFail e = do
  msg <- renderAssertionFail e
  pure
    TestResult
      { testResultSuccess = False
      , testResultLabel = Color.red "FAIL"
      , testResultMessage = TestResultMessageSection msg
      }

testResultFromError :: SomeException -> TestResult
testResultFromError e =
  TestResult
    { testResultSuccess = False
    , testResultLabel = Color.red "ERROR"
    , testResultMessage = TestResultMessageInline $ Text.pack msg
    }
  where
    msg =
      case fromException e of
        -- In GHC 9.10+, SomeException shows the callstack, which we don't
        -- want to see for known Skeletest errors
        Just (err :: SkeletestError) -> displayException err
        Nothing -> displayException e

{----- AssertionFail -----}

data AssertionFail = AssertionFail
  { testInfo :: TestInfo
  , testFailMessage :: Text
  , testFailContext :: FailContext
  , callStack :: CallStack
  }
  deriving (Show)

instance Exception AssertionFail

-- | Context for failures, in order of most recently added -> least recently added
type FailContext = [Text]

-- | Render a test failure like:
--
-- @
-- At test/Skeletest/Internal/TestTargetsSpec.hs:19:
-- |
-- |           parseTestTargets input `shouldBe` Right (Just expected)
-- |                                   ^^^^^^^^
--
-- Right 1 ≠ Left 1
-- @
renderAssertionFail :: AssertionFail -> IO Text
renderAssertionFail AssertionFail{..} = do
  prettyStackTrace <- mapM renderCallLine . reverse $ GHC.getCallStack callStack
  pure . Text.intercalate "\n\n" . concat $
    [ prettyStackTrace
    , if null testFailContext
        then []
        else [Text.intercalate "\n" $ reverse testFailContext]
    , [testFailMessage]
    ]
  where
    renderCallLine (_, loc) = do
      let
        path = GHC.srcLocFile loc
        lineNum = GHC.srcLocStartLine loc
        startCol = GHC.srcLocStartCol loc
        endCol = GHC.srcLocEndCol loc

      mLine <-
        try (Text.readFile path) >>= \case
          Right srcFile -> pure $ getLineNum lineNum srcFile
          Left (_ :: SomeException) -> pure Nothing
      let (srcLine, pointerLine) =
            case mLine of
              Just line ->
                ( line
                , Text.replicate (startCol - 1) " " <> Text.replicate (endCol - startCol) "^"
                )
              Nothing ->
                ( "<unknown line>"
                , ""
                )

      pure . Text.intercalate "\n" $
        [ Text.pack path <> ":" <> (Text.pack . show) lineNum <> ":"
        , "|"
        , "| " <> srcLine
        , "| " <> pointerLine
        ]

    getLineNum n = listToMaybe . take 1 . drop (n - 1) . Text.lines