packages feed

seihou-cli-0.12.1.0: test/Seihou/CLI/CommandReceiptE2ESpec.hs

module Seihou.CLI.CommandReceiptE2ESpec (tests) where

import Control.Monad (filterM)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.IO qualified as TIO
import Seihou.CLI.SeedRunE2ESpec (body, invoke, prepare, readManifest, success)
import Seihou.CLI.SeihouBinary (seihouBinary)
import Seihou.CLI.UpdateSpec (UpdateFixture (..), prepareUpdateFixture)
import Seihou.Core.Types
import Seihou.Manifest.Types (manifestFromJSON)
import Seihou.Prelude
import System.Directory qualified as D
import System.Environment (getEnvironment)
import System.Exit (ExitCode (..))
import System.IO.Temp (withSystemTempDirectory)
import System.Process (CreateProcess (..), callProcess, proc, readCreateProcessWithExitCode, readProcess)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

initCommand :: Text
initCommand = "git init -q -b master"

commitCommand :: Text
commitCommand = "git add -A && git -c user.name='Seihou Test' -c user.email=test@example.com -c commit.gpgsign=false commit -qm 'Initial commit'"

-- | Replace the demo module with a managed step and the given commands. The
-- fixture's HOME and XDG directories live inside the project, so ignore them
-- before any command snapshots the tree.
writeCommandModule :: FilePath -> FilePath -> [Text] -> IO ()
writeCommandModule root modul commands = do
  TIO.writeFile (root </> ".gitignore") "/home/\n/config/\n/cache/\n"
  TIO.writeFile (modul </> "module.dhall") (body "managed" (", commands = [" <> T.intercalate ", " (map command commands) <> "]"))
  where
    command text = "S.Command::{action = S.Command.Action.Apply \"" <> T.replace "\"" "\\\"" text <> "\"}"

