sydtest 0.20.0.1 → 0.33.0.0
raw patch · 27 files changed
Files
- CHANGELOG.md +225/−0
- Setup.hs +2/−0
- src/Test/Syd.hs +20/−2
- src/Test/Syd/Def/AroundAll.hs +14/−2
- src/Test/Syd/Def/Scenario.hs +72/−19
- src/Test/Syd/Def/SetupFunc.hs +9/−0
- src/Test/Syd/Def/Specify.hs +27/−28
- src/Test/Syd/Modify.hs +12/−0
- src/Test/Syd/Mutation/Forest.hs +390/−0
- src/Test/Syd/MutationMode.hs +14/−0
- src/Test/Syd/MutationMode/Common.hs +604/−0
- src/Test/Syd/MutationMode/CoverageList.hs +28/−0
- src/Test/Syd/MutationMode/CoverageListLocations.hs +46/−0
- src/Test/Syd/MutationMode/Single.hs +78/−0
- src/Test/Syd/MutationMode/SingleCoverage.hs +77/−0
- src/Test/Syd/OptParse.hs +366/−66
- src/Test/Syd/Output.hs +28/−614
- src/Test/Syd/Output/Common.hs +265/−0
- src/Test/Syd/Output/Pretty.hs +377/−0
- src/Test/Syd/Output/Terse.hs +98/−0
- src/Test/Syd/Run.hs +40/−25
- src/Test/Syd/Runner.hs +7/−33
- src/Test/Syd/Runner/Asynchronous.hs +63/−28
- src/Test/Syd/Runner/Synchronous/Interleaved.hs +14/−9
- src/Test/Syd/Runner/Synchronous/Separate.hs +17/−15
- src/Test/Syd/SpecDef.hs +29/−4
- sydtest.cabal +15/−3
CHANGELOG.md view
@@ -1,5 +1,230 @@ # Changelog +## [0.33.0.0] - 2026-10-01++### Fixed++* A timed-out mutation counts as killed again.++ Mutating code easily stops it terminating. A `ConstBool` on a loop's exit+ condition turns `while done` into `while True`, and the suite then runs+ until its budget is gone rather than failing an assertion. A suite that+ never finishes is still a suite that noticed the mutation, which is what a+ kill means. 0.32.0.0 scored those as neither killed nor survived and+ failed the run over them, so a mutation the tests had caught was reported+ as one they had not.++ The reason to doubt the kill is that a busy machine overruns the budget+ too, and crediting that would let load raise the score. The retry, also+ new in 0.32.0.0, is what answers it: an overrun is given three goes, so one+ that survives all three is the mutation's doing rather than the machine's.+ The two belong together, and only the retry was worth keeping on its own.+++## [0.32.0.0] - 2026-10-01++### Changed++* A timed-out mutation is no longer scored as killed. It is its own line in+ the report, and it stops its group like a survivor does.+++## [0.31.0.0] - 2026-09-11++### Changed++* `SuiteOutcome`'s `SuiteKilled` carries the killed child's log file.++### Fixed++* A failed control names the output of the run that killed it.+++## [0.30.0.0] - 2026-09-02++### Changed++* `scenarioDir`, `scenarioDirRecur` and `scenarioDirOfDirs` take a `Path b Dir`+ and hand the callback a `Path Rel File` or a `Path Rel Dir`, relative to that+ directory, where all three took and gave a `FilePath`.+ Pass `[reldir|test_resources/scenarios|]` for the directory, and join the+ scenario to it to read it.++ Test descriptions are unchanged, so a `--filter` over them still selects the+ same tests.++## [0.29.0.0] - 2026-08-19++### Added++* `parallelWith`, to declare that at most a given number of the tests below it+ may run at once. For tests that contend for something the suite does not+ own, such as one database server shared by a database per test, where running+ all of them at once is slower than running some of them and `sequential`+ gives up more than it needs to.++### Changed++* `Parallelism` has a third constructor, `ParallelWith`, so any exhaustive+ match on it needs a new case.++## [0.28.0.0] - 2026-08-08++### Added++* `scenarioDirOfDirs`, for scenarios that consist of more than one file. It+ defines a test for each subdirectory of the given directory, and hands that+ subdirectory to the test definition.++### Changed++* `scenarioDir` and `scenarioDirRecur` now define a single failing test when+ they find no files, instead of defining no tests at all. An empty scenario+ directory usually means the scenario files were omitted by accident, for+ example because they were not packaged in `extra-source-files`.++## [0.27.2.0] - 2026-07-16++### Changed++* Mutation testing now runs each mutation's covering tests cheapest-first,+ using the per-test timings measured during the coverage phase. Because a+ mutation child runs its covering tests under fail-fast, reaching a failing+ (killing) test sooner cuts the mutation phase's wall-clock; on two sample+ projects the mutation phase ran roughly 6% and 24% faster, with identical+ verdicts. The ordering is a drop-in alternative to execution-order+ randomisation: it is applied only when the suite randomises execution order,+ and it never reorders a `doNotRandomiseExecutionOrder` block, so which tests+ run (and therefore every mutation verdict) is unchanged.++## [0.27.1.0] - 2026-06-29++### Fixed++* The spec-forest runners now seed the global `System.Random` generator from+ the configured seed before running. Previously only the top-level `sydTest`+ runner did this, so the mutation and coverage children — which run a forest+ directly — left the global generator unseeded. As a result, tests that draw+ from the global generator (`randomIO`, `newStdGen`, ...) rather than from+ QuickCheck's replay seed were non-reproducible under those children, which+ could produce spurious mutation kills or survivors.++## [0.27.0.0] - 2026-06-20++### Changed++* `renderMutationProgressEvent` now takes the mutation's 1-based index and the+ total mutation count as two `Int` arguments, and prefixes each per-mutation+ progress line with a coloured `[X/Y]` counter so a long mutation run shows+ how far along it is.++## [0.26.1.0] - 2026-06-17++### Added++* The mutation run report now prints, under each surviving mutation, the+ exact disable annotation for it (using the mutation's operator and enclosing+ binding) and any mitigation hint the operator attached to it. Exposed as+ `survivorMitigationLines` from `Test.Syd.MutationMode`.++## [0.26.0.0] - 2026-06-09++### Changed++* `renderMutationProgressEvent` now takes a `Bool` (verbose) first argument.+ The mutation runner prints a concise one-line progress message per+ mutation by default and the full source diff of each mutation only when+ the new `--debug` driver flag is set, so a long run no longer floods the+ log with the diffs of killed mutations.++## [0.25.0.1] - 2026-06-04++### Changed++* Diff-rendering helpers (`renderUnifiedDiff` and the colour helpers+ `delColour`, `addColour`, `emphasiseIntraLine`, `renderDelSide`,+ `renderAddSide`) moved into `sydtest-mutation-runtime` so the plugin's+ manifest writer and `sydtest`'s mutation report share one+ implementation. `Test.Syd.Output.Common` and+ `Test.Syd.MutationMode.Common` re-export them; existing call sites are+ unaffected.++## [0.25.0.0] - 2026-05-21++### Added++* `--mutation-coverage-list-locations`: a child-side mutation mode that prints,+ as a JSON array, each leaf test's id and the source location of its+ `it`/`prop`/`specify` call site. Used by the diff-scoped mutation runner to+ map a changed test-source line back to the tests defined there.+* `flattenTestForestWithIdsAndCallStacks` in `Test.Syd.Mutation.Forest`, which+ pairs each leaf `TestId` with the `CallStack` captured at its definition site.++## [0.24.0.0] - 2026-05-18++### Added++* Mutation testing. Instrument a library with the+ `sydtest-mutation-plugin` GHC plugin to record mutation sites, then run the+ test suite with `--mutation-coverage` and `--mutation` to find untested+ behaviour. Surviving mutations indicate gaps in the test suite.+* `delColour`, `addColour`, `emphasiseIntraLine`, `renderDelSide`,+ `renderAddSide` in `Test.Syd.Output.Common` for diff rendering.++### Changed++* Equality-assertion diff output (`formatDiff`) now uses the same colour+ scheme as the mutation-mode unified diff: paired-line foregrounds are+ dull red/green, intra-line changes are bold + bright red/green, and+ whitespace-only changes get a background fill.++## [0.23.0.2] - 2026-02-26++### Changed++* Fixed a missing import on Windows.++## [0.23.0.1] - 2026-01-26++### Changed++* Fixed AI autodetection++## [0.23.0.0] - 2026-01-26++### Added++* Terse output format+* AI-executor detection++### Changed++* Simplified outputting code.++This is technically a breaking change, but if you are not using the sydtest+output code directly, it should not break anything for you.++## [0.22.0.0] - 2025-09-28++### Changed++* Fail on an empty test suite.++## [0.21.0.0] - 2025-05-09++This is technically a breaking change, but if you are not using the sydtest+constructors directly, it should not break anything for you.++### Added++* `aroundAllWithAll`+* `setupAroundAllWithAll`++### Changed++* Gave the 'DefAroundAllWithNode' constructor access to all outer resources+ instead of just the latest one.+ ## [0.20.0.1] - 2025-05-09 ### Added
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
src/Test/Syd.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# OPTIONS_GHC -fno-warn-duplicate-exports #-}@@ -86,6 +87,7 @@ -- ** Scenario tests scenarioDir, scenarioDirRecur,+ scenarioDirOfDirs, -- ** Expectations shouldBe,@@ -133,6 +135,7 @@ aroundAll, aroundAll_, aroundAllWith,+ aroundAllWithAll, -- *** Dependencies around each of a group of tests before,@@ -158,6 +161,7 @@ -- ****** AroundAll setupAroundAll, setupAroundAllWith,+ setupAroundAllWithAll, -- *** Declaring different test settings modifyMaxSuccess,@@ -170,6 +174,7 @@ -- *** Declaring parallelism sequential, parallel,+ parallelWith, withParallelism, Parallelism (..), @@ -253,6 +258,9 @@ import Control.Monad import Control.Monad.IO.Class+import qualified Data.ByteString as SB+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE import Path import Path.IO import System.Exit@@ -261,6 +269,10 @@ import Test.Syd.Expectation import Test.Syd.HList import Test.Syd.Modify+import Test.Syd.MutationMode.CoverageList (runCoverageListMode)+import Test.Syd.MutationMode.CoverageListLocations (runCoverageListLocationsMode)+import Test.Syd.MutationMode.Single (runSingleMutationMode)+import Test.Syd.MutationMode.SingleCoverage (runSingleCoverageMode) import Test.Syd.OptParse import Test.Syd.Output import Test.Syd.ReRun@@ -277,7 +289,13 @@ sydTest :: Spec -> IO () sydTest spec = do sets <- getSettings- sydTestWith sets spec+ case settingMutation sets of+ Just MutationSettings {mutationFailFast, mutationMode} -> case mutationMode of+ MutationModeCoverageList -> runCoverageListMode sets spec+ MutationModeCoverageListLocations -> runCoverageListLocationsMode sets spec+ MutationModeCoverageChild ch -> runSingleCoverageMode sets mutationFailFast ch spec+ MutationModeMutateChild ch -> runSingleMutationMode sets ch spec+ Nothing -> sydTestWith sets spec -- | Evaluate a test suite definition and then run it, with given 'Settings' --@@ -289,7 +307,7 @@ when (settingProfile sets) $ do p <- resolveFile' "sydtest-profile.html" writeSvgReport (fromAbsFile p) resultForest- putStrLn $ "Wrote profile graph to " <> fromAbsFile p+ SB.putStr $ TE.encodeUtf8 $ T.pack ("Wrote profile graph to " <> fromAbsFile p) <> "\n" when (shouldExitFail sets (timedValue resultForest)) (exitWith (ExitFailure 1))
src/Test/Syd/Def/AroundAll.hs view
@@ -114,7 +114,8 @@ TestDefM otherOuters inner result aroundAll func = wrapForest $ \forest -> DefAroundAllNode func forest --- | Run a custom action before and/or after all spec items in a group to provide access to a resource 'a' while using a resource 'b'+-- | Run a custom action before and/or after all spec items in a group to+-- provide access to a resource 'a' while using a resource 'b' -- -- See the @FOOTGUN@ note in the docs for 'around_'. aroundAllWith ::@@ -123,7 +124,18 @@ ((newOuter -> IO ()) -> (oldOuter -> IO ())) -> TestDefM (newOuter ': oldOuter ': otherOuters) inner result -> TestDefM (oldOuter ': otherOuters) inner result-aroundAllWith func = wrapForest $ \forest -> DefAroundAllWithNode func forest+aroundAllWith func = wrapForest $ \forest ->+ DefAroundAllWithNode (\useNew (HCons x _) -> func useNew x) forest++-- | Run a custom action before and/or after all spec items in a group to+-- provide access to a resource 'a' while using all outer resources.+aroundAllWithAll ::+ forall newOuter oldOuter otherOuters inner result.+ -- | The function that provides the new outer resource (once), using the old outer resource.+ ((newOuter -> IO ()) -> (HList (oldOuter ': otherOuters) -> IO ())) ->+ TestDefM (newOuter ': oldOuter ': otherOuters) inner result ->+ TestDefM (oldOuter ': otherOuters) inner result+aroundAllWithAll func = wrapForest $ \forest -> DefAroundAllWithNode func forest -- | Declare a node in the spec def forest wrapForest ::
src/Test/Syd/Def/Scenario.hs view
@@ -1,4 +1,4 @@-module Test.Syd.Def.Scenario (scenarioDir, scenarioDirRecur) where+module Test.Syd.Def.Scenario (scenarioDir, scenarioDirRecur, scenarioDirOfDirs) where import Control.Monad import Control.Monad.IO.Class@@ -8,40 +8,93 @@ import qualified System.FilePath as FP import Test.Syd.Def.Specify import Test.Syd.Def.TestDefM+import Test.Syd.Expectation -- | Define a test for each file in the given directory. --+-- Subdirectories are ignored, use 'scenarioDirRecur' to descend into them or+-- 'scenarioDirOfDirs' to treat each of them as a scenario.+--+-- If the directory is empty or absent, this defines a single failing test+-- instead, because that usually means the scenario files were omitted by+-- accident.+--+-- The scenario is given relative to the directory, so it is the name to say the+-- test is about and joining it to the directory is what reads it. Neither has to+-- be recovered from the other.+-- -- Example: ----- > scenarioDir "test_resources/even" $ \fp ->+-- > let dir = [reldir|test_resources/even|]+-- > scenarioDir dir $ \rf -> -- > it "contains an even number" $ do--- > s <- readFile fp+-- > s <- readFile (fromRelFile (dir </> rf)) -- > n <- readIO s -- > (n :: Int) `shouldSatisfy` even-scenarioDir :: FilePath -> (FilePath -> TestDefM outers inner ()) -> TestDefM outers inner ()-scenarioDir = scenarioDirHelper listDirRel+scenarioDir :: Path b Dir -> (Path Rel File -> TestDefM outers inner ()) -> TestDefM outers inner ()+scenarioDir = scenarioDirHelper "files" (fmap snd . listDirRel) -- | Define a test for each file in the given directory, recursively. --+-- If the directory contains no files, or is absent, this defines a single+-- failing test instead, because that usually means the scenario files were+-- omitted by accident.+-- -- Example: ----- > scenarioDirRecur "test_resources/odd" $ \fp ->+-- > let dir = [reldir|test_resources/odd|]+-- > scenarioDirRecur dir $ \rf -> -- > it "contains an odd number" $ do--- > s <- readFile fp+-- > s <- readFile (fromRelFile (dir </> rf)) -- > n <- readIO s -- > (n :: Int) `shouldSatisfy` odd-scenarioDirRecur :: FilePath -> (FilePath -> TestDefM outers inner ()) -> TestDefM outers inner ()-scenarioDirRecur = scenarioDirHelper listDirRecurRel+scenarioDirRecur :: Path b Dir -> (Path Rel File -> TestDefM outers inner ()) -> TestDefM outers inner ()+scenarioDirRecur = scenarioDirHelper "files" (fmap snd . listDirRecurRel) +-- | Define a test for each subdirectory of the given directory.+--+-- Use this when a single scenario consists of more than one file. Files in+-- the given directory itself are ignored, and so is any nesting below the+-- subdirectories: each subdirectory is one scenario, whatever it contains.+--+-- If there are no subdirectories, or the directory is absent, this defines a+-- single failing test instead, because that usually means the scenarios were+-- omitted by accident.+--+-- Example:+--+-- > let dir = [reldir|test_resources/same|]+-- > scenarioDirOfDirs dir $ \rd ->+-- > it "contains two files with the same contents" $ do+-- > a <- readFile (fromRelFile (dir </> rd </> [relfile|a|]))+-- > b <- readFile (fromRelFile (dir </> rd </> [relfile|b|]))+-- > a `shouldBe` b+scenarioDirOfDirs :: Path b Dir -> (Path Rel Dir -> TestDefM outers inner ()) -> TestDefM outers inner ()+scenarioDirOfDirs = scenarioDirHelper "directories" (fmap fst . listDirRel)+ scenarioDirHelper ::- (Path Abs Dir -> IO ([Path Rel Dir], [Path Rel File])) ->- FilePath ->- (FilePath -> TestDefM outers inner ()) ->+ -- | What the lister looks for, for the description of the failing test that+ -- an empty scenario directory produces.+ String ->+ -- | The scenarios, relative to the given directory+ (Path Abs Dir -> IO [Path Rel t]) ->+ Path b Dir ->+ (Path Rel t -> TestDefM outers inner ()) -> TestDefM outers inner ()-scenarioDirHelper lister dp func =- describe dp $ do- ad <- liftIO $ resolveDir' dp- fs <- liftIO $ fmap (fromMaybe []) $ forgivingAbsence $ snd <$> lister ad- forM_ fs $ \rf -> do- let fp = dp FP.</> fromRelFile rf- describe (fromRelFile rf) $ func fp+scenarioDirHelper noun lister dir func =+ describe (described dir) $ do+ ad <- liftIO $ makeAbsolute dir+ ss <- liftIO $ fmap (fromMaybe []) $ forgivingAbsence $ lister ad+ if null ss+ then it (unwords ["has scenario", noun]) $ \_ ->+ (expectationFailure $ unwords ["No scenario", noun, "found in", described dir] :: IO ())+ else forM_ ss $ \s ->+ describe (described s) $ func s++-- | A path as a test description.+--+-- The separator a directory's rendering ends in comes off, so that a scenario+-- reads the same whether it is a file or a directory, and so that these+-- descriptions are the ones they were before the scenarios became typed.+described :: Path b t -> String+described = FP.dropTrailingPathSeparator . toFilePath
src/Test/Syd/Def/SetupFunc.hs view
@@ -111,3 +111,12 @@ setupAroundAllWith sf = aroundAllWith $ \takeNewOuter oldOuter -> let SetupFunc provideNewOuter = sf oldOuter in provideNewOuter $ \newOuter -> takeNewOuter newOuter++-- | Use 'aroundAllWithAll' with a 'SetupFunc'+setupAroundAllWithAll ::+ (HList (oldOuter ': outers) -> SetupFunc newOuter) ->+ TestDefM (newOuter ': oldOuter ': outers) inner result ->+ TestDefM (oldOuter ': outers) inner result+setupAroundAllWithAll sf = aroundAllWithAll $ \takeNewOuter oldOuter ->+ let SetupFunc provideNewOuter = sf oldOuter+ in provideNewOuter $ \newOuter -> takeNewOuter newOuter
src/Test/Syd/Def/Specify.hs view
@@ -93,9 +93,9 @@ -- > describe "readFile and writeFile" $ -- > it "reads back what it wrote for this example" $ do -- > let cts = "hello world"--- > let fp = "test.txt"--- > writeFile fp cts--- > cts' <- readFile fp+-- > let file = [relfile|test.txt|]+-- > writeFile (fromRelFile file) cts+-- > cts' <- readFile (fromRelFile file) -- > cts' `shouldBe` cts -- --@@ -111,10 +111,10 @@ -- -- > describe "readFile and writeFile" $ -- > it "reads back what it wrote for any example" $ do--- > forAllValid $ \fp ->+-- > forAllValid $ \file -> -- > forAllValid $ \cts -> do--- > writeFile fp cts--- > cts' <- readFile fp+-- > writeFile (fromRelFile file) cts+-- > cts' <- readFile (fromRelFile file) -- > cts' `shouldBe` cts -- --@@ -137,9 +137,9 @@ -- > in around setUpTempDir $ describe "readFile and writeFile" $ -- > it "reads back what it wrote for this example" $ \tempDir -> do -- > let cts = "hello world"--- > let fp = tempDir </> "test.txt"--- > writeFile fp cts--- > cts' <- readFile fp+-- > let file = tempDir </> [relfile|test.txt|]+-- > writeFile (fromAbsFile file) cts+-- > cts' <- readFile (fromAbsFile file) -- > cts' `shouldBe` cts -- --@@ -158,9 +158,9 @@ -- > in around setUpTempDir $ describe "readFile and writeFile" $ -- > it "reads back what it wrote for this example" $ \tempDir -> -- > property $ \cts -> do--- > let fp = tempDir </> "test.txt"--- > writeFile fp cts--- > cts' <- readFile fp+-- > let file = tempDir </> [relfile|test.txt|]+-- > writeFile (fromAbsFile file) cts+-- > cts' <- readFile (fromAbsFile file) -- > cts' `shouldBe` cts it :: forall outers inner test.@@ -179,8 +179,7 @@ t sets progressReporter- ( \func -> supplyArgs (\_ arg2 -> func () arg2)- ),+ (\func -> supplyArgs (\_ arg2 -> func () arg2)), testDefCallStack = callStack } tell [DefSpecifyNode (T.pack s) testDef ()]@@ -240,9 +239,9 @@ -- > in aroundAll setUpTempDir describe "readFile and writeFile" $ -- > itWithOuter "reads back what it wrote for this example" $ \tempDir -> do -- > let cts = "hello world"--- > let fp = tempDir </> "test.txt"--- > writeFile fp cts--- > cts' <- readFile fp+-- > let file = tempDir </> [relfile|test.txt|]+-- > writeFile (fromAbsFile file) cts+-- > cts' <- readFile (fromAbsFile file) -- > cts' `shouldBe` cts -- --@@ -259,11 +258,11 @@ -- -- > let setUpTempDir func = withSystemTempDir $ \tempDir -> func tempDir -- > in aroundAll setUpTempDir describe "readFile and writeFile" $--- > itWithouter "reads back what it wrote for this example" $ \tempDir ->+-- > itWithOuter "reads back what it wrote for this example" $ \tempDir -> -- > property $ \cts -> do--- > let fp = tempDir </> "test.txt"--- > writeFile fp cts--- > cts' <- readFile fp+-- > let file = tempDir </> [relfile|test.txt|]+-- > writeFile (fromAbsFile file) cts+-- > cts' <- readFile (fromAbsFile file) -- > cts' `shouldBe` cts itWithOuter :: (HasCallStack, IsTest test, Arg1 test ~ inner, Arg2 test ~ outer) =>@@ -337,9 +336,9 @@ -- > let setUpTempDir func = withSystemTempDir $ \tempDir -> func tempDir -- > in aroundAll setUpTempDir describe "readFile and writeFile" $ before (pure "hello world") $ -- > itWithBoth "reads back what it wrote for this example" $ \tempDir cts -> do--- > let fp = tempDir </> "test.txt"--- > writeFile fp cts--- > cts' <- readFile fp+-- > let file = tempDir </> [relfile|test.txt|]+-- > writeFile (fromAbsFile file) cts+-- > cts' <- readFile (fromAbsFile file) -- > cts' `shouldBe` cts -- --@@ -355,12 +354,12 @@ -- ===== IO property test -- -- > let setUpTempDir func = withSystemTempDir $ \tempDir -> func tempDir--- > in aroundAll setUpTempDir describe "readFile and writeFile" $ before (pure "test.txt") $+-- > in aroundAll setUpTempDir describe "readFile and writeFile" $ before (pure [relfile|test.txt|]) $ -- > itWithBoth "reads back what it wrote for this example" $ \tempDir fileName -> -- > property $ \cts -> do--- > let fp = tempDir </> fileName--- > writeFile fp cts--- > cts' <- readFile fp+-- > let file = tempDir </> fileName+-- > writeFile (fromAbsFile file) cts+-- > cts' <- readFile (fromAbsFile file) -- > cts' `shouldBe` cts itWithBoth :: ( HasCallStack,
src/Test/Syd/Modify.hs view
@@ -14,6 +14,7 @@ -- * Declaring parallelism sequential, parallel,+ parallelWith, withParallelism, Parallelism (..), @@ -79,6 +80,17 @@ -- | Declare that all tests below may be run in parallel. (This is the default.) parallel :: TestDefM a b c -> TestDefM a b c parallel = withParallelism Parallel++-- | Declare that at most this many of the tests below may run at once.+--+-- The bound is across everything below this point together, not per group, and+-- it does not add threads: it only ever holds tests back. Reach for it when+-- tests contend for something the suite does not own, such as one database+-- server shared by a database per test, where running all of them at once is+-- slower than running some of them and 'sequential' gives up more than it+-- needs to.+parallelWith :: Word -> TestDefM a b c -> TestDefM a b c+parallelWith = withParallelism . ParallelWith -- | Annotate a test group with 'Parallelism'. withParallelism :: Parallelism -> TestDefM a b c -> TestDefM a b c
+ src/Test/Syd/Mutation/Forest.hs view
@@ -0,0 +1,390 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Forest operations keyed by 'TestId': flattening, filtering, and trie+-- construction. Lives in @sydtest@ so it can reference 'SpecDefForest'+-- directly; re-exported from @sydtest-mutation@.+module Test.Syd.Mutation.Forest+ ( -- * Test identifier tries+ TestIdTrie (..),+ testIdTrieFromSet,+ testIdTrieFromList,++ -- * Forest operations+ flattenTestForestWithIds,+ flattenTestForestWithIdsAndCallStacks,+ filterTestForestByTrie,+ reorderTestForestByTiming,+ reorderForMutationChild,+ )+where++import Control.Monad.State.Strict (State, evalState, gets, modify')+import Control.Monad.Trans.Writer.CPS (WriterT, execWriterT, tell)+import Data.List (sortOn)+import qualified Data.List.NonEmpty as NE+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Set (Set)+import qualified Data.Set as Set+import Data.Text (Text)+import GHC.Stack (CallStack)+import Test.Syd.Mutation.TestId (TestId (..))+import Test.Syd.SpecDef++-- * TestIdTrie++-- | A trie over 'TestId' paths, used for efficient filtering of a 'TestForest'.+data TestIdTrie+ = -- | This path identifies a selected leaf test.+ TrieLeaf+ | -- | Intermediate node: descend into matching children.+ TrieNode (Map (Text, Word) TestIdTrie)+ deriving (Eq, Show)++instance Semigroup TestIdTrie where+ TrieLeaf <> _ = TrieLeaf+ _ <> TrieLeaf = TrieLeaf+ TrieNode m1 <> TrieNode m2 = TrieNode (Map.unionWith (<>) m1 m2)++instance Monoid TestIdTrie where+ mempty = TrieNode Map.empty++-- | Build a 'TestIdTrie' from a set of 'TestId's.+testIdTrieFromSet :: Set TestId -> TestIdTrie+testIdTrieFromSet = testIdTrieFromList . Set.toList++-- | Build a 'TestIdTrie' from a list of 'TestId's.+testIdTrieFromList :: [TestId] -> TestIdTrie+testIdTrieFromList = foldr insertId (TrieNode Map.empty)+ where+ insertId (TestId steps) trie = insertSteps (NE.toList steps) trie++ insertSteps [] _ = TrieLeaf+ insertSteps ((t, i) : rest) trie =+ let child = insertSteps rest (childOf t i trie)+ in case trie of+ TrieLeaf -> TrieLeaf+ TrieNode m -> TrieNode (Map.insert (t, i) child m)++ childOf t i = \case+ TrieLeaf -> TrieLeaf+ TrieNode m -> Map.findWithDefault (TrieNode Map.empty) (t, i) m++-- * Forest operations++-- | Flatten a 'TestForest' into a list of '(TestId, value)' pairs.+--+-- 'TestId's are assigned by traversing the forest in order. Sibling nodes+-- with the same description text receive a zero-based per-description index+-- so that duplicate descriptions are still uniquely identified.+flattenTestForestWithIds :: SpecDefForest '[] () result -> [(TestId, result)]+flattenTestForestWithIds = flattenTestForestWith (\_callStack e -> e)++-- | Like 'flattenTestForestWithIds', but pair each leaf 'TestId' with the+-- 'CallStack' captured at its @it@\/@prop@\/@specify@ call site+-- ('testDefCallStack') instead of the leaf value. The 'TestId' assignment is+-- identical, so the two stay in lockstep: a leaf's id here is the same id that+-- the coverage phase records coverage against.+--+-- The 'CallStack' lets the diff-scoped runner map a changed test-source line+-- back to the tests defined there.+flattenTestForestWithIdsAndCallStacks :: SpecDefForest '[] () result -> [(TestId, CallStack)]+flattenTestForestWithIdsAndCallStacks = flattenTestForestWith (\callStack _e -> callStack)++-- | Flatten a 'TestForest' into a list of '(TestId, value)' pairs, where each+-- leaf's value is computed from its 'CallStack' ('testDefCallStack') and its+-- leaf value by the given projection. 'flattenTestForestWithIds' and+-- 'flattenTestForestWithIdsAndCallStacks' are the two projections we use.+--+-- 'TestId's are assigned by traversing the forest in order. Sibling nodes+-- with the same description text receive a zero-based per-description index so+-- that duplicate descriptions are still uniquely identified.+flattenTestForestWith :: forall result a. (CallStack -> result -> a) -> SpecDefForest '[] () result -> [(TestId, a)]+flattenTestForestWith leaf f = evalState (execWriterT (goForest [] f)) Map.empty+ where+ -- The wrapper nodes (setup, before-all, ...) preserve the forest's @extra@+ -- (result) type parameter, so it is uniformly @result@ throughout; only+ -- @outers@ and @inner@ vary, which is why those stay polymorphic here.+ --+ -- Each call to 'goForest' establishes its own fresh sibling-index counter+ -- and restores the caller's counter on the way out. Both 'DefDescribeNode'+ -- and the wrapper nodes descend via 'goForest', so each wrapped sub-forest+ -- gets its own counter, matching the accumulator-style implementation this+ -- replaces.+ goForest :: [(Text, Word)] -> SpecDefForest outers inner result -> WriterT [(TestId, a)] (State (Map Text Word)) ()+ goForest path sub = do+ saved <- gets id+ modify' (const Map.empty)+ mapM_ (goTree path) sub+ modify' (const saved)++ goTree ::+ [(Text, Word)] ->+ SpecDefTree outers inner result ->+ WriterT [(TestId, a)] (State (Map Text Word)) ()+ goTree path tree = do+ mkey <- nextKey tree+ case tree of+ DefSpecifyNode _ td e ->+ case mkey of+ Nothing -> pure ()+ Just key -> tell [(TestId (NE.fromList (reverse (key : path))), leaf (testDefCallStack td) e)]+ DefPendingNode _ _ -> pure ()+ DefDescribeNode _ sub ->+ case mkey of+ Nothing -> pure ()+ Just key -> goForest (key : path) sub+ DefSetupNode _ sub -> goForest path sub+ DefBeforeAllNode _ sub -> goForest path sub+ DefBeforeAllWithNode _ sub -> goForest path sub+ DefWrapNode _ sub -> goForest path sub+ DefAroundAllNode _ sub -> goForest path sub+ DefAroundAllWithNode _ sub -> goForest path sub+ DefAfterAllNode _ sub -> goForest path sub+ DefParallelismNode _ sub -> goForest path sub+ DefRandomisationNode _ sub -> goForest path sub+ DefTimeoutNode _ sub -> goForest path sub+ DefRetriesNode _ sub -> goForest path sub+ DefFlakinessNode _ sub -> goForest path sub+ DefExpectationNode _ sub -> goForest path sub++ -- [tag:ReorderIdScheme] 'reorderTestForestByTiming' replicates this+ -- per-description sibling-index assignment so its cost lookups line up with+ -- the baselines recorded against these ids.+ nextKey :: SpecDefTree outers inner result -> WriterT [(TestId, a)] (State (Map Text Word)) (Maybe (Text, Word))+ nextKey tree = case descriptionOf tree of+ Nothing -> pure Nothing+ Just t -> do+ idx <- gets (Map.findWithDefault 0 t)+ modify' (Map.insert t (idx + 1))+ pure (Just (t, idx))++ descriptionOf :: SpecDefTree outers inner result -> Maybe Text+ descriptionOf = \case+ DefSpecifyNode t _ _ -> Just t+ DefPendingNode t _ -> Just t+ DefDescribeNode t _ -> Just t+ _ -> Nothing++-- | Filter a 'TestForest' to only the tests present in the given 'TestIdTrie'.+--+-- Wrapper nodes (setup, before-all, around-all, etc.) are kept whenever any+-- of their children are kept.+filterTestForestByTrie :: TestIdTrie -> TestForest '[] () -> TestForest '[] ()+filterTestForestByTrie trie = snd . filterForest trie Map.empty+ where+ -- 'filterForestRev' accumulates in reverse so each kept tree is consed+ -- onto the head of @acc@ in O(1). We reverse once at the end of+ -- 'filterForest' to restore source order. The previous implementation+ -- used @acc ++ [tree]@ at every step, which is O(N^2) over sibling+ -- count and visible on large per-test coverage runs.+ filterForest ::+ TestIdTrie ->+ Map Text Word ->+ SpecDefForest outers inner () ->+ (Map Text Word, SpecDefForest outers inner ())+ filterForest t seen sub =+ let (seen', revAcc) = foldl (filterTree t) (seen, []) sub+ in (seen', reverse revAcc)++ filterTree ::+ TestIdTrie ->+ (Map Text Word, SpecDefForest outers inner ()) ->+ SpecDefTree outers inner () ->+ (Map Text Word, SpecDefForest outers inner ())+ filterTree t (seen, acc) tree =+ let (mkey, seen') = nextKey seen tree+ in case tree of+ DefSpecifyNode name td e ->+ case mkey of+ Nothing -> (seen', acc)+ Just key -> case matchLeaf t key of+ False -> (seen', acc)+ True -> (seen', DefSpecifyNode name td e : acc)+ DefPendingNode _ _ -> (seen', acc)+ DefDescribeNode name sub ->+ case mkey of+ Nothing -> (seen', acc)+ Just key -> case stepTrie t key of+ Nothing -> (seen', acc)+ Just subTrie ->+ let (_, sub') = filterForest subTrie Map.empty sub+ in if null sub' then (seen', acc) else (seen', DefDescribeNode name sub' : acc)+ DefSetupNode func sub -> keepWrapper seen' acc (DefSetupNode func) (filterForest t Map.empty sub)+ DefBeforeAllNode func sub -> keepWrapper seen' acc (DefBeforeAllNode func) (filterForest t Map.empty sub)+ DefBeforeAllWithNode func sub -> keepWrapper seen' acc (DefBeforeAllWithNode func) (filterForest t Map.empty sub)+ DefWrapNode func sub -> keepWrapper seen' acc (DefWrapNode func) (filterForest t Map.empty sub)+ DefAroundAllNode func sub -> keepWrapper seen' acc (DefAroundAllNode func) (filterForest t Map.empty sub)+ DefAroundAllWithNode func sub -> keepWrapper seen' acc (DefAroundAllWithNode func) (filterForest t Map.empty sub)+ DefAfterAllNode func sub -> keepWrapper seen' acc (DefAfterAllNode func) (filterForest t Map.empty sub)+ DefParallelismNode p sub -> keepWrapper seen' acc (DefParallelismNode p) (filterForest t Map.empty sub)+ DefRandomisationNode p sub -> keepWrapper seen' acc (DefRandomisationNode p) (filterForest t Map.empty sub)+ DefTimeoutNode f sub -> keepWrapper seen' acc (DefTimeoutNode f) (filterForest t Map.empty sub)+ DefRetriesNode f sub -> keepWrapper seen' acc (DefRetriesNode f) (filterForest t Map.empty sub)+ DefFlakinessNode fm sub -> keepWrapper seen' acc (DefFlakinessNode fm) (filterForest t Map.empty sub)+ DefExpectationNode em sub -> keepWrapper seen' acc (DefExpectationNode em) (filterForest t Map.empty sub)++ keepWrapper ::+ Map Text Word ->+ SpecDefForest outers inner () ->+ (SpecDefForest outers2 inner2 () -> SpecDefTree outers inner ()) ->+ (Map Text Word, SpecDefForest outers2 inner2 ()) ->+ (Map Text Word, SpecDefForest outers inner ())+ keepWrapper seen' acc wrap (_, sub')+ | null sub' = (seen', acc)+ | otherwise = (seen', wrap sub' : acc)++ matchLeaf :: TestIdTrie -> (Text, Word) -> Bool+ matchLeaf TrieLeaf _ = True+ matchLeaf (TrieNode m) key = case Map.lookup key m of+ Just TrieLeaf -> True+ _ -> False++ stepTrie :: TestIdTrie -> (Text, Word) -> Maybe TestIdTrie+ stepTrie TrieLeaf _ = Just TrieLeaf+ stepTrie (TrieNode m) key = Map.lookup key m++ nextKey :: Map Text Word -> SpecDefTree outers inner c -> (Maybe (Text, Word), Map Text Word)+ nextKey seen tree = case descriptionOf tree of+ Nothing -> (Nothing, seen)+ Just t ->+ let idx = Map.findWithDefault 0 t seen+ in (Just (t, idx), Map.insert t (idx + 1) seen)++ descriptionOf :: SpecDefTree outers inner c -> Maybe Text+ descriptionOf = \case+ DefSpecifyNode t _ _ -> Just t+ DefPendingNode t _ -> Just t+ DefDescribeNode t _ -> Just t+ _ -> Nothing++-- | Reorder a 'TestForest' so that, at every sibling level, cheaper tests come+-- first. A test's cost is its recorded baseline in the given map; a missing+-- baseline counts as @0@, so an untimed test runs first. A subtree is ordered+-- by the minimum cost of any leaf it contains, so the group most likely to yield+-- a cheap early kill runs first.+--+-- This is a drop-in alternative to execution-order randomisation, legal under+-- exactly the same conditions. It mirrors 'randomiseTestForest': inside a+-- 'DoNotRandomiseExecutionOrder' scope it leaves the whole subtree as-is at+-- every depth, and the caller only applies it when the suite's execution-order+-- randomisation is enabled.+--+-- Only sibling order changes. Wrapper nodes keep wrapping their own children,+-- so shared setup and teardown still run once per group and are never split.+-- The sort is stable, so equal-cost siblings keep their source order.+--+-- 'TestId's are assigned with the same scheme as 'flattenTestForestWith', purely+-- to look costs up; nothing downstream reads the ids of the reordered forest.+-- When this runs on a forest that 'filterTestForestByTrie' has thinned, the+-- per-description sibling index of same-named siblings can shift relative to the+-- baseline map's keys. That degrades ordering quality for same-named siblings+-- only, never which tests run.+reorderTestForestByTiming :: Map TestId Word -> TestForest '[] () -> TestForest '[] ()+reorderTestForestByTiming costs = snd . goForest []+ where+ -- Reorder the sub-forest and report the minimum leaf cost it contains+ -- ('maxBound' when it contains no leaf, so leafless subtrees sort last).+ goForest ::+ [(Text, Word)] ->+ SpecDefForest outers inner () ->+ (Word, SpecDefForest outers inner ())+ goForest path sub =+ let keyed =+ reverse $+ snd $+ foldl+ ( \(seen, acc) tree ->+ let (mkey, seen') = nextKey seen tree+ in (seen', goTree path mkey tree : acc)+ )+ (Map.empty, [])+ sub+ in ( if null keyed then maxBound else minimum (map fst keyed),+ map snd (sortOn fst keyed)+ )++ goTree ::+ [(Text, Word)] ->+ Maybe (Text, Word) ->+ SpecDefTree outers inner () ->+ (Word, SpecDefTree outers inner ())+ goTree path mkey tree = case tree of+ DefSpecifyNode name td e -> case mkey of+ Nothing -> (maxBound, tree)+ Just key ->+ let tid = TestId (NE.fromList (reverse (key : path)))+ in (Map.findWithDefault 0 tid costs, DefSpecifyNode name td e)+ DefPendingNode _ _ -> (maxBound, tree)+ DefDescribeNode name sub -> case mkey of+ Nothing -> (maxBound, tree)+ Just key ->+ let (cost, sub') = goForest (key : path) sub+ in (cost, DefDescribeNode name sub')+ DefSetupNode func sub -> wrap (DefSetupNode func) path sub+ DefBeforeAllNode func sub -> wrap (DefBeforeAllNode func) path sub+ DefBeforeAllWithNode func sub -> wrap (DefBeforeAllWithNode func) path sub+ DefWrapNode func sub -> wrap (DefWrapNode func) path sub+ DefAroundAllNode func sub -> wrap (DefAroundAllNode func) path sub+ DefAroundAllWithNode func sub -> wrap (DefAroundAllWithNode func) path sub+ DefAfterAllNode func sub -> wrap (DefAfterAllNode func) path sub+ DefParallelismNode p sub -> wrap (DefParallelismNode p) path sub+ DefTimeoutNode f sub -> wrap (DefTimeoutNode f) path sub+ DefRetriesNode f sub -> wrap (DefRetriesNode f) path sub+ DefFlakinessNode fm sub -> wrap (DefFlakinessNode fm) path sub+ DefExpectationNode em sub -> wrap (DefExpectationNode em) path sub+ DefRandomisationNode eor sub -> case eor of+ RandomiseExecutionOrder -> wrap (DefRandomisationNode eor) path sub+ -- [tag:ReorderRandomiseBoundary] Mirror 'randomiseTestForest': inside a+ -- 'DoNotRandomiseExecutionOrder' scope, leave the whole subtree as-is at+ -- every depth. Still compute its cost (order-invariant) to position the+ -- node among its own siblings; discard the reordered structure.+ DoNotRandomiseExecutionOrder ->+ let (cost, _) = goForest path sub+ in (cost, DefRandomisationNode eor sub)++ -- Reorder a wrapper's children and keep the wrapper wrapping them. The+ -- wrapper contributes no path step (matching 'flattenTestForestWith'), so+ -- its children stay keyed under the same @path@.+ wrap ::+ (SpecDefForest a b () -> SpecDefTree outers inner ()) ->+ [(Text, Word)] ->+ SpecDefForest a b () ->+ (Word, SpecDefTree outers inner ())+ wrap con path sub =+ let (cost, sub') = goForest path sub+ in (cost, con sub')++ -- [ref:ReorderIdScheme] Assign the per-description sibling index exactly as+ -- 'flattenTestForestWith' does, so cost lookups hit the baseline map.+ nextKey :: Map Text Word -> SpecDefTree outers inner () -> (Maybe (Text, Word), Map Text Word)+ nextKey seen tree = case descriptionOf tree of+ Nothing -> (Nothing, seen)+ Just t ->+ let idx = Map.findWithDefault 0 t seen+ in (Just (t, idx), Map.insert t (idx + 1) seen)++ descriptionOf :: SpecDefTree outers inner () -> Maybe Text+ descriptionOf = \case+ DefSpecifyNode t _ _ -> Just t+ DefPendingNode t _ -> Just t+ DefDescribeNode t _ -> Just t+ _ -> Nothing++-- | Decide the execution order for a mutation child's (already filtered)+-- forest. Reorder cheapest-first with 'reorderTestForestByTiming' only when+-- the suite has execution-order randomisation enabled (so this stays a drop-in+-- alternative to that randomisation) and a baseline is available; otherwise+-- leave the forest untouched.+--+-- Kept pure and separate from the child's IO so the gate is testable: the+-- child reads the baseline and looks up 'settingRandomiseExecutionOrder', then+-- hands both here.+reorderForMutationChild :: Bool -> Maybe (Map TestId Word) -> TestForest '[] () -> TestForest '[] ()+reorderForMutationChild randomiseEnabled mCosts forest =+ case (randomiseEnabled, mCosts) of+ (True, Just costs) -> reorderTestForestByTiming costs forest+ _ -> forest
+ src/Test/Syd/MutationMode.hs view
@@ -0,0 +1,14 @@+-- | Compatibility shim: parent-side mutation runners (previously+-- 'runCoverageMode' / 'runMutationMode') have moved to the+-- @sydtest-mutation-driver@ package.+--+-- This module now re-exports the shared utilities from+-- 'Test.Syd.MutationMode.Common' and the in-tree child runners.+module Test.Syd.MutationMode+ ( module Test.Syd.MutationMode.Common,+ module Test.Syd.MutationMode.SingleCoverage,+ )+where++import Test.Syd.MutationMode.Common+import Test.Syd.MutationMode.SingleCoverage
+ src/Test/Syd/MutationMode/Common.hs view
@@ -0,0 +1,604 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Utilities and types shared by the in-tree mutation child entry points+-- ('Test.Syd.MutationMode.Single', 'Test.Syd.MutationMode.SingleCoverage')+-- and the out-of-tree mutation driver (@sydtest-mutation-driver@).+--+-- Everything in this module is parent-vs-child neutral: it lives in+-- @sydtest@ because both the child entry points and the driver need it.+module Test.Syd.MutationMode.Common+ ( -- * Mutation outcomes+ MutationResult (..),+ SuiteOutcome (..),+ OutcomeTally (..),+ emptyOutcomeTally,+ tallyGroups,+ classifySyncExceptionAsKilled,+ retryingIO,+ isMutationFailure,+ mutationResultId,+ resultToOutcome,+ runOneGroup,++ -- * Exceptions+ MutationFailFast (..),+ CoverageFailFast (..),++ -- * Reporting+ renderMutationRunReport,+ renderMutationProgressEvent,+ renderUnifiedDiff,+ formatMutationLog,+ survivorMitigationLines,++ -- * Coverage progress events+ CoverageProgressEvent (..),+ CoverageProgressTestEvent (..),+ CoverageProgressPhase (..),+ CoverageProgressSkipReason (..),+ renderCoverageProgressEvent,+ emitCoverageEvent,++ -- * Timing utilities+ diffMonotonicMicros,+ )+where++import Control.Exception (Exception)+import qualified Control.Exception as Exception+import Data.List (intercalate)+import qualified Data.Text as T+import Data.Word (Word64)+import Path+import System.IO (stderr)+import Test.Syd.Mutation.AugmentedManifest+ ( AugmentedMutationRecord (..),+ ControlFailedMutation (..),+ ControlTally (..),+ MutationGroupReport (..),+ MutationOutcome (..),+ MutationProgressEvent (..),+ MutationRunReport (..),+ MutationTally (..),+ SkippedMutation (..),+ SurvivedMutation (..),+ TimedOutMutation (..),+ UncoveredMutation (..),+ mutationGroupReportOutcomes,+ )+import Test.Syd.Mutation.Manifest.Render (renderUnifiedDiff)+import Test.Syd.Mutation.Runtime (MutationId (..), renderMutationId)+import Test.Syd.Mutation.TestId (TestId, renderTestId)+import Test.Syd.OptParse (Settings, settingTerminalCapabilities)+import Text.Colour (Chunk, chunk, cyan, fore, green, hPutChunksUtf8With, red, unlinesChunks, yellow)++-- | Difference of two 'getMonotonicTimeNSec' readings expressed in+-- microseconds. The monotonic clock is not affected by NTP slew or+-- step, so timing comparisons here are robust against system-clock+-- changes that 'getCurrentTime' would have observed.+diffMonotonicMicros :: Word64 -> Word64 -> Word+diffMonotonicMicros end start =+ -- 'getMonotonicTimeNSec' is monotonically non-decreasing, so @end >=+ -- start@ holds whenever they were measured in this order. Guard+ -- with a defensive max anyway, in case a future change captures the+ -- two times across an unexpected boundary.+ fromIntegral ((max end start - start) `div` 1000)++data MutationResult+ = MutationUncovered UncoveredMutation+ | MutationKilled AugmentedMutationRecord+ | -- | At least one suite's child exceeded its monotonic-clock timeout, and+ -- still did when given another go. Counted as killed in the overall+ -- score, and also recorded separately in the report: a mutation that+ -- turns a loop into one that never ends is one the suite noticed, which+ -- is what a kill means. The retry is what makes that safe to say, since+ -- a single overrun on a busy machine would not have meant it.+ MutationTimedOut TimedOutMutation+ | MutationSurvived SurvivedMutation+ | -- | The mutation was not tested because an earlier mutation in the same+ -- group already failed (survived or was uncovered). Within-group+ -- fail-fast records every remaining alternative as 'MutationSkipped'+ -- without spawning a child.+ MutationSkipped SkippedMutation+ | -- | A control (no-op) mutation survived, as it must: the control passed.+ -- A control changes no behaviour, so its survival confirms the harness+ -- reports a non-diff correctly. Not counted in the killed\/survived score.+ MutationControlPassed AugmentedMutationRecord+ | -- | A control (no-op) mutation was killed: the control failed. A no-op+ -- cannot be legitimately killed, so this means the mutation testing is+ -- unsound (a flaky\/nondeterministic suite or a harness bug). Treated as a+ -- failure of the whole run, like a survivor.+ MutationControlFailed ControlFailedMutation+ deriving (Eq, Show)++-- | Per-suite outcome of running a single mutation child.+data SuiteOutcome+ = -- | Child exited non-zero — mutation killed by this suite. The optional+ -- log path points at the captured stdout\/stderr, which is kept only when+ -- the kill is itself the finding: a killed control. Keeping it for every+ -- kill would mean a log per mutation in the manifest, which is the bulk of+ -- a run and says nothing a passing suite does not.+ SuiteKilled (Maybe (Path Rel File))+ | -- | Child exited zero — mutation survived in this suite. The optional+ -- log path points at the captured stdout/stderr (when a report dir is+ -- configured).+ SuiteSurvived (Maybe (Path Rel File))+ | -- | Child exceeded its monotonic-clock timeout and was terminated by the+ -- parent. Carries the elapsed microseconds and (optionally) the+ -- log path.+ SuiteTimedOut Word (Maybe (Path Rel File))+ deriving (Eq, Show)++-- | Wrap a per-suite runner so that any synchronous exception escaping it is+-- treated as 'SuiteKilled'. A mutation that makes the child process+-- unrunnable (e.g. 'BlockedIndefinitelyOnMVar' / @<<loop>>@ propagating from+-- 'waitExitCode', an 'IOException' from log-file IO, a parse failure on the+-- child's output) is conceptually indistinguishable from a mutation that+-- makes the child crash — both mean the test detected the mutation.+--+-- 'SomeAsyncException' is re-thrown so Ctrl-C and other cancellations still+-- propagate out of the runner.+classifySyncExceptionAsKilled :: IO SuiteOutcome -> IO SuiteOutcome+classifySyncExceptionAsKilled action =+ Exception.handle+ ( \(e :: Exception.SomeException) -> case Exception.fromException e of+ Just (_ :: Exception.SomeAsyncException) -> Exception.throwIO e+ Nothing -> pure (SuiteKilled Nothing)+ )+ action++-- | Retry an 'IO' action that produces a tagged failure reason.+--+-- @retryingIO retriesLeft onRetry action@ runs @action@; on 'Left' it calls+-- @onRetry@ with the reason and the number of retries remaining, then runs+-- @action@ again with a decremented counter. When @retriesLeft@ reaches+-- zero, the final 'Left' is returned unchanged (so the caller can decide+-- how to surface the failure).+--+-- A @retriesLeft@ of 0 means "no retries, one attempt". A @retriesLeft@ of+-- 3 means "up to 4 total attempts".+--+-- The action itself decides what counts as a transient failure: returning+-- 'Right' bypasses retry, returning 'Left' triggers it.+--+-- Synchronous exceptions thrown by @action@ are not caught here — they are+-- not, in the coverage-child use case, the kind of failure we want to+-- retry through. Use 'Exception.handle' inside @action@ if you need to+-- convert exceptions to 'Left'.+retryingIO ::+ -- | Initial number of retries (0 = no retries; one attempt total).+ Word ->+ -- | Called once per retry, with the reason and the number of retries+ -- still remaining after the current attempt.+ (String -> Word -> IO ()) ->+ -- | The action to run.+ IO (Either String a) ->+ IO (Either String a)+retryingIO retriesLeft onRetry action = do+ result <- action+ case result of+ Right v -> pure (Right v)+ Left reason+ | retriesLeft > 0 -> do+ onRetry reason (retriesLeft - 1)+ retryingIO (retriesLeft - 1) onRetry action+ | otherwise -> pure (Left reason)++-- | Thrown inside a 'mapConcurrently' worker to abort the mutation run when+-- fail-fast is on and a surviving or uncovered mutation is observed. The+-- sibling workers are cancelled by 'mapConcurrently' on the first exception.+data MutationFailFast = MutationFailFast+ deriving (Show)++instance Exception MutationFailFast++-- | Thrown inside a coverage 'mapConcurrently' worker to abort the run when+-- a coverage child reports that its baseline test failed (exit code 2) and+-- fail-fast is on. Distinct from 'MutationFailFast' because the cause is+-- different — the suite was already red before any mutation ran, so the+-- mutation scores would be meaningless.+data CoverageFailFast = CoverageFailFast+ deriving (Show)++instance Exception CoverageFailFast++-- | A mutation result that should trip within-group fail-fast: the test+-- suite did not detect the mutation (survivor) or no test reaches the+-- mutation site (uncovered). Timeouts count as killed, so they do not+-- trip; 'MutationSkipped' is itself a consequence of a prior failure and+-- does not trip again.+isMutationFailure :: MutationResult -> Bool+isMutationFailure = \case+ MutationSurvived _ -> True+ MutationUncovered _ -> True+ MutationKilled _ -> False+ -- [ref:TimeoutIsAKill]+ MutationTimedOut _ -> False+ MutationSkipped _ -> False+ -- A passing control is the expected outcome, so it is not a failure (and must+ -- not trip fail-fast). A failed control IS a failure: a no-op cannot be+ -- legitimately killed, and flaky tests are already retried, so it means the+ -- mutation testing is unsound - fail like a survivor.+ MutationControlPassed _ -> False+ MutationControlFailed _ -> True++-- | The 'MutationId' of the mutation that produced a failing result, used as+-- the @cause@ on 'SkippedMutation' entries for subsequent group members.+mutationResultId :: MutationResult -> Maybe MutationId+mutationResultId = \case+ MutationSurvived sm -> Just (augmentedMutationRecordId (survivedMutationRecord sm))+ MutationUncovered um -> Just (augmentedMutationRecordId (uncoveredMutationRecord um))+ _ -> Nothing++resultToOutcome :: MutationResult -> MutationOutcome+resultToOutcome = \case+ MutationKilled r -> OutcomeKilled r+ MutationSurvived sm -> OutcomeSurvived sm+ MutationTimedOut tm -> OutcomeTimedOut tm+ MutationUncovered um -> OutcomeUncovered um+ MutationSkipped sk -> OutcomeSkipped sk+ MutationControlPassed r -> OutcomeControlPassed r+ MutationControlFailed cf -> OutcomeControlFailed cf++-- | Per-outcome-kind counts produced by 'tallyGroups'. Each 'OutcomeTimedOut'+-- bumps both 'tallyKilled' and 'tallyTimedOut' because a timed-out mutation is+-- treated as killed for scoring; 'tallyTimedOut' carries the separate count+-- for visibility.+data OutcomeTally = OutcomeTally+ { tallyKilled :: !Word,+ tallySurvived :: !Word,+ tallyTimedOut :: !Word,+ tallyUncovered :: !Word,+ tallySkipped :: !Word,+ -- | Control (no-op) mutations that survived as they must (controls passed).+ tallyControlPassed :: !Word,+ -- | Control (no-op) mutations that were killed (controls failed) - the+ -- mutation testing is unsound, and the run fails.+ tallyControlFailed :: !Word+ }++emptyOutcomeTally :: OutcomeTally+emptyOutcomeTally =+ OutcomeTally+ { tallyKilled = 0,+ tallySurvived = 0,+ tallyTimedOut = 0,+ tallyUncovered = 0,+ tallySkipped = 0,+ tallyControlPassed = 0,+ tallyControlFailed = 0+ }++tallyGroups :: [MutationGroupReport] -> OutcomeTally+tallyGroups = foldr (\(MutationGroupReport os) acc -> foldr step acc os) emptyOutcomeTally+ where+ step = \case+ OutcomeKilled _ -> \t -> t {tallyKilled = tallyKilled t + 1}+ -- [ref:TimeoutIsAKill]+ OutcomeTimedOut _ -> \t -> t {tallyKilled = tallyKilled t + 1, tallyTimedOut = tallyTimedOut t + 1}+ OutcomeSurvived _ -> \t -> t {tallySurvived = tallySurvived t + 1}+ OutcomeUncovered _ -> \t -> t {tallyUncovered = tallyUncovered t + 1}+ OutcomeSkipped _ -> \t -> t {tallySkipped = tallySkipped t + 1}+ OutcomeControlPassed _ -> \t -> t {tallyControlPassed = tallyControlPassed t + 1}+ OutcomeControlFailed _ -> \t -> t {tallyControlFailed = tallyControlFailed t + 1}++-- | Run one mutation group: walk records sequentially, calling the supplied+-- @run@ for each record, and once one record's result is 'isMutationFailure'+-- record every remaining record as 'MutationSkipped' instead of running it.+--+-- Returns the results in source order. When @globalFailFast@ is 'True' and+-- a non-skipped result is a failure, 'MutationFailFast' is thrown after the+-- group's results have been written to the accumulator so a partial report+-- can still be assembled by the caller.+runOneGroup ::+ -- | Whether to throw 'MutationFailFast' after the first failing result.+ Bool ->+ -- | How to run one mutation. Receives the record being tested.+ (AugmentedMutationRecord -> IO MutationResult) ->+ -- | Called once per produced 'MutationResult' (in source order) so the+ -- caller can stream results into an accumulator.+ (MutationResult -> IO ()) ->+ -- | Records of this group in source order.+ [AugmentedMutationRecord] ->+ IO ()+runOneGroup globalFailFast runOne onResult = loop Nothing+ where+ loop _ [] = pure ()+ loop (Just causeMid) (rec : rest) = do+ let r = MutationSkipped (SkippedMutation rec causeMid)+ onResult r+ loop (Just causeMid) rest+ loop Nothing (rec : rest) = do+ r <- runOne rec+ onResult r+ let failFastNow = globalFailFast && isMutationFailure r+ if failFastNow+ then do+ -- Record every remaining record in this group as skipped so the+ -- partial report (assembled by the caller after catching+ -- 'MutationFailFast') reflects the entire group, not just the+ -- records processed before the abort.+ loop (mutationResultId r) rest+ Exception.throwIO MutationFailFast+ else loop (mutationResultId r) rest++renderMutationRunReport :: MutationRunReport -> [[Chunk]]+renderMutationRunReport MutationRunReport {..} =+ let MutationTally {..} = mutationRunReportMutations+ ControlTally {..} = mutationRunReportControls+ in [ [chunk "Killed: ", fore green (chunk (T.pack (show mutationTallyKilled)))],+ [chunk " (of which timed out: ", fore yellow (chunk (T.pack (show mutationTallyTimedOut))), chunk ")"],+ [chunk "Survived: ", fore red (chunk (T.pack (show mutationTallySurvived)))],+ [chunk "Uncovered: ", fore yellow (chunk (T.pack (show mutationTallyUncovered)))],+ [chunk "Skipped: ", fore yellow (chunk (T.pack (show mutationTallySkipped)))]+ ]+ -- Control (no-op) lines only appear when controls ran, so runs without+ -- any control mutation render exactly as before.+ ++ ( if controlTallyPassed == 0 && controlTallyFailed == 0+ then []+ else+ [ [chunk "Controls passed: ", fore green (chunk (T.pack (show controlTallyPassed)))],+ [ chunk "Controls failed: ",+ fore+ (if controlTallyFailed == 0 then green else red)+ (chunk (T.pack (show controlTallyFailed)))+ ]+ ]+ )+ ++ ( if null controlFaileds+ then []+ else+ [[], [fore red (chunk "Failed controls (mutation testing may be unsound):")]]+ ++ concatMap renderControlFailed controlFaileds+ ++ [[], remediationHeader "A failed control is a no-op mutation that was killed. It means:"]+ ++ remediationControlBody+ )+ ++ ( if null timedOuts+ then []+ else+ [[], [chunk "Timed-out mutations:"]]+ ++ concatMap renderTimedOut timedOuts+ )+ ++ ( if null survivors+ then []+ else+ [[], [chunk "Surviving mutations:"]]+ ++ concatMap renderSurvivor survivors+ ++ [[], remediationHeader "To resolve a surviving mutation:"]+ ++ remediationSurvivorBody+ )+ ++ ( if null uncovereds+ then []+ else+ [[], [chunk "Uncovered mutations:"]]+ ++ concatMap renderUncovered uncovereds+ ++ [[], remediationHeader "To resolve an uncovered mutation:"]+ ++ remediationUncoveredBody+ )+ ++ ( if null skippeds+ then []+ else+ [[], [chunk "Skipped mutations:"]]+ ++ concatMap renderSkipped skippeds+ )+ where+ allOutcomes = concatMap mutationGroupReportOutcomes mutationRunReportGroups+ survivors = [s | OutcomeSurvived s <- allOutcomes]+ timedOuts = [t | OutcomeTimedOut t <- allOutcomes]+ uncovereds = [u | OutcomeUncovered u <- allOutcomes]+ skippeds = [sk | OutcomeSkipped sk <- allOutcomes]+ controlFaileds = [cf | OutcomeControlFailed cf <- allOutcomes]+ renderControlFailed cf =+ let rec = controlFailedMutationRecord cf+ mid = augmentedMutationRecordId rec+ -- The output of the run that killed it is the only place the+ -- offending test is named, so say where it is rather than leaving+ -- the reader to know the naming convention.+ logLines = case controlFailedMutationLogFile cf of+ Nothing -> []+ Just relFile ->+ [[chunk " Output of the run that killed it: ", fore cyan (chunk (T.pack (fromRelFile relFile)))]]+ in ([] : formatMutationLog mid rec) ++ logLines+ renderSurvivor sm =+ let rec = survivedMutationRecord sm+ mid = augmentedMutationRecordId rec+ in ([] : formatMutationLog mid rec) ++ survivorMitigationLines rec+ renderTimedOut tm =+ let rec = timedOutMutationRecord tm+ mid = augmentedMutationRecordId rec+ secs = fromIntegral (timedOutMutationElapsedMicros tm) / (1000000 :: Double)+ header =+ [ chunk "[timed out after ",+ fore yellow (chunk (T.pack (show secs))),+ chunk "s]"+ ]+ in [] : header : formatMutationLog mid rec+ renderUncovered um =+ let rec = uncoveredMutationRecord um+ mid = augmentedMutationRecordId rec+ in [] : formatMutationLog mid rec+ renderSkipped sk =+ let rec = skippedMutationRecord sk+ mid = augmentedMutationRecordId rec+ cause = skippedMutationCause sk+ header =+ [ chunk "[skipped - failed in same group: ",+ fore yellow (chunk (T.pack (renderMutationId cause))),+ chunk "]"+ ]+ in [] : header : formatMutationLog mid rec+ remediationHeader t = [fore cyan (chunk t)]+ -- Kept in sync with the disable-annotation syntax in+ -- sydtest-mutation-plugin (Test.Syd.Mutation.Plugin.Instrument:+ -- 'parseFunMutationAnns') and the global+ -- 'mutationPluginConfigDisabledMutations' / 'exceptions' fields in+ -- Test.Syd.Mutation.Plugin.OptParse.+ remediationSurvivorBody =+ [ [chunk " 1. Kill it: add or strengthen a test so the mutation causes a test failure."],+ [chunk " 2. Disable it on this binding:"],+ [chunk " {-# ANN funName (\"DisableMutation: <Operator>\" :: String) #-}"],+ [chunk " or for every operator on a binding:"],+ [chunk " {-# ANN funName (\"DisableMutations\" :: String) #-}"],+ [chunk " or for the whole module:"],+ [chunk " {-# ANN module (\"DisableMutations\" :: String) #-}"],+ [chunk " or globally in the plugin config (sydtest-mutation-plugin.yaml):"],+ [chunk " disabled-mutations: [<Operator>]"]+ ]+ remediationUncoveredBody =+ [ [chunk " 1. Cover it: add a test that exercises the mutation site so the coverage phase records a covering test."],+ [chunk " 2. Disable it: same annotations and config keys as for survivors above."]+ ]+ remediationControlBody =+ [ [chunk " - a test is flaky or nondeterministic (depends on time, ordering, randomness, or shared state), or"],+ [chunk " - the mutation harness itself has a bug."],+ [chunk " Flaky tests are already retried, so this fails the run like a survivor: fix the test or the harness."]+ ]++-- | Render the per-mutation progress line emitted as each mutation is+-- tested. @index@\/@total@ are this mutation's 1-based position in the run+-- and the total mutation count, shown as a coloured @[X\/Y]@ counter. The+-- @verbose@ flag controls detail: in concise mode (the default) this is the+-- single locating line @[X\/Y] Testing mutation \<operator\> at+-- \<file\>:\<line\>:\<cols\>@, so a long run shows steady progress — including+-- how far along it is — without flooding the log; in verbose\/debug mode the+-- full source diff of the mutation follows — the same block the report prints+-- for a survivor.+renderMutationProgressEvent :: Bool -> Int -> Int -> MutationProgressEvent -> [[Chunk]]+renderMutationProgressEvent verbose index total (MutationProgressEvent rec) =+ let logLines = formatMutationLog (augmentedMutationRecordId rec) rec+ -- A coloured @[X/Y]@ counter so the run shows its progress. X is+ -- right-padded to the width of Y so the "Testing mutation" text stays+ -- in a steady column as the count climbs.+ indexStr = show index+ totalStr = show total+ paddedIndex = replicate (length totalStr - length indexStr) ' ' ++ indexStr+ counter = fore cyan (chunk (T.pack (concat ["[", paddedIndex, "/", totalStr, "] "])))+ withPrefix = case logLines of+ [] -> [[counter, chunk "Testing mutation"]]+ (firstLine : rest) -> (counter : chunk "Testing mutation " : firstLine) : rest+ in if verbose then withPrefix else take 1 withPrefix++-- | Progress event for the coverage phase.+data CoverageProgressEvent+ = -- | Per-test event: emitted once when a coverage child for a test is+ -- about to run, and once when it has finished.+ CoverageProgressTest !CoverageProgressTestEvent+ | -- | Suite-level event: emitted once when the whole coverage phase is+ -- skipped because there is nothing to do.+ CoverageProgressSkipped !CoverageProgressSkipReason++data CoverageProgressTestEvent = CoverageProgressTestEvent+ { coverageProgressIndex :: !Int,+ coverageProgressTotal :: !Int,+ coverageProgressTestId :: !TestId,+ coverageProgressTestPhase :: !CoverageProgressPhase+ }++data CoverageProgressPhase+ = CoverageProgressStarting+ | -- | Number of mutations covered by this test.+ CoverageProgressDone !Int++-- | Why the coverage phase was skipped.+data CoverageProgressSkipReason+ = -- | The mutation manifest contained no records (every instrumentable+ -- module was disabled, e.g. via @{-# ANN module ("DisableMutations" ...) #-}@).+ CoverageSkipNoMutations+ | -- | The test spec produced no leaf tests, so there is nothing to run+ -- coverage on.+ CoverageSkipNoTests++renderCoverageProgressEvent :: CoverageProgressEvent -> [[Chunk]]+renderCoverageProgressEvent = \case+ CoverageProgressTest CoverageProgressTestEvent {coverageProgressIndex, coverageProgressTotal, coverageProgressTestId, coverageProgressTestPhase} ->+ let prefix =+ [ chunk "coverage (",+ chunk (T.pack (show coverageProgressIndex)),+ chunk "/",+ chunk (T.pack (show coverageProgressTotal)),+ chunk "): "+ ]+ tidChunk = chunk (renderTestId coverageProgressTestId)+ in case coverageProgressTestPhase of+ CoverageProgressStarting ->+ [prefix ++ [fore cyan (chunk "running "), tidChunk]]+ CoverageProgressDone n ->+ [ prefix+ ++ [ fore green (chunk "done "),+ tidChunk,+ chunk " (",+ chunk (T.pack (show n)),+ chunk " mutations)"+ ]+ ]+ CoverageProgressSkipped reason ->+ [ [ fore yellow (chunk "coverage: skipped "),+ chunk $ case reason of+ CoverageSkipNoMutations -> "(no mutations in manifest)"+ CoverageSkipNoTests -> "(no tests in spec)"+ ]+ ]++emitCoverageEvent :: Settings -> CoverageProgressEvent -> IO ()+emitCoverageEvent settings ev =+ hPutChunksUtf8With+ (settingTerminalCapabilities settings)+ stderr+ (unlinesChunks (renderCoverageProgressEvent ev))++-- | Per-survivor mitigation guidance, appended under a surviving mutation in+-- the report: the exact disable annotation for this mutation (using its+-- operator and, when known, its enclosing binding) and any mitigation hint the+-- operator attached (e.g. an equivalent-mutant suppression).+survivorMitigationLines :: AugmentedMutationRecord -> [[Chunk]]+survivorMitigationLines rec = disableLine : mitigationLines+ where+ op = augmentedMutationRecordOperator rec+ disableLine = case augmentedMutationRecordBinding rec of+ Just b ->+ [ chunk " disable: ",+ fore yellow (chunk (T.pack (concat ["{-# ANN ", T.unpack b, " (\"DisableMutation: ", T.unpack op, "\" :: String) #-}"])))+ ]+ Nothing ->+ [ chunk " disable: add ",+ fore yellow (chunk op),+ chunk " to disabled-mutations in the plugin config"+ ]+ mitigationLines = case augmentedMutationRecordMitigation rec of+ Nothing -> []+ Just m -> [[chunk " mitigation: ", chunk m]]++formatMutationLog :: MutationId -> AugmentedMutationRecord -> [[Chunk]]+formatMutationLog (MutationId parts) AugmentedMutationRecord {augmentedMutationRecordOperator, augmentedMutationRecordOriginal, augmentedMutationRecordReplacement, augmentedMutationRecordSourceLines, augmentedMutationRecordMutatedLines, augmentedMutationRecordSourceFile, augmentedMutationRecordLine, augmentedMutationRecordContextBefore, augmentedMutationRecordContextAfter} =+ case parts of+ (modName : _op : lineStr : colStartStr : colEndStr : _) ->+ let filePath = case augmentedMutationRecordSourceFile of+ Just p -> fromRelFile p+ Nothing -> moduleToFilePath modName+ -- Append "#<index>" so identical replStr alternatives (e.g. ListLit's+ -- drop-first and drop-last on a 3-element list, both "2 elements")+ -- are distinguishable in the human-readable header line.+ variantSuffix = case parts of+ [_, _, _, _, _, _, altIdx] -> " #" ++ altIdx+ _ -> ""+ headerText = T.pack $ T.unpack augmentedMutationRecordOperator ++ " at " ++ filePath ++ ":" ++ lineStr ++ ":" ++ colStartStr ++ "-" ++ colEndStr ++ variantSuffix+ headerLine = [chunk headerText]+ in case augmentedMutationRecordSourceLines of+ [] ->+ [ headerLine,+ [fore red (chunk (" - " <> augmentedMutationRecordOriginal))],+ [fore green (chunk (" + " <> augmentedMutationRecordReplacement))]+ ]+ _ ->+ headerLine : renderUnifiedDiff (fromIntegral augmentedMutationRecordLine) augmentedMutationRecordContextBefore augmentedMutationRecordSourceLines augmentedMutationRecordMutatedLines augmentedMutationRecordContextAfter+ _ ->+ [[chunk (T.pack $ intercalate "/" parts)]]+ where+ moduleToFilePath m = map (\c -> if c == '.' then '/' else c) m ++ ".hs"++-- 'renderUnifiedDiff' moved to 'Test.Syd.Mutation.Manifest.Render' so it+-- is shared with the plugin's @.txt@ manifest writer.
+ src/Test/Syd/MutationMode/CoverageList.hs view
@@ -0,0 +1,28 @@+-- | The child-process entry point that enumerates a suite's leaf tests.+--+-- Prints every leaf test id on stdout, one per line, and exits. Used by+-- @sydtest-mutation-driver@ to enumerate tests for the coverage phase.+module Test.Syd.MutationMode.CoverageList+ ( runCoverageListMode,+ )+where++import qualified Data.ByteString as SB+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Test.Syd.Def+import Test.Syd.Mutation.Forest (flattenTestForestWithIds)+import Test.Syd.Mutation.TestId (renderTestId)+import Test.Syd.OptParse++-- | Child-side entry point that prints every leaf test id on stdout and+-- exits. Used by 'sydtest-mutation-driver' to enumerate tests for the+-- coverage phase.+runCoverageListMode :: Settings -> Spec -> IO ()+runCoverageListMode sets spec = do+ specForest <- execTestDefM sets spec+ let leafIds = map fst (flattenTestForestWithIds specForest)+ -- Emit UTF-8 bytes, not 'putStrLn' which encodes through the handle's locale+ -- encoding: a test described with non-ASCII characters would otherwise crash+ -- this child in a C/POSIX-locale build sandbox.+ SB.putStr (TE.encodeUtf8 (T.unlines (map renderTestId leafIds)))
+ src/Test/Syd/MutationMode/CoverageListLocations.hs view
@@ -0,0 +1,46 @@+-- | The child-process entry point that lists each leaf test's source+-- location.+--+-- Prints, as a JSON array, the source location of every leaf test's+-- @it@\/@prop@\/@specify@ call site, then exits. Used by the diff-scoped+-- mutation runner to map a changed test-source line back to the tests defined+-- there.+module Test.Syd.MutationMode.CoverageListLocations+ ( runCoverageListLocationsMode,+ )+where++import qualified Data.ByteString.Lazy as LB+import GHC.Stack (getCallStack, srcLocFile, srcLocStartLine)+import Path+import Test.Syd.Def+import Test.Syd.Mutation.Forest (flattenTestForestWithIdsAndCallStacks)+import Test.Syd.Mutation.TestLocation (TestLocation (..), encodeTestLocations)+import Test.Syd.OptParse++-- | Child-side entry point that prints, as a JSON array, the source location+-- of every leaf test's @it@\/@prop@\/@specify@ call site, then exits. Each+-- element is a 'TestLocation' (test id, source file, line).+--+-- A leaf whose 'CallStack' is empty (it carries no recorded frame), or whose+-- source file does not parse as a relative path, is omitted: it cannot be+-- mapped to a source line, so the diff-scoped runner has no use for it.+--+-- The most-recent ('head') frame of the 'CallStack' is the+-- @it@\/@prop@\/@specify@ call site, because those combinators use+-- 'withFrozenCallStack' to fix the user's call site as the top frame.+runCoverageListLocationsMode :: Settings -> Spec -> IO ()+runCoverageListLocationsMode sets spec = do+ specForest <- execTestDefM sets spec+ let leaves = flattenTestForestWithIdsAndCallStacks specForest+ locations =+ [ TestLocation+ { testLocationTestId = tid,+ testLocationFile = relFile,+ testLocationLine = fromIntegral (srcLocStartLine srcLoc)+ }+ | (tid, cs) <- leaves,+ (_, srcLoc) : _ <- [getCallStack cs],+ Just relFile <- [parseRelFile (srcLocFile srcLoc)]+ ]+ LB.putStr (encodeTestLocations locations)
+ src/Test/Syd/MutationMode/Single.hs view
@@ -0,0 +1,78 @@+{-# LANGUAGE OverloadedStrings #-}++-- | The child-process entry point invoked once per mutation when the parent+-- mutation runner spawns it. Filters the spec to the tests that cover the+-- requested mutation, sets the mutation as active, and runs them synchronously+-- under fail-fast.+module Test.Syd.MutationMode.Single+ ( runSingleMutationMode,+ )+where++import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe)+import System.Exit (ExitCode (..), exitSuccess, exitWith)+import Test.Syd.Def+import Test.Syd.Mutation.AugmentedManifest+ ( augmentedMutationRecordCoveringTests,+ lookupAugmentedMutationRecord,+ readAugmentedManifestFile,+ )+import Test.Syd.Mutation.Forest (filterTestForestByTrie, reorderForMutationChild, testIdTrieFromList)+import Test.Syd.Mutation.Runtime (parseMutationId, setActiveMutation)+import Test.Syd.Mutation.TestBaselineMap (TestBaselineMap (..), readTestBaselineMapDirIfExists)+import Test.Syd.Mutation.TestId (TestId)+import Test.Syd.OptParse+import Test.Syd.Output (printOutputSpecForest)+import Test.Syd.Run (Timed (..))+import Test.Syd.Runner.Synchronous (runSpecForestSynchronously)+import Test.Syd.SpecDef (shouldExitFail)++-- | Child process: run only the tests covering a single mutation and exit+-- with the appropriate exit code.+--+-- When @mutationChildSuiteName@ is set, only the covering tests for that+-- suite are run. Otherwise the union of all suites' covering tests is used+-- (single-suite / backward-compatible behaviour).+runSingleMutationMode :: Settings -> MutationChildSettings -> Spec -> IO ()+runSingleMutationMode settings mutChild spec = do+ mid <- case parseMutationId (mutationChildId mutChild) of+ Nothing -> fail "runSingleMutationMode: no valid mutation-child id"+ Just m -> pure m+ augmented <- readAugmentedManifestFile (mutationChildAugmentedManifestDir mutChild)+ specForest <- execTestDefM settings spec+ let coveringTestsMap =+ maybe+ Map.empty+ augmentedMutationRecordCoveringTests+ (lookupAugmentedMutationRecord mid augmented)+ coveringTests :: [TestId]+ coveringTests = case mutationChildSuiteName mutChild of+ Just suiteName ->+ fromMaybe [] (Map.lookup suiteName coveringTestsMap)+ Nothing ->+ -- single-suite / backward-compat: union of all suites+ concatMap snd (Map.toList coveringTestsMap)+ forest = case coveringTests of+ [] -> specForest+ ts -> filterTestForestByTrie (testIdTrieFromList ts) specForest+ -- Order the covering tests cheapest-first so the fail-fast run reaches a+ -- killing test sooner. This is a drop-in alternative to execution-order+ -- randomisation, so 'reorderForMutationChild' only reorders when the suite+ -- has randomisation enabled; if the author fixed the order+ -- (--no-randomise-execution-order), the forest is left as-is. 'execTestDefM'+ -- above already ran the (seeded) shuffle that keeps ids in sync with the+ -- coverage phase, which is why the reorder happens after filtering.+ mBaseline <- readTestBaselineMapDirIfExists (mutationChildAugmentedManifestDir mutChild)+ let orderedForest =+ reorderForMutationChild+ (settingRandomiseExecutionOrder settings)+ (fmap (\(TestBaselineMap costs) -> costs) mBaseline)+ forest+ setActiveMutation (Just mid)+ timedResult <- runSpecForestSynchronously (settings {settingThreads = Synchronous, settingFailFast = True}) orderedForest+ setActiveMutation Nothing+ printOutputSpecForest settings timedResult+ if shouldExitFail settings (timedValue timedResult)+ then exitWith (ExitFailure 1)+ else exitSuccess
+ src/Test/Syd/MutationMode/SingleCoverage.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | The child-process entry point invoked once per leaf test by the+-- coverage-collection phase of the mutation driver.+--+-- Runs a single test with the coverage-collection 'IORef' installed,+-- writes its 'TestCoverageMap' and 'TestBaselineMap' to the configured+-- files, and exits.+module Test.Syd.MutationMode.SingleCoverage+ ( runSingleCoverageMode,+ )+where++import Control.Monad (when)+import Data.IORef+import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import GHC.Clock (getMonotonicTimeNSec)+import System.Exit (ExitCode (..), exitWith)+import System.IO (stderr)+import Test.Syd.Def+import Test.Syd.Mutation.Forest (filterTestForestByTrie, testIdTrieFromList)+import Test.Syd.Mutation.Runtime (withCoverageSlot)+import Test.Syd.Mutation.TestBaselineMap (TestBaselineMap (..), writeTestBaselineMapFile)+import Test.Syd.Mutation.TestCoverageMap (TestCoverageMap (..), writeTestCoverageMapFile)+import Test.Syd.Mutation.TestId (parseTestIdFilterArg, renderTestId)+import Test.Syd.MutationMode.Common (diffMonotonicMicros)+import Test.Syd.OptParse+import Test.Syd.Output (printOutputSpecForest)+import Test.Syd.Run (Timed (..))+import Test.Syd.Runner.Synchronous (runSpecForestSynchronously)+import Test.Syd.SpecDef (shouldExitFail)+import Text.Colour (chunk, fore, hPutChunksUtf8With, red, unlinesChunks)++-- | Child process: run the single test identified by @--mutation-coverage-one@,+-- write its 'TestCoverageMap' to @--mutation-coverage-output@, write its+-- monotonic-clock baseline to @--mutation-coverage-baseline-output@, and exit.+runSingleCoverageMode :: Settings -> Bool -> CoverageChildSettings -> Spec -> IO ()+runSingleCoverageMode settings failFast covChild spec = do+ tid <- case parseTestIdFilterArg (coverageChildTestId covChild) of+ Nothing -> fail "runSingleCoverageMode: no valid coverage-child test id"+ Just t -> pure t+ let outputFile = coverageChildOutput covChild+ baselineFile = coverageChildBaselineOutput covChild+ specForest <- execTestDefM settings spec+ let coverageSettings =+ settings+ { settingThreads = Synchronous,+ settingMaxSuccess = 1+ }+ trie = testIdTrieFromList [tid]+ filtered = filterTestForestByTrie trie specForest+ ref <- newIORef Set.empty+ startTime <- getMonotonicTimeNSec+ resultForest <- withCoverageSlot ref $ runSpecForestSynchronously coverageSettings filtered+ endTime <- getMonotonicTimeNSec+ covered <- readIORef ref+ let coverageMap = TestCoverageMap (Map.singleton tid covered)+ elapsedMicros = diffMonotonicMicros endTime startTime+ writeTestCoverageMapFile outputFile coverageMap+ writeTestBaselineMapFile baselineFile (TestBaselineMap (Map.singleton tid elapsedMicros))+ -- Mutation testing only makes sense against a passing baseline: if a test+ -- is red before any mutation is applied, its mutation scores are+ -- meaningless. Print the offending test's output and a loud warning in+ -- both fail-fast and non-fail-fast cases. Under fail-fast, also exit with+ -- code 2 so the parent aborts the run (see the coverage-parent runner).+ when (shouldExitFail settings (timedValue resultForest)) $ do+ printOutputSpecForest settings resultForest+ hPutChunksUtf8With (settingTerminalCapabilities settings) stderr $+ unlinesChunks+ [ [ fore red $ chunk "coverage: WARNING: test failed during baseline run for ",+ fore red $ chunk (renderTestId tid),+ fore red $ chunk " — mutation scores against this baseline are unreliable"+ ]+ ]+ when failFast $ exitWith (ExitFailure 2)
src/Test/Syd/OptParse.hs view
@@ -5,11 +5,16 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE ScopedTypeVariables #-} module Test.Syd.OptParse where import Autodocodec import Control.Applicative+import Control.Concurrent (getNumCapabilities)+import Control.Monad+import Control.Monad.IO.Class+import Data.List.NonEmpty (NonEmpty (..)) import Data.Maybe import Data.Text (Text) import GHC.Generics (Generic)@@ -50,8 +55,8 @@ settingGoldenStart :: !Bool, -- | Whether to overwrite golden tests instead of having them fail settingGoldenReset :: !Bool,- -- | Whether to use colour in the output, 'Nothing' means "detect"- settingColour :: !(Maybe Bool),+ -- | Whether to use colour in the output+ settingTerminalCapabilities :: !TerminalCapabilities, -- | The filters to use to select which tests to run settingFilters :: ![Text], -- | Whether to stop upon the first test failure@@ -71,18 +76,100 @@ -- | How to report progress settingReportProgress :: !ReportProgress, -- | Profiling mode- settingProfile :: !Bool+ settingProfile :: !Bool,+ -- | Output format+ settingOutputFormat :: !OutputFormat,+ -- | When 'Just', run in mutation testing mode under the selected+ -- sub-mode. 'Nothing' means normal test execution.+ settingMutation :: !(Maybe MutationSettings) } deriving (Show, Eq, Generic) +-- | Top-level mutation-testing configuration. The 'mutationMode' selects+-- which of the three child mutation entry points runs, and+-- 'mutationFailFast' is a cross-mode flag. All other mutation-related+-- options live on the mode-specific records.+--+-- Parent-side orchestration (coverage collection, mutation dispatch) is+-- handled by the @sydtest-mutation-driver@ executable, which spawns+-- sydtest test suites as children using these child modes.+data MutationSettings = MutationSettings+ { -- | Stop the mutation/coverage run as soon as a surviving, uncovered,+ -- or failing test is observed. True suits CI; set to false for+ -- iterated development where the full report is wanted.+ mutationFailFast :: !Bool,+ mutationMode :: !MutationMode+ }+ deriving (Show, Eq, Generic)++-- | One of the three child-side mutation entry points, with the options+-- it needs. Parent-side modes live in @sydtest-mutation-driver@.+data MutationMode+ = -- | Child process: print every leaf test's id on stdout and exit.+ -- Used by the driver to enumerate tests for the coverage phase.+ MutationModeCoverageList+ | -- | Child process: print every leaf test's id and the source+ -- location ('srcFile:line') of its @it@\/@prop@ call site, one+ -- @id\\tloc@ pair per line, then exit. Used by the diff-scoped+ -- runner to map changed test-source lines back to test ids.+ MutationModeCoverageListLocations+ | -- | Child process that collects coverage for one test.+ MutationModeCoverageChild !CoverageChildSettings+ | -- | Child process that runs only the tests covering one mutation.+ MutationModeMutateChild !MutationChildSettings+ deriving (Show, Eq, Generic)++-- | Options for the coverage-child process: the single test to run, plus+-- the two output files for its coverage map and monotonic-clock baseline.+data CoverageChildSettings = CoverageChildSettings+ { coverageChildTestId :: !Text,+ coverageChildOutput :: !FilePath,+ coverageChildBaselineOutput :: !FilePath,+ coverageChildSuiteName :: !(Maybe Text)+ }+ deriving (Show, Eq, Generic)++-- | Options for the mutation-child process: the mutation id to evaluate,+-- the augmented-manifest directory to read it from, and the suite name+-- whose covering tests should be selected.+data MutationChildSettings = MutationChildSettings+ { mutationChildId :: !String,+ mutationChildAugmentedManifestDir :: !(Path Abs Dir),+ mutationChildSuiteName :: !(Maybe Text)+ }+ deriving (Show, Eq, Generic)++-- | Output format for test results+data OutputFormat+ = -- | Pretty output with colors, unicode symbols, and detailed formatting+ OutputFormatPretty+ | -- | Terse output optimized for machine/AI consumption+ OutputFormatTerse+ deriving (Show, Eq, Generic, Enum, Bounded)++instance HasCodec OutputFormat where+ codec =+ stringConstCodec $+ (OutputFormatPretty, "pretty")+ :| [(OutputFormatTerse, "terse")]+ instance HasParser Settings where settingsParser = subEnv_ "sydtest" $ withConfigurableYamlConfig (runIO $ resolveFile' ".sydtest.yaml") $- checkMapEither combine settingsParser+ checkMapIO combine settingsParser where+ combine :: Flags -> IO (Either String Settings) combine Flags {..} = do- let d func = func defaultSettings+ let d :: forall a. (Settings -> a) -> a+ d func = func defaultSettings+ terminalCapabilities <- case flagColour of+ Just False -> pure WithoutColours+ Just True -> pure With8BitColours+ Nothing -> case flagAiExecutor of+ Just True -> pure WithoutColours+ _ -> detectTerminalCapabilities+ let threads = fromMaybe ( if flagDebug@@ -90,62 +177,152 @@ else d settingThreads ) flagThreads- progress <- case flagReportProgress of+ case threads of+ ByCapabilities -> do+ i <- getNumCapabilities++ when (i == 1) $ do+ let outputLine :: [Chunk] -> IO ()+ outputLine lineChunks =+ liftIO $+ -- Emit UTF-8 bytes directly so output never depends on the+ -- handle's locale encoding; the trailing newline is folded+ -- into the chunks to keep this one byte-level write.+ putChunksUtf8With terminalCapabilities (lineChunks <> [chunk "\n"])+ mapM_+ ( outputLine+ . (: [])+ . fore red+ )+ [ chunk "WARNING: Only one CPU core detected, make sure to compile your test suite with these ghc options:",+ chunk " -threaded -rtsopts -with-rtsopts=-N",+ chunk " (This is important for correctness as well as speed, as a parallel test suite can find thread safety problems.)"+ ]+ _ -> pure ()+ errOrProgress <- case flagReportProgress of Nothing -> pure $- if threads == Synchronous- then- if flagDebug- then ReportProgress- else d settingReportProgress- else d settingReportProgress+ Right $+ if threads == Synchronous+ then+ if flagDebug+ then ReportProgress+ else d settingReportProgress+ else d settingReportProgress+ Just ReportNoProgress -> pure $ Right ReportNoProgress Just ReportProgress -> if threads /= Synchronous- then Left "Reporting progress in asynchronous runners is not supported. You can use --synchronous or --debug to use a synchronous runner."- else pure ReportProgress- Just ReportNoProgress -> pure ReportNoProgress- pure- Settings- { settingSeed = flagSeed,- settingRandomiseExecutionOrder =- fromMaybe- ( if flagDebug- then False- else d settingRandomiseExecutionOrder- )- flagRandomiseExecutionOrder,- settingThreads = threads,- settingMaxSuccess = flagMaxSuccess,- settingMaxSize = flagMaxSize,- settingMaxDiscard = flagMaxDiscard,- settingMaxShrinks = flagMaxShrinks,- settingGoldenStart = flagGoldenStart,- settingGoldenReset = flagGoldenReset,- settingColour = flagColour,- settingFilters = flagFilters,- settingFailFast =- fromMaybe- ( if flagDebug- then True- else d settingFailFast- )- flagFailFast,- settingIterations = flagIterations,- settingTimeout = flagTimeout,- settingRetries =- fromMaybe- ( if flagDebug- then 0- else d settingRetries- )- flagRetries,- settingFailOnFlaky = flagFailOnFlaky,- settingSkipPassed = flagSkipPassed,- settingReportFile = flagReportFile,- settingReportProgress = progress,- settingProfile = flagProfile- }+ then pure $ Left "Reporting progress in asynchronous runners is not supported. You can use --synchronous or --debug to use a synchronous runner."+ else pure $ Right ReportProgress+ let combined = do+ progress <- errOrProgress+ mMutation <- resolveMutationSettings Flags {..}+ pure+ Settings+ { settingSeed = flagSeed,+ settingRandomiseExecutionOrder =+ fromMaybe+ ( if flagDebug+ then False+ else d settingRandomiseExecutionOrder+ )+ flagRandomiseExecutionOrder,+ settingThreads = threads,+ settingMaxSuccess = flagMaxSuccess,+ settingMaxSize = flagMaxSize,+ settingMaxDiscard = flagMaxDiscard,+ settingMaxShrinks = flagMaxShrinks,+ settingGoldenStart = flagGoldenStart,+ settingGoldenReset = flagGoldenReset,+ settingTerminalCapabilities = terminalCapabilities,+ settingFilters = flagFilters,+ settingFailFast =+ fromMaybe+ ( if flagDebug+ then True+ else d settingFailFast+ )+ flagFailFast,+ settingIterations = flagIterations,+ settingTimeout = flagTimeout,+ settingRetries =+ fromMaybe+ ( if flagDebug+ then 0+ else d settingRetries+ )+ flagRetries,+ settingFailOnFlaky = flagFailOnFlaky,+ settingSkipPassed = flagSkipPassed,+ settingReportFile = flagReportFile,+ settingReportProgress = progress,+ settingProfile = flagProfile,+ settingOutputFormat =+ fromMaybe+ ( case flagAiExecutor of+ Nothing -> OutputFormatPretty+ Just False -> OutputFormatPretty+ Just True -> OutputFormatTerse+ )+ flagOutputFormat,+ settingMutation = mMutation+ }+ pure combined +-- | Pick at most one child-side 'MutationMode' from the parsed flags.+--+-- The three flags are dispatched in priority order:+--+-- 1. 'flagMutationCoverageList' selects coverage-list (enumerate tests).+-- 2. 'flagMutationCoverageOne' selects coverage-child.+-- 3. 'flagMutationOne' selects mutation-child.+--+-- Parent-side orchestration is the job of @sydtest-mutation-driver@.+resolveMutationSettings :: Flags -> Either String (Maybe MutationSettings)+resolveMutationSettings Flags {..} =+ let failFast = fromMaybe defaultMutationFailFast flagMutationFailFast+ mkMutation mode = Just MutationSettings {mutationFailFast = failFast, mutationMode = mode}+ in case (flagMutationCoverageList, flagMutationCoverageOne, flagMutationOne) of+ (True, _, _) ->+ pure $ mkMutation MutationModeCoverageList+ _+ | flagMutationCoverageListLocations ->+ pure $ mkMutation MutationModeCoverageListLocations+ (False, Just tid, _) -> do+ outputFile <- case flagMutationCoverageOutput of+ Just f -> Right f+ Nothing -> Left "--mutation-coverage-one requires --mutation-coverage-output"+ baselineFile <- case flagMutationCoverageBaselineOutput of+ Just f -> Right f+ Nothing -> Left "--mutation-coverage-one requires --mutation-coverage-baseline-output"+ pure $+ mkMutation $+ MutationModeCoverageChild+ CoverageChildSettings+ { coverageChildTestId = tid,+ coverageChildOutput = outputFile,+ coverageChildBaselineOutput = baselineFile,+ coverageChildSuiteName = flagMutationSuiteName+ }+ (False, Nothing, Just mid) -> do+ augDir <- case flagMutationAugmentedManifestDir of+ Just d -> Right d+ Nothing -> Left "--mutation-one requires --mutation-augmented-manifest-dir"+ pure $+ mkMutation $+ MutationModeMutateChild+ MutationChildSettings+ { mutationChildId = mid,+ mutationChildAugmentedManifestDir = augDir,+ mutationChildSuiteName = flagMutationSuiteName+ }+ (False, Nothing, Nothing) -> pure Nothing++-- | Default value of 'mutationFailFast'. True suits CI so a single+-- survivor aborts the run; flip to False locally for the full report.+defaultMutationFailFast :: Bool+defaultMutationFailFast = True+ defaultSettings :: Settings defaultSettings = let d func = func defaultTestRunSettings@@ -159,7 +336,7 @@ settingMaxShrinks = d testRunSettingMaxShrinks, settingGoldenStart = d testRunSettingGoldenStart, settingGoldenReset = d testRunSettingGoldenReset,- settingColour = Nothing,+ settingTerminalCapabilities = With8BitColours, settingFilters = mempty, settingFailFast = False, settingIterations = OneIteration,@@ -169,7 +346,9 @@ settingSkipPassed = False, settingReportProgress = ReportNoProgress, settingReportFile = Nothing,- settingProfile = False+ settingProfile = False,+ settingOutputFormat = OutputFormatPretty,+ settingMutation = Nothing } -- 60 seconds@@ -179,12 +358,6 @@ defaultRetries :: Word defaultRetries = 3 -deriveTerminalCapababilities :: Settings -> IO TerminalCapabilities-deriveTerminalCapababilities settings = case settingColour settings of- Just False -> pure WithoutColours- Just True -> pure With8BitColours- Nothing -> detectTerminalCapabilities- #ifdef mingw32_HOST_OS detectTerminalCapabilities :: IO TerminalCapabilities detectTerminalCapabilities = do@@ -196,6 +369,7 @@ detectTerminalCapabilities :: IO TerminalCapabilities detectTerminalCapabilities = getTerminalCapabilitiesFromEnv #endif+ -- We use an intermediate 'Flags' type so that default values can change based -- on parse settings. For example, the default value for 'flagThreads' depends -- on the value of 'flagDebug'.@@ -220,7 +394,18 @@ flagReportFile :: !(Maybe (Path Abs File)), flagReportProgress :: !(Maybe ReportProgress), flagDebug :: !Bool,- flagProfile :: !Bool+ flagProfile :: !Bool,+ flagAiExecutor :: !(Maybe Bool),+ flagOutputFormat :: !(Maybe OutputFormat),+ flagMutationAugmentedManifestDir :: !(Maybe (Path Abs Dir)),+ flagMutationOne :: !(Maybe String),+ flagMutationSuiteName :: !(Maybe Text),+ flagMutationCoverageOne :: !(Maybe Text),+ flagMutationCoverageOutput :: !(Maybe FilePath),+ flagMutationCoverageBaselineOutput :: !(Maybe FilePath),+ flagMutationCoverageList :: !Bool,+ flagMutationCoverageListLocations :: !Bool,+ flagMutationFailFast :: !(Maybe Bool) } deriving (Show, Eq, Generic) @@ -358,6 +543,121 @@ name "profile", value $ settingProfile defaultSettings ]+ flagAiExecutor <-+ optional $+ choice+ [ setting+ [ help "Indicate that an AI is executing tests, sets defaults to 'no colours' and 'terse output'",+ switch True,+ long "ai-executor"+ ],+ setting+ [ help "Turn off ai mode. This lets AIs opt out of ai-executor mode",+ switch False,+ long "no-ai-executor"+ ],+ setting+ [ help "Activate AI executor mode based on env vars",+ reader exists,+ -- Feel free to add env vars here.+ unprefixedEnv "CLAUDECODE",+ metavar "ANY"+ ]+ ]++ flagOutputFormat <-+ optional $+ choice+ [ setting+ [ help "Use terse output (compact, no colors, failures only)",+ switch OutputFormatTerse,+ long "terse"+ ],+ setting+ [ help "Use pretty output (colors, unicode, detailed formatting)",+ switch OutputFormatPretty,+ long "pretty"+ ]+ ]+ flagMutationAugmentedManifestDir <-+ optional $+ directoryPathSetting+ [ help "Directory for manifest-augmented.json; defaults to current working directory",+ option,+ long "mutation-augmented-manifest-dir"+ ]+ flagMutationOne <-+ optional $+ setting+ [ help "Run only this single mutation id (used internally by child processes)",+ reader str,+ option,+ long "mutation-one",+ metavar "MUTATION_ID",+ hidden+ ]+ flagMutationSuiteName <-+ optional $+ setting+ [ help "Name of this test suite executable (used in multi-suite mutation testing)",+ reader str,+ option,+ long "mutation-suite-name",+ metavar "NAME",+ hidden+ ]+ flagMutationCoverageOne <-+ optional $+ setting+ [ help "Collect coverage for only this single test id (used internally by coverage child processes)",+ reader str,+ option,+ long "mutation-coverage-one",+ metavar "TEST_ID",+ hidden+ ]+ flagMutationCoverageOutput <-+ optional $+ setting+ [ help "File path where coverage child process writes its coverage map (used internally)",+ reader str,+ option,+ long "mutation-coverage-output",+ metavar "FILE",+ hidden+ ]+ flagMutationCoverageBaselineOutput <-+ optional $+ setting+ [ help "File path where coverage child process writes its baseline timing (used internally)",+ reader str,+ option,+ long "mutation-coverage-baseline-output",+ metavar "FILE",+ hidden+ ]+ flagMutationCoverageList <-+ setting+ [ help "List every leaf test id on stdout and exit (used internally by the mutation driver to enumerate tests)",+ switch True,+ long "mutation-coverage-list",+ hidden,+ value False+ ]+ flagMutationCoverageListLocations <-+ setting+ [ help "List every leaf test id and its source location on stdout and exit (used internally by the diff-scoped mutation runner)",+ switch True,+ long "mutation-coverage-list-locations",+ hidden,+ value False+ ]+ flagMutationFailFast <-+ optional $+ yesNoSwitch+ [ help "Stop the mutation run as soon as a surviving or uncovered mutation is observed",+ name "mutation-fail-fast"+ ] pure Flags {..} data Timeout@@ -422,7 +722,7 @@ w -> Asynchronous w ) <$> setting- [ help "How many threads to use to execute tests in asynchrnously",+ [ help "How many threads to use to execute tests in asynchronously", reader auto, option, long "jobs",
src/Test/Syd/Output.hs view
@@ -1,621 +1,35 @@-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE MultiWayIf #-}-{-# LANGUAGE NumericUnderscores #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE TypeFamilies #-}-{-# LANGUAGE TypeSynonymInstances #-}+module Test.Syd.Output+ ( -- * Main dispatch function+ printOutputSpecForest, -module Test.Syd.Output where+ -- * Re-exports+ module Test.Syd.Output.Common,+ module Test.Syd.Output.Pretty,+ module Test.Syd.Output.Terse,+ )+where -import Control.Arrow (second)-import Control.Exception-import Data.List (sortOn)-import qualified Data.List as L-import Data.List.NonEmpty (NonEmpty (..))-import qualified Data.List.NonEmpty as NE-import Data.Map (Map)-import qualified Data.Map as M-import Data.Maybe-import Data.String (IsString (..))-import Data.Text (Text)-import qualified Data.Text as T+import qualified Data.ByteString.Lazy as LBS import qualified Data.Text.Lazy.Builder as LTB-import qualified Data.Text.Lazy.Builder as Text-import qualified Data.Text.Lazy.IO as LTIO-import Data.Word-import GHC.Stack-import Myers.Diff-import Safe-import Test.QuickCheck.IO ()+import qualified Data.Text.Lazy.Encoding as LTE import Test.Syd.OptParse-import Test.Syd.Run+import Test.Syd.Output.Common+import Test.Syd.Output.Pretty+import Test.Syd.Output.Terse+import Test.Syd.Run (Timed) import Test.Syd.SpecDef-import Test.Syd.SpecForest-import Text.Colour-import Text.Printf printOutputSpecForest :: Settings -> Timed ResultForest -> IO ()-printOutputSpecForest settings results = do- tc <- deriveTerminalCapababilities settings- LTIO.putStr $ LTB.toLazyText $ renderResultReport settings tc results--renderResultReport :: Settings -> TerminalCapabilities -> Timed ResultForest -> Text.Builder-renderResultReport settings tc rf =- mconcat $- map- (\line -> renderChunksBuilder tc line <> "\n")- (outputResultReport settings rf)--outputResultReport :: Settings -> Timed ResultForest -> [[Chunk]]-outputResultReport settings trf =- let rf = timedValue trf- in concat- [ outputTestsHeader,- outputSpecForest settings 0 (resultForestWidth rf) rf,- [ [chunk ""],- [chunk ""]- ],- outputFailuresWithHeading settings rf,- [[chunk ""]],- outputStats (computeTestSuiteStats settings <$> trf),- [[chunk ""]],- if settingProfile settings- then outputProfilingInfo trf- else []- ]--outputFailuresHeader :: [[Chunk]]-outputFailuresHeader = outputHeader "Failures:"--outputFailuresWithHeading :: Settings -> ResultForest -> [[Chunk]]-outputFailuresWithHeading settings rf =- if shouldExitFail settings rf- then- concat- [ outputFailuresHeader,- outputFailures settings rf- ]- else []--outputStats :: Timed TestSuiteStats -> [[Chunk]]-outputStats timed =- let TestSuiteStats {..} = timedValue timed- sumTimeSeconds :: Double- sumTimeSeconds = fromIntegral testSuiteStatSumTime / 1_000_000_000- totalTimeSeconds :: Double- totalTimeSeconds = fromIntegral (timedTime timed) / 1_000_000_000- in map (padding :) $- concat- [ [ [ chunk "Examples: ",- fore green $ chunk (T.pack (show testSuiteStatExamples))- ]- | testSuiteStatExamples /= testSuiteStatSuccesses- ],- [ [ chunk "Passed: ",- fore green $ chunk (T.pack (show testSuiteStatSuccesses))- ],- [ chunk "Failed: ",- ( if testSuiteStatFailures > 0- then fore red- else fore green- )- $ chunk (T.pack (show testSuiteStatFailures))- ]- ],- [ [ chunk "Flaky: ",- fore red $ chunk (T.pack (show testSuiteStatFlakyTests))- ]- | testSuiteStatFlakyTests > 0- ],- [ [ chunk "Pending: ",- fore magenta $ chunk (T.pack (show testSuiteStatPending))- ]- | testSuiteStatPending > 0- ],- [ [ chunk "Sum of test runtimes:",- fore yellow $ chunk $ T.pack (printf "%13.2f seconds" sumTimeSeconds)- ],- [ chunk "Test suite took: ",- fore yellow $ chunk $ T.pack (printf "%13.2f seconds" totalTimeSeconds)- ]- ]- ]--outputProfilingInfo :: Timed ResultForest -> [[Chunk]]-outputProfilingInfo Timed {..} =- map- ( \(path, nanos) ->- [ timeChunkFor nanos,- " ",- chunk $ T.intercalate "." path- ]- )- ( sortOn- snd- ( map- (second (timedTime . testDefVal))- (flattenSpecForest timedValue)- )- )--outputTestsHeader :: [[Chunk]]-outputTestsHeader = outputHeader "Tests:"--outputHeader :: Text -> [[Chunk]]-outputHeader t =- [ [fore blue $ chunk t],- [chunk ""]- ]--outputSpecForest :: Settings -> Int -> Int -> ResultForest -> [[Chunk]]-outputSpecForest settings level treeWidth = concatMap (outputSpecTree settings level treeWidth)--outputSpecTree :: Settings -> Int -> Int -> ResultTree -> [[Chunk]]-outputSpecTree settings level treeWidth = \case- SpecifyNode t td -> outputSpecifyLines settings level treeWidth t td- PendingNode t mr -> outputPendingLines t mr- DescribeNode t sf -> outputDescribeLine t : map (padding :) (outputSpecForest settings (level + 1) treeWidth sf)- SubForestNode sf -> outputSpecForest settings level treeWidth sf--outputDescribeLine :: Text -> [Chunk]-outputDescribeLine t = [fore yellow $ chunk t]--outputSpecifyLines :: Settings -> Int -> Int -> Text -> TDef (Timed TestRunReport) -> [[Chunk]]-outputSpecifyLines settings level treeWidth specifyText (TDef timed _) =- let testRunReport = timedValue timed- executionTime = timedTime timed- status = testRunReportStatus settings testRunReport- TestRunResult {..} = testRunReportReportedRun testRunReport- withStatusColour = fore (statusColour status)- pad = (chunk (T.pack (replicate paddingSize ' ')) :)- timeChunk = timeChunkFor executionTime- in concatMap- (filter (not . null))- [ [ [ withStatusColour $ chunk (statusCheckMark status),- withStatusColour $ chunk specifyText,- spacingChunk level specifyText (chunkText timeChunk) treeWidth,- timeChunk- ]- ],- map pad $ retriesChunks testRunReport,- [ pad- [ chunk "passed for all of ",- case w of- 0 -> fore red $ chunk "0"- _ -> fore green $ chunk (T.pack (printf "%d" w)),- " inputs."- ]- | status == TestPassed,- w <- maybeToList testRunResultNumTests- ],- map pad $ labelsChunks (fromMaybe 1 testRunResultNumTests) testRunResultLabels,- map pad $ classesChunks testRunResultClasses,- map pad $ tablesChunks testRunResultTables,- [pad $ outputGoldenCase gc | gc <- maybeToList testRunResultGoldenCase]- ]--exampleNrChunk :: Word -> Word -> Chunk-exampleNrChunk total current =- let digits :: Word- digits = max 2 $ succ $ floor $ logBase 10 $ (fromIntegral :: Word -> Double) total- formatStr = "%" <> show digits <> "d"- in chunk $ T.pack $ printf formatStr current--timeChunkFor :: Word64 -> Chunk-timeChunkFor executionTime =- let t = fromIntegral executionTime / 1_000_000 :: Double -- milliseconds- executionTimeText = T.pack (printf "%10.2f ms" t)- withTimingColour =- if- | t < 10 -> fore green- | t < 100 -> fore yellow- | t < 1_000 -> fore orange- | t < 10_000 -> fore red- | otherwise -> fore darkRed- in withTimingColour $ chunk executionTimeText--retriesChunks :: TestRunReport -> [[Chunk]]-retriesChunks testRunReport =- case testRunReportRetries testRunReport of- Nothing -> []- Just retries ->- let flaky = testRunReportWasFlaky testRunReport- mMessage = case testRunReportFlakinessMode testRunReport of- MayBeFlaky mmesg -> mmesg- MayNotBeFlaky -> Nothing- in if flaky- then- concat- [ [["Retries: ", chunk (T.pack (show retries)), fore red " !!! FLAKY !!!"]],- [[fore magenta $ chunk $ T.pack message] | message <- maybeToList mMessage]- ]- else [["Retries: ", chunk (T.pack (show retries)), " (does not look flaky)"]]--labelsChunks :: Word -> Maybe (Map [String] Int) -> [[Chunk]]-labelsChunks _ Nothing = []-labelsChunks totalCount (Just labels)- | M.null labels = []- | map fst (M.toList labels) == [[]] = []- | otherwise =- [chunk "Labels"]- : map- ( pad- . ( \(ss, i) ->- [ chunk- ( T.pack- ( printf- "%5.2f%% %s"- (100 * fromIntegral i / fromIntegral totalCount :: Double)- (commaList (map show ss))- )- )- ]- )- )- (M.toList labels)- where- pad = (chunk (T.pack (replicate paddingSize ' ')) :)--classesChunks :: Maybe (Map String Int) -> [[Chunk]]-classesChunks Nothing = []-classesChunks (Just classes)- | M.null classes = []- | otherwise =- [chunk "Classes"]- : map- ( pad- . ( \(s, i) ->- [ chunk- ( T.pack- ( printf "%5.2f%% %s" (100 * fromIntegral i / fromIntegral total :: Double) s- )- )- ]- )- )- (M.toList classes)- where- pad = (chunk (T.pack (replicate paddingSize ' ')) :)- total = sum $ map snd $ M.toList classes--tablesChunks :: Maybe (Map String (Map String Int)) -> [[Chunk]]-tablesChunks Nothing = []-tablesChunks (Just tables) = concatMap (uncurry goTable) $ M.toList tables- where- goTable :: String -> Map String Int -> [[Chunk]]- goTable tableName percentages =- [chunk " "]- : [chunk (T.pack tableName)]- : map- ( pad- . ( \(s, i) ->- [ chunk- ( T.pack- ( printf "%5.2f%% %s" (100 * fromIntegral i / fromIntegral total :: Double) s- )- )- ]- )- )- (M.toList percentages)- where- pad = (chunk (T.pack (replicate paddingSize ' ')) :)- total = sum $ map snd $ M.toList percentages--outputPendingLines :: Text -> Maybe Text -> [[Chunk]]-outputPendingLines specifyText mReason =- filter- (not . null)- [ [fore magenta $ chunk specifyText],- case mReason of- Nothing -> []- Just reason -> [padding, chunk reason]- ]--outputFailureLabels :: Maybe (Map [String] Int) -> [[Chunk]]-outputFailureLabels Nothing = []-outputFailureLabels (Just labels)- | labels == M.singleton [] 1 = []- | otherwise = [["Labels: ", chunk (T.pack (commaList (map show (concat $ M.keys labels))))]]--commaList :: [String] -> String-commaList [] = []-commaList [s] = s-commaList (s1 : rest) = s1 ++ ", " ++ commaList rest--outputFailureClasses :: Maybe (Map String Int) -> [[Chunk]]-outputFailureClasses Nothing = []-outputFailureClasses (Just classes)- | M.null classes = []- | otherwise = [["Class: ", chunk (T.pack (commaList (M.keys classes)))]]--outputGoldenCase :: GoldenCase -> [Chunk]-outputGoldenCase = \case- GoldenNotFound -> [fore red $ chunk "Golden output not found"]- GoldenStarted -> [fore cyan $ chunk "Golden output created"]- GoldenReset -> [fore cyan $ chunk "Golden output reset"]---- The chunk for spacing between the description and the timing------ initial padding | checkmark | description | THIS CHUNK | execution time-spacingChunk :: Int -> Text -> Text -> Int -> Chunk-spacingChunk level descriptionText executionTimeText treeWidth = chunk $ T.pack $ replicate paddingWidth ' '- where- paddingWidth =- let preferredMaxWidth = 80- checkmarkWidth = 2- minimumSpacing = 1- actualDescriptionWidth = T.length descriptionText- actualTimingWidth = T.length executionTimeText- totalNecessaryWidth = treeWidth + checkmarkWidth + minimumSpacing + actualTimingWidth -- All timings are the same width- actualMaxWidth = max totalNecessaryWidth preferredMaxWidth- in actualMaxWidth - paddingSize * level - actualTimingWidth - actualDescriptionWidth--outputFailures :: Settings -> ResultForest -> [[Chunk]]-outputFailures settings rf =- let failures = filter (testRunReportFailed settings . timedValue . testDefVal . snd) $ flattenSpecForest rf- nbDigitsInFailureCount :: Int- nbDigitsInFailureCount = floor (logBase 10 (L.genericLength failures) :: Double)- padFailureDetails = (chunk (T.pack (replicate (nbDigitsInFailureCount + 4) ' ')) :)- in map (padding :) $- concatMap (filter (not . null)) $- indexed failures $ \w (ts, TDef timed cs) ->- let testRunReport = timedValue timed- status = testRunReportStatus settings testRunReport- TestRunResult {..} = testRunReportReportedRun testRunReport- in concat- [ [ [ fore cyan $- chunk $- T.pack $- replicate 2 ' '- ++ case headMay $ getCallStack cs of- Nothing -> "Unknown location"- Just (_, SrcLoc {..}) ->- concat- [ srcLocFile,- ":",- show srcLocStartLine- ]- ],- map- (fore (statusColour status))- [ chunk $ statusCheckMark status,- chunk $ T.pack (printf ("%" ++ show nbDigitsInFailureCount ++ "d ") w),- chunk $ T.intercalate "." ts- ]- ],- map padFailureDetails $ retriesChunks testRunReport,- map (padFailureDetails . (: []) . chunk . T.pack) $- case (testRunResultNumTests, testRunResultNumShrinks) of- (Nothing, _) -> []- (Just numTests, Nothing) -> [printf "Failed after %d tests" numTests]- (Just numTests, Just 0) -> [printf "Failed after %d tests" numTests]- (Just numTests, Just numShrinks) -> [printf "Failed after %d tests and %d shrinks" numTests numShrinks],- map (padFailureDetails . (\c -> [chunk "Generated: ", c]) . fore yellow . chunk . T.pack) testRunResultFailingInputs,- map padFailureDetails $ outputFailureLabels testRunResultLabels,- map padFailureDetails $ outputFailureClasses testRunResultClasses,- map padFailureDetails $ maybe [] outputSomeException testRunResultException,- [padFailureDetails $ outputGoldenCase gc | gc <- maybeToList testRunResultGoldenCase],- concat [map padFailureDetails $ stringChunks ei | ei <- maybeToList testRunResultExtraInfo],- [[chunk ""]]- ]--outputSomeException :: SomeException -> [[Chunk]]-outputSomeException outerException =- case fromException outerException :: Maybe Contextual of- Just (Contextual innerException s) -> outputSomeException (SomeException innerException) ++ stringChunks s- Nothing ->- case fromException outerException :: Maybe Assertion of- Just a -> outputAssertion a- Nothing -> stringChunks $ displayException outerException--outputAssertion :: Assertion -> [[Chunk]]-outputAssertion = \case- NotEqualButShouldHaveBeenEqualWithDiff actual expected diffM -> outputEqualityAssertionFailed actual expected diffM- EqualButShouldNotHaveBeenEqual actual notExpected -> outputNotEqualAssertionFailed actual notExpected- PredicateFailedButShouldHaveSucceeded actual mName -> outputPredicateSuccessAssertionFailed actual mName- PredicateSucceededButShouldHaveFailed actual mName -> outputPredicateFailAssertionFailed actual mName- ExpectationFailed s -> stringChunks s- Context a' context -> outputAssertion a' ++ stringChunks context---- | Split a list of 'Chunk's into lines of [Chunks].------ This is rather complicated because chunks may contain newlines, in which--- case they need to be split into two chunks on separate lines but with the--- same colour information.--- However, separate chunks are not necessarily on separate lines because there--- may not be a newline inbetween.-splitChunksIntoLines :: [Chunk] -> [[Chunk]]-splitChunksIntoLines =- -- We maintain a list of 'currently traversing lines'.- -- These are already split into newlines and therefore definitely belong on separate lines.- -- We still need to keep the last of the current line though, because it- -- does not end in a newline and should therefore not necessarily belong on- -- a separate line by itself.- go ([] :| []) -- Start with an empty current line.- where- -- CurrentlyTraversingLines -> ChunksToStillSplit -> SplitChunks- go :: NonEmpty [Chunk] -> [Chunk] -> [[Chunk]]- go cls cs = case NE.uncons cls of- (currentLine, mRest) -> case mRest of- -- If there's only one current line, that's the last one of the currently traversing lines.- -- We split the next chunk into lines and append the first line of that to the current line.- Nothing -> case cs of- -- If there is only one current line, and no more chunks, it's the last line.- [] -> [currentLine]- -- If there are chunks left, split the first one into lines.- (c : rest) -> case T.splitOn "\n" (chunkText c) of- -- Should not happen, but would be fine, just skip this chunk- [] -> go cls rest- -- If the chunk had more than one lines- (l : ls) -> case NE.nonEmpty ls of- -- If there was only one line in the chunk, we continue with the- -- same current line onto the rest of the chunks- Nothing -> go ((currentLine <> [c {chunkText = l}]) :| []) rest- -- If there was more than one line in that chunk, that line is now considered finished.- -- We then make all the lines of this new chunk the new current lines, one chunk per line.- Just ne -> (currentLine <> [c {chunkText = l}]) : go (NE.map (\l' -> [c {chunkText = l'}]) ne) rest- -- If there is more than one current line, all but the last one are considered finished.- -- We skip them one by one.- Just ne -> currentLine : go ne cs--outputEqualityAssertionFailed :: String -> String -> Maybe [PolyDiff Text Text] -> [[Chunk]]-outputEqualityAssertionFailed actual expected diffM =- case diffM of- Just diff -> formatDiff actual expected diff- Nothing ->- concat- [ [[chunk "Expected these values to be equal:"]],- [[chunk "Diff computation took too long and was canceled"]],- [[fromString actual]],- [[fromString expected]]- ]--formatDiff :: String -> String -> [PolyDiff Text Text] -> [[Chunk]]-formatDiff actual expected diff =- let -- Add a header to a list of lines of chunks- chunksLinesWithHeader :: Chunk -> [[Chunk]] -> [[Chunk]]- chunksLinesWithHeader header = \case- -- If there is only one line, put the header on that line.- [cs] -> [header : cs]- -- If there is more than one line, put the header on a separate line before- cs -> [header] : cs-- -- If it's only whitespace, change the background, otherwise change the foreground- foreOrBack :: Colour -> Text -> Chunk- foreOrBack c t =- (if T.null (T.strip t) then back c else fore c)- (chunk t)- actualChunks :: [[Chunk]]- actualChunks = chunksLinesWithHeader (fore blue "Actual: ") $- splitChunksIntoLines $- flip mapMaybe diff $ \case- First t -> Just $ foreOrBack red t- Second _ -> Nothing- Both t _ -> Just $ chunk t- expectedChunks :: [[Chunk]]- expectedChunks = chunksLinesWithHeader (fore blue "Expected: ") $- splitChunksIntoLines $- flip mapMaybe diff $ \case- First _ -> Nothing- Second t -> Just $ foreOrBack green t- Both t _ -> Just $ chunk t- inlineDiffChunks :: [[Chunk]]- inlineDiffChunks =- if length (lines actual) == 1 && length (lines expected) == 1- then []- else chunksLinesWithHeader (fore blue "Inline diff: ") $- splitChunksIntoLines $- flip map diff $ \case- First t -> foreOrBack red t- Second t -> foreOrBack green t- Both t _ -> chunk t- in concat- [ [[chunk "Expected these values to be equal:"]],- actualChunks,- expectedChunks,- inlineDiffChunks- ]--outputNotEqualAssertionFailed :: String -> String -> [[Chunk]]-outputNotEqualAssertionFailed actual notExpected =- if actual == notExpected -- String equality- then- [ [chunk "Did not expect equality of the values but both were:"],- [chunk (T.pack actual)]- ]- else- [ [chunk "These two values were considered equal but should not have been equal:"],- [fore blue "Actual : ", chunk (T.pack actual)],- [fore blue "Not Expected: ", chunk (T.pack notExpected)]- ]--outputPredicateSuccessAssertionFailed :: String -> Maybe String -> [[Chunk]]-outputPredicateSuccessAssertionFailed actual mName =- concat- [ [ [chunk "Predicate failed, but should have succeeded, on this value:"],- [chunk (T.pack actual)]- ],- concat [map (chunk "Predicate: " :) (stringChunks name) | name <- maybeToList mName]- ]--outputPredicateFailAssertionFailed :: String -> Maybe String -> [[Chunk]]-outputPredicateFailAssertionFailed actual mName =- concat- [ [ [chunk "Predicate succeeded, but should have failed, on this value:"],- [chunk (T.pack actual)]- ],- concat [map (chunk "Predicate: " :) (stringChunks name) | name <- maybeToList mName]- ]--mContextChunks :: Maybe String -> [[Chunk]]-mContextChunks = maybe [] stringChunks--stringChunks :: String -> [[Chunk]]-stringChunks s =- let ls = lines s- in map ((: []) . chunk . T.pack) ls--indexed :: [a] -> (Word -> a -> b) -> [b]-indexed ls func = zipWith func [1 ..] ls--statusColour :: TestStatus -> Colour-statusColour = \case- TestPassed -> green- TestFailed -> red--statusCheckMark :: TestStatus -> Text-statusCheckMark = \case- TestPassed -> "\10003 "- TestFailed -> "\10007 "--resultForestWidth :: SpecForest a -> Int-resultForestWidth = goF 0- where- goF :: Int -> SpecForest a -> Int- goF level = maximum . map (goT level)- goT :: Int -> SpecTree a -> Int- goT level = \case- SpecifyNode t _ -> T.length t + level * paddingSize- PendingNode t _ -> T.length t + level * paddingSize- DescribeNode _ sdf -> goF (succ level) sdf- SubForestNode sdf -> goF level sdf--specForestWidth :: SpecDefForest a b c -> Int-specForestWidth = goF 0- where- goF :: Int -> SpecDefForest a b c -> Int- goF level = \case- [] -> 0- ts -> maximum $ map (goT level) ts- goT :: Int -> SpecDefTree a b c -> Int- goT level = \case- DefSpecifyNode t _ _ -> T.length t + level * paddingSize- DefPendingNode t _ -> T.length t + level * paddingSize- DefDescribeNode _ sdf -> goF (succ level) sdf- DefSetupNode _ sdf -> goF level sdf- DefBeforeAllNode _ sdf -> goF level sdf- DefBeforeAllWithNode _ sdf -> goF level sdf- DefWrapNode _ sdf -> goF level sdf- DefAroundAllNode _ sdf -> goF level sdf- DefAroundAllWithNode _ sdf -> goF level sdf- DefAfterAllNode _ sdf -> goF level sdf- DefParallelismNode _ sdf -> goF level sdf- DefTimeoutNode _ sdf -> goF level sdf- DefRetriesNode _ sdf -> goF level sdf- DefRandomisationNode _ sdf -> goF level sdf- DefFlakinessNode _ sdf -> goF level sdf- DefExpectationNode _ sdf -> goF level sdf--padding :: Chunk-padding = chunk $ T.replicate paddingSize " "--paddingSize :: Int-paddingSize = 2--orange :: Colour-orange = colour256 166--darkRed :: Colour-darkRed = colour256 160+printOutputSpecForest settings results =+ -- Encode to UTF-8 and write the bytes, rather than a Text-based putStr that+ -- encodes through the handle's locale encoding: under a C/POSIX locale+ -- (e.g. a Nix build sandbox) that handle is ASCII and crashes on the report's+ -- non-ASCII status markers and box-drawing characters.+ LBS.putStr $+ LTE.encodeUtf8 $+ LTB.toLazyText $+ let renderer =+ case settingOutputFormat settings of+ OutputFormatTerse -> renderTerseSummary+ OutputFormatPretty -> renderPrettyReport+ in renderer settings results
+ src/Test/Syd/Output/Common.hs view
@@ -0,0 +1,265 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE MultiWayIf #-}+{-# LANGUAGE NumericUnderscores #-}+{-# LANGUAGE OverloadedStrings #-}+-- {-# LANGUAGE RecordWildCards #-}+-- {-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies #-}++module Test.Syd.Output.Common where++import Control.Exception+import Data.List.NonEmpty (NonEmpty (..))+import qualified Data.List.NonEmpty as NE+import Data.Maybe+import Data.String (IsString (..))+import Data.Text (Text)+import qualified Data.Text as T+import Data.Typeable (cast)+import Data.Word+import Myers.Diff+import Test.Syd.Mutation.Manifest.Render (addColour, delColour, emphasiseIntraLine, renderAddSide, renderDelSide)+import Test.Syd.Run+import Test.Syd.SpecDef+import Test.Syd.SpecForest+import Text.Colour+import Text.Printf++padding :: Chunk+padding = chunk $ T.replicate paddingSize " "++paddingSize :: Int+paddingSize = 2++orange :: Colour+orange = colour256 166++darkRed :: Colour+darkRed = colour256 160++statusColour :: TestStatus -> Colour+statusColour = \case+ TestPassed -> green+ TestFailed -> red++statusCheckMark :: TestStatus -> Text+statusCheckMark = \case+ TestPassed -> "\10003 "+ TestFailed -> "\10007 "++timeChunkFor :: Word64 -> Chunk+timeChunkFor executionTime =+ let t = fromIntegral executionTime / 1_000_000 :: Double -- milliseconds+ executionTimeText = T.pack (printf "%10.2f ms" t)+ withTimingColour =+ if+ | t < 10 -> fore green+ | t < 100 -> fore yellow+ | t < 1_000 -> fore orange+ | t < 10_000 -> fore red+ | otherwise -> fore darkRed+ in withTimingColour $ chunk executionTimeText++stringChunks :: String -> [[Chunk]]+stringChunks s =+ let ls = lines s+ in map ((: []) . chunk . T.pack) ls++indexed :: [a] -> (Word -> a -> b) -> [b]+indexed ls func = zipWith func [1 ..] ls++commaList :: [String] -> String+commaList [] = []+commaList [s] = s+commaList (s1 : rest) = s1 ++ ", " ++ commaList rest++mContextChunks :: Maybe String -> [[Chunk]]+mContextChunks = maybe [] stringChunks++outputSomeException :: SomeException -> [[Chunk]]+outputSomeException outerException =+ case fromException outerException :: Maybe Contextual of+ Just (Contextual innerException s) ->+ -- Check if innerException is already a SomeException to avoid double-wrapping+ let innerSE = case cast innerException of+ Just se -> se :: SomeException+ Nothing -> SomeException innerException+ in outputSomeException innerSE ++ stringChunks s+ Nothing ->+ case fromException outerException :: Maybe Assertion of+ Just a -> outputAssertion a+ Nothing -> stringChunks $ displayException outerException++outputAssertion :: Assertion -> [[Chunk]]+outputAssertion = \case+ NotEqualButShouldHaveBeenEqualWithDiff actual expected diffM -> outputEqualityAssertionFailed actual expected diffM+ EqualButShouldNotHaveBeenEqual actual notExpected -> outputNotEqualAssertionFailed actual notExpected+ PredicateFailedButShouldHaveSucceeded actual mName -> outputPredicateSuccessAssertionFailed actual mName+ PredicateSucceededButShouldHaveFailed actual mName -> outputPredicateFailAssertionFailed actual mName+ ExpectationFailed s -> stringChunks s+ Context a' context -> outputAssertion a' ++ stringChunks context++-- | Split a list of 'Chunk's into lines of [Chunks].+--+-- This is rather complicated because chunks may contain newlines, in which+-- case they need to be split into two chunks on separate lines but with the+-- same colour information.+-- However, separate chunks are not necessarily on separate lines because there+-- may not be a newline inbetween.+splitChunksIntoLines :: [Chunk] -> [[Chunk]]+splitChunksIntoLines =+ -- We maintain a list of 'currently traversing lines'.+ -- These are already split into newlines and therefore definitely belong on separate lines.+ -- We still need to keep the last of the current line though, because it+ -- does not end in a newline and should therefore not necessarily belong on+ -- a separate line by itself.+ go ([] :| []) -- Start with an empty current line.+ where+ -- CurrentlyTraversingLines -> ChunksToStillSplit -> SplitChunks+ go :: NonEmpty [Chunk] -> [Chunk] -> [[Chunk]]+ go cls cs = case NE.uncons cls of+ (currentLine, mRest) -> case mRest of+ -- If there's only one current line, that's the last one of the currently traversing lines.+ -- We split the next chunk into lines and append the first line of that to the current line.+ Nothing -> case cs of+ -- If there is only one current line, and no more chunks, it's the last line.+ [] -> [currentLine]+ -- If there are chunks left, split the first one into lines.+ (c : rest) -> case T.splitOn "\n" (chunkText c) of+ -- Should not happen, but would be fine, just skip this chunk+ [] -> go cls rest+ -- If the chunk had more than one lines+ (l : ls) -> case NE.nonEmpty ls of+ -- If there was only one line in the chunk, we continue with the+ -- same current line onto the rest of the chunks+ Nothing -> go ((currentLine <> [c {chunkText = l}]) :| []) rest+ -- If there was more than one line in that chunk, that line is now considered finished.+ -- We then make all the lines of this new chunk the new current lines, one chunk per line.+ Just ne -> (currentLine <> [c {chunkText = l}]) : go (NE.map (\l' -> [c {chunkText = l'}]) ne) rest+ -- If there is more than one current line, all but the last one are considered finished.+ -- We skip them one by one.+ Just ne -> currentLine : go ne cs++outputEqualityAssertionFailed :: String -> String -> Maybe [PolyDiff Text Text] -> [[Chunk]]+outputEqualityAssertionFailed actual expected diffM =+ case diffM of+ Just diff -> formatDiff actual expected diff+ Nothing ->+ concat+ [ [[chunk "Expected these values to be equal:"]],+ [[chunk "Diff computation took too long and was canceled"]],+ [[fromString actual]],+ [[fromString expected]]+ ]++-- The diff-colour helpers ('delColour', 'addColour', 'emphasiseIntraLine',+-- 'renderDelSide', 'renderAddSide') used to live here. They were moved to+-- 'Test.Syd.Mutation.Manifest.Render' so the mutation runtime can render+-- manifest diffs without depending on @sydtest@. Re-exported below for+-- backward compatibility.++formatDiff :: String -> String -> [PolyDiff Text Text] -> [[Chunk]]+formatDiff actual expected diff =+ let -- Add a header to a list of lines of chunks+ chunksLinesWithHeader :: Chunk -> [[Chunk]] -> [[Chunk]]+ chunksLinesWithHeader header = \case+ -- If there is only one line, put the header on that line.+ [cs] -> [header : cs]+ -- If there is more than one line, put the header on a separate line before+ cs -> [header] : cs++ actualChunks :: [[Chunk]]+ actualChunks =+ chunksLinesWithHeader (fore blue "Actual: ") $+ splitChunksIntoLines $+ renderDelSide diff+ expectedChunks :: [[Chunk]]+ expectedChunks =+ chunksLinesWithHeader (fore blue "Expected: ") $+ splitChunksIntoLines $+ renderAddSide diff+ inlineDiffChunks :: [[Chunk]]+ inlineDiffChunks =+ if length (lines actual) == 1 && length (lines expected) == 1+ then []+ else chunksLinesWithHeader (fore blue "Inline diff: ") $+ splitChunksIntoLines $+ flip map diff $ \case+ First t -> emphasiseIntraLine delColour brightRed t+ Second t -> emphasiseIntraLine addColour brightGreen t+ Both t _ -> chunk t+ in concat+ [ [[chunk "Expected these values to be equal:"]],+ actualChunks,+ expectedChunks,+ inlineDiffChunks+ ]++outputNotEqualAssertionFailed :: String -> String -> [[Chunk]]+outputNotEqualAssertionFailed actual notExpected =+ if actual == notExpected -- String equality+ then+ [ [chunk "Did not expect equality of the values but both were:"],+ [chunk (T.pack actual)]+ ]+ else+ [ [chunk "These two values were considered equal but should not have been equal:"],+ [fore blue "Actual : ", chunk (T.pack actual)],+ [fore blue "Not Expected: ", chunk (T.pack notExpected)]+ ]++outputPredicateSuccessAssertionFailed :: String -> Maybe String -> [[Chunk]]+outputPredicateSuccessAssertionFailed actual mName =+ concat+ [ [ [chunk "Predicate failed, but should have succeeded, on this value:"],+ [chunk (T.pack actual)]+ ],+ concat [map (chunk "Predicate: " :) (stringChunks name) | name <- maybeToList mName]+ ]++outputPredicateFailAssertionFailed :: String -> Maybe String -> [[Chunk]]+outputPredicateFailAssertionFailed actual mName =+ concat+ [ [ [chunk "Predicate succeeded, but should have failed, on this value:"],+ [chunk (T.pack actual)]+ ],+ concat [map (chunk "Predicate: " :) (stringChunks name) | name <- maybeToList mName]+ ]++resultForestWidth :: SpecForest a -> Int+resultForestWidth = goF 0+ where+ goF :: Int -> SpecForest a -> Int+ goF level = maximum . map (goT level)+ goT :: Int -> SpecTree a -> Int+ goT level = \case+ SpecifyNode t _ -> T.length t + level * paddingSize+ PendingNode t _ -> T.length t + level * paddingSize+ DescribeNode _ sdf -> goF (succ level) sdf+ SubForestNode sdf -> goF level sdf++specForestWidth :: SpecDefForest a b c -> Int+specForestWidth = goF 0+ where+ goF :: Int -> SpecDefForest a b c -> Int+ goF level = \case+ [] -> 0+ ts -> maximum $ map (goT level) ts+ goT :: Int -> SpecDefTree a b c -> Int+ goT level = \case+ DefSpecifyNode t _ _ -> T.length t + level * paddingSize+ DefPendingNode t _ -> T.length t + level * paddingSize+ DefDescribeNode _ sdf -> goF (succ level) sdf+ DefSetupNode _ sdf -> goF level sdf+ DefBeforeAllNode _ sdf -> goF level sdf+ DefBeforeAllWithNode _ sdf -> goF level sdf+ DefWrapNode _ sdf -> goF level sdf+ DefAroundAllNode _ sdf -> goF level sdf+ DefAroundAllWithNode _ sdf -> goF level sdf+ DefAfterAllNode _ sdf -> goF level sdf+ DefParallelismNode _ sdf -> goF level sdf+ DefTimeoutNode _ sdf -> goF level sdf+ DefRetriesNode _ sdf -> goF level sdf+ DefRandomisationNode _ sdf -> goF level sdf+ DefFlakinessNode _ sdf -> goF level sdf+ DefExpectationNode _ sdf -> goF level sdf
+ src/Test/Syd/Output/Pretty.hs view
@@ -0,0 +1,377 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE NumericUnderscores #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++module Test.Syd.Output.Pretty where++import Control.Arrow (second)+import Data.List (sortOn)+import qualified Data.List as L+import Data.Map (Map)+import qualified Data.Map as M+import Data.Maybe+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Lazy.Builder as Text+import GHC.Stack+import Safe+import Test.Syd.OptParse+import Test.Syd.Output.Common+import Test.Syd.Run+import Test.Syd.SpecDef+import Test.Syd.SpecForest+import Text.Colour+import Text.Printf++renderPrettyReport :: Settings -> Timed ResultForest -> Text.Builder+renderPrettyReport settings rf =+ mconcat $+ map+ (\line -> renderChunksBuilder (settingTerminalCapabilities settings) line <> "\n")+ (outputResultReport settings rf)++outputResultReport :: Settings -> Timed ResultForest -> [[Chunk]]+outputResultReport settings trf =+ let rf = timedValue trf+ in concat+ [ outputTestsHeader,+ outputSpecForest settings 0 (resultForestWidth rf) rf,+ [ [chunk ""],+ [chunk ""]+ ],+ outputPrettySummary settings trf+ ]++outputPrettySummary :: Settings -> Timed ResultForest -> [[Chunk]]+outputPrettySummary settings trf =+ let rf = timedValue trf+ in concat+ [ outputFailuresWithHeading settings rf,+ [[chunk ""]],+ outputStats (computeTestSuiteStats settings <$> trf),+ [[chunk ""]],+ if settingProfile settings+ then outputProfilingInfo trf+ else []+ ]++outputFailuresHeader :: [[Chunk]]+outputFailuresHeader = outputHeader "Failures:"++outputFailuresWithHeading :: Settings -> ResultForest -> [[Chunk]]+outputFailuresWithHeading settings rf =+ if anyFailedTests settings rf+ then+ concat+ [ outputFailuresHeader,+ outputFailures settings rf+ ]+ else []++outputStats :: Timed TestSuiteStats -> [[Chunk]]+outputStats timed =+ let TestSuiteStats {..} = timedValue timed+ sumTimeSeconds :: Double+ sumTimeSeconds = fromIntegral testSuiteStatSumTime / 1_000_000_000+ totalTimeSeconds :: Double+ totalTimeSeconds = fromIntegral (timedTime timed) / 1_000_000_000+ in map (padding :) $+ concat+ [ [ [ chunk "Examples: ",+ fore green $ chunk (T.pack (show testSuiteStatExamples))+ ]+ | testSuiteStatExamples /= testSuiteStatSuccesses+ ],+ [ [ chunk "Passed: ",+ ( if testSuiteStatSuccesses <= 0+ then fore red+ else fore green+ )+ $ chunk (T.pack (show testSuiteStatSuccesses))+ ],+ [ chunk "Failed: ",+ ( if testSuiteStatFailures > 0+ then fore red+ else fore green+ )+ $ chunk (T.pack (show testSuiteStatFailures))+ ]+ ],+ [ [ chunk "Flaky: ",+ fore red $ chunk (T.pack (show testSuiteStatFlakyTests))+ ]+ | testSuiteStatFlakyTests > 0+ ],+ [ [ chunk "Pending: ",+ fore magenta $ chunk (T.pack (show testSuiteStatPending))+ ]+ | testSuiteStatPending > 0+ ],+ [ [ chunk "Sum of test runtimes:",+ fore yellow $ chunk $ T.pack (printf "%13.2f seconds" sumTimeSeconds)+ ],+ [ chunk "Test suite took: ",+ fore yellow $ chunk $ T.pack (printf "%13.2f seconds" totalTimeSeconds)+ ]+ ]+ ]++outputProfilingInfo :: Timed ResultForest -> [[Chunk]]+outputProfilingInfo Timed {..} =+ map+ ( \(path, nanos) ->+ [ timeChunkFor nanos,+ " ",+ chunk $ T.intercalate "." path+ ]+ )+ ( sortOn+ snd+ ( map+ (second (timedTime . testDefVal))+ (flattenSpecForest timedValue)+ )+ )++outputTestsHeader :: [[Chunk]]+outputTestsHeader = outputHeader "Tests:"++outputHeader :: Text -> [[Chunk]]+outputHeader t =+ [ [fore blue $ chunk t],+ [chunk ""]+ ]++outputSpecForest :: Settings -> Int -> Int -> ResultForest -> [[Chunk]]+outputSpecForest settings level treeWidth = concatMap (outputSpecTree settings level treeWidth)++outputSpecTree :: Settings -> Int -> Int -> ResultTree -> [[Chunk]]+outputSpecTree settings level treeWidth = \case+ SpecifyNode t td -> outputSpecifyLines settings level treeWidth t td+ PendingNode t mr -> outputPendingLines t mr+ DescribeNode t sf -> outputDescribeLine t : map (padding :) (outputSpecForest settings (level + 1) treeWidth sf)+ SubForestNode sf -> outputSpecForest settings level treeWidth sf++outputDescribeLine :: Text -> [Chunk]+outputDescribeLine t = [fore yellow $ chunk t]++outputSpecifyLines :: Settings -> Int -> Int -> Text -> TDef (Timed TestRunReport) -> [[Chunk]]+outputSpecifyLines settings level treeWidth specifyText (TDef timed _) =+ let testRunReport = timedValue timed+ executionTime = timedTime timed+ status = testRunReportStatus settings testRunReport+ TestRunResult {..} = testRunReportReportedRun testRunReport+ withStatusColour = fore (statusColour status)+ pad = (chunk (T.pack (replicate paddingSize ' ')) :)+ timeChunk = timeChunkFor executionTime+ in concatMap+ (filter (not . null))+ [ [ [ withStatusColour $ chunk (statusCheckMark status),+ withStatusColour $ chunk specifyText,+ spacingChunk level specifyText (chunkText timeChunk) treeWidth,+ timeChunk+ ]+ ],+ map pad $ retriesChunks testRunReport,+ [ pad+ [ chunk "passed for all of ",+ case w of+ 0 -> fore red $ chunk "0"+ _ -> fore green $ chunk (T.pack (printf "%d" w)),+ " inputs."+ ]+ | status == TestPassed,+ w <- maybeToList testRunResultNumTests+ ],+ map pad $ labelsChunks (fromMaybe 1 testRunResultNumTests) testRunResultLabels,+ map pad $ classesChunks testRunResultClasses,+ map pad $ tablesChunks testRunResultTables,+ [pad $ outputGoldenCase gc | gc <- maybeToList testRunResultGoldenCase]+ ]++exampleNrChunk :: Word -> Word -> Chunk+exampleNrChunk total current =+ let digits :: Word+ digits = max 2 $ succ $ floor $ logBase 10 $ (fromIntegral :: Word -> Double) total+ formatStr = "%" <> show digits <> "d"+ in chunk $ T.pack $ printf formatStr current++retriesChunks :: TestRunReport -> [[Chunk]]+retriesChunks testRunReport =+ case testRunReportRetries testRunReport of+ Nothing -> []+ Just retries ->+ let flaky = testRunReportWasFlaky testRunReport+ mMessage = case testRunReportFlakinessMode testRunReport of+ MayBeFlaky mmesg -> mmesg+ MayNotBeFlaky -> Nothing+ in if flaky+ then+ concat+ [ [["Retries: ", chunk (T.pack (show retries)), fore red " !!! FLAKY !!!"]],+ [[fore magenta $ chunk $ T.pack message] | message <- maybeToList mMessage]+ ]+ else [["Retries: ", chunk (T.pack (show retries)), " (does not look flaky)"]]++labelsChunks :: Word -> Maybe (Map [String] Int) -> [[Chunk]]+labelsChunks _ Nothing = []+labelsChunks totalCount (Just labels)+ | M.null labels = []+ | map fst (M.toList labels) == [[]] = []+ | otherwise =+ [chunk "Labels"]+ : map+ ( pad+ . ( \(ss, i) ->+ [ chunk+ ( T.pack+ ( printf+ "%5.2f%% %s"+ (100 * fromIntegral i / fromIntegral totalCount :: Double)+ (commaList (map show ss))+ )+ )+ ]+ )+ )+ (M.toList labels)+ where+ pad = (chunk (T.pack (replicate paddingSize ' ')) :)++classesChunks :: Maybe (Map String Int) -> [[Chunk]]+classesChunks Nothing = []+classesChunks (Just classes)+ | M.null classes = []+ | otherwise =+ [chunk "Classes"]+ : map+ ( pad+ . ( \(s, i) ->+ [ chunk+ ( T.pack+ (printf "%5.2f%% %s" (100 * fromIntegral i / fromIntegral total :: Double) s)+ )+ ]+ )+ )+ (M.toList classes)+ where+ pad = (chunk (T.pack (replicate paddingSize ' ')) :)+ total = sum $ map snd $ M.toList classes++tablesChunks :: Maybe (Map String (Map String Int)) -> [[Chunk]]+tablesChunks Nothing = []+tablesChunks (Just tables) = concatMap (uncurry goTable) $ M.toList tables+ where+ goTable :: String -> Map String Int -> [[Chunk]]+ goTable tableName percentages =+ [chunk " "]+ : [chunk (T.pack tableName)]+ : map+ ( pad+ . ( \(s, i) ->+ [ chunk+ ( T.pack+ (printf "%5.2f%% %s" (100 * fromIntegral i / fromIntegral total :: Double) s)+ )+ ]+ )+ )+ (M.toList percentages)+ where+ pad = (chunk (T.pack (replicate paddingSize ' ')) :)+ total = sum $ map snd $ M.toList percentages++outputPendingLines :: Text -> Maybe Text -> [[Chunk]]+outputPendingLines specifyText mReason =+ filter+ (not . null)+ [ [fore magenta $ chunk specifyText],+ case mReason of+ Nothing -> []+ Just reason -> [padding, chunk reason]+ ]++outputFailureLabels :: Maybe (Map [String] Int) -> [[Chunk]]+outputFailureLabels Nothing = []+outputFailureLabels (Just labels)+ | labels == M.singleton [] 1 = []+ | otherwise = [["Labels: ", chunk (T.pack (commaList (map show (concat $ M.keys labels))))]]++outputFailureClasses :: Maybe (Map String Int) -> [[Chunk]]+outputFailureClasses Nothing = []+outputFailureClasses (Just classes)+ | M.null classes = []+ | otherwise = [["Class: ", chunk (T.pack (commaList (M.keys classes)))]]++outputGoldenCase :: GoldenCase -> [Chunk]+outputGoldenCase = \case+ GoldenNotFound -> [fore red $ chunk "Golden output not found"]+ GoldenStarted -> [fore cyan $ chunk "Golden output created"]+ GoldenReset -> [fore cyan $ chunk "Golden output reset"]++-- The chunk for spacing between the description and the timing+--+-- initial padding | checkmark | description | THIS CHUNK | execution time+spacingChunk :: Int -> Text -> Text -> Int -> Chunk+spacingChunk level descriptionText executionTimeText treeWidth = chunk $ T.pack $ replicate paddingWidth ' '+ where+ paddingWidth =+ let preferredMaxWidth = 80+ checkmarkWidth = 2+ minimumSpacing = 1+ actualDescriptionWidth = T.length descriptionText+ actualTimingWidth = T.length executionTimeText+ totalNecessaryWidth = treeWidth + checkmarkWidth + minimumSpacing + actualTimingWidth -- All timings are the same width+ actualMaxWidth = max totalNecessaryWidth preferredMaxWidth+ in actualMaxWidth - paddingSize * level - actualTimingWidth - actualDescriptionWidth++outputFailures :: Settings -> ResultForest -> [[Chunk]]+outputFailures settings rf =+ let failures = filter (testRunReportFailed settings . timedValue . testDefVal . snd) $ flattenSpecForest rf+ nbDigitsInFailureCount :: Int+ nbDigitsInFailureCount = floor (logBase 10 (L.genericLength failures) :: Double)+ padFailureDetails = (chunk (T.pack (replicate (nbDigitsInFailureCount + 4) ' ')) :)+ in map (padding :) $+ concatMap (filter (not . null)) $+ indexed failures $ \w (ts, TDef timed cs) ->+ let testRunReport = timedValue timed+ status = testRunReportStatus settings testRunReport+ TestRunResult {..} = testRunReportReportedRun testRunReport+ in concat+ [ [ [ fore cyan $+ chunk $+ T.pack $+ replicate 2 ' '+ ++ case headMay $ getCallStack cs of+ Nothing -> "Unknown location"+ Just (_, SrcLoc {..}) ->+ concat+ [ srcLocFile,+ ":",+ show srcLocStartLine+ ]+ ],+ map+ (fore (statusColour status))+ [ chunk $ statusCheckMark status,+ chunk $ T.pack (printf ("%" ++ show nbDigitsInFailureCount ++ "d ") w),+ chunk $ T.intercalate "." ts+ ]+ ],+ map padFailureDetails $ retriesChunks testRunReport,+ map (padFailureDetails . (: []) . chunk . T.pack) $+ case (testRunResultNumTests, testRunResultNumShrinks) of+ (Nothing, _) -> []+ (Just numTests, Nothing) -> [printf "Failed after %d tests" numTests]+ (Just numTests, Just 0) -> [printf "Failed after %d tests" numTests]+ (Just numTests, Just numShrinks) -> [printf "Failed after %d tests and %d shrinks" numTests numShrinks],+ map (padFailureDetails . (\c -> [chunk "Generated: ", c]) . fore yellow . chunk . T.pack) testRunResultFailingInputs,+ map padFailureDetails $ outputFailureLabels testRunResultLabels,+ map padFailureDetails $ outputFailureClasses testRunResultClasses,+ map padFailureDetails $ maybe [] outputSomeException testRunResultException,+ [padFailureDetails $ outputGoldenCase gc | gc <- maybeToList testRunResultGoldenCase],+ concat [map padFailureDetails $ stringChunks ei | ei <- maybeToList testRunResultExtraInfo],+ [[chunk ""]]+ ]
+ src/Test/Syd/Output/Terse.hs view
@@ -0,0 +1,98 @@+{-# LANGUAGE NumericUnderscores #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++module Test.Syd.Output.Terse where++import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Lazy.Builder as Text+import GHC.Stack+import Safe+import Test.Syd.OptParse+import Test.Syd.Output.Common+import Test.Syd.Run+import Test.Syd.SpecDef+import Test.Syd.SpecForest+import Text.Colour+import Text.Printf++-- | Render a terse report+renderTerseSummary :: Settings -> Timed ResultForest -> Text.Builder+renderTerseSummary settings trf =+ mconcat $+ map+ (\line -> renderChunksBuilder (settingTerminalCapabilities settings) line <> "\n")+ (outputTerseSummary settings trf)++-- | Output the terse report as chunks.+outputTerseSummary :: Settings -> Timed ResultForest -> [[Chunk]]+outputTerseSummary settings trf =+ let rf = timedValue trf+ failures = filter (testRunReportFailed settings . timedValue . testDefVal . snd) $ flattenSpecForest rf+ stats = computeTestSuiteStats settings rf+ totalTimeSeconds = fromIntegral (timedTime trf) / 1_000_000_000 :: Double+ in concat+ [ concatMap (outputTerseFailure settings) failures,+ [outputTerseStats stats totalTimeSeconds]+ ]++-- | Output a single failure in terse format.+outputTerseFailure :: Settings -> ([Text], TDef (Timed TestRunReport)) -> [[Chunk]]+outputTerseFailure _settings (ts, TDef timed cs) =+ let testRunReport = timedValue timed+ TestRunResult {..} = testRunReportReportedRun testRunReport+ location = case headMay $ getCallStack cs of+ Nothing -> "Unknown location"+ Just (_, SrcLoc {..}) -> concat [srcLocFile, ":", show srcLocStartLine]+ testPath = T.intercalate "." ts+ in concat+ [ [ [ chunk "FAIL ",+ chunk (T.pack location),+ chunk " ",+ chunk testPath+ ]+ ],+ map+ (\l -> if null l then l else padding : l)+ (maybe [] outputSomeException testRunResultException),+ [[chunk ""]]+ ]++-- | Output the summary line in terse format.+--+-- Format: Summary: X failed, Y passed, Z pending (Ns)+outputTerseStats :: TestSuiteStats -> Double -> [Chunk]+outputTerseStats TestSuiteStats {..} totalTimeSeconds =+ concat $+ concat+ [ [[padding]],+ [ [ chunk "Passed: ",+ ( if testSuiteStatSuccesses <= 0+ then fore red+ else fore green+ )+ $ chunk (T.pack (show testSuiteStatSuccesses))+ ],+ [ chunk ", Failed: ",+ ( if testSuiteStatFailures > 0+ then fore red+ else fore green+ )+ $ chunk (T.pack (show testSuiteStatFailures))+ ]+ ],+ [ [ chunk ", Flaky: ",+ fore red $ chunk (T.pack (show testSuiteStatFlakyTests))+ ]+ | testSuiteStatFlakyTests > 0+ ],+ [ [ chunk ", Pending: ",+ fore magenta $ chunk (T.pack (show testSuiteStatPending))+ ]+ | testSuiteStatPending > 0+ ],+ [ [ fore yellow $ chunk $ T.pack (printf " (%0.2f s)" totalTimeSeconds)+ ]+ ]+ ]
src/Test/Syd/Run.hs view
@@ -33,6 +33,7 @@ import GHC.Generics (Generic) import Myers.Diff (Diff, getTextDiff) import OptEnvConf+import System.Random (mkStdGen, setStdGen) import System.Timeout (timeout) import Test.QuickCheck import Test.QuickCheck.Gen@@ -533,34 +534,48 @@ instance HasParser SeedSetting where settingsParser =- choice- [ setting- [ help "Use a random seed for pseudo-randomness",- switch RandomSeed,- long "random-seed"- ],- RandomSeed- <$ setting+ withDefault (testRunSettingSeed defaultTestRunSettings) $+ choice+ [ setting [ help "Use a random seed for pseudo-randomness",- OptEnvConf.reader exists,- env "RANDOM_SEED",- metavar "ANY"+ switch RandomSeed,+ long "random-seed" ],- FixedSeed- <$> setting+ RandomSeed+ <$ setting+ [ help "Use a random seed for pseudo-randomness",+ OptEnvConf.reader exists,+ env "RANDOM_SEED",+ metavar "ANY"+ ],+ FixedSeed+ <$> setting+ [ help "Seed for pseudo-randomness",+ OptEnvConf.reader auto,+ option,+ long "seed",+ env "SEED",+ metavar "INT"+ ],+ setting [ help "Seed for pseudo-randomness",- OptEnvConf.reader auto,- option,- long "seed",- env "SEED",- metavar "INT"- ],- setting- [ help "Seed for pseudo-randomness",- conf "seed"- ],- pure $ testRunSettingSeed defaultTestRunSettings- ]+ conf "seed"+ ]+ ]++-- | Seed the global 'System.Random' generator from the 'SeedSetting'.+--+-- QuickCheck-based property tests already replay deterministically from the+-- seed (see 'makeQuickCheckArgs'), but tests that draw from the global+-- generator directly (@randomIO@, @newStdGen@, ...) would otherwise be+-- non-reproducible. Every spec-forest runner calls this before running, so a+-- 'FixedSeed' makes the whole run reproducible regardless of how a test sources+-- its randomness — and any new entry point that runs a forest inherits the+-- behaviour for free, rather than having to remember to set it.+setPseudorandomness :: SeedSetting -> IO ()+setPseudorandomness = \case+ RandomSeed -> pure ()+ FixedSeed seed -> setStdGen (mkStdGen seed) data TestRunResult = TestRunResult { testRunResultStatus :: !TestStatus,
src/Test/Syd/Runner.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeFamilies #-}@@ -14,12 +13,11 @@ where import Control.Concurrent (getNumCapabilities)-import Control.Monad-import Control.Monad.IO.Class-import qualified Data.Text.IO as TIO+import qualified Data.ByteString as SB+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE import System.Environment import System.Mem (performGC)-import System.Random (mkStdGen, setStdGen) import Test.Syd.Def import Test.Syd.OptParse import Test.Syd.Output@@ -27,7 +25,6 @@ import Test.Syd.Runner.Asynchronous import Test.Syd.Runner.Synchronous import Test.Syd.SpecDef-import Text.Colour import Text.Printf -- | Set the command line argument of the underlying action to empty.@@ -66,41 +63,23 @@ sydTestOnce :: Settings -> TestDefM '[] () r -> IO (Timed ResultForest) sydTestOnce settings spec = do specForest <- execTestDefM settings spec- tc <- deriveTerminalCapababilities settings withNullArgs $ do- setPseudorandomness (settingSeed settings) case settingThreads settings of Synchronous -> runSpecForestInterleavedWithOutputSynchronously settings specForest ByCapabilities -> do i <- fromIntegral <$> getNumCapabilities-- when (i == 1) $ do- let outputLine :: [Chunk] -> IO ()- outputLine lineChunks = liftIO $ do- putChunksLocaleWith tc lineChunks- TIO.putStrLn ""- mapM_- ( outputLine- . (: [])- . fore red- )- [ chunk "WARNING: Only one CPU core detected, make sure to compile your test suite with these ghc options:",- chunk " -threaded -rtsopts -with-rtsopts=-N",- chunk " (This is important for correctness as well as speed, as a parallel test suite can find thread safety problems.)"- ] runSpecForestInterleavedWithOutputAsynchronously settings i specForest Asynchronous i -> runSpecForestInterleavedWithOutputAsynchronously settings i specForest sydTestIterations :: Maybe Word -> Settings -> TestDefM '[] () r -> IO (Timed ResultForest)-sydTestIterations totalIterations settings spec =+sydTestIterations totalIterations settings spec = do withNullArgs $ do nbCapabilities <- fromIntegral <$> getNumCapabilities let runOnce settings_ = do- setPseudorandomness (settingSeed settings_) specForest <- execTestDefM settings_ spec- r <- timeItT 0 $ case settingThreads settings_ of+ r <- case settingThreads settings_ of Synchronous -> runSpecForestSynchronously settings_ specForest ByCapabilities -> runSpecForestAsynchronously settings_ nbCapabilities specForest Asynchronous i -> runSpecForestAsynchronously settings_ i specForest@@ -111,10 +90,10 @@ newSeedSetting <- case settingSeed settings of FixedSeed seed -> do let newSeed = seed + fromIntegral iteration- putStrLn $ printf "Running iteration: %4d with seed %4d" iteration newSeed+ SB.putStr $ TE.encodeUtf8 $ T.pack (printf "Running iteration: %4d with seed %4d" iteration newSeed) <> "\n" pure $ FixedSeed newSeed RandomSeed -> do- putStrLn $ printf "Running iteration: %4d with random seeds" iteration+ SB.putStr $ TE.encodeUtf8 $ T.pack (printf "Running iteration: %4d with random seeds" iteration) <> "\n" pure RandomSeed rf <- runOnce $ settings {settingSeed = newSeedSetting} if shouldExitFail settings (timedValue rf)@@ -128,8 +107,3 @@ rf <- go 0 printOutputSpecForest settings rf pure rf--setPseudorandomness :: SeedSetting -> IO ()-setPseudorandomness = \case- RandomSeed -> pure ()- FixedSeed seed -> setStdGen (mkStdGen seed)
src/Test/Syd/Runner/Asynchronous.hs view
@@ -16,13 +16,13 @@ import Control.Concurrent.Async as Async import Control.Concurrent.MVar+import Control.Concurrent.QSem import Control.Concurrent.STM as STM import Control.Exception import Control.Monad import Control.Monad.Reader import Data.Maybe import qualified Data.Text as T-import qualified Data.Text.IO as TIO import Data.Word import GHC.Clock (getMonotonicTimeNSec) import Test.QuickCheck.IO ()@@ -35,8 +35,9 @@ import Test.Syd.SpecForest import Text.Colour -runSpecForestAsynchronously :: Settings -> Word -> TestForest '[] () -> IO ResultForest+runSpecForestAsynchronously :: Settings -> Word -> TestForest '[] () -> IO (Timed ResultForest) runSpecForestAsynchronously settings nbThreads testForest = do+ setPseudorandomness (settingSeed settings) handleForest <- makeHandleForest testForest failFastVar <- newEmptyMVar let runRunner = runner settings nbThreads failFastVar handleForest@@ -46,12 +47,31 @@ runSpecForestInterleavedWithOutputAsynchronously :: Settings -> Word -> TestForest '[] () -> IO (Timed ResultForest) runSpecForestInterleavedWithOutputAsynchronously settings nbThreads testForest = do+ setPseudorandomness (settingSeed settings) handleForest <- makeHandleForest testForest failFastVar <- newEmptyMVar suiteBegin <- getMonotonicTimeNSec let runRunner = runner settings nbThreads failFastVar handleForest- runPrinter = liftIO $ printer settings failFastVar suiteBegin handleForest+ runPrinter = case settingOutputFormat settings of+ OutputFormatPretty -> liftIO $ printer settings failFastVar suiteBegin handleForest+ OutputFormatTerse -> liftIO $ waiter failFastVar handleForest ((), resultForest) <- concurrently runRunner runPrinter++ let outputLine :: [Chunk] -> IO ()+ outputLine lineChunks =+ liftIO $+ -- Emit UTF-8 bytes directly so output never depends on the handle's+ -- locale encoding (a C/POSIX-locale handle is ASCII and would crash on+ -- the non-ASCII status markers). The trailing newline is folded into+ -- the chunks so this stays a single byte-level write.+ putChunksUtf8With (settingTerminalCapabilities settings) (lineChunks <> [chunk "\n"])+ outputLines :: [[Chunk]] -> IO ()+ outputLines = mapM_ outputLine++ outputLines $ case settingOutputFormat settings of+ OutputFormatPretty -> outputPrettySummary settings resultForest+ OutputFormatTerse -> outputTerseSummary settings resultForest+ pure resultForest type HandleForest a b = SpecDefForest a b (MVar (Timed TestRunReport))@@ -220,11 +240,17 @@ -- It's not enough to just not have two tests running at the -- same time, because they also need to be executed in order. case eParallelism of- Sequential -> do+ RunSequential -> do waitForWorkersDone job 0- Parallel -> do+ RunParallel -> do enqueueJob jobQueue job+ RunParallelWith sem ->+ -- Still queued like any other job, so this only ever+ -- holds tests back; it never runs more of them than+ -- there are workers.+ enqueueJob jobQueue $ \workerNr ->+ bracket_ (waitQSem sem) (signalQSem sem) (job workerNr) DefPendingNode _ _ -> pure () DefDescribeNode _ sdf -> goForest sdf DefSetupNode func sdf -> do@@ -261,16 +287,16 @@ ) DefAroundAllWithNode func sdf -> do e <- ask- let HCons x _ = eExternalResources e+ let outers = eExternalResources e liftIO $ func ( \b -> do runReaderT (goForest sdf)- (e {eExternalResources = HCons b (eExternalResources e)})+ (e {eExternalResources = HCons b outers}) waitForWorkersDone )- x+ outers DefAfterAllNode func sdf -> do e <- ask liftIO $@@ -279,9 +305,10 @@ waitForWorkersDone func (eExternalResources e) )- DefParallelismNode p' sdf ->+ DefParallelismNode p' sdf -> do+ runParallelism <- liftIO $ resolveParallelism p' withReaderT- (\e -> e {eParallelism = p'})+ (\e -> e {eParallelism = runParallelism}) (goForest sdf) DefRandomisationNode _ sdf -> goForest sdf -- Ignore, randomisation has already happened.@@ -305,7 +332,7 @@ runReaderT (goForest handleForest) Env- { eParallelism = Parallel,+ { eParallelism = RunParallel, eTimeout = settingTimeout settings, eRetries = settingRetries settings, eFlakinessMode = MayNotBeFlaky,@@ -314,11 +341,26 @@ } waitForWorkersDone -- Make sure all jobs are done before cancelling the runners. +-- | 'Parallelism', with the semaphore a bound needs already made.+--+-- One semaphore per 'DefParallelismNode', so everything below that node shares+-- the one bound rather than each group getting its own.+data RunParallelism+ = RunParallel+ | RunParallelWith !QSem+ | RunSequential++resolveParallelism :: Parallelism -> IO RunParallelism+resolveParallelism = \case+ Parallel -> pure RunParallel+ ParallelWith w -> RunParallelWith <$> newQSem (fromIntegral w)+ Sequential -> pure RunSequential+ type R a = ReaderT (Env a) IO -- Not exported, on purpose. data Env externalResources = Env- { eParallelism :: !Parallelism,+ { eParallelism :: !RunParallelism, eTimeout :: !Timeout, eRetries :: !Word, eFlakinessMode :: !FlakinessMode,@@ -328,12 +370,14 @@ printer :: Settings -> MVar () -> Word64 -> HandleForest '[] () -> IO (Timed ResultForest) printer settings failFastVar suiteBegin handleForest = do- tc <- deriveTerminalCapababilities settings- let outputLine :: [Chunk] -> IO ()- outputLine lineChunks = liftIO $ do- putChunksLocaleWith tc lineChunks- TIO.putStrLn ""+ outputLine lineChunks =+ liftIO $+ -- Emit UTF-8 bytes directly so output never depends on the handle's+ -- locale encoding (a C/POSIX-locale handle is ASCII and would crash on+ -- the non-ASCII status markers). The trailing newline is folded into+ -- the chunks so this stays a single byte-level write.+ putChunksUtf8With (settingTerminalCapabilities settings) (lineChunks <> [chunk "\n"]) treeWidth :: Int treeWidth = specForestWidth handleForest@@ -394,9 +438,6 @@ DefExpectationNode _ sdf -> fmap SubForestNode <$> goForest sdf mapM_ outputLine outputTestsHeader resultForest <- fromMaybe [] <$> runReaderT (goForest handleForest) 0- outputLine [chunk " "]- mapM_ outputLine $ outputFailuresWithHeading settings resultForest- outputLine [chunk " "] suiteEnd <- getMonotonicTimeNSec let timedResult = Timed@@ -405,13 +446,7 @@ timedBegin = suiteBegin, timedEnd = suiteEnd }- mapM_ outputLine $ outputStats (computeTestSuiteStats settings <$> timedResult)- outputLine [chunk " "] - when (settingProfile settings) $ do- mapM_ outputLine (outputProfilingInfo timedResult)- outputLine [chunk " "]- pure timedResult addLevel :: P a -> P a@@ -419,8 +454,8 @@ type P = ReaderT Int IO -waiter :: MVar () -> HandleForest '[] () -> IO ResultForest-waiter failFastVar handleForest = do+waiter :: MVar () -> HandleForest '[] () -> IO (Timed ResultForest)+waiter failFastVar handleForest = timeItT 0 $ do let goForest :: HandleForest a b -> IO (Maybe ResultForest) goForest hts = do rts <- catMaybes <$> mapM goTree hts
src/Test/Syd/Runner/Synchronous/Interleaved.hs view
@@ -13,7 +13,6 @@ import Control.Monad.IO.Class import Control.Monad.Reader import qualified Data.Text as T-import qualified Data.Text.IO as TIO import Test.Syd.HList import Test.Syd.OptParse import Test.Syd.Output@@ -26,11 +25,15 @@ runSpecForestInterleavedWithOutputSynchronously :: Settings -> TestForest '[] () -> IO (Timed ResultForest) runSpecForestInterleavedWithOutputSynchronously settings testForest = do- tc <- deriveTerminalCapababilities settings+ setPseudorandomness (settingSeed settings) let outputLine :: [Chunk] -> IO ()- outputLine lineChunks = liftIO $ do- putChunksLocaleWith tc lineChunks- TIO.putStrLn ""+ outputLine lineChunks =+ liftIO $+ -- Emit UTF-8 bytes directly so output never depends on the handle's+ -- locale encoding (a C/POSIX-locale handle is ASCII and would crash on+ -- the non-ASCII status markers). The trailing newline is folded into+ -- the chunks so this stays a single byte-level write.+ putChunksUtf8With (settingTerminalCapabilities settings) (lineChunks <> [chunk "\n"]) treeWidth :: Int treeWidth = specForestWidth testForest@@ -44,7 +47,9 @@ liftIO $ outputLine $ pad level line outputLinesR :: [[Chunk]] -> R a ()- outputLinesR = mapM_ outputLineR+ outputLinesR cs = case settingOutputFormat settings of+ OutputFormatPretty -> mapM_ outputLineR cs+ OutputFormatTerse -> return () let goForest :: TestForest a () -> R a (Next ResultForest) goForest [] = pure (Continue [])@@ -139,7 +144,7 @@ ) DefAroundAllWithNode func sdf -> do e <- ask- let HCons x _ = eExternalResources e+ let outers = eExternalResources e liftIO $ fmap SubForestNode <$> applySimpleWrapper@@ -147,9 +152,9 @@ ( \b -> runReaderT (goForest sdf)- (e {eExternalResources = HCons b (eExternalResources e)})+ (e {eExternalResources = HCons b outers}) )- x+ outers DefAfterAllNode func sdf -> do e <- ask let externalResources = eExternalResources e
src/Test/Syd/Runner/Synchronous/Separate.hs view
@@ -17,18 +17,20 @@ import Test.Syd.SpecDef import Test.Syd.SpecForest -runSpecForestSynchronously :: Settings -> TestForest '[] () -> IO ResultForest-runSpecForestSynchronously settings testForest =- extractNext- <$> runReaderT- (goForest testForest)- Env- { eTimeout = settingTimeout settings,- eRetries = settingRetries settings,- eFlakinessMode = MayNotBeFlaky,- eExpectationMode = ExpectPassing,- eExternalResources = HNil- }+runSpecForestSynchronously :: Settings -> TestForest '[] () -> IO (Timed ResultForest)+runSpecForestSynchronously settings testForest = do+ setPseudorandomness (settingSeed settings)+ timeItT 0 $+ extractNext+ <$> runReaderT+ (goForest testForest)+ Env+ { eTimeout = settingTimeout settings,+ eRetries = settingRetries settings,+ eFlakinessMode = MayNotBeFlaky,+ eExpectationMode = ExpectPassing,+ eExternalResources = HNil+ } where goForest :: forall a. TestForest a () -> R a (Next ResultForest) goForest [] = pure (Continue [])@@ -98,7 +100,7 @@ ) DefAroundAllWithNode func sdf -> do e <- ask- let HCons x _ = eExternalResources e+ let outers = eExternalResources e liftIO $ fmap SubForestNode <$> applySimpleWrapper@@ -106,9 +108,9 @@ ( \b -> runReaderT (goForest sdf)- (e {eExternalResources = HCons b (eExternalResources e)})+ (e {eExternalResources = HCons b outers}) )- x+ outers DefAfterAllNode func sdf -> do e <- ask let externalResources = eExternalResources e
src/Test/Syd/SpecDef.hs view
@@ -107,7 +107,7 @@ SpecDefTree otherOuters inner extra DefAroundAllWithNode :: -- | The function that provides the new outer resource (once), using the old outer resource.- ((newOuter -> IO ()) -> (oldOuter -> IO ())) ->+ ((newOuter -> IO ()) -> (HList (oldOuter ': otherOuters) -> IO ())) -> SpecDefForest (newOuter ': oldOuter ': otherOuters) inner extra -> SpecDefTree (oldOuter ': otherOuters) inner extra DefAfterAllNode ::@@ -281,6 +281,7 @@ DefRandomisationNode eor sdf -> DefRandomisationNode eor <$> case eor of RandomiseExecutionOrder -> goForest sdf+ -- [ref:ReorderRandomiseBoundary] DoNotRandomiseExecutionOrder -> pure sdf markSpecForestAsPending :: Maybe Text -> SpecDefForest outers inner result -> SpecDefForest outers inner result@@ -309,8 +310,17 @@ DefExpectationNode i sdf -> DefExpectationNode i $ goForest sdf data Parallelism- = Parallel- | Sequential+ = -- | As many at once as there are threads to run them.+ Parallel+ | -- | At most this many at once, however many threads there are.+ --+ -- For tests that contend for something the test suite does not own, like+ -- one database server behind a database per test. Running fewer of those at+ -- once can be faster than running all of them, and is the smaller+ -- instrument where 'Sequential' would do.+ ParallelWith !Word+ | -- | One at a time.+ Sequential deriving (Show, Eq, Generic) data ExecutionOrderRandomisation@@ -403,7 +413,22 @@ } shouldExitFail :: Settings -> ResultForest -> Bool-shouldExitFail settings = any (any (testRunReportFailed settings . timedValue . testDefVal))+shouldExitFail settings resultForest =+ -- Fail if there were no tests.+ --+ -- This is technically valid but in practice we don't ever want to+ -- consider an empty test suite succesfull.+ --+ -- By considering an empty test suite unsuccesful, we can catch cases in+ -- which we have accidentally used a filter that does not match any+ -- tests at all.+ null resultForest+ -- ... or if any tests failed.+ || anyFailedTests settings resultForest++anyFailedTests :: Settings -> ResultForest -> Bool+anyFailedTests settings resultForest =+ any (any (testRunReportFailed settings . timedValue . testDefVal)) resultForest data TestRunReport = TestRunReport { testRunReportExpectationMode :: !ExpectationMode,
sydtest.cabal view
@@ -1,11 +1,11 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.37.0.+-- This file has been generated from package.yaml by hpack version 0.38.3. -- -- see: https://github.com/sol/hpack name: sydtest-version: 0.20.0.1+version: 0.33.0.0 synopsis: A modern testing framework for Haskell with good defaults and advanced testing features. description: A modern testing framework for Haskell with good defaults and advanced testing features. Sydtest aims to make the common easy and the hard possible. See https://github.com/NorfairKing/sydtest#readme for more information. category: Testing@@ -40,8 +40,18 @@ Test.Syd.Expectation Test.Syd.HList Test.Syd.Modify+ Test.Syd.Mutation.Forest+ Test.Syd.MutationMode+ Test.Syd.MutationMode.Common+ Test.Syd.MutationMode.CoverageList+ Test.Syd.MutationMode.CoverageListLocations+ Test.Syd.MutationMode.Single+ Test.Syd.MutationMode.SingleCoverage Test.Syd.OptParse Test.Syd.Output+ Test.Syd.Output.Common+ Test.Syd.Output.Pretty+ Test.Syd.Output.Terse Test.Syd.Path Test.Syd.ReRun Test.Syd.Run@@ -72,7 +82,7 @@ , fast-myers-diff >=0.0.1 , filepath , mtl- , opt-env-conf >=0.5+ , opt-env-conf >=0.10 , path , path-io , pretty-show@@ -83,7 +93,9 @@ , safe-coloured-text , stm , svg-builder+ , sydtest-mutation-runtime >=0.1 , text+ , transformers , vector default-language: Haskell2010 if os(windows)