packages feed

hspec-junit-formatter-1.3.0.0: library/Test/Hspec/JUnit/Format.hs

{-# OPTIONS_GHC -Wno-ambiguous-fields #-}

module Test.Hspec.JUnit.Format
  ( junit
  ) where

import Prelude

import Conduit
import Control.Applicative ((<|>))
import Control.Exception (displayException)
import Control.Monad.Reader (runReaderT)
import Data.List (intercalate)
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NE
import Data.Monoid (First (..))
import Data.Semigroup (Sum (..))
import Data.Semigroup.Generic (GenericSemigroupMonoid (..))
import Data.Text (Text, pack)
import Data.Time (UTCTime, getCurrentTime)
import Data.Typeable (typeOf)
import GHC.Generics (Generic)
import Numeric.Natural (Natural)
import System.Directory (createDirectoryIfMissing)
import System.FilePath (takeDirectory)
import Test.Hspec.Api.Format.V1
  ( Event (..)
  , Format
  , FormatConfig
  , Item (..)
  , Location (..)
  , Path
  )
import Test.Hspec.Api.Format.V1 qualified as Hspec
import Test.Hspec.JUnit.Config as Config
import Test.Hspec.JUnit.Render
import Test.Hspec.JUnit.Schema
import Text.XML.Stream.Render (def, renderBytes)
import Text.XML.Stream.Render.Internal (rsPretty)

junit :: JUnitConfig -> FormatConfig -> IO Format
junit junitConfig _config = pure $ \case
  Started -> pure ()
  GroupStarted _ -> pure ()
  GroupDone _ -> pure ()
  Progress _ _ -> pure ()
  ItemStarted _ -> pure ()
  ItemDone _ _ -> pure ()
  Done items -> do
    now <- getCurrentTime
    createDirectoryIfMissing True $ takeDirectory file
    runConduitRes
      $ transPipe (`runReaderT` junitConfig)
      $ renderTestsuites (formatTestsuites now suiteName items)
        .| renderBytes renderConfig
        .| sinkFile file
 where
  file = getJUnitConfigOutputFile junitConfig
  suiteName = getJUnitConfigSuiteName junitConfig
  renderConfig = def {rsPretty = getJUnitConfigPretty junitConfig}

formatTestsuites :: UTCTime -> Text -> [(Path, Item)] -> Testsuites
formatTestsuites now suiteName items =
  Testsuites
    { name = Attr suiteName
    , tests = getSum totals.tests
    , failures = getSum totals.failures
    , errors = getSum totals.errors
    , skipped = getSum totals.skipped
    , assertions = getSum totals.assertions
    , time = getSum totals.time
    , timestamp = Attr now
    , children = suites
    }
 where
  -- Group all items by prefix and then convert each group to a suite. In an
  -- ideal world, we would build a tree instead so we could correctly render
  -- nested contexts as nested testsuites, but that is hard and I'm tired.
  suites :: [Testsuite]
  suites = map (formatTestsuite now) $ NE.groupAllWith (fst . fst) items

  totals :: SuiteTotals
  totals = foldMap getSuiteTotals suites

formatTestsuite :: UTCTime -> NonEmpty (Path, Item) -> Testsuite
formatTestsuite now items =
  Testsuite
    { name = Attr $ pack $ intercalate "/" path
    , tests = getSum totals.tests
    , failures = getSum totals.failures
    , errors = getSum totals.errors
    , skipped = getSum totals.skipped
    , assertions = getSum totals.assertions
    , time = getSum totals.time
    , timestamp = Attr now
    , file = getFirst totals.file
    , children
    }
 where
  path :: [String]
  path = fst $ fst $ NE.head items

  children :: [TestsuiteChild]
  children = NE.toList $ TestsuiteTestcase . formatTestcase <$> items

  totals :: SuiteTotals
  totals = foldMap getSuiteChildTotals children

formatTestcase :: (Path, Item) -> Testcase
formatTestcase ((path, name), item) =
  Testcase
    { name = Attr $ pack name
    , classname = Attr $ pack $ intercalate "/" path
    , assertions = Attr 0 -- not available with Hspec
    , time = Attr $ itemDuration item
    , file = Attr . locationFile <$> location
    , line = Attr . fromIntegral . locationLine <$> location
    , properties = Nothing
    , systemStream = []
    , child
    }
 where
  (location, child) = case itemResult item of
    Hspec.Success ->
      ( itemLocation item
      , TestcaseEmpty
      )
    Hspec.Pending mLocation mMessage ->
      ( mLocation <|> itemLocation item
      , TestcaseSkipped $ Skipped $ maybe "" (Attr . pack) mMessage
      )
    Hspec.Failure mLocation reason ->
      ( mLocation <|> itemLocation item
      , formatFailureReason reason
      )

formatFailureReason :: Hspec.FailureReason -> TestcaseChild
formatFailureReason = \case
  Hspec.NoReason ->
    TestcaseFailure
      $ Failure
        { message = "No reason given"
        , type_ = "Failure.NoReason"
        , content = "No reason given"
        }
  Hspec.Reason msg ->
    TestcaseFailure
      $ Failure
        { message = Attr $ firstLineOf msg
        , type_ = "Failure.Reason"
        , content = Content $ pack $ msg <> "\n"
        }
  Hspec.ExpectedButGot mPrefix expected got ->
    TestcaseFailure
      $ Failure
        { message = Attr "Received value did not match expected"
        , type_ = Attr "Failure.ExpectedButGot"
        , content =
            Content
              $ pack
              $ mconcat
                [ maybe "" (<> "\n") mPrefix
                , "Expected: " <> show expected <> "\n"
                , " but got: " <> show got <> "\n"
                ]
        }
  Hspec.Error mPrefix ex ->
    TestcaseError
      $ Error
        { message = Attr $ firstLineOf $ displayException ex
        , type_ = Attr $ pack $ show $ typeOf ex
        , content =
            Content
              $ pack
              $ mconcat
                [ maybe "" (<> "\n") mPrefix
                , displayException ex <> "\n"
                ]
        }
 where
  firstLineOf =
    maybe "" (pack . NE.head)
      . NE.nonEmpty
      . lines

data SuiteTotals = SuiteTotals
  { tests :: Sum (Attr Natural)
  , failures :: Sum (Attr Natural)
  , errors :: Sum (Attr Natural)
  , skipped :: Sum (Attr Natural)
  , assertions :: Sum (Attr Natural)
  , time :: Sum (Attr Seconds)
  , file :: First (Attr FilePath)
  }
  deriving stock (Generic)
  deriving (Monoid, Semigroup) via GenericSemigroupMonoid SuiteTotals

getSuiteTotals :: Testsuite -> SuiteTotals
getSuiteTotals s = foldMap getSuiteChildTotals s.children

getSuiteChildTotals :: TestsuiteChild -> SuiteTotals
getSuiteChildTotals = \case
  TestsuiteTestcase tc ->
    let
      base :: SuiteTotals
      base = mempty {tests = 1, time = Sum tc.time, file = First tc.file}
    in
      case tc.child of
        TestcaseEmpty -> base
        TestcaseSkipped {} -> base {skipped = 1}
        TestcaseFailure {} -> base {failures = 1}
        TestcaseError {} -> base {errors = 1}
  TestsuiteTestsuite s -> getSuiteTotals s
  _ -> mempty