demoReceipts :: Manifest -> [CommandReceipt]
demoReceipts manifest =
  concat
    [ Map.elems (application ^. #commandReceipts)
    | application <- manifest ^. #applications,
      application ^. #target == AppliedModuleTarget "demo"
    ]

-- | The manifest blob recorded at HEAD, as bytes and decoded.
committedManifest :: FilePath -> IO (BS.ByteString, Manifest)
committedManifest root = do
  blob <- readProcess "git" ["-C", root, "show", "HEAD:.seihou/manifest.json"] ""
  let bytes = TE.encodeUtf8 (T.pack blob)
  case manifestFromJSON (LBS.fromStrict bytes) of
    Left err -> fail err
    Right manifest -> pure (bytes, manifest)

-- | Turn the update fixture's project into a git repository, and publish a
-- 2.0.0 of @demo@ whose only command is @command@.
prepareCommandUpdate :: FilePath -> Text -> IO (FilePath, UpdateFixture)
prepareCommandUpdate root command = do
  fixture <- prepareUpdateFixture root
  binary <- seihouBinary
  let project = fixture ^. #projectRoot
      remote = fixture ^. #remote
  callProcess "git" ["-C", project, "init", "-q", "-b", "master"]
  callProcess "git" ["-C", project, "add", "-A"]
  callProcess "git" ["-C", project, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "commit", "-qm", "Adopt demo 1.0.0"]
  released <- TIO.readFile (remote </> "module.dhall")
  TIO.writeFile (remote </> "module.dhall") (T.replace "printf should-not-run >> command.log" command released)
  callProcess "git" ["-C", remote, "-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "commit", "-qam", "v2 command"]
  pure (binary, fixture)

runUpdate :: FilePath -> UpdateFixture -> [String] -> IO (ExitCode, Text)
runUpdate binary fixture args = do
  inherited <- getEnvironment
  let environment = ("XDG_CONFIG_HOME", fixture ^. #xdgHome) : filter ((/= "XDG_CONFIG_HOME") . fst) inherited
  (code, out, err) <- readCreateProcessWithExitCode (proc binary args) {cwd = Just (fixture ^. #projectRoot), env = Just environment} ""
  pure (code, T.pack (out <> err))

tests :: IO TestTree
tests = testSpec "command receipts end-to-end" $ do
  it "commits a complete manifest when a module command commits the project" $
    withSystemTempDirectory "receipt-run-commit" $ \root -> do
      (binary, modul) <- prepare root
      writeCommandModule root modul [initCommand, commitCommand]
      _ <- success binary root ["run", "demo"]
      readProcess "git" ["-C", root, "status", "--porcelain"] "" `shouldReturn` ""
      (committedBytes, committed) <- committedManifest root
      map (^. #command) (demoReceipts committed) `shouldMatchList` [initCommand, commitCommand]
      BS.readFile (root </> ".seihou/manifest.json") `shouldReturn` committedBytes
      withSystemTempDirectory "receipt-run-clone" $ \cloneParent -> do
        let clone = cloneParent </> "clone"
        callProcess "git" ["clone", "-q", root, clone]
        output <- success binary clone ["update", "demo", "--dry-run"]
        output `shouldSatisfy` T.isInfixOf "Already up to date."

  it "withdraws the published receipts when a later command fails" $
    withSystemTempDirectory "receipt-run-failure" $ \root -> do
      (binary, modul) <- prepare root
      writeCommandModule root modul [initCommand, commitCommand, "exit 7"]
      (code, out, err) <- invoke binary root ["run", "demo"]
      let output = T.pack (out <> err)
      code `shouldBe` ExitFailure 1
      output `shouldSatisfy` T.isInfixOf "Command failed (exit 7)"
      output `shouldSatisfy` T.isInfixOf "published in advance were withdrawn"
      onDisk <- readManifest root
      demoReceipts onDisk `shouldBe` []
      readProcess "git" ["-C", root, "status", "--porcelain"] "" `shouldReturn` " M .seihou/manifest.json\n"
      -- The accepted residual: the snapshot the commit took over-claims.
      (_, committed) <- committedManifest root
      length (demoReceipts committed) `shouldBe` 3

  it "keeps --no-commands and --dry-run behavior unchanged" $ do
    withSystemTempDirectory "receipt-run-disabled" $ \root -> do
      (binary, modul) <- prepare root
      writeCommandModule root modul [initCommand, commitCommand]
      _ <- success binary root ["run", "demo"]
      before <- demoReceipts <$> readManifest root
      length before `shouldBe` 2
      _ <- success binary root ["run", "demo", "--no-commands", "--force"]
      after <- demoReceipts <$> readManifest root
      after `shouldBe` before
    withSystemTempDirectory "receipt-run-dry" $ \root -> do
      (binary, modul) <- prepare root
      writeCommandModule root modul [initCommand, commitCommand]
      _ <- success binary root ["run", "demo", "--dry-run"]
      D.doesPathExist (root </> ".seihou/manifest.json") `shouldReturn` False
      D.doesPathExist (root </> ".git") `shouldReturn` False

  it "commits a complete manifest when an update's new command commits the project" $
    withSystemTempDirectory "receipt-update-commit" $ \root -> do
      let command = "git add -A && git -c user.name='Seihou Test' -c user.email=test@example.com -c commit.gpgsign=false commit -qm 'Adopt demo 2.0.0'"
      (binary, fixture) <- prepareCommandUpdate root command
      let project = fixture ^. #projectRoot
      (code, output) <- runUpdate binary fixture ["update", "demo", "--json"]
      code `shouldBe` ExitSuccess
      output `shouldSatisfy` T.isInfixOf "\"outcome\":\"applied\""
      readProcess "git" ["-C", project, "status", "--porcelain"] "" `shouldReturn` ""
      (committedBytes, committed) <- committedManifest project
      map (^. #command) (demoReceipts committed) `shouldBe` [command]
      map (^. #moduleVersion) (committed ^. #modules) `shouldBe` [Just "2.0.0"]
      BS.readFile (fixture ^. #manifestPath) `shouldReturn` committedBytes

  it "restores the pre-update manifest and files when an update's command fails" $
    withSystemTempDirectory "receipt-update-failure" $ \root -> do
      (binary, fixture) <- prepareCommandUpdate root "exit 7"
      let project = fixture ^. #projectRoot
      beforeManifest <- BS.readFile (fixture ^. #manifestPath)
      beforeReadme <- BS.readFile (fixture ^. #projectFile)
      (code, output) <- runUpdate binary fixture ["update", "demo", "--json"]
      code `shouldNotBe` ExitSuccess
      output `shouldSatisfy` T.isInfixOf "\"command_failed\""
      BS.readFile (fixture ^. #manifestPath) `shouldReturn` beforeManifest
      BS.readFile (fixture ^. #projectFile) `shouldReturn` beforeReadme
      let transactions = project </> ".seihou/transactions"
      exists <- D.doesDirectoryExist transactions
      entries <- if exists then D.listDirectory transactions else pure []
      leftovers <- filterM (D.doesDirectoryExist . (transactions </>)) entries
      leftovers `shouldBe` []