packages feed

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

module Test.Hspec.JUnit.Render
  ( renderTestsuites
  ) where

import Prelude

import Conduit (ConduitT)
import Control.Monad (unless)
import Control.Monad.Reader (MonadReader (..), asks)
import Data.Array qualified as Array
import Data.Fixed (Nano)
import Data.Foldable (traverse_)
import Data.Semigroup (Endo (..))
import Data.Text (Text, pack)
import Data.Text qualified as T
import Data.Time.ISO8601 (formatISO8601)
import Data.XML.Types (Event)
import Test.Hspec.JUnit.Config
import Test.Hspec.JUnit.Schema
import Text.Regex.Base qualified as Regex
import Text.Regex.TDFA.Text (Regex)
import Text.XML.Stream.Render (attr, content, optionalAttr, tag)

renderTestsuites
  :: MonadReader JUnitConfig m
  => Testsuites
  -> ConduitT i Event m ()
renderTestsuites s = do
  tag
    "testsuites"
    ( attr "name" (unAttr id s.name)
        <> attr "tests" (unAttr (pack . show) s.tests)
        <> attr "failures" (unAttr (pack . show) s.failures)
        <> attr "errors" (unAttr (pack . show) s.errors)
        <> attr "skipped" (unAttr (pack . show) s.skipped)
        <> attr "assertions" (unAttr (pack . show) s.assertions)
        <> attr "time" (unAttr unSeconds s.time)
        <> attr "timestamp" (unAttr (pack . formatISO8601) s.timestamp)
    )
    $ traverse_ renderTestsuite s.children

renderTestsuite
  :: MonadReader JUnitConfig m
  => Testsuite
  -> ConduitT i Event m ()
renderTestsuite s = do
  prefix <- asks getJUnitPrefixSourcePath

  tag
    "testsuite"
    ( attr "name" (unAttr id s.name)
        <> attr "tests" (unAttr (pack . show) s.tests)
        <> attr "failures" (unAttr (pack . show) s.failures)
        <> attr "errors" (unAttr (pack . show) s.errors)
        <> attr "skipped" (unAttr (pack . show) s.skipped)
        <> attr "assertions" (unAttr (pack . show) s.assertions)
        <> attr "time" (unAttr unSeconds s.time)
        <> attr "timestamp" (unAttr (pack . formatISO8601) s.timestamp)
        <> optionalAttr "file" (unAttr (pack . prefix) <$> s.file)
    )
    $ traverse_ renderTestsuiteChild s.children

renderTestsuiteChild
  :: MonadReader JUnitConfig m
  => TestsuiteChild
  -> ConduitT i Event m ()
renderTestsuiteChild = \case
  TestsuiteProperties x -> renderProperties x
  TestsuiteSystemOut x -> renderSystemOut x
  TestsuiteSystemErr x -> renderSystemErr x
  TestsuiteTestcase x -> renderTestcase x
  TestsuiteTestsuite x -> renderTestsuite x

renderProperties
  :: MonadReader JUnitConfig m
  => Properties
  -> ConduitT i Event m ()
renderProperties ps = unless (null ps.unwrap) $ do
  tag "properties" mempty $ traverse_ renderProperty $ ps.unwrap

renderProperty
  :: MonadReader JUnitConfig m
  => Property
  -> ConduitT i Event m ()
renderProperty p = case p.value of
  Left a ->
    tag "property" (attr "name" (unAttr id p.name) <> attr "value" (unAttr id a))
      $ pure ()
  Right c -> tag "property" (attr "name" (unAttr id p.name)) $ renderContent c

renderSystemOut
  :: MonadReader JUnitConfig m
  => SystemOut
  -> ConduitT i Event m ()
renderSystemOut = tag "system-out" mempty . renderContent . (.unwrap)

renderSystemErr
  :: MonadReader JUnitConfig m
  => SystemErr
  -> ConduitT i Event m ()
