packages feed

heni-hspec-0.1.0.0: src/Test/Heni/Hspec.hs

{-# LANGUAGE CPP #-}

-- | Hspec formatter and an explicit QuickCheck replay helper. Replace @hspec@
-- with 'heniHspec'; framework dependencies stay outside Heni's core.
module Test.Heni.Hspec (heniHspec, heniProperty) where

import Control.Exception (throwIO)
import Control.Monad (unless, when)
import Data.Maybe (fromMaybe)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import System.Environment (getArgs, lookupEnv)
import System.IO (hPutStrLn, stderr)
import Test.Heni.Adapter
import Test.Heni.Expectation (ReplayEvidence (..))
import Test.Hspec qualified as H
import Test.Hspec.Api.Format.V2 qualified as F
import Test.Hspec.Core.Runner qualified as R
import Test.QuickCheck qualified as QC
import Test.QuickCheck.Random (QCGen)
import Text.Read (readMaybe)

-- | Native Hspec options are parsed first; the Heni selector is a stable-ID
-- substring intersected with that selection. History-based reruns are rejected
-- inside an attempt because they can execute a different test set.
heniHspec :: H.Spec -> IO ()
heniHspec spec =
  currentEvidenceAttempt >>= \case
    Nothing -> H.hspec spec
    Just attempt -> guardEvidenceAttempt attempt $ do
      (initial, forest) <- R.evalSpec R.defaultConfig spec
      args <- getArgs
      config <- R.readConfig initial args
      when (R.configRerun config || R.configRerunAllOnSuccess config) (throwIO (EvidenceError "Hspec history reruns are unsupported inside a Heni attempt"))
      when (R.configDryRun config) (throwIO (EvidenceError "Hspec dry-run does not prove execution inside a Heni attempt"))
      selector <- fmap Text.pack <$> lookupEnv "HENI_SELECTOR"
      let include path = fromMaybe (const True) (R.configFilterPredicate config) path && maybe True (`Text.isInfixOf` pathId path) selector
          finalConfig = F.useFormatter ("heni", formatter attempt) config {R.configFilterPredicate = Just include}
      R.runSpecForest forest finalConfig >>= R.evaluateResult

pathId :: F.Path -> Text
pathId (groups, name) = stableTestId (map Text.pack (groups ++ [name]))

formatter :: EvidenceAttempt -> F.FormatConfig -> IO F.Format
formatter attempt config = pure $ \case
  F.Done items -> do
    property <- readPropertyReplay attempt
    let ids = map (pathId . fst) items
        errors = ["Hspec completed a partial selection" | length items /= F.formatConfigExpectedTotalCount config]
        observation (path, item) = case F.itemResult item of
          F.Success -> TestPassed (pathId path)
          F.Pending {} -> TestSkipped (pathId path)
          F.Failure _ (F.Error _ e) -> TestSetupFailed (pathId path) (Text.pack (show e))
          F.Failure {} -> TestFailed (pathId path)
    emitSuite attempt (SuiteEvidence ("hspec/" <> Text.pack VERSION_hspec <> ";heni-hspec/0.1.0.0") Nothing ids (map observation items) errors (fst <$> property) (maybe [] snd property))
  _ -> pure ()

-- | The ID must equal the enclosing Hspec example's stable full ID. Records
-- QuickCheck's native seed/size, generator arguments and failing input. Exactly
-- one replay property is supported per selected attempt. Invalid replay data
-- makes the example pending, which can never satisfy a required reproduction.
heniProperty :: (QC.Testable property) => Text -> QC.Args -> property -> H.Expectation
heniProperty ident args property = do
  token <- lookupEnv "HENI_REPLAY_TOKEN"
  expectedGenerator <- lookupEnv "HENI_REPLAY_GENERATOR"
  let generator = Text.pack ("QuickCheck/" ++ VERSION_QuickCheck ++ ";" ++ show (QC.maxSuccess args, QC.maxDiscardRatio args, QC.maxSize args, QC.maxShrinks args))
      reject why = hPutStrLn stderr why >> H.pendingWith why
  replay <- case token of
    Nothing -> pure (QC.replay args)
    Just value -> do
      unless (expectedGenerator == Just (Text.unpack generator)) (reject "QuickCheck generator configuration mismatch")
      case readMaybe value :: Maybe (QCGen, Int) of
        Nothing -> reject "invalid native QuickCheck replay token" >> pure Nothing
        Just pair -> pure (Just pair)
  result <- QC.quickCheckWithResult (args {QC.chatty = False, QC.replay = replay, QC.maxSuccess = if token == Nothing then QC.maxSuccess args else 1}) property
  case result of
    QC.Success {} -> pure ()
    QC.Failure {QC.usedSeed, QC.usedSize, QC.theException, QC.failingTestCase, QC.output} -> do
      case theException of Just e -> throwIO e; Nothing -> pure ()
      recordPropertyReplay (ReplayEvidence (Text.pack (show (usedSeed, usedSize))) generator (Set.singleton ident)) (map Text.pack failingTestCase)
      H.expectationFailure output
    other -> H.pendingWith (QC.output other)