seihou-cli-0.14.0.0: test/Seihou/CLI/InstallClosureSpec.hs
module Seihou.CLI.InstallClosureSpec (tests) where
import Data.Either (isLeft)
import Data.Text qualified as T
import Seihou.CLI.InstallClosure (installClosureInto, publishStagedClosure, stageClosuresInto)
import Seihou.CLI.InstallShared (installModuleDirInto)
import System.Directory
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)
tests :: IO TestTree
tests = testSpec "Seihou.CLI.InstallClosure" $ do
it "acquires same-registry recipe bases, materializes their source, and repairs a missing base" $
withSystemTempDirectory "closure-recipes" $ \tmp -> do
let repo = tmp </> "repo"
cache = tmp </> "cache"
source = "https://example.com/registry.git"
moduleDir repo "member"
recipeDir repo "base" "[\"member\"]" ""
recipeDir repo "child" "[] : List Text" ", extends = Some {recipe = \"base\"}"
registry repo ["member"] ["base", "child"]
installClosureInto cache False repo source [("child", repo </> "child")] `shouldReturn` Right ()
doesFileExist (cache </> "base/recipe.dhall") `shouldReturn` True
normalized <- readFile (cache </> "child/recipe.dhall")
T.pack normalized `shouldSatisfy` T.isInfixOf source
removeDirectoryRecursive (cache </> "base")
installClosureInto cache False repo source [("child", repo </> "child")] `shouldReturn` Right ()
doesFileExist (cache </> "base/recipe.dhall") `shouldReturn` True
it "normalizes read-only descriptors and payload imports without changing source permissions" $
withSystemTempDirectory "closure-read-only" $ \tmp -> do
let repo = tmp </> "repo"
cache = tmp </> "cache"
descriptor = repo </> "member/module.dhall"
payload = repo </> "member/files/value.dhall"
moduleDir repo "member"
createDirectoryIfMissing True (repo </> "member/files")
writeFile payload "let value = \"hello\" in value"
mapM_ (\file -> getPermissions file >>= setPermissions file . setOwnerWritable False) [descriptor, payload]
before <- mapM getPermissions [descriptor, payload]
installClosureInto cache False repo "https://example.com/registry.git" [("member", repo </> "member")] `shouldReturn` Right ()
mapM getPermissions [descriptor, payload] `shouldReturn` before
normalized <- readFile (cache </> "member/files/value.dhall")
T.strip (T.pack normalized) `shouldBe` "\"hello\""
it "rejects wrong-kind bases, range violations, cycles and invalid merged overrides before publishing" $
withSystemTempDirectory "closure-recipe-refusals" $ \tmp -> do
let repo = tmp </> "repo"
cache = tmp </> "cache"
source = "https://example.com/registry.git"
moduleDir repo "member"
recipeDir repo "base" "[\"member\"]" ""
registry repo ["member"] ["base", "child"]
mapM_
( \extension -> do
recipeDir repo "child" "[] : List Text" extension
result <- installClosureInto cache False repo source [("child", repo </> "child")]
result `shouldSatisfy` isLeft
doesDirectoryExist cache `shouldReturn` False
)
[ ", extends = Some {recipe = \"member\"}",
", extends = Some {recipe = \"base\", versionRange = Some {minimum = \"2\", before = \"3\"}}",
", extends = Some {recipe = \"child\"}",
", extends = Some {recipe = \"base\"}, moduleOverrides = [{module = \"missing\", vars = [{name = \"x\", value = \"new\"}]}]"
]
it "rejects indirect recipe cycles and recipe names used as module edges" $
withSystemTempDirectory "closure-recipe-kinds" $ \tmp -> do
let repo = tmp </> "repo"
cache = tmp </> "cache"
source = "https://example.com/registry.git"
moduleDir repo "member"
recipeDir repo "base" "[] : List Text" ", extends = Some {recipe = \"child\"}"
recipeDir repo "child" "[] : List Text" ", extends = Some {recipe = \"base\"}"
registry repo ["member"] ["base", "child"]
cycleResult <- installClosureInto cache False repo source [("child", repo </> "child")]
cycleResult `shouldSatisfy` isLeft
doesDirectoryExist cache `shouldReturn` False
recipeDir repo "base" "[\"member\"]" ""
recipeDir repo "child" "[\"base\"]" ""
wrongKind <- installClosureInto cache False repo source [("child", repo </> "child")]
wrongKind `shouldSatisfy` isLeft
doesDirectoryExist cache `shouldReturn` False
it "refuses an escaping selected path without creating a cache" $
withSystemTempDirectory "closure" $ \tmp -> do
let repo = tmp </> "repo"
cache = tmp </> "cache"
createDirectory repo
result <- installClosureInto cache False repo "https://example.com/registry" [("escape", tmp)]
result `shouldSatisfy` isLeft
doesDirectoryExist cache `shouldReturn` False
it "preflights all roots before replacing any existing artifact" $
withSystemTempDirectory "closure" $ \tmp -> do
let repo = tmp </> "repo"
cache = tmp </> "cache"
moduleDir repo "good"
_ <- installModuleDirInto cache False (repo </> "good") "good" "https://example.com/registry" Nothing Nothing []
writeFile (cache </> "good" </> "marker") "original"
createDirectory (repo </> "bad")
writeFile (repo </> "bad" </> "recipe.dhall") "this is not Dhall"
result <- installClosureInto cache False repo "https://example.com/registry" [("good", repo </> "good"), ("bad", repo </> "bad")]
result `shouldSatisfy` isLeft
readFile (cache </> "good" </> "marker") `shouldReturn` "original"
doesDirectoryExist (cache </> "bad") `shouldReturn` False
it "restores an earlier replacement when a later destination cannot be written" $
withSystemTempDirectory "closure" $ \tmp -> do
let repo = tmp </> "repo"
cache = tmp </> "cache"
moduleDir repo "good"
moduleDir repo "blocked"
_ <- installModuleDirInto cache False (repo </> "good") "good" "https://example.com/registry" Nothing Nothing []
writeFile (cache </> "good" </> "marker") "original"
writeFile (cache </> "blocked") "occupied file"
result <- installClosureInto cache False repo "https://example.com/registry" [("good", repo </> "good"), ("blocked", repo </> "blocked")]
result `shouldSatisfy` isLeft
readFile (cache </> "good" </> "marker") `shouldReturn` "original"
readFile (cache </> "blocked") `shouldReturn` "occupied file"
it "restores every cache entry if publishing a staged recipe refresh fails" $
withSystemTempDirectory "closure-upgrade-rollback" $ \tmp -> do
let repo = tmp </> "repo"
staged = tmp </> "staged"
cache = tmp </> "cache"
source = "https://example.com/registry.git"
moduleDir repo "first"
moduleDir repo "second"
installClosureInto cache False repo source [("first", repo </> "first"), ("second", repo </> "second")] `shouldReturn` Right ()
writeFile (cache </> "first/marker") "original"
result <- stageClosuresInto False staged False [(repo, source, [("first", repo </> "first"), ("second", repo </> "second")])]
case result of
Left err -> expectationFailure (T.unpack err)
Right artifacts -> do
removeDirectoryRecursive (staged </> "second")
published <- publishStagedClosure cache artifacts
published `shouldSatisfy` isLeft
readFile (cache </> "first/marker") `shouldReturn` "original"
doesFileExist (cache </> "second/module.dhall") `shouldReturn` True
moduleDir :: FilePath -> String -> IO ()
moduleDir root name = do
schema <- canonicalizePath "../schema/Module.dhall"
let dir = root </> name
createDirectoryIfMissing True dir
writeFile (dir </> "module.dhall") ("let Module = " <> schema <> " in Module::{ name = \"" <> name <> "\", version = Some \"1.0.0\" }")
recipeDir :: FilePath -> String -> String -> String -> IO ()
recipeDir root name members extension = do
let dir = root </> name
createDirectoryIfMissing True dir
writeFile (dir </> "recipe.dhall") $
"{ name = "
<> show name
<> ", version = Some \"1.0.0\", description = None Text, modules = "
<> members
<> ", vars = [] : List {name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text}"
<> ", prompts = [] : List {var : Text, text : Text, when : Optional Text, choices : Optional (List Text)}"
<> extension
<> "}"
registry :: FilePath -> [String] -> [String] -> IO ()
registry root modules recipes =
writeFile (root </> "seihou-registry.dhall") $
"{repoName = \"fixture\", repoDescription = None Text, modules = " <> entries modules <> ", recipes = " <> entries recipes <> "}"
where
entries names = "[" <> concat (zipWith (\i name -> (if i == (0 :: Int) then "" else ",") <> "{name = " <> show name <> ", path = " <> show name <> ", version = None Text, description = None Text, tags = [] : List Text}") [0 ..] names) <> "]"