diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,369 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to the [Package Versioning Policy (PVP)](https://pvp.haskell.org/).
+
+## *Unreleased*
+
+## [0.10.1.0] - 2025-11-03
+
+### Changed
+- Updated dependency on tar ^>= 0.7
+
+## [0.10.0.0] - 2025-09-09
+
+### Added
+- Comprehensive CHANGELOG.md with complete project history
+- Descriptive error messages for workspace test failures
+- Sum type `WorkspacePolicy` to replace boolean blindness in `workspaceWithConfig`
+
+### Changed
+- `workspaceWithConfig` now uses `WorkspacePolicy` instead of `Bool` parameter
+- Improved error messages in workspace tests with specific failure contexts
+- Updated documentation to reference Package Versioning Policy (PVP) instead of SemVer
+
+## [0.9.0.0] - 2025-06-24
+
+### Changed
+- Fix hanging problem with moduleWorkspace
+
+### Added
+- Workspace behavior tests and improved workspace API
+- Directory dependency to test suite
+- GHC 9.8 to GHA build matrix
+- Claude IDE command templates for git operations
+
+### Fixed  
+- Preserve workspace directories on test failure for debugging
+
+## [0.8.0.0] - 2025-06-14
+
+### Added
+- New `testUnitIO` function
+
+### Changed
+- Make binDist search components recursively
+
+### Removed
+- Delete unused 'h' file
+
+## [0.7.2.0] - 2025-06-07
+
+### Added
+- MonadAssertion instance to UnitIO
+
+## [0.7.1.0] - 2025-06-06
+
+### Added
+- New assertion functions: `tryAssertion`, `assertFailure` and `assertFailure_`
+- Re-export MonadBaseControl, bracket_ and bracket
+
+## [0.7.0.0] - 2024-12-24
+
+### Added
+- Add `expectFailure` combinator
+- Add `expectFailureWith` combinator and tests
+- New UnitIO monad
+- Thread safe golden test support
+- Add diffVsGoldenFileExcludeTrace function
+- Re-export Golden module from Test module
+- Move orphans to own file
+
+### Changed
+- Generalize resulting monad in `expectFailure`
+- Define `expectFailure` in terms of `expectFailureWith`
+- Haskell CI: reduce number of shellcheck warnings
+- Haskell CI: simplify build workflow, using cardano-cli's CI as guidance
+- Loosen base version, to allow building with GHC 9.12
+- Align haddock pipeline with Haskell pipeline
+- Make the GitHub page workflow manually triggerable (workflow_dispatch)
+
+### Fixed
+- Fix haddock for `threadDelay`
+- Fix haddock for `byDeadline` and `byDuration`
+- Process: more informative error message when plan.json does not exist
+- Detail: fix a warning in the Haskell pipeline
+
+## [0.6.5.1] - 2024-11-20
+
+### Changed
+- Enforce UTF-8 encoding on reading and writing files
+
+## [0.6.5.0] - 2024-07-27
+
+### Added
+- Make it build with ghc-9.10
+- CI: Add ghc-9.10 to the build matrix
+
+## [0.6.4.0] - 2024-05-16
+
+### Added
+- Add missing `HasCallStack` to writeGoldenFile, reportGoldenFileMissing, checkAgainstGoldenFile
+
+### Changed
+- Use removePathForcibly to remove directories
+
+## [0.6.3.0] - 2024-05-03
+
+### Added
+- Add TestWatchdog and Tripwire with their tests
+- Add test for asyncRegister_
+
+### Changed
+- Switch to using haskell-actions/setup
+- Allow the port to be reused immediately after it is closed
+
+## [0.6.2.0] - 2024-04-23
+
+### Added
+- New randomPort, reserveRandomPort and portInUse functions
+
+### Changed
+- Apply suggestions from code review
+- Remove double space and add quotes
+
+## [0.6.1.0] - 2024-02-13
+
+### Added
+- Add test to ensure a directory doesn't exist (`assertDirectoryMissing`)
+- Add test to ensure a directory exists (`assertDirectoryExists`)
+
+### Changed
+- Allow reading files into any FromJSON
+- Lower aeson bound
+- #39 Lower bound on aeson
+- Remove double printing of a command with its arguments
+
+## [0.6.0.2] - 2024-01-29
+
+### Fixed
+- Fix missing call sites for short-circuiting functions
+
+## [0.6.0.1] - 2024-01-23
+
+### Fixed
+- Fix missing stderr in failed commands
+
+## [0.6.0.0] - 2024-01-16
+
+### Added
+- Add exec variant that allows a negative call (#55)
+- Add concurrency abstractions from lifted-async and lifted-base
+
+## [0.5.1.0] - 2024-01-04
+
+### Added
+- New RECREATE_GOLDEN_FILES which will causes golden tests to always create golden files replacing any existing golden files
+
+### Changed
+- Use MultiwayIf to simplify code
+
+## [0.5.0.0] - 2023-11-23
+
+### Added
+- Add upper bound to tar
+
+### Changed
+- Make it build with ghc-9.8
+- CI: Add ghc-9.8.1 to build matrix
+
+### Removed
+- Remove unused import
+
+## [0.4.8.0] - 2023-11-21
+
+### Changed
+- Make it build with ghc-9.8
+- CI: Add ghc-9.8.1 to build matrix
+
+### Removed
+- Remove unused import
+
+## [0.4.7.1] - 2023-10-05
+
+### Removed
+- Remove dependency on hw-aeson
+
+## [0.4.7.0] - 2023-06-27
+
+### Added
+- New indexM function
+
+## [0.4.6.0] - 2023-06-19
+
+### Added
+- Add a way to log golden files
+- Create new process groups for newly spawned processes (#42)
+
+## [0.4.5.1] - 2023-05-10
+
+### Fixed
+- Fix empty case for diffVsGoldenFile
+
+### Changed
+- Put the golden-file filename in the failure message of diffVsGoldenFile
+
+## [0.4.5.0] - 2023-05-09
+
+### Added
+- New downloadToFile function
+- New downloadAndExtractGithubCommitToTemp
+
+### Fixed
+- Fix annotation for renameFile
+
+## [0.4.4.1] - 2023-05-05
+
+### Added
+- Make it build with ghc-9.6
+- CI: Add ghc-9.6.1 to build matrix
+
+## [0.4.4.0] - 2023-05-04
+
+### Added
+- Add extra assertion functions, fix readM to show correct line (#33)
+
+### Changed
+- Tidy up imports
+
+## [0.4.3.0] - 2023-05-03
+
+### Added
+- Add assertWithinTolerance and readM
+
+## [0.4.2.0] - 2023-04-28
+
+### Added
+- New functions assertFileExists assertFileMissing assertFilesMissing
+- New Hedgehog.Extras.Test.Golden module
+
+### Removed
+- Delete assertFileExists from Network module because it doesn't belong here
+
+### Changed
+- Use filepath operator (</>) over (<>) with "/"
+
+## [0.4.1.0] - 2023-04-12
+
+### Added
+- New Ok versions of functions that require the operation to succeed
+- New createSubdirectoryIfMissing function
+
+### Changed
+- Modify createDirectoryIfMissing to return its argument. createDirectoryIfMissing_ will be the version that returns ()
+
+### Fixed
+- Fix compile errors
+
+## [0.4.0.1] - 2023-03-06
+
+### Fixed
+- Fix retry function
+
+## [0.4.0.0] - 2023-03-06
+
+### Changed
+- Pass retry count to retry function
+
+## [0.3.0.3] - 2023-01-09
+
+### Fixed
+- Fix moduleWorkspace
+
+## [0.3.0.2] - 2023-01-07
+
+### Added
+- Generic instance
+
+## [0.3.0.1] - 2023-01-07
+
+### Changed
+- Don't create an intermediate workspace directory
+- Find the nearest plan.json
+- Restructure cabal file
+- Update copyright
+- Copy over waitNamedPipe from Win32-network
+
+### Removed
+- Remove unused dependency on Win32-network
+
+### Fixed
+- Use setup-haskell action
+
+## [0.3.0.0] - 2022-12-13
+
+### Added
+- Add errorMessage to argument of deadline functions
+- getProjectBase: explore up the dir hierarchy to find cabal.project
+- New fromJustM function
+- Add .gitignore file
+
+### Changed
+- Update GH actions versions
+- Relax bounds on aeson
+
+## Early Versions (0.1.0.0 - 0.2.x)
+
+### 2022
+- New rewriteArrayElements function
+- Update to support aeson >= 2.0.0 (#8)
+- Replace waitByDeadlineX functions to byDeadlineX functions that trigger retries by assertion failure (#7)
+- Add retry support (#6)
+
+### 2021
+- Module re-exports (#5)
+- Generic error handling functions (#4)
+- New exec, binFlex functions. New execConfigCwd field (#3)
+- Add support for YAML (#2)
+- Force evaluation of string for cat (#1)
+- Setup Github Actions CI
+- Downgrade cabal file version to 2.4 because 3.0 is incompatible with stack
+- Drop support for ghc-8.6.5
+- Harmonise component versions and remove redundant conditionals
+- Import cardano-submit-api from cardano-rest repo
+- Test support for running CLI commands with environment variables
+- Run golden tests at the value level rather than the text level
+- Update copyright to 2021
+- Cabal 3.4 in Github Actions
+
+### 2020
+- New copyRewriteJsonFile function to work around permissioning issues on Hydra
+- Remove redundant imports
+- General chairman test code quality changes
+- Chairman test running on Windows and Linux
+- Report all log files on any test failure
+- Bump versions to 1.21.0 and update the change logs
+- Run byron-shelley testnet
+- Run a chairman process per node
+- Increase test code re-use by moving test support code into separate common hedgehog-extras library
+
+[Unreleased]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.10.0.0...HEAD
+[0.10.0.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.9.0.0...v0.10.0.0
+[0.9.0.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.8.0.0...v0.9.0.0
+[0.8.0.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.7.2.0...v0.8.0.0
+[0.7.2.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.7.1.0...v0.7.2.0
+[0.7.1.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.7.0.0...v0.7.1.0
+[0.7.0.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.6.5.1...v0.7.0.0
+[0.6.5.1]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.6.5.0...v0.6.5.1
+[0.6.5.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.6.4.0...v0.6.5.0
+[0.6.4.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.6.3.0...v0.6.4.0
+[0.6.3.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.6.2.0...v0.6.3.0
+[0.6.2.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.6.1.0...v0.6.2.0
+[0.6.1.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.6.0.2...v0.6.1.0
+[0.6.0.2]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.6.0.1...v0.6.0.2
+[0.6.0.1]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.6.0.0...v0.6.0.1
+[0.6.0.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.5.1.0...v0.6.0.0
+[0.5.1.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.5.0.0...v0.5.1.0
+[0.5.0.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.4.8.0...v0.5.0.0
+[0.4.8.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.4.7.1...v0.4.8.0
+[0.4.7.1]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.4.7.0...v0.4.7.1
+[0.4.7.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.4.6.0...v0.4.7.0
+[0.4.6.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.4.5.1...v0.4.6.0
+[0.4.5.1]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.4.5.0...v0.4.5.1
+[0.4.5.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.4.4.1...v0.4.5.0
+[0.4.4.1]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.4.4.0...v0.4.4.1
+[0.4.4.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.4.3.0...v0.4.4.0
+[0.4.3.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.4.2.0...v0.4.3.0
+[0.4.2.0]: https://github.com/input-output-hk/hedgehog-extras/compare/v0.3.0.2...v0.4.2.0
diff --git a/LICENSE b/LICENSE
--- a/LICENSE
+++ b/LICENSE
@@ -187,7 +187,7 @@
       same "printed page" as the copyright notice for easier
       identification within third-party archives.
 
-   Copyright 2021 Input Output (Hong Kong) Ltd.
+   Copyright 2025 Input Output (Hong Kong) Ltd.
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
diff --git a/NOTICE b/NOTICE
--- a/NOTICE
+++ b/NOTICE
@@ -1,4 +1,4 @@
-Copyright 2021-2023 Input Output (Hong Kong) Ltd.
+Copyright 2021-2025 Input Output (Hong Kong) Ltd.
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,73 @@
+# hedgehog-extras
+
+![Dependency Status](https://github.com/input-output-hk/hedgehog-extras/actions/workflows/cabal-outdated.yml/badge.svg)
+[![CI](https://github.com/input-output-hk/hedgehog-extras/actions/workflows/haskell.yml/badge.svg)](https://github.com/input-output-hk/hedgehog-extras/actions/workflows/haskell.yml)
+
+Supplemental library for hedgehog.
+
+## About
+
+This library provides additional utilities and helpers for working with the [Hedgehog](https://hackage.haskell.org/package/hedgehog) property-based testing framework.
+
+## Installation
+
+Add to your `package.yaml` or `.cabal` file:
+
+```yaml
+dependencies:
+  - hedgehog-extras
+```
+
+## Releasing
+
+This project has a hybrid automated/manual release flow:
+
+### Automatic Tagging (on main branch)
+
+When you push a version bump to main, CI automatically creates a git tag:
+
+1. Update [CHANGELOG.md] to include the *Unreleased* changes in the changelog for the new version.
+1. Update `hedgehog-extras.cabal` and set `version:` to the new version
+1. Commit and push to main: `git commit -m "Bump version to X.Y.Z.W" && git push origin main`
+1. CI automatically creates and pushes tag `vX.Y.Z.W`
+1. The release workflow then runs automatically
+
+### Manual Tagging
+
+Alternatively, you can manually create and push tags:
+
+1. Update `hedgehog-extras.cabal` with the new version
+2. Commit changes: `git commit -m "Release X.Y.Z.W"`
+3. Create tag: `git tag -a vX.Y.Z.W -m "Release version X.Y.Z.W"`
+4. Push both: `git push origin main && git push origin vX.Y.Z.W`
+
+### What Happens Automatically
+
+When a tag is pushed (either automatically or manually), GitHub Actions:
+- Runs the full test suite across all platforms and GHC versions
+- Validates the cabal project with `cabal check`
+- Creates source distributions (`cabal v2-sdist`)
+- **Uploads to Hackage as a candidate** (requires `HACKAGE_USER` and `HACKAGE_PASS` secrets)
+- Generates release notes from git commit history
+- Creates a GitHub Release
+
+### Publishing the Hackage Candidate
+
+After the workflow completes:
+1. Check the candidate at https://hackage.haskell.org/package/hedgehog-extras/candidates
+2. Test the candidate package
+3. When ready, publish it from the Hackage candidate page
+
+### Notes
+
+- The workflow is defined in `.github/workflows/haskell.yml`
+- Packages are uploaded as **candidates**, not published releases
+- Release notes are auto-generated from git commits since the last tag
+- The workflow requires `HACKAGE_USER` and `HACKAGE_PASS` repository secrets
+- Workflow requires `contents: write` permission (already configured)
+
+## License
+
+Copyright 2025 Input Output Global, Inc.
+
+Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for details.
diff --git a/hedgehog-extras.cabal b/hedgehog-extras.cabal
--- a/hedgehog-extras.cabal
+++ b/hedgehog-extras.cabal
@@ -1,7 +1,7 @@
 cabal-version: 2.4
 
 name:                   hedgehog-extras
-version:                0.6.5.1
+version:                0.10.1.0
 synopsis:               Supplemental library for hedgehog
 description:            Supplemental library for hedgehog.
 category:               Test
@@ -11,52 +11,55 @@
 license-files:          LICENSE
                         NOTICE
 build-type:             Simple
-
+extra-doc-files:        CHANGELOG.md
+                        README.md
 source-repository head
   type:                 git
   location:             https://github.com/input-output-hk/hedgehog-extras
 
-common aeson                        { build-depends: aeson                            >= 2.0.0.0                  }
-common aeson-pretty                 { build-depends: aeson-pretty                     >= 0.8.5                    }
-common async                        { build-depends: async                                                        }
-common base                         { build-depends: base                             >= 4.12       && < 4.21     }
-common bytestring                   { build-depends: bytestring                                                   }
-common deepseq                      { build-depends: deepseq                                                      }
-common Diff                         { build-depends: Diff                                                         }
-common directory                    { build-depends: directory                                                    }
-common exceptions                   { build-depends: exceptions                                                   }
-common filepath                     { build-depends: filepath                                                     }
-common hedgehog                     { build-depends: hedgehog                                                     }
-common hedgehog-quickcheck          { build-depends: hedgehog-quickcheck                                          }
-common http-conduit                 { build-depends: http-conduit                                                 }
-common lifted-async                 { build-depends: lifted-async                                                 }
-common lifted-base                  { build-depends: lifted-base                                                  }
-common mmorph                       { build-depends: mmorph                                                       }
-common monad-control                { build-depends: monad-control                                                }
-common mtl                          { build-depends: mtl                                                          }
-common network                      { build-depends: network                                                      }
-common process                      { build-depends: process                                                      }
-common resourcet                    { build-depends: resourcet                                                    }
-common retry                        { build-depends: retry                             >= 0.9                     }
-common stm                          { build-depends: stm                                                          }
-common tar                          { build-depends: tar                              ^>= 0.6                     }
-common tasty                        { build-depends: tasty                                                        }
-common tasty-hedgehog               { build-depends: tasty-hedgehog                                               }
-common tasty-quickcheck             { build-depends: tasty-quickcheck                                             }
-common temporary                    { build-depends: temporary                                                    }
-common text                         { build-depends: text                                                         }
-common time                         { build-depends: time                             >= 1.9.1                    }
-common transformers                 { build-depends: transformers                                                 }
-common transformers-base            { build-depends: transformers-base                                            }
-common unliftio                     { build-depends: unliftio                                                     }
-common yaml                         { build-depends: yaml                                                         }
-common zlib                         { build-depends: zlib                                                         }
+common aeson                        { build-depends: aeson                            >= 2.0.0.0 && < 2.3         }
+common aeson-pretty                 { build-depends: aeson-pretty                     >= 0.8.5   && < 0.9         }
+common async                        { build-depends: async                                          < 2.3         }
+common base                         { build-depends: base                             >= 4.12    && < 4.22        }
+common bytestring                   { build-depends: bytestring                                     < 0.13        }
+common containers                   { build-depends: containers                                     < 0.9         }
+common deepseq                      { build-depends: deepseq                                        < 1.6         }
+common Diff                         { build-depends: Diff                                           < 1.1         }
+common directory                    { build-depends: directory                                      < 1.4         }
+common exceptions                   { build-depends: exceptions                                     < 0.11        }
+common filepath                     { build-depends: filepath                                       < 1.6         }
+common hedgehog                     { build-depends: hedgehog                                       < 1.8         }
+common hedgehog-quickcheck          { build-depends: hedgehog-quickcheck                            < 0.2         }
+common http-conduit                 { build-depends: http-conduit                                   < 2.4         }
+common lifted-async                 { build-depends: lifted-async                                   < 0.12        }
+common lifted-base                  { build-depends: lifted-base                                    < 0.3         }
+common mmorph                       { build-depends: mmorph                                         < 1.3         }
+common monad-control                { build-depends: monad-control                                  < 1.1         }
+common mtl                          { build-depends: mtl                                            < 2.4         }
+common network                      { build-depends: network                                        < 3.3         }
+common process                      { build-depends: process                                        < 1.7         }
+common resourcet                    { build-depends: resourcet                                      < 1.4         }
+common retry                        { build-depends: retry                            >= 0.9     && < 0.10        }
+common stm                          { build-depends: stm                                            < 2.6         }
+common tar                          { build-depends: tar                              >= 0.7     && < 0.8         }
+common tasty                        { build-depends: tasty                                          < 1.6         }
+common tasty-discover               { build-depends: tasty-discover                   >= 5.0.2   && < 5.3         }
+common tasty-hedgehog               { build-depends: tasty-hedgehog                                 < 1.5         }
+common tasty-quickcheck             { build-depends: tasty-quickcheck                               < 0.11        }
+common temporary                    { build-depends: temporary                                      < 1.4         }
+common text                         { build-depends: text                                           < 2.2         }
+common time                         { build-depends: time                             >= 1.9.1   && < 1.16        }
+common transformers                 { build-depends: transformers                                   < 0.7         }
+common transformers-base            { build-depends: transformers-base                              < 0.5         }
+common unliftio                     { build-depends: unliftio                                       < 0.3         }
+common yaml                         { build-depends: yaml                                           < 0.12        }
+common zlib                         { build-depends: zlib                                           < 0.8         }
 
 common hedgehog-extras              { build-depends: hedgehog-extras                                              }
 
 common Win32
   if os(windows)
-    build-depends:      Win32   >= 2.5.4.1
+    build-depends:      Win32   >= 2.5.4.1 && < 2.15
 
 common project-config
   default-language:     Haskell2010
@@ -75,6 +78,7 @@
                         aeson,
                         async,
                         bytestring,
+                        containers,
                         deepseq,
                         Diff,
                         directory,
@@ -84,15 +88,17 @@
                         http-conduit,
                         lifted-async,
                         lifted-base,
-                        monad-control,
                         mmorph,
+                        monad-control,
                         mtl,
                         network,
                         process,
                         resourcet,
-                        retry,
                         stm,
                         tar,
+                        tasty,
+                        tasty-discover,
+                        tasty-hedgehog,
                         temporary,
                         text,
                         time,
@@ -110,6 +116,7 @@
   exposed-modules:      Hedgehog.Extras
                         Hedgehog.Extras.Aeson
                         Hedgehog.Extras.Internal.Cli
+                        Hedgehog.Extras.Internal.Orphans
                         Hedgehog.Extras.Internal.Plan
                         Hedgehog.Extras.Internal.Test.Integration
                         Hedgehog.Extras.Stock
@@ -132,18 +139,23 @@
                         Hedgehog.Extras.Test.Golden
                         Hedgehog.Extras.Test.MonadAssertion
                         Hedgehog.Extras.Test.Network
+                        Hedgehog.Extras.Test.Prim
                         Hedgehog.Extras.Test.Process
                         Hedgehog.Extras.Test.TestWatchdog
                         Hedgehog.Extras.Test.Tripwire
+                        Hedgehog.Extras.Test.Unit
 
 test-suite hedgehog-extras-test
   import:               base, project-config,
+                        directory,
                         hedgehog,
                         hedgehog-extras,
+                        lifted-base,
                         network,
                         process,
                         resourcet,
                         tasty,
+                        tasty-discover,
                         tasty-hedgehog,
                         transformers,
                         time,
@@ -152,7 +164,10 @@
   type:                 exitcode-stdio-1.0
 
   other-modules:        Hedgehog.Extras.Stock.IO.Network.PortSpec
+                        Hedgehog.Extras.Test.TestExpectFailure
                         Hedgehog.Extras.Test.TestWatchdogSpec
+                        Hedgehog.Extras.Test.UnitSpec
+                        Hedgehog.Extras.Test.WorkspaceSpec
 
   build-tool-depends:   tasty-discover:tasty-discover
   ghc-options:          -threaded -rtsopts "-with-rtsopts=-N -T"
diff --git a/src/Hedgehog/Extras/Internal/Orphans.hs b/src/Hedgehog/Extras/Internal/Orphans.hs
new file mode 100644
--- /dev/null
+++ b/src/Hedgehog/Extras/Internal/Orphans.hs
@@ -0,0 +1,25 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE ImportQualifiedPost #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE TypeFamilies #-}
+
+{-# OPTIONS_GHC -Wno-orphans #-}
+
+module Hedgehog.Extras.Internal.Orphans () where
+
+import Control.Applicative
+import Control.Monad.Base
+import Control.Monad.IO.Class
+import Control.Monad.Trans.Control
+import Control.Monad.Trans.Resource
+import System.IO (IO)
+import UnliftIO qualified
+
+instance MonadBase IO (ResourceT IO) where
+  liftBase = liftIO
+
+instance MonadBaseControl IO (ResourceT IO) where
+  type StM (ResourceT IO) a = a
+  liftBaseWith = UnliftIO.withRunInIO
+  restoreM = pure
+
diff --git a/src/Hedgehog/Extras/Internal/Plan.hs b/src/Hedgehog/Extras/Internal/Plan.hs
--- a/src/Hedgehog/Extras/Internal/Plan.hs
+++ b/src/Hedgehog/Extras/Internal/Plan.hs
@@ -1,5 +1,7 @@
 {-# LANGUAGE DeriveGeneric #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE NoFieldSelectors #-}
 
 module Hedgehog.Extras.Internal.Plan
   ( Plan(..)
@@ -7,7 +9,10 @@
   ) where
 
 import           Control.Applicative
+import           Control.Monad
 import           Data.Aeson
+import qualified Data.Aeson as A
+import qualified Data.Aeson.KeyMap as M
 import           Data.Eq
 import           Data.Function
 import           Data.Maybe
@@ -18,6 +23,7 @@
 data Component = Component
   { componentName :: Maybe Text
   , binFile :: Maybe Text
+  , components :: [Component]
   }
   deriving (Generic, Eq, Show)
 
@@ -31,6 +37,14 @@
         <$> v .: "install-plan"
 
 instance FromJSON Component where
-    parseJSON = withObject "Plan" $ \v -> Component
-        <$> v .:? "component-name"
-        <*> v .:? "bin-file"
+    parseJSON = withObject "Plan" $ \v -> do
+      componentName <- v .:? "component-name"
+      binFile <- v .:? "bin-file"
+      componentsTuples <- join . maybeToList . fmap M.toAscList <$> v .:? "components"
+      -- sub-components are an object with components name as a key
+      components <- forM componentsTuples $ \(subComponentName, subComponent) ->
+        parseJSON $
+          A.Object $
+            M.insert "component-name" (toJSON subComponentName) subComponent
+      pure Component{..}
+
diff --git a/src/Hedgehog/Extras/Internal/Win32/NamedPipes.hsc b/src/Hedgehog/Extras/Internal/Win32/NamedPipes.hsc
--- a/src/Hedgehog/Extras/Internal/Win32/NamedPipes.hsc
+++ b/src/Hedgehog/Extras/Internal/Win32/NamedPipes.hsc
@@ -23,7 +23,6 @@
 import Data.String (String)
 import Foreign.C.String (withCString)
 import System.IO (IO)
-import System.Win32.File
 import System.Win32.Types hiding (try)
 
 -- | Timeout in milliseconds.
diff --git a/src/Hedgehog/Extras/Test.hs b/src/Hedgehog/Extras/Test.hs
--- a/src/Hedgehog/Extras/Test.hs
+++ b/src/Hedgehog/Extras/Test.hs
@@ -5,8 +5,10 @@
 import           Hedgehog.Extras.Test.Base as X
 import           Hedgehog.Extras.Test.Concurrent as X
 import           Hedgehog.Extras.Test.File as X
+import           Hedgehog.Extras.Test.Golden as X
 import           Hedgehog.Extras.Test.MonadAssertion as X
 import           Hedgehog.Extras.Test.Network as X
 import           Hedgehog.Extras.Test.Process as X
 import           Hedgehog.Extras.Test.TestWatchdog as X
 import           Hedgehog.Extras.Test.Tripwire as X
+import           Hedgehog.Extras.Test.Unit as X
diff --git a/src/Hedgehog/Extras/Test/Base.hs b/src/Hedgehog/Extras/Test/Base.hs
--- a/src/Hedgehog/Extras/Test/Base.hs
+++ b/src/Hedgehog/Extras/Test/Base.hs
@@ -6,6 +6,8 @@
   ( propertyOnce
 
   , workspace
+  , workspaceWithConfig
+  , WorkspacePolicy(..)
   , moduleWorkspace
 
   , note
@@ -60,6 +62,11 @@
 
   , failWithCustom
   , failMessage
+  , expectFailure
+  , expectFailureWith
+  , tryAssertion
+  , assertFailure
+  , assertFailure_
 
   , assertByDeadlineM
   , assertByDeadlineIO
@@ -85,36 +92,41 @@
 
   , retry
   , retry'
+
+  , MonadBaseControl
+  , bracket
+  , bracket_
   ) where
 
 import           Control.Applicative (Applicative (..))
-import           Control.Monad (Functor (fmap), Monad (return, (>>=)), mapM_, unless, void, when)
+import           Control.Exception (IOException)
+import           Control.Exception.Lifted (bracket, bracket_, try)
+import           Control.Monad.Trans.Control (MonadBaseControl)
+import           Control.Monad (Functor (fmap), Monad (return, (>>=)), mapM_, unless, void)
 import           Control.Monad.Catch (MonadCatch)
 import           Control.Monad.Morph (hoist)
 import           Control.Monad.Reader (MonadIO (..), MonadReader (ask))
-import           Control.Monad.Trans.Resource (MonadResource, ReleaseKey, register, runResourceT)
+import           Control.Monad.Trans.Resource (MonadResource, ReleaseKey, runResourceT)
 import           Data.Aeson (Result (..))
-import           Data.Bool (Bool (..), otherwise, (&&))
+import           Data.Bool (Bool (..), otherwise, (||))
 import           Data.Either (Either (..), either)
-import           Data.Eq (Eq ((/=)))
+import           Data.Eq (Eq ((==)))
 import           Data.Foldable (for_)
 import           Data.Function (const, ($), (.))
 import           Data.Functor ((<$>))
 import           Data.Int (Int)
 import           Data.Maybe (Maybe (..), listToMaybe, maybe)
-import           Data.Monoid (Monoid (..))
 import           Data.Semigroup (Semigroup (..))
 import           Data.String (String)
 import           Data.Time.Clock (NominalDiffTime, UTCTime)
 import           Data.Traversable (Traversable)
 import           Data.Tuple (snd)
 import           GHC.Stack
-import           Hedgehog (MonadTest)
 import           Hedgehog.Extras.Internal.Test.Integration (Integration, IntegrationState (..))
 import           Hedgehog.Extras.Stock.CallStack (callerModuleName)
 import           Hedgehog.Extras.Stock.Monad (forceM)
-import           Hedgehog.Extras.Test.MonadAssertion (MonadAssertion)
-import           Hedgehog.Internal.Property (Diff, liftTest, mkTest)
+import           Hedgehog.Extras.Test.MonadAssertion
+import           Hedgehog.Extras.Test.Prim
 import           Hedgehog.Internal.Source (getCaller)
 import           Prelude (Num (..), Ord (..), floor)
 import           System.FilePath ((</>))
@@ -123,10 +135,7 @@
 
 import qualified Control.Concurrent as IO
 import qualified Control.Concurrent.STM as STM
-import           Control.Exception (IOException)
-import           Control.Monad.Catch (Handler (..))
 import qualified Control.Monad.Trans.Resource as IO
-import qualified Control.Retry as R
 import qualified Data.List as L
 import qualified Data.Time.Clock as DTC
 import qualified GHC.Stack as GHC
@@ -140,21 +149,35 @@
 import qualified System.Info as IO
 import qualified System.IO as IO
 import qualified System.IO.Temp as IO
+import Hedgehog.Internal.Property
 
 {- HLINT ignore "Reduce duplication" -}
 
+-- | Policy for workspace directory cleanup behavior
+data WorkspacePolicy
+  = PreserveWorkspace    -- ^ Always preserve workspace directories, even on success
+  | CleanupOnSuccess     -- ^ Clean up workspace directories only when tests succeed
+  deriving (Eq, Show)
+
 -- | Run a property with only one test.  This is intended for allowing hedgehog
 -- to run unit tests.
 propertyOnce :: HasCallStack => Integration () -> H.Property
 propertyOnce = H.withTests 1 . H.property . hoist runResourceT . hoist H.runIntegrationReaderT
 
--- | Takes a 'CallStack' so the error can be rendered at the appropriate call site.
-failWithCustom :: MonadTest m => CallStack -> Maybe Diff -> String -> m a
-failWithCustom cs mdiff msg = liftTest $ mkTest (Left $ H.Failure (getCaller cs) msg mdiff, mempty)
+-- | Invert the behavior of a property: success becomes failure and vice versa.
+expectFailure :: (MonadTest m, MonadIO m, HasCallStack) => H.TestT IO a -> m ()
+expectFailure = GHC.withFrozenCallStack $ expectFailureWith (pure . const ())
 
--- | Takes a 'CallStack' so the error can be rendered at the appropriate call site.
-failMessage :: MonadTest m => CallStack -> String -> m a
-failMessage cs = failWithCustom cs Nothing
+-- | Invert the behavior of a property: success becomes failure and vice versa.
+-- This function behaves like 'expectFailure' but it allows to check the failure
+-- is as expected. The function takes a 'Failure' and should itself be a test
+-- that fails if the failure is not as expected.
+expectFailureWith :: (MonadTest m, MonadIO m, HasCallStack) => (H.Failure -> m ()) -> H.TestT IO b -> m ()
+expectFailureWith checkFailure prop = GHC.withFrozenCallStack $ do
+  (res, _) <- H.evalIO $ H.runTestT prop
+  case res of
+    Left e -> checkFailure e
+    _ -> H.failWith Nothing "Expected the test to fail but it passed" -- Property pased but we expected a failure
 
 -- | Create a workspace directory which will exist for at least the duration of
 -- the supplied block.
@@ -165,32 +188,85 @@
 -- The directory will be deleted if the block succeeds, but left behind if
 -- the block fails.
 workspace
-  :: MonadTest m
-  => HasCallStack
+  :: HasCallStack
+  => MonadCatch m
+  => MonadBaseControl IO m
   => MonadResource m
+  => MonadTest m
+  => H.MonadAssertion m
   => FilePath
   -> (FilePath -> m ())
   -> m ()
 workspace prefixPath f = withFrozenCallStack $ do
-  systemTemp <- H.evalIO IO.getCanonicalTemporaryDirectory
   maybeKeepWorkspace <- H.evalIO $ IO.lookupEnv "KEEP_WORKSPACE"
-  ws <- H.evalIO $ IO.createTempDirectory systemTemp $ prefixPath <> "-test"
+  let policy = if IO.os == "mingw32" || maybeKeepWorkspace == Just "1"
+              then PreserveWorkspace
+              else CleanupOnSuccess
+  workspaceWithConfig policy prefixPath f
+
+-- | Create a workspace directory with explicit configuration for cleanup behavior.
+--
+-- The directory will have the supplied prefix but contain a generated random
+-- suffix to prevent interference between tests
+--
+-- The directory will be deleted if the block succeeds, but left behind if
+-- the block fails (unless policy is PreserveWorkspace, in which case it's always preserved).
+workspaceWithConfig
+  :: HasCallStack
+  => MonadCatch m
+  => MonadBaseControl IO m
+  => MonadResource m
+  => MonadTest m
+  => H.MonadAssertion m
+  => WorkspacePolicy  -- ^ Cleanup policy: PreserveWorkspace always preserves, CleanupOnSuccess deletes on success only
+  -> FilePath
+  -> (FilePath -> m ())
+  -> m ()
+workspaceWithConfig policy prefixPath f = withFrozenCallStack $ evalM $ do
+  ws <- do
+    systemTemp <- H.evalIO IO.getCanonicalTemporaryDirectory
+    H.evalIO $ IO.createTempDirectory systemTemp $ prefixPath <> "-test"
+  
   H.annotate $ "Workspace: " <> ws
   H.evalIO $ IO.writeFile (ws </> "module") callerModuleName
-  f ws
-  when (IO.os /= "mingw32" && maybeKeepWorkspace /= Just "1") $ do
-    -- try to delete the directory 20 times, 100ms apart
-    let retryPolicy = R.constantDelay 100000 <> R.limitRetries 20
-        -- retry only on IOExceptions
-        ioExH _ = Handler $ \(_ :: IOException) -> pure True
-    -- For some reason, the temporary directory removal sometimes fails.
-    -- Lets wrap this in MonadResource to try multiple times, during the cleanup, before we fail.
-    void
-      . register
-      . R.recovering retryPolicy [ioExH]
-      . const
-      $ IO.removePathForcibly ws
+  
+  result <- H.catchAssertion (fmap Right (f ws)) (return . Left)
+  
+  case result of
+    Right _ -> do
+      -- Test succeeded, clean up if policy allows
+      case policy of
+        CleanupOnSuccess -> removeWorkspaceRetries ws 20
+        PreserveWorkspace -> pure ()
+    Left assertion -> do
+      -- Test failed, preserve workspace for debugging (unless policy forces cleanup)
+      case policy of
+        CleanupOnSuccess -> do
+          H.annotate $ "Test failed, preserving workspace for debugging: " <> ws
+        PreserveWorkspace -> pure ()
+      -- Re-throw the original assertion failure
+      H.throwAssertion assertion
+  where
+    removeWorkspaceRetries
+      :: MonadBaseControl IO m
+      => MonadResource m
+      => MonadTest m
+      => FilePath
+      -> Int
+      -> m ()
+    removeWorkspaceRetries ws retries = GHC.withFrozenCallStack $ do
+      result <- try (liftIO (IO.removePathForcibly ws))
+      case result of
+        Right () -> return ()
+        Left (_ :: IOException) -> do
+          if retries > 0
+            then do
+              liftIO (IO.threadDelay 100000) -- wait 100ms before retrying
+              removeWorkspaceRetries ws (retries - 1)
+            else do
+              failMessage GHC.callStack "Failed to remove workspace directory after multiple attempts"
 
+
 -- | Create a workspace directory which will exist for at least the duration of
 -- the supplied block.
 --
@@ -201,7 +277,16 @@
 -- the block fails.
 --
 -- The 'prefix' argument should not contain directory delimeters.
-moduleWorkspace :: (MonadTest m, MonadResource m, HasCallStack) => String -> (FilePath -> m ()) -> m ()
+moduleWorkspace
+  :: HasCallStack
+  => MonadCatch m
+  => MonadBaseControl IO m
+  => MonadResource m
+  => MonadTest m
+  => H.MonadAssertion m
+  => String
+  -> (FilePath -> m ())
+  -> m ()
 moduleWorkspace prefix f = GHC.withFrozenCallStack $ do
   let srcModule = maybe "UnknownModule" (GHC.srcLocModule . snd) (listToMaybe (GHC.getCallStack GHC.callStack))
   workspace (prefix <> "-" <> srcModule) f
@@ -482,13 +567,13 @@
 jsonErrorFailM :: (MonadTest m, HasCallStack) => m (Result a) -> m a
 jsonErrorFailM f = GHC.withFrozenCallStack $ f >>= jsonErrorFail
 
--- | Run the operation 'f' once a second until it returns 'True' or the deadline expires.
+-- | Run the operation 'f' once per 'period' until it returns 'True' or the deadline expires.
 --
 -- Expiration of the deadline results in an assertion failure
 byDeadlineIO :: (MonadAssertion m, MonadTest m, MonadIO m, HasCallStack) => NominalDiffTime -> UTCTime -> String -> IO a -> m a
 byDeadlineIO period deadline errorMessage f = GHC.withFrozenCallStack $ byDeadlineM period deadline errorMessage $ liftIO f
 
--- | Run the operation 'f' once a second until it returns 'True' or the deadline expires.
+-- | Run the operation 'f' once per 'period' until it returns 'True' or the deadline expires.
 --
 -- Expiration of the deadline results in an assertion failure
 byDeadlineM :: forall m a. (MonadAssertion m, MonadTest m, MonadIO m, HasCallStack) => NominalDiffTime -> UTCTime -> String -> m a -> m a
@@ -510,13 +595,13 @@
               void $ failMessage GHC.callStack $ "Condition not met by deadline: " <> errorMessage
               H.throwAssertion e
 
--- | Run the operation 'f' once a second until it returns 'True' or the duration expires.
+-- | Run the operation 'f' once per 'period' until it returns 'True' or the duration expires.
 --
 -- Expiration of the duration results in an assertion failure
 byDurationIO :: (MonadAssertion m, MonadTest m, MonadIO m, HasCallStack) => NominalDiffTime -> NominalDiffTime -> String -> IO a -> m a
 byDurationIO period duration errorMessage f = GHC.withFrozenCallStack $ byDurationM period duration errorMessage $ liftIO f
 
--- | Run the operation 'f' once a second until it returns 'True' or the duration expires.
+-- | Run the operation 'f' once per 'period' until it returns 'True' or the duration expires.
 --
 -- Expiration of the duration results in an assertion failure
 byDurationM :: (MonadAssertion m, MonadTest m, MonadIO m, HasCallStack) => NominalDiffTime -> NominalDiffTime -> String -> m a -> m a
@@ -529,8 +614,8 @@
 -- Expiration of the deadline results in an assertion failure
 assertByDeadlineIO :: (MonadTest m, MonadIO m, HasCallStack) => UTCTime -> IO Bool -> m ()
 assertByDeadlineIO deadline f = GHC.withFrozenCallStack $ do
-  success <- liftIO f
-  unless success $ do
+  succeeded <- liftIO f
+  unless succeeded $ do
     currentTime <- liftIO DTC.getCurrentTime
     if currentTime < deadline
       then do
@@ -545,8 +630,8 @@
 -- Expiration of the deadline results in an assertion failure
 assertByDeadlineM :: (MonadTest m, MonadIO m, HasCallStack) => UTCTime -> m Bool -> m ()
 assertByDeadlineM deadline f = GHC.withFrozenCallStack $ do
-  success <- f
-  unless success $ do
+  succeeded <- f
+  unless succeeded $ do
     currentTime <- liftIO DTC.getCurrentTime
     if currentTime < deadline
       then do
@@ -564,8 +649,8 @@
 -- Expiration of the deadline results in an assertion failure
 assertByDeadlineIOFinally :: (MonadTest m, MonadIO m, HasCallStack) => UTCTime -> IO Bool -> m () -> m ()
 assertByDeadlineIOFinally deadline f g = GHC.withFrozenCallStack $ do
-  success <- liftIO f
-  unless success $ do
+  succeeded <- liftIO f
+  unless succeeded $ do
     currentTime <- liftIO DTC.getCurrentTime
     if currentTime < deadline
       then do
@@ -584,8 +669,8 @@
 -- Expiration of the deadline results in an assertion failure
 assertByDeadlineMFinally :: (MonadTest m, MonadIO m, HasCallStack) => UTCTime -> m Bool -> m () -> m ()
 assertByDeadlineMFinally deadline f g = GHC.withFrozenCallStack $ do
-  success <- f
-  unless success $ do
+  succeeded <- f
+  unless succeeded $ do
     currentTime <- liftIO DTC.getCurrentTime
     if currentTime < deadline
       then do
diff --git a/src/Hedgehog/Extras/Test/Concurrent.hs b/src/Hedgehog/Extras/Test/Concurrent.hs
--- a/src/Hedgehog/Extras/Test/Concurrent.hs
+++ b/src/Hedgehog/Extras/Test/Concurrent.hs
@@ -1,9 +1,7 @@
 {-# LANGUAGE FlexibleInstances #-}
 {-# LANGUAGE MultiParamTypeClasses #-}
 {-# LANGUAGE TypeFamilies #-}
-{-# OPTIONS_GHC -Wno-orphans #-}
 
-
 {- | This modules provides concurrency abstractions for hedgehog tests. Using "lifted-base" one can execute
 expensive test actions concurrently.
 
@@ -73,20 +71,17 @@
   , module System.Timeout.Lifted
   ) where
 
-import           Control.Applicative
 import           Control.Concurrent.Async.Lifted
 import qualified Control.Concurrent.Lifted as IO
 import           Control.Concurrent.MVar.Lifted
-import           Control.Monad.Base
 import           Control.Monad.IO.Class
-import           Control.Monad.Trans.Control
 import           Control.Monad.Trans.Resource
 import           Data.Function
 import           Data.Int
 import qualified GHC.Stack as GHC
 import           System.IO (IO)
 import           System.Timeout.Lifted
-import qualified UnliftIO
+import           Hedgehog.Extras.Internal.Orphans ()
 
 import           Control.Monad
 import           Control.Monad.Catch (MonadCatch)
@@ -94,7 +89,7 @@
 import           Hedgehog
 import qualified Hedgehog as H
 
--- | Delay the thread by 'n' milliseconds.
+-- | Delay the thread by 'n' microseconds.
 threadDelay :: (HasCallStack, MonadTest m, MonadIO m) => Int -> m ()
 threadDelay n = GHC.withFrozenCallStack . H.evalIO $ IO.threadDelay n
 
@@ -109,11 +104,3 @@
   where
     cleanUp :: Async a -> IO ()
     cleanUp a = cancel a >> void (link a)
-
-instance MonadBase IO (ResourceT IO) where
-  liftBase = liftIO
-
-instance MonadBaseControl IO (ResourceT IO) where
-  type StM (ResourceT IO) a = a
-  liftBaseWith = UnliftIO.withRunInIO
-  restoreM = pure
diff --git a/src/Hedgehog/Extras/Test/Golden.hs b/src/Hedgehog/Extras/Test/Golden.hs
--- a/src/Hedgehog/Extras/Test/Golden.hs
+++ b/src/Hedgehog/Extras/Test/Golden.hs
@@ -1,44 +1,59 @@
+{-# LANGUAGE FlexibleContexts #-}
 {-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE OverloadedStrings #-}
 
 module Hedgehog.Extras.Test.Golden
   ( diffVsGoldenFile,
     diffFileVsGoldenFile,
+    diffVsGoldenFileExcludeTrace,
   ) where
 
 import           Control.Applicative
-import           Control.Exception (bracket_)
+import           Control.Exception.Lifted
 import           Control.Monad
 import           Control.Monad.IO.Class (MonadIO (liftIO))
+import           Control.Monad.Trans.Control
 import           Data.Algorithm.Diff (PolyDiff (Both), getGroupedDiff)
 import           Data.Algorithm.DiffOutput (ppDiff)
 import           Data.Bool
 import           Data.Eq
 import           Data.Function
+import           Data.Map (Map)
 import           Data.Maybe
 import           Data.Monoid
 import           Data.String
 import           GHC.Stack (HasCallStack, callStack)
 import           Hedgehog (MonadTest)
 import           Hedgehog.Extras.Test.Base (failMessage)
+import           System.Directory (canonicalizePath, getCurrentDirectory)
 import           System.FilePath (takeDirectory)
+import           System.FilePath.Posix (makeRelative)
 import           System.IO (FilePath, IO)
 
 import qualified Control.Concurrent.QSem as IO
+import qualified Control.Concurrent.STM as STM
 import qualified Data.List as List
+import qualified Data.Map as Map
+import qualified Data.Text as Text
 import qualified GHC.Stack as GHC
-import qualified Hedgehog.Extras.Test as H
+import qualified Hedgehog.Extras.Test.Base as H
+import qualified Hedgehog.Extras.Test.File as H
 import qualified Hedgehog.Internal.Property as H
 import qualified System.Directory as IO
 import qualified System.Environment as IO
 import qualified System.IO as IO
 import qualified System.IO.Unsafe as IO
 
-sem :: IO.QSem
-sem = IO.unsafePerformIO $ IO.newQSem 1
-{-# NOINLINE sem #-}
-
-semBracket :: IO a -> IO a
-semBracket = bracket_ (IO.waitQSem sem) (IO.signalQSem sem)
+semBracket :: ()
+  => MonadIO m
+  => MonadBaseControl IO m
+  => IO.QSem
+  -> m a
+  -> m a
+semBracket sem =
+  bracket_
+    (liftIO (IO.waitQSem sem))
+    (liftIO (IO.signalQSem sem))
 
 -- | The file to log whenever a golden file is referenced.
 mGoldenFileLogFile :: Maybe FilePath
@@ -103,6 +118,23 @@
         ]
       failMessage callStack $ ppDiff difference
 
+tvGoldenFileSems :: STM.TVar (Map FilePath IO.QSem)
+tvGoldenFileSems = IO.unsafePerformIO $ STM.newTVarIO mempty
+{-# NOINLINE tvGoldenFileSems #-}
+
+getGoldenFileSem :: FilePath -> IO IO.QSem
+getGoldenFileSem filePath = do
+  newSem <- IO.newQSem 1
+
+  STM.atomically $ do
+    sems <- STM.readTVar tvGoldenFileSems
+    case Map.lookup filePath sems of
+      Just sem -> return sem
+      Nothing  -> do
+        let newGoldenFileSems = Map.insert filePath newSem sems
+        STM.writeTVar tvGoldenFileSems newGoldenFileSems
+        return newSem
+
 -- | Diff contents against the golden file.  If CREATE_GOLDEN_FILES environment is
 -- set to "1", then should the golden file not exist it would be created.  If
 -- RECREATE_GOLDEN_FILES is set to "1", then should the golden file exist it would
@@ -120,22 +152,33 @@
 -- each input.
 diffVsGoldenFile
   :: HasCallStack
-  => (MonadIO m, MonadTest m)
+  => MonadIO m
+  => MonadBaseControl IO m
+  => MonadTest m
   => String   -- ^ Actual content
   -> FilePath -- ^ Reference file
   -> m ()
-diffVsGoldenFile actualContent goldenFile = GHC.withFrozenCallStack $ do
-  forM_ mGoldenFileLogFile $ \logFile ->
-    liftIO $ semBracket $ IO.appendFile logFile $ goldenFile <> "\n"
+diffVsGoldenFile actualContent goldenFile =
+  GHC.withFrozenCallStack $ do
+    realPath <- liftIO $ canonicalizePath goldenFile
+    cwd <- liftIO getCurrentDirectory
 
-  fileExists <- liftIO $ IO.doesFileExist goldenFile
+    let relativeGoldenPath = makeRelative cwd realPath
 
-  if
-    | recreateGoldenFiles -> writeGoldenFile goldenFile actualContent
-    | fileExists          -> checkAgainstGoldenFile goldenFile actualLines
-    | createGoldenFiles   -> writeGoldenFile goldenFile actualContent
-    | otherwise           -> reportGoldenFileMissing goldenFile
+    sem <- liftIO $ getGoldenFileSem relativeGoldenPath
 
+    GHC.withFrozenCallStack $ semBracket sem $ do
+      forM_ mGoldenFileLogFile $ \logFile ->
+        liftIO $ IO.appendFile logFile $ goldenFile <> "\n"
+
+      fileExists <- liftIO $ IO.doesFileExist goldenFile
+
+      if
+        | recreateGoldenFiles -> writeGoldenFile goldenFile actualContent
+        | fileExists          -> checkAgainstGoldenFile goldenFile actualLines
+        | createGoldenFiles   -> writeGoldenFile goldenFile actualContent
+        | otherwise           -> reportGoldenFileMissing goldenFile
+
   where
     actualLines = List.lines actualContent
 
@@ -152,10 +195,41 @@
 -- files are never overwritten.
 diffFileVsGoldenFile
   :: HasCallStack
-  => (MonadIO m, MonadTest m)
+  => MonadBaseControl IO m
+  => MonadIO m
+  => MonadTest m
   => FilePath -- ^ Actual file
   -> FilePath -- ^ Reference file
   -> m ()
 diffFileVsGoldenFile actualFile referenceFile = GHC.withFrozenCallStack $ do
   contents <- H.readFile actualFile
   diffVsGoldenFile contents referenceFile
+
+-- | Diff contents against the golden file, excluding the trace.  If CREATE_GOLDEN_FILES environment is
+-- set to "1", then should the golden file not exist it would be created.  If
+-- RECREATE_GOLDEN_FILES is set to "1", then should the golden file exist it would
+-- be recreated. If GOLDEN_FILE_LOG_FILE is set to a filename, then the golden file
+-- path will be logged to the specified file.
+--
+-- Set the environment variable when you intend to generate or re-generate the golden
+-- file for example when running the test for the first time or if the golden file
+-- genuinely needs to change.
+--
+-- To re-generate a golden file you must also delete the golden file because golden
+-- files are never overwritten.
+diffVsGoldenFileExcludeTrace
+  :: MonadBaseControl IO m
+  => MonadIO m
+  => MonadTest m
+  => HasCallStack
+  => String -> FilePath -> m ()
+diffVsGoldenFileExcludeTrace inputString refFile =
+  GHC.withFrozenCallStack $ do
+    case List.uncons $ Text.splitOn "CallStack" $ Text.pack inputString of
+      Just (stackTraceRemoved, _) -> diffVsGoldenFile (Text.unpack stackTraceRemoved) refFile
+      Nothing ->
+        H.failWith Nothing $
+          List.unlines
+            [ "Input string was empty"
+            , "Reference file: " <> refFile
+            ]
diff --git a/src/Hedgehog/Extras/Test/MonadAssertion.hs b/src/Hedgehog/Extras/Test/MonadAssertion.hs
--- a/src/Hedgehog/Extras/Test/MonadAssertion.hs
+++ b/src/Hedgehog/Extras/Test/MonadAssertion.hs
@@ -3,17 +3,27 @@
 
 module Hedgehog.Extras.Test.MonadAssertion
   ( MonadAssertion(..)
+  , assertFailure
+  , assertFailure_
+  , tryAssertion
   ) where
 
+import           Control.Applicative (Applicative(..))
 import           Control.Monad
 import           Control.Monad.Trans.Class
 import           Data.Either
 import           Data.Function
-import           Data.Monoid (mempty)
+import           Data.Functor ((<$>))
+import           Data.Monoid
+import           GHC.Stack (HasCallStack)
+import           Hedgehog (MonadTest(..))
+import           Hedgehog.Extras.Test.Prim
+import           Text.Show (Show(..))
 
 import qualified Control.Monad.Trans.Except as E
 import qualified Control.Monad.Trans.Resource as IO
 import qualified Control.Monad.Trans.Resource.Internal as IO
+import qualified GHC.Stack as GHC
 import qualified Hedgehog as H
 import qualified Hedgehog.Internal.Property as H
 
@@ -30,3 +40,37 @@
   catchAssertion r h = IO.ResourceT $ \i -> IO.unResourceT r i `catchAssertion` \e -> IO.unResourceT (h e) i
 
 deriving instance Monad m => MonadAssertion (H.PropertyT m)
+
+-- | Attempt to run a function that may assert, returning either a failure or the result of the assertion.
+tryAssertion :: ()
+  => MonadAssertion m
+  => m a
+  -> m (Either H.Failure a)
+tryAssertion m =
+  catchAssertion (Right <$> m) (pure . Left)
+
+-- | Run the given action and succeed if the action fails, but fail if it succeeds.
+assertFailure :: ()
+  => HasCallStack
+  => Show a
+  => MonadAssertion m
+  => MonadTest m
+  => m a
+  -> m H.Failure
+assertFailure f = do
+  result <- tryAssertion f
+  case result of
+    Left e -> pure e
+    Right a -> failMessage GHC.callStack $ show a
+
+-- | Run the given action and succeed if the action fails, but fail if it succeeds.
+assertFailure_ :: ()
+  => HasCallStack
+  => Show a
+  => MonadAssertion m
+  => MonadTest m
+  => m a
+  -> m ()
+assertFailure_ f =
+  GHC.withFrozenCallStack $
+    void $ assertFailure f
diff --git a/src/Hedgehog/Extras/Test/Network.hs b/src/Hedgehog/Extras/Test/Network.hs
--- a/src/Hedgehog/Extras/Test/Network.hs
+++ b/src/Hedgehog/Extras/Test/Network.hs
@@ -86,7 +86,7 @@
   H.note_ $ "Downloading " <> url <> " to " <> path
   H.evalIO $ HTTP.simpleHttp url >>= LBS.writeFile path
 
-tarErrors :: TAR.GenEntries tarPath linkTarget e -> [e]
+tarErrors :: TAR.GenEntries content tarPath linkTarget e -> [e]
 tarErrors entries = TAR.foldEntries (flip const) id (:) entries []
 
 -- | Download a github commit to a temporary directory, extract it and return the path to the extracted directory.
diff --git a/src/Hedgehog/Extras/Test/Prim.hs b/src/Hedgehog/Extras/Test/Prim.hs
new file mode 100644
--- /dev/null
+++ b/src/Hedgehog/Extras/Test/Prim.hs
@@ -0,0 +1,30 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module Hedgehog.Extras.Test.Prim
+  ( failWithCustom
+  , failMessage
+
+  ) where
+
+import           Data.Either (Either (..))
+import           Data.Function
+import           Data.Maybe
+import           Data.Monoid (Monoid (..))
+import           Data.String (String)
+import           GHC.Stack
+import           Hedgehog (MonadTest)
+import           Hedgehog.Internal.Property (Diff, liftTest, mkTest)
+import           Hedgehog.Internal.Source (getCaller)
+
+import qualified Hedgehog.Internal.Property as H
+
+{- HLINT ignore "Reduce duplication" -}
+
+-- | Takes a 'CallStack' so the error can be rendered at the appropriate call site.
+failWithCustom :: MonadTest m => CallStack -> Maybe Diff -> String -> m a
+failWithCustom cs mdiff msg = liftTest $ mkTest (Left $ H.Failure (getCaller cs) msg mdiff, mempty)
+
+-- | Takes a 'CallStack' so the error can be rendered at the appropriate call site.
+failMessage :: MonadTest m => CallStack -> String -> m a
+failMessage cs = failWithCustom cs Nothing
diff --git a/src/Hedgehog/Extras/Test/Process.hs b/src/Hedgehog/Extras/Test/Process.hs
--- a/src/Hedgehog/Extras/Test/Process.hs
+++ b/src/Hedgehog/Extras/Test/Process.hs
@@ -1,4 +1,6 @@
 {-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE NamedFieldPuns #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TypeApplications #-}
@@ -25,20 +27,21 @@
   , defaultExecConfig
   ) where
 
-import           Control.Monad (Monad (..), MonadFail (fail), void, unless)
+import           Control.Applicative (pure, (<|>))
+import           Control.Monad (Monad (..), MonadFail (fail), unless, void)
 import           Control.Monad.Catch (MonadCatch)
 import           Control.Monad.IO.Class (MonadIO, liftIO)
 import           Control.Monad.Trans.Resource (MonadResource, ReleaseKey, register)
 import           Data.Aeson (eitherDecode)
-import           Data.Bool (Bool (..))
+import           Data.Bool (Bool (True), otherwise)
 import           Data.Either (Either (..))
 import           Data.Eq (Eq (..))
-import           Data.Function (($), (&), (.))
+import           Data.Function (($), (.))
 import           Data.Functor ((<$>))
 import           Data.Int (Int)
 import           Data.Maybe (Maybe (..))
 import           Data.Monoid (Last (..), mempty, (<>))
-import           Data.String (String)
+import           Data.String (IsString (..), String)
 import           GHC.Generics (Generic)
 import           GHC.Stack (HasCallStack)
 import           Hedgehog (MonadTest)
@@ -55,6 +58,7 @@
 
 import qualified Data.ByteString.Lazy as LBS
 import qualified Data.List as L
+import           Data.Text (Text)
 import qualified Data.Text as T
 import qualified GHC.Stack as GHC
 import qualified Hedgehog as H
@@ -284,7 +288,7 @@
   maybeEnvBin <- liftIO $ IO.lookupEnv binaryEnv
   case maybeEnvBin of
     Just envBin -> return envBin
-    Nothing -> binDist pkg
+    Nothing -> binDist pkg binaryEnv
 
 -- | Consult the "plan.json" generated by cabal to get the path to the executable corresponding.
 -- to a haskell package.  It is assumed that the project has already been configured and the
@@ -294,25 +298,40 @@
   :: (HasCallStack, MonadTest m, MonadIO m)
   => String
   -- ^ Package name
+  -> String
+  -- ^ Environment variable pointing to the binary to run (used for error messages only)
   -> m FilePath
   -- ^ Path to executable
-binDist pkg = do
+binDist pkg binaryEnv = do
   doesPlanExist <- liftIO $ IO.doesFileExist planJsonFile
   unless doesPlanExist $
-    error $ "Could not find plan.json in the path: " <> planJsonFile
-  contents <- H.evalIO . LBS.readFile $ planJsonFile
+    error $ "Could not find plan.json in the path: "
+              <> planJsonFile
+              <> ". Please run \"cabal build "
+              <> pkg
+              <> "\" if you are working with sources. Otherwise define "
+              <> binaryEnv
+              <> " and have it point to the executable you want."
 
-  case eitherDecode contents of
-    Right plan -> case L.filter matching (plan & installPlan) of
-      (component:_) -> case component & binFile of
-        Just bin -> return $ addExeSuffix (T.unpack bin)
-        Nothing -> error $ "missing \"bin-file\" key in plan component: " <> show component <> " in the plan in: " <> planJsonFile
-      [] -> error $ "Cannot find \"component-name\" key with the value \"exe:" <> pkg <> "\" in the plan in: " <> planJsonFile
-    Left message -> error $ "Cannot decode plan in " <> planJsonFile <> ": " <> message
-  where matching :: Component -> Bool
-        matching component = case componentName component of
-          Just name -> name == "exe:" <> T.pack pkg
-          Nothing -> False
+  Plan{installPlan} <- eitherDecode <$> H.evalIO (LBS.readFile planJsonFile)
+      >>= \case
+        Left message -> error $ "Cannot decode plan in " <> planJsonFile <> ": " <> message
+        Right plan -> pure plan
+
+  let componentName = "exe:" <> fromString pkg
+  case findComponent componentName installPlan of
+    Just Component{binFile=Just binFilePath} -> pure . addExeSuffix $ T.unpack binFilePath
+    Just component@Component{binFile=Nothing} ->
+      error $ "missing \"bin-file\" key in plan component: " <> show component <> " in the plan in: " <> planJsonFile
+    Nothing ->
+      error $ "Cannot find \"component-name\" key with the value \"exe:" <> pkg <> "\" in the plan in: " <> planJsonFile
+  where
+    findComponent :: Text -> [Component] -> Maybe Component
+    findComponent _ [] = Nothing
+    findComponent needle (c@Component{componentName, components}:topLevelComponents)
+      | componentName == Just needle = Just c
+      | otherwise = findComponent needle topLevelComponents <|> findComponent needle components
+
 
 -- | Create a 'CreateProcess' describing how to start a process given the Cabal package name
 -- corresponding to the executable, an environment variable pointing to the executable,
diff --git a/src/Hedgehog/Extras/Test/Unit.hs b/src/Hedgehog/Extras/Test/Unit.hs
new file mode 100644
--- /dev/null
+++ b/src/Hedgehog/Extras/Test/Unit.hs
@@ -0,0 +1,55 @@
+{-# LANGUAGE BlockArguments #-}
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE ImportQualifiedPost #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module Hedgehog.Extras.Test.Unit
+  ( UnitIO(..)
+  , testUnitIO
+  ) where
+
+import Control.Monad.Base
+import Control.Monad.Catch (MonadCatch)
+import Control.Monad.IO.Class (MonadIO)
+import Control.Monad.Morph
+import Control.Monad.Trans.Control (MonadBaseControl(..))
+import Control.Monad.Trans.Resource
+import Data.Maybe
+import Data.Monoid
+import Hedgehog
+import Hedgehog.Extras.Internal.Orphans ()
+import Hedgehog.Extras.Test.MonadAssertion (MonadAssertion)
+import Hedgehog.Internal.Property qualified as H
+import Prelude
+import Test.Tasty.Discover
+import Test.Tasty.Discover.TastyInfo qualified as TastyInfo
+import Test.Tasty.Hedgehog (testProperty)
+
+import qualified Test.Tasty as T
+
+newtype UnitIO a = UnitIO { runTestIO :: TestT (ResourceT IO) a }
+  deriving newtype (Applicative)
+  deriving newtype (Functor)
+  deriving newtype (Monad)
+  deriving newtype (MonadAssertion)
+  deriving newtype (MonadBase IO)
+  deriving newtype (MonadBaseControl IO)
+  deriving newtype (MonadCatch)
+  deriving newtype (MonadFail)
+  deriving newtype (MonadIO)
+  deriving newtype (MonadResource)
+  deriving newtype (MonadTest)
+  deriving newtype (MonadThrow)
+
+instance Tasty (UnitIO ()) where
+  tasty info = pure . testUnitIO testName
+    where testName = fromMaybe "" . getLast $ TastyInfo.name info
+
+testUnitIO :: T.TestName -> UnitIO () -> T.TestTree
+testUnitIO testName =
+  testProperty testName . H.withTests 1 . H.property . hoist runResourceT . H.test . runTestIO
diff --git a/test/Hedgehog/Extras/Test/TestExpectFailure.hs b/test/Hedgehog/Extras/Test/TestExpectFailure.hs
new file mode 100644
--- /dev/null
+++ b/test/Hedgehog/Extras/Test/TestExpectFailure.hs
@@ -0,0 +1,37 @@
+module Hedgehog.Extras.Test.TestExpectFailure where
+
+import           Prelude
+import           Hedgehog (property, Property, MonadTest, (===), success)
+import           Hedgehog.Extras.Test.Base (expectFailureWith, expectFailure)
+import           Hedgehog.Internal.Property (Failure (..), failWith)
+import           GHC.Stack (HasCallStack)
+import           Control.Monad.IO.Class (MonadIO)
+
+hprop_expect_always_fails_prop :: Property
+hprop_expect_always_fails_prop = property $ do
+  expectFailureWith failureCheck alwaysFailsProp
+  where
+    failureCheck :: (MonadTest m, HasCallStack) => Failure -> m ()
+    failureCheck (Failure _ reason _) =
+      reason === "This property always fails"
+
+    alwaysFailsProp :: (MonadTest m, HasCallStack) => m ()
+    alwaysFailsProp = failWith Nothing "This property always fails"
+
+hprop_bad_expect_always_fails_fails_prop :: Property
+hprop_bad_expect_always_fails_fails_prop = property $ do
+  expectFailure badExpectFailure
+  where
+    badExpectFailure :: (MonadIO m, MonadTest m, HasCallStack) => m ()
+    badExpectFailure = expectFailureWith badFailureCheck alwaysFailsProp
+
+    badFailureCheck :: (MonadTest m, HasCallStack) => Failure -> m ()
+    badFailureCheck (Failure _ reason _) =
+      reason === "This property sometimes fails"
+
+    alwaysFailsProp :: (MonadTest m, HasCallStack) => m ()
+    alwaysFailsProp = failWith Nothing "This property always fails"
+
+hprop_bad_expect_failure_fails_prop :: Property
+hprop_bad_expect_failure_fails_prop = property $ do
+  expectFailure (expectFailure success)
diff --git a/test/Hedgehog/Extras/Test/UnitSpec.hs b/test/Hedgehog/Extras/Test/UnitSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hedgehog/Extras/Test/UnitSpec.hs
@@ -0,0 +1,13 @@
+module Hedgehog.Extras.Test.UnitSpec where
+
+import Control.Exception.Lifted
+import Data.Bool
+import Hedgehog
+import Hedgehog.Extras.Test.Unit
+
+tasty_unit :: UnitIO ()
+tasty_unit = do
+  bracket_
+    (True === True)
+    (True === True)
+    (True === True)
diff --git a/test/Hedgehog/Extras/Test/WorkspaceSpec.hs b/test/Hedgehog/Extras/Test/WorkspaceSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hedgehog/Extras/Test/WorkspaceSpec.hs
@@ -0,0 +1,257 @@
+{-# LANGUAGE BangPatterns #-}
+module Hedgehog.Extras.Test.WorkspaceSpec where
+
+import Control.Applicative
+import Control.Exception (throwIO)
+import Control.Monad.IO.Class
+import Data.Bool
+import Data.Either
+import Data.Function (($))
+import Data.IORef
+import Data.Maybe
+import Data.Semigroup ((<>))
+import GHC.Err (error)
+import Hedgehog
+import Hedgehog.Extras.Test.Base
+import Hedgehog.Extras.Test.Unit
+import System.IO.Error (userError)
+import Text.Show
+
+import qualified System.Directory as IO
+import qualified Hedgehog.Internal.Property as H
+
+-- | Test that workspace directories are removed on successful completion when keepWorkspace=False
+tasty_workspace_removed_on_success_on_keepWorkspace_False :: UnitIO ()
+tasty_workspace_removed_on_success_on_keepWorkspace_False = do
+  workspacePath <- liftIO $ newIORef Nothing
+  
+  -- This workspace operation will succeed
+  workspaceWithConfig CleanupOnSuccess "test-success" $ \ws -> do
+    liftIO $ writeIORef workspacePath (Just ws)
+    -- Successful operation - no failing assertions
+    True === True
+  
+  -- After successful completion, workspace should be removed
+  maybePath <- liftIO $ readIORef workspacePath
+  case maybePath of
+    Nothing -> do
+      H.failWith Nothing "Expected workspace path to be recorded, but got Nothing"
+    Just path -> do
+      exists <- liftIO $ IO.doesDirectoryExist path
+      annotate $ "Workspace path: " <> path
+      annotate $ "Directory exists after success: " <> show exists
+      -- On success, directory should be removed
+      exists === False
+
+-- | Test that workspace directories are preserved when assertion fails (keepWorkspace=False)
+tasty_workspace_kept_on_assertion_on_keepWorkspace_False :: UnitIO ()
+tasty_workspace_kept_on_assertion_on_keepWorkspace_False = do
+  -- This test will intentionally trigger an assertion failure to verify
+  -- that the workspace directory is preserved for debugging
+  workspacePath <- liftIO $ newIORef Nothing
+  
+  result <- tryAssertion $ workspaceWithConfig CleanupOnSuccess "test-failure" $ \ws -> do
+    -- Store the workspace path so we can check it later
+    liftIO $ writeIORef workspacePath (Just ws)
+    -- Intentionally trigger assertion failure
+    False === True
+  
+  case result of
+    Left _ -> do
+      -- Assertion failed as expected, now check if workspace was correctly preserved
+      maybePath <- liftIO $ readIORef workspacePath
+      case maybePath of
+        Nothing -> do
+          H.failWith Nothing "Expected workspace path to be recorded after failed assertion, but got Nothing"
+        Just path -> do
+          exists <- liftIO $ IO.doesDirectoryExist path
+          -- Correct behavior: directory is preserved on assertion failure for debugging
+          annotate $ "Workspace path: " <> path
+          annotate $ "Directory exists: " <> show exists
+          -- Directory should be preserved when assertion fails
+          exists === True
+          -- Clean up manually since assertion failed and we preserved it
+          liftIO $ IO.removeDirectoryRecursive path
+    Right _ -> do
+      -- Assertion unexpectedly passed, this shouldn't happen
+      annotate "Test was supposed to fail but didn't"
+      failure
+
+-- | Test that workspace directories are preserved when pure exception occurs (keepWorkspace=False)
+tasty_workspace_kept_on_pure_exception_on_keepWorkspace_False :: UnitIO ()
+tasty_workspace_kept_on_pure_exception_on_keepWorkspace_False = do
+  workspacePath <- liftIO $ newIORef Nothing
+  
+  result <- tryAssertion $ workspaceWithConfig CleanupOnSuccess "test-pure-exception" $ \ws -> do
+    -- Store the workspace path so we can check it later
+    liftIO $ writeIORef workspacePath (Just ws)
+    -- Force evaluation of pure code that throws an exception
+    let !_ = error "Pure code exception in workspace" :: ()
+    pure ()
+  
+  case result of
+    Left _ -> do
+      -- Exception was thrown as expected, now check if workspace was correctly preserved
+      maybePath <- liftIO $ readIORef workspacePath
+      case maybePath of
+        Nothing ->
+          H.failWith Nothing "Expected workspace path to be recorded after pure exception, but got Nothing"
+        Just path -> do
+          exists <- liftIO $ IO.doesDirectoryExist path
+          annotate $ "Workspace path after pure exception: " <> path
+          annotate $ "Directory exists after pure exception: " <> show exists
+          -- Correct behavior: directory is preserved when pure code throws exception
+          exists === True
+          -- Clean up manually since exception occurred and we preserved it
+          liftIO $ IO.removeDirectoryRecursive path
+    Right _ -> do
+      -- Pure code unexpectedly didn't throw exception
+      annotate "Pure code was supposed to throw exception but didn't"
+      failure
+
+-- | Test that workspace directories are preserved when IO exception occurs (keepWorkspace=False)
+tasty_workspace_kept_on_io_exception_on_keepWorkspace_False :: UnitIO ()
+tasty_workspace_kept_on_io_exception_on_keepWorkspace_False = do
+  workspacePath <- liftIO $ newIORef Nothing
+  
+  result <- tryAssertion $ workspaceWithConfig CleanupOnSuccess "test-io-exception" $ \ws -> do
+    -- Store the workspace path so we can check it later
+    liftIO $ writeIORef workspacePath (Just ws)
+    -- Throw an IO exception within the workspace
+    liftIO $ throwIO (userError "IO exception in workspace")
+  
+  case result of
+    Left _ -> do
+      -- Exception was thrown as expected, now check if workspace was correctly preserved
+      maybePath <- liftIO $ readIORef workspacePath
+      case maybePath of
+        Nothing ->
+          H.failWith Nothing "Expected workspace path to be recorded after IO exception, but got Nothing"
+        Just path -> do
+          exists <- liftIO $ IO.doesDirectoryExist path
+          annotate $ "Workspace path after IO exception: " <> path
+          annotate $ "Directory exists after IO exception: " <> show exists
+          -- Correct behavior: directory is preserved when IO exception is thrown
+          exists === True
+          -- Clean up manually since exception occurred and we preserved it
+          liftIO $ IO.removeDirectoryRecursive path
+    Right _ -> do
+      -- IO operation unexpectedly didn't throw exception
+      annotate "IO operation was supposed to throw exception but didn't"
+      failure
+
+-- | Test that workspace directories are preserved on successful completion when keepWorkspace=True
+tasty_workspace_kept_on_success_on_keepWorkspace_True :: UnitIO ()
+tasty_workspace_kept_on_success_on_keepWorkspace_True = do
+  workspacePath <- liftIO $ newIORef Nothing
+  
+  -- This workspace operation will succeed
+  workspaceWithConfig PreserveWorkspace "test-keep-success" $ \ws -> do
+    liftIO $ writeIORef workspacePath (Just ws)
+    -- Successful operation - no failing assertions
+    True === True
+  
+  -- After successful completion, workspace should still be preserved due to keepWorkspace=True
+  maybePath <- liftIO $ readIORef workspacePath
+  case maybePath of
+    Nothing -> do
+      H.failWith Nothing "Expected workspace path to be recorded with keepWorkspace=True, but got Nothing"
+    Just path -> do
+      exists <- liftIO $ IO.doesDirectoryExist path
+      annotate $ "Workspace path with keepWorkspace=True (success): " <> path
+      annotate $ "Directory exists after success with keepWorkspace=True: " <> show exists
+      -- With keepWorkspace=True, directory should be preserved even on success
+      exists === True
+      -- Clean up manually since we kept it
+      liftIO $ IO.removeDirectoryRecursive path
+
+-- | Test that workspace directories are preserved when assertion fails (keepWorkspace=True)
+tasty_workspace_kept_on_assertion_on_keepWorkspace_True :: UnitIO ()
+tasty_workspace_kept_on_assertion_on_keepWorkspace_True = do
+  workspacePath <- liftIO $ newIORef Nothing
+  
+  result <- tryAssertion $ workspaceWithConfig PreserveWorkspace "test-keep" $ \ws -> do
+    liftIO $ writeIORef workspacePath (Just ws)
+    -- This assertion will fail, but workspace should be kept due to keepWorkspace=True
+    False === True
+  
+  case result of
+    Left _ -> do
+      maybePath <- liftIO $ readIORef workspacePath
+      case maybePath of
+        Nothing -> do
+          H.failWith Nothing "Expected workspace path to be recorded with keepWorkspace=True after failed assertion, but got Nothing"
+        Just path -> do
+          exists <- liftIO $ IO.doesDirectoryExist path
+          annotate $ "Workspace path: " <> path
+          annotate $ "Directory exists with keepWorkspace=True: " <> show exists
+          -- With keepWorkspace=True, directory should always be preserved
+          exists === True
+          -- Clean up manually since we kept it
+          liftIO $ IO.removeDirectoryRecursive path
+    Right _ -> do
+      annotate "Test was supposed to fail but didn't"
+      failure
+
+-- | Test that workspace directories are preserved when pure exception occurs (keepWorkspace=True)
+tasty_workspace_kept_on_pure_exception_on_keepWorkspace_True :: UnitIO ()
+tasty_workspace_kept_on_pure_exception_on_keepWorkspace_True = do
+  workspacePath <- liftIO $ newIORef Nothing
+  
+  result <- tryAssertion $ workspaceWithConfig PreserveWorkspace "test-keep-pure-exception" $ \ws -> do
+    -- Store the workspace path so we can check it later
+    liftIO $ writeIORef workspacePath (Just ws)
+    -- Force evaluation of pure code that throws an exception
+    let !_ = error "Pure code exception with keepWorkspace=True" :: ()
+    pure ()
+  
+  case result of
+    Left _ -> do
+      -- Exception was thrown as expected, now check if workspace was correctly preserved
+      maybePath <- liftIO $ readIORef workspacePath
+      case maybePath of
+        Nothing -> do
+          H.failWith Nothing "Expected workspace path to be recorded with keepWorkspace=True after pure exception, but got Nothing"
+        Just path -> do
+          exists <- liftIO $ IO.doesDirectoryExist path
+          annotate $ "Workspace path after pure exception with keepWorkspace=True: " <> path
+          annotate $ "Directory exists after pure exception with keepWorkspace=True: " <> show exists
+          -- With keepWorkspace=True, directory should always be preserved
+          exists === True
+          -- Clean up manually since we kept it
+          liftIO $ IO.removeDirectoryRecursive path
+    Right _ -> do
+      -- Pure code unexpectedly didn't throw exception
+      annotate "Pure code was supposed to throw exception but didn't"
+      failure
+
+-- | Test that workspace directories are preserved when IO exception occurs (keepWorkspace=True)
+tasty_workspace_kept_on_io_exception_on_keepWorkspace_True :: UnitIO ()
+tasty_workspace_kept_on_io_exception_on_keepWorkspace_True = do
+  workspacePath <- liftIO $ newIORef Nothing
+  
+  result <- tryAssertion $ workspaceWithConfig PreserveWorkspace "test-keep-io-exception" $ \ws -> do
+    -- Store the workspace path so we can check it later
+    liftIO $ writeIORef workspacePath (Just ws)
+    -- Throw an IO exception within the workspace
+    liftIO $ throwIO (userError "IO exception with keepWorkspace=True")
+  
+  case result of
+    Left _ -> do
+      -- Exception was thrown as expected, now check if workspace was correctly preserved
+      maybePath <- liftIO $ readIORef workspacePath
+      case maybePath of
+        Nothing -> do
+          H.failWith Nothing "Expected workspace path to be recorded with keepWorkspace=True after IO exception, but got Nothing"
+        Just path -> do
+          exists <- liftIO $ IO.doesDirectoryExist path
+          annotate $ "Workspace path after IO exception with keepWorkspace=True: " <> path
+          annotate $ "Directory exists after IO exception with keepWorkspace=True: " <> show exists
+          -- With keepWorkspace=True, directory should always be preserved
+          exists === True
+          -- Clean up manually since we kept it
+          liftIO $ IO.removeDirectoryRecursive path
+    Right _ -> do
+      -- IO operation unexpectedly didn't throw exception
+      annotate "IO operation was supposed to throw exception but didn't"
+      failure
