tasty-discover-5.2.0: src/Test/Tasty/Discover.hs
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE InstanceSigs #-}
module Test.Tasty.Discover
( Tasty(..)
, TastyInfo
, SkipTest(..)
, Flavored(..)
, flavored
, name
, description
, nameOf
, descriptionOf
, skip
, applySkips
, platform
, evaluatePlatformExpression
) where
import Data.Maybe
import Data.Monoid
import System.Console.ANSI (Color(..), ColorIntensity(..), ConsoleLayer(..), SGR(..), setSGRCode)
import System.Info (os)
import Test.Tasty.Discover.TastyInfo (TastyInfo)
import Test.Tasty.Discover.Internal.Config (SkipTest(..))
import qualified Test.Tasty as TT
import qualified Test.Tasty.Runners as TR
import qualified Test.Tasty.Providers as TP
import qualified Test.Tasty.Discover.TastyInfo as TI
{- $skipPlatform
Guidelines for using 'skip' and 'platform'
-----------------------------------------
TL;DR:
- For tests exposed via @tasty_@ functions, prefer using the 'Flavored' pattern to apply
transformations like 'skip' and 'platform' so they take effect at the TestTree level.
- Directly applying 'skip' to an already-constructed 'TT.TestTree' marks the subtree as
skipped (the test can observe 'SkipTest' via 'TT.askOption'), but the outer 'Tasty'
instance may not replace it with a top-level "[SKIPPED]" node.
Patterns:
- Skip with 'Flavored':
@
tasty_mySkipped :: Flavored TT.TestTree
tasty_mySkipped = flavored skip $ TT.testCase "will be skipped" $ pure ()
@
-
@
tasty_linuxOnly :: Flavored TT.TestTree
tasty_linuxOnly = flavored (platform "linux") $ TT.testCase "Linux only" $ pure ()
@
Platform expressions:
- Names: @"linux"@, @"darwin"@, @"windows"@ (mapped to @"mingw32"@), @"mingw32"@, and @"unix"@ (matches linux|darwin)
- Operators: NOT @!@, AND @&@, OR @|@
-- Examples:
@
platform "!windows & !darwin" -- neither Windows nor Darwin
platform "linux | darwin" -- Linux or Darwin
platform "unix" -- Linux or Darwin
@
Combining:
- You can compose transformations: e.g., @flavored (platform "linux") . flavored skip@
or wrap once with a composed function @flavored (platform "linux" . skip)@.
See 'skip' and 'platform' for function-specific details.
-}
class Tasty a where
tasty :: TastyInfo -> a -> IO TT.TestTree
instance Tasty TT.TestTree where
tasty _ a = pure a
instance Tasty [TT.TestTree] where
tasty info a = pure $ TT.testGroup (descriptionOf info) a
instance Tasty (IO TT.TestTree) where
tasty _ a = a
instance Tasty (IO [TT.TestTree]) where
tasty info a = TT.testGroup (descriptionOf info) <$> a
-- | A general-purpose wrapper for transforming TestTrees generated by tasty_ functions.
--
-- The Flavored type allows you to apply transformations to test trees before they
-- are added to the test suite. This enables applying various options and modifications
-- such as skipping tests, setting timeouts, adding metadata, grouping, etc.
--
-- Example usage:
-- @
-- -- Skip a test
-- tasty_skipThis :: Flavored Property
-- tasty_skipThis = flavored skip $ property $ do
-- -- This test will be skipped
-- H.failure
-- @
data Flavored a = Flavored
{ flavoring :: TT.TestTree -> TT.TestTree -- ^ Transformation function to apply
, unFlavored :: a -- ^ The wrapped test value
}
-- | Create a Flavored wrapper with a specific transformation function.
--
-- @flavored f a@ applies transformation @f@ to the TestTree generated from @a@.
flavored :: (TT.TestTree -> TT.TestTree) -> a -> Flavored a
flavored f a = Flavored f a
instance Tasty a => Tasty (Flavored a) where
tasty :: TastyInfo -> Flavored a -> IO TT.TestTree
tasty info (Flavored f a) = do
testTree <- tasty info a
pure $ f testTree
nameOf :: TastyInfo -> String
nameOf info =
fromMaybe "<unnamed>" (getLast (TI.name info))
descriptionOf :: TastyInfo -> String
descriptionOf info =
fromMaybe "<undescribed>" (getLast (TI.description info))
name :: String -> TastyInfo
name n = mempty
{ TI.name = Last $ Just n
}
description :: String -> TastyInfo
description n = mempty
{ TI.description = Last $ Just n
}
-- | Mark a test tree to be skipped by setting the SkipTest option to True.
--
-- Usage guidelines: see the @Guidelines for using 'skip' and 'platform'@ section ('skipPlatform').
-- In short, for @tasty_@ tests prefer 'flavored' 'skip' to let the outer 'Tasty' instance
-- short-circuit at the TestTree level. Direct 'skip' on a pre-built tree applies the option
-- to the subtree; the test can still observe 'SkipTest' via 'TT.askOption'.
--
-- Examples:
-- @
-- -- Direct usage on a TestTree (the test can read SkipTest via askOption)
-- test_directSkip :: TestTree
-- test_directSkip = skip $ testCase "will be skipped" $ pure ()
--
-- -- Preferred for tasty_ tests: apply at the right stage using Flavored
-- tasty_skipProperty :: Flavored Property
-- tasty_skipProperty = flavored skip $ property $ do
-- -- This property will be skipped
-- H.failure
-- @
skip :: TT.TestTree -> TT.TestTree
skip = TT.adjustOption (const (SkipTest True))
-- | Transform a TestTree to apply skipping behavior throughout the entire tree.
--
-- This function wraps a TestTree so that when the 'SkipTest' option is set to 'True',
-- all individual tests within the tree are replaced with skipped placeholder tests.
-- This is useful when you want to conditionally skip an entire group of tests while
-- still showing each test as skipped in the output.
--
-- The function works by:
--
-- * Checking the 'SkipTest' option via 'TT.askOption'
-- * If skipping is enabled, using 'TT.foldTestTree' to traverse the tree and rebuild it with:
--
-- * All single tests replaced with test cases showing "[SKIPPED]"
-- * Test groups preserved with their structure intact
-- * Resources skipped (not acquired)
--
-- * If skipping is disabled, returning the tree unchanged
--
-- This is particularly useful in combination with 'platform' for platform-specific test suites:
--
-- @
-- tasty_testTree_no_darwin :: Flavored (IO TestTree)
-- tasty_testTree_no_darwin =
-- flavored (platform "!darwin") $ pure $ applySkips $ testGroup "Non-Darwin group"
-- [ testProperty "Test 1" $ \\(x :: Int) -> x == x
-- , testCase "Test 2" $ pure ()
-- ]
-- @
--
-- On Darwin, this will show:
--
-- @
-- Non-Darwin group
-- Test 1 [SKIPPED]: OK
-- Test 2 [SKIPPED]: OK
-- @
--
-- @since 5.1.0
-- | A simple test type for skipped tests
data SkippedTest = SkippedTest
deriving stock (Show, Eq)
instance TP.IsTest SkippedTest where
run _ _ _ = return $ TP.testPassed ""
testOptions = return []
applySkips :: TT.TestTree -> TT.TestTree
applySkips tree = TT.askOption $ \(SkipTest shouldSkip) ->
if shouldSkip
then transformTree tree
else tree
where
yellowText :: String -> String
yellowText text = setSGRCode [SetColor Foreground Vivid Yellow] ++ text ++ setSGRCode [Reset]
transformTree :: TT.TestTree -> TT.TestTree
transformTree t = case TR.foldTestTree
TR.TreeFold
{ TR.foldSingle = \_ testName _ -> [TP.singleTest (testName ++ " " ++ yellowText "[SKIPPED]") SkippedTest]
, TR.foldGroup = \_ groupName trees -> [TT.testGroup groupName (concat trees)]
, TR.foldResource = \_ _ _ -> []
, TR.foldAfter = \_ _ _ trees -> trees
}
mempty
t of
[result] -> result
results -> TT.testGroup "" results
-- | Conditionally run a test based on a platform expression.
--
-- Usage guidelines, syntax, and examples: see the @Guidelines for using 'skip' and 'platform'@
-- section ('skipPlatform').
--
-- The expression supports logical operations with platform names:
-- - Platform names: "linux", "darwin", "mingw32", "windows", "unix"
-- - Negation: "!platform" (not on platform)
-- - Conjunction: "platform1 & platform2" (on both platforms)
-- - Disjunction: "platform1 | platform2" (on either platform)
-- - Parentheses: "(platform1 | platform2) & !platform3"
--
-- Examples:
-- @
-- -- Only on Linux
-- test_linuxOnly :: TestTree
-- test_linuxOnly = platform "linux" $ testCase "Linux only" $ pure ()
--
-- -- Not on Windows or macOS
-- test_notWinMac :: TestTree
-- test_notWinMac = platform "!windows & !darwin" $ testCase "Unix-like only" $ pure ()
--
-- -- On Linux or macOS but not Windows
-- test_unixLike :: TestTree
-- test_unixLike = platform "(linux | darwin) & !windows" $ testCase "Unix-like" $ pure ()
-- @
platform :: String -> TT.TestTree -> TT.TestTree
platform expr testTree =
if evaluatePlatformExpression expr os
then testTree
else skip testTree
-- | Evaluate a platform expression against a given platform string.
--
-- Inputs:
-- - The first argument is the platform expression (e.g. @"linux | darwin"@, @"!windows"@).
-- - The second argument is the current platform, typically @System.Info.os@ (e.g. @"linux"@, @"darwin"@, @"mingw32"@).
--
-- Semantics (result is 'True' when the test should run):
-- - Supported platform names: @"linux"@, @"darwin"@, @"mingw32"@, @"windows"@ (alias for @"mingw32"@), and @"unix"@ (alias for @"linux | darwin"@).
-- - Supported operators: NOT @!@, AND @&@, OR @|@.
-- - Unknown simple names evaluate to 'False' (do not run).
-- - Malformed or empty expressions evaluate to 'True' (default to running).
-- Malformed includes the presence of operator characters without a valid parse.
-- - Parentheses characters @(@ and @)@ are tokenized but grouping is not currently implemented;
-- using parentheses in the expression will cause it to be treated as malformed and therefore
-- default to 'True' (run). Prefer composing with @&@ and @|@ without parentheses.
--
-- Examples:
--
-- @
-- evaluatePlatformExpression "linux" "linux" == True
-- evaluatePlatformExpression "linux" "darwin" == False
-- evaluatePlatformExpression "!windows" "mingw32" == False
-- evaluatePlatformExpression "linux|darwin" "darwin" == True
-- evaluatePlatformExpression "unix" "darwin" == True -- alias for linux|darwin
-- evaluatePlatformExpression "unknown" "linux" == False -- unknown simple name
-- evaluatePlatformExpression "" "linux" == True -- empty -> run
-- @
evaluatePlatformExpression :: String -> String -> Bool
evaluatePlatformExpression expr currentPlatform =
case parsePlatformExpression expr of
Just result -> evalExpression result currentPlatform
Nothing ->
-- If it's just a simple unknown platform name, return False
-- If it's an empty/malformed expression, return True
let malformedOrEmpty = null (words expr) || any (`elem` ['&', '|', '!', '(', ')']) expr
in malformedOrEmpty
-- Parse a platform expression with logical operators
parsePlatformExpression :: String -> Maybe PlatformExpr
parsePlatformExpression expr = parseOr (tokenize expr)
-- Tokenize the expression preserving logical operators
tokenize :: String -> [String]
tokenize = words . concatMap tokenizeChar
where
tokenizeChar '&' = " & "
tokenizeChar '|' = " | "
tokenizeChar '(' = " ( "
tokenizeChar ')' = " ) "
tokenizeChar c = [c]
-- Parse OR expressions (lowest precedence)
parseOr :: [String] -> Maybe PlatformExpr
parseOr tokens = case break (== "|") tokens of
(left, []) -> parseAnd left
(left, _:right) -> do
leftExpr <- parseAnd left
rightExpr <- parseOr right
return $ Or leftExpr rightExpr
-- Parse AND expressions (higher precedence)
parseAnd :: [String] -> Maybe PlatformExpr
parseAnd tokens = case break (== "&") tokens of
(left, []) -> parseAtom left
(left, _:right) -> do
leftExpr <- parseAtom left
rightExpr <- parseAnd right
return $ And leftExpr rightExpr
-- Parse atomic expressions (platform names and negation)
parseAtom :: [String] -> Maybe PlatformExpr
parseAtom [] = Nothing
parseAtom tokens = case tokens of
["linux"] -> Just $ PlatformName "linux"
["darwin"] -> Just $ PlatformName "darwin"
["windows"] -> Just $ PlatformName "mingw32"
["mingw32"] -> Just $ PlatformName "mingw32"
["unix"] -> Just $ Or (PlatformName "linux") (PlatformName "darwin")
["!linux"] -> Just $ Not (PlatformName "linux")
["!darwin"] -> Just $ Not (PlatformName "darwin")
["!windows"] -> Just $ Not (PlatformName "mingw32")
["!mingw32"] -> Just $ Not (PlatformName "mingw32")
["!unix"] -> Just $ Not (Or (PlatformName "linux") (PlatformName "darwin"))
_ -> Nothing
-- Simple expression data type
data PlatformExpr
= PlatformName String
| Not PlatformExpr
| And PlatformExpr PlatformExpr
| Or PlatformExpr PlatformExpr
deriving stock (Show, Eq)
-- Evaluate the expression against the current platform
evalExpression :: PlatformExpr -> String -> Bool
evalExpression expr currentPlatform = case expr of
PlatformName platformName -> currentPlatform == platformName
Not e -> not (evalExpression e currentPlatform)
And e1 e2 -> evalExpression e1 currentPlatform && evalExpression e2 currentPlatform
Or e1 e2 -> evalExpression e1 currentPlatform || evalExpression e2 currentPlatform