heni-0.1.0.0: test/workspace/Main.hs
module Main (main) where
import Control.Concurrent (threadDelay)
import Control.Exception
import Control.Monad (forM_, unless, when)
import Data.ByteString qualified as BS
import Data.IORef
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import System.Directory qualified as Dir
import System.Environment (lookupEnv)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.Posix.Files qualified as Posix
import System.Posix.Temp (mkdtemp)
import System.Process qualified as Process
import System.Timeout (timeout)
import Test.Heni
import Test.Heni.Edit
import Test.Heni.Workspace
assert' :: String -> Bool -> IO ()
assert' message condition = unless condition (error message)
checked :: (Show e) => Either e a -> a
checked = either (error . show) id
target :: FilePath -> RelativeTarget
target = checked . mkRelativeTarget
caseWith :: [Edit] -> CaseDefinition
caseWith edits =
CaseDefinition
(checked (mkCaseId "workspace-test"))
"Test checked mutations"
Set.empty
edits
(checked (mkCommandId "build"))
(Just (checked (mkCommandId "test")))
Nothing
(Diagnostic Test 1 "witness")
GenericCommands
withTree :: (FilePath -> IO a) -> IO a
withTree action = bracket (Dir.getTemporaryDirectory >>= \tmp -> mkdtemp (tmp </> "heni-test-")) Dir.removePathForcibly action
-- Test-only bounded helper until EP-3 supplies the production supervisor. Git
-- doesn't spawn descendants for apply; timeout cancels its process bracket.
gitRunner :: PatchRunner
gitRunner workspace args = do
let root = workspaceRoot workspace
result <- timeout 5000000 (Process.readCreateProcessWithExitCode (Process.proc "git" ("-C" : root : args)) "")
pure $ case result of
Just (ExitSuccess, _, _) -> Right ()
Just (_, out, err) -> Left (Text.pack (out ++ err))
Nothing -> Left "git apply timeout"
main :: IO ()
main = withTree $ \source -> do
BS.writeFile (source </> "dirty.txt") "anchor\n"
BS.writeFile (source </> "untracked.bin") (BS.pack [0, 255, 1])
Dir.createDirectory (source </> "dist-newstyle")
BS.writeFile (source </> "dist-newstyle/ignored") "build"
Dir.createDirectory (source </> "mutation-results")
BS.writeFile (source </> "mutation-results/report.json") "ignored"
withSnapshot source defaultSnapshotPolicy $ \snapshot -> do
let files = snapshotFiles snapshot
edit = Replace (target "dirty.txt") "anchor" "changed" 1
run edits = withEditedWorkspace gitRunner noHooks snapshot (caseWith edits) (\w e -> (,) <$> BS.readFile (workspaceRoot w </> "dirty.txt") <*> pure e)
assert' "selected dirty/untracked bytes" (Map.size files == 2 && Map.member (target "untracked.bin") files)
assert' "SHA256 identity" (Text.length (snapshotIdentity snapshot) == 64)
withSnapshot source defaultSnapshotPolicy $ \again -> assert' "deterministic identity" (snapshotIdentity again == snapshotIdentity snapshot)
result <- run [edit]
assert' "checked replacement and touched inventory" (case result of Right ("changed\n", EditEvidence [FileChange {}]) -> True; _ -> False)
original <- BS.readFile (source </> "dirty.txt")
assert' "source untouched" (original == "anchor\n")
withWorkspace snapshot $ \other -> do
originalOther <- BS.readFile (workspaceRoot other </> "dirty.txt")
assert' "other workspace pristine" (originalOther == "anchor\n")
forM_ [[Replace (target "dirty.txt") "missing" "bad" 1], [Replace (target "dirty.txt") "anchor" "x" 2], [edit, Replace (target "dirty.txt") "missing" "x" 1], [edit, Replace (target "dirty.txt") "changed" "anchor" 1]] $ \edits -> do
failed <- run edits
assert' "stale/ambiguous/partial/no-op rejected" (case failed of Left InvalidEdit {} -> True; _ -> False)
malformed <- withEditedWorkspace gitRunner noHooks snapshot (caseWith [Replace (target "untracked.bin") "x" "y" 1]) (\_ _ -> pure ())
assert' "UTF8 error is invalid edit" (case malformed of Left InvalidEdit {} -> True; _ -> False)
-- No callback is published on partial failure.
published <- newIORef False
_ <- withEditedWorkspace gitRunner noHooks snapshot (caseWith [edit, Replace (target "dirty.txt") "missing" "bad" 1]) (\_ _ -> writeIORef published True)
readIORef published >>= assert' "partial candidate unpublished" . not
created <- withEditedWorkspace gitRunner noHooks snapshot (caseWith [Create (target "new.txt") "created", Delete (target "dirty.txt") "anchor\n"]) $ \_ (EditEvidence changes) -> pure (length changes)
assert' "creation/deletion recorded" (created == Right 2)
duplicateCreate <- withEditedWorkspace gitRunner noHooks snapshot (caseWith [Create (target "dirty.txt") "created"]) (\_ _ -> pure ())
assert' "creation precondition" (case duplicateCreate of Left InvalidEdit {} -> True; _ -> False)
let control = case caseWith [] of CaseDefinition i d ts es b t s _ mode -> CaseDefinition i d ts es b t s Control mode
controlResult <- withEditedWorkspace gitRunner noHooks snapshot control (\_ (EditEvidence changes) -> pure (null changes))
assert' "unchanged control accepted" (controlResult == Right True)
withWorkspace snapshot $ \w -> do
let WorkspaceOps {writeTarget} = workspaceOperations w
writeTarget (target "dirty.txt") "changed"
differs <- try (verifyWorkspace snapshot w) :: IO (Either WorkspaceError ())
assert' "verification detects changes" (case differs of Left WorkspaceMismatch {} -> True; _ -> False)
resetWorkspace snapshot w
verifyWorkspace snapshot w
withTree $ \outside -> do
BS.writeFile (outside </> "victim") "safe"
Posix.createSymbolicLink (outside </> "victim") (workspaceRoot w </> "escape")
rejected <- try (writeTarget (target "escape") "bad") :: IO (Either WorkspaceError ())
assert' "symlink write rejected" (case rejected of Left UnsafeSource {} -> True; _ -> False)
BS.readFile (outside </> "victim") >>= assert' "outside bytes untouched" . (== "safe")
-- A deleted owned root produces an observable reset failure.
Dir.removePathForcibly (workspaceRoot w)
reset <- try (resetWorkspace snapshot w) :: IO (Either IOException ())
assert' "reset failure invalidates workspace" (case reset of Left _ -> True; _ -> False)
let patch = "diff --git a/dirty.txt b/dirty.txt\n--- a/dirty.txt\n+++ b/dirty.txt\n@@ -1 +1 @@\n-anchor\n+patched\n"
available <- Dir.findExecutable "git"
required <- lookupEnv "HENI_REQUIRE_GIT"
when (required == Just "1" && available == Nothing) (error "patch validation requires Git")
case available of
Nothing -> putStrLn "Git unavailable: optional positive patch checks skipped"
Just _ -> do
patched <- run [Patch patch]
assert' "textual patch" (case patched of Right ("patched\n", _) -> True; _ -> False)
stalePatch <- run [Patch (patch <> "diff --git a/missing b/missing\n--- a/missing\n+++ b/missing\n@@ -1 +1 @@\n-old\n+new\n")]
assert' "partial patch discarded" (case stalePatch of Left InvalidEdit {} -> True; _ -> False)
again <- run [edit]
assert' "patch failure cannot contaminate next case" (case again of Right ("changed\n", _) -> True; _ -> False)
unsafePatch <- run [Patch (Text.replace "--- a/dirty.txt" "--- a/../escape" patch)]
assert' "all patch header paths checked" (case unsafePatch of Left InvalidEdit {} -> True; _ -> False)
missingGit <- withEditedWorkspace (\_ _ -> throwIO (userError "git is unavailable")) noHooks snapshot (caseWith [Patch patch]) (\_ _ -> pure ())
assert' "missing Git infrastructure capability" (case missingGit of Left EditInfrastructure {} -> True; _ -> False)
let hooks = CaseHooks [("cancel", \_ -> threadDelay 10000000)] Nothing Nothing
cancelled <- timeout 20000 (withEditedWorkspace gitRunner hooks snapshot (caseWith [CustomEdit "cancel"]) (\_ _ -> pure ()))
assert' "cancellation bounded" (case cancelled of Nothing -> True; _ -> False)
withWorkspace snapshot (verifyWorkspace snapshot)
validateOutputLocation source defaultSnapshotPolicy (source </> "mutation-results")
invalidOutput <- try (validateOutputLocation source defaultSnapshotPolicy (source </> "new-results")) :: IO (Either WorkspaceError ())
assert' "nested output exclusion required" (case invalidOutput of Left _ -> True; _ -> False)
withTree $ \outside -> do
Posix.createSymbolicLink outside (source </> "source-link")
rejected <- try (withSnapshot source defaultSnapshotPolicy (\_ -> pure ())) :: IO (Either WorkspaceError ())
assert' "source symlink rejected" (case rejected of Left UnsafeSource {} -> True; _ -> False)
putStrLn "Workspace isolation, transactional edits, patch and recovery checks passed"