renderSystemErr = tag "system-err" mempty . renderContent . (.unwrap)

renderTestcase
  :: MonadReader JUnitConfig m
  => Testcase
  -> ConduitT i Event m ()
renderTestcase c = do
  prefix <- asks getJUnitPrefixSourcePath

  tag
    "testcase"
    ( attr "name" (unAttr id c.name)
        <> attr "classname" (unAttr id c.classname)
        <> attr "assertions" (unAttr (pack . show) c.assertions)
        <> attr "time" (unAttr unSeconds c.time)
        <> optionalAttr "file" (unAttr (pack . prefix) <$> c.file)
        <> optionalAttr "line" (unAttr (pack . show) <$> c.line)
    )
    $ do
      traverse_ renderProperties c.properties
      traverse_ (either renderSystemOut renderSystemErr) c.systemStream
      renderTestcaseChild c.child

renderTestcaseChild
  :: MonadReader JUnitConfig m
  => TestcaseChild
  -> ConduitT i Event m ()
renderTestcaseChild = \case
  TestcaseEmpty -> pure ()
  TestcaseSkipped x -> renderSkipped x
  TestcaseFailure x -> renderFailure x
  TestcaseError x -> renderError x

renderSkipped
  :: MonadReader JUnitConfig m
  => Skipped
  -> ConduitT i Event m ()
renderSkipped s = tag "skipped" (attr "message" (unAttr id s.message)) $ pure ()

renderFailure
  :: MonadReader JUnitConfig m
  => Failure
  -> ConduitT i Event m ()
renderFailure f =
  tag
    "failure"
    (attr "message" (unAttr id f.message) <> attr "type" (unAttr id f.type_))
    $ renderContent f.content

renderError
  :: MonadReader JUnitConfig m
  => Error
  -> ConduitT i Event m ()
renderError e =
  tag
    "error"
    (attr "message" (unAttr id e.message) <> attr "type" (unAttr id e.type_))
    $ renderContent e.content

renderContent
  :: MonadReader JUnitConfig m
  => Content
  -> ConduitT i Event m ()
renderContent c = do
  shouldDropConsoleFormatting <- asks getJUnitConfigDropConsoleFormatting

  let clean =
        if shouldDropConsoleFormatting
          then dropConsoleFormatting
          else id

  content $ clean $ c.unwrap

unSeconds :: Seconds -> Text
unSeconds (Seconds d) = pack $ show $ realToFrac @_ @Nano d

-- | Safely render an attribute
--
-- HTML encoding is handled by @xml-conduit@, but additionally we:
--
-- * Drop console formatting (always)
-- * Extra-escape unsupported characters (e.g @\a@ becomes @\\a@)
unAttr :: (a -> Text) -> Attr a -> Text
unAttr f = escapeIllegal . dropConsoleFormatting . f . (.unwrap)

escapeIllegal :: Text -> Text
escapeIllegal =
  appEndo
    $ foldMap
      (\(a, b) -> Endo $ T.replace a b)
      [ ("\0", "\\0")
      , ("\a", "\\a")
      , ("\b", "\\b")
      , ("\f", "\\f")
      , ("\v", "\\v")
      , ("\ESC", "\\ESC")
      ]

-- | Drop ANSI control characters which might be used to set colors
--
-- We always do this in attribute values, since they'd only break XML, but we
-- only do it in content if configured by options, since we want any content
-- node changes to be opt-in.
dropConsoleFormatting :: Text -> Text
dropConsoleFormatting input =
  foldr (dropBetween . (Array.! 0)) input $ Regex.matchAll escRegex input

escRegex :: Regex
escRegex = Regex.makeRegex ("\x1b\\[[0-9;]*[mGKHF]" :: Text)

dropBetween :: (Int, Int) -> Text -> Text
dropBetween (offset, len) input = begining <> end
 where
  (begining, rest) = T.splitAt offset input
  (_, end) = T.splitAt len rest