packages feed

seihou-cli-0.10.0.0: test/Seihou/CLI/AgentUpgradeE2ESpec.hs

-- | @seihou agent upgrade@ through the real binary: the brief under
-- @--debug@, the never-fail fallbacks, and @--check@ as the definition of
-- done. No provider is contacted.
module Seihou.CLI.AgentUpgradeE2ESpec (tests) where

import Control.Lens ((^.))
import Data.Char (isHexDigit)
import Data.Generics.Labels ()
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.CLI.SeihouBinary (seihouBinary)
import Seihou.CLI.UpdateSpec (CoOwnerWriteMode (..), SharedPathFixture (..))
import Seihou.CLI.UpgradeFixture (portableEnvironment, preparePortableFixture, snapshotTree)
import System.Directory (createDirectoryIfMissing, createFileLink, doesFileExist, findExecutable)
import System.Environment (getEnvironment)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import System.Process (CreateProcess (..), proc, readCreateProcessWithExitCode)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "seihou agent upgrade end-to-end" spec

spec :: Spec
spec = do
  describe "seihou agent --debug upgrade" $ do
    it "prints the brief for a schema-6 project and changes nothing in it" $
      withSystemTempDirectory "seihou-agent-upgrade-debug" $ \root -> do
        fixture <- preparePortableFixture CoOwnerAppendsPredatingEvidence root
        before <- snapshotTree (fixture ^. #projectRoot)
        (code, out, err) <- runIn root fixture [] ["agent", "--debug", "upgrade", "alpha"]
        expectSuccess "agent --debug upgrade" code out err
        out `shouldSatisfy` T.isInfixOf "seihou agent upgrade alpha --check"
        out `shouldSatisfy` T.isInfixOf "## Repair playbook"
        out `shouldSatisfy` T.isInfixOf "schema 6"
        out `shouldSatisfy` T.isInfixOf ".gitignore"
        out `shouldNotSatisfy` hasDigest
        out `shouldNotSatisfy` T.isInfixOf "{{"
        err `shouldSatisfy` T.isInfixOf "Upgrade brief: "
        snapshotTree (fixture ^. #projectRoot) `shouldReturn` before

    it "writes a brief outside any project" $
      withSystemTempDirectory "seihou-agent-upgrade-empty" $ \root -> do
        let empty = root </> "empty"
        createDirectoryIfMissing True empty
        (code, out, err) <- runBare root empty [] ["agent", "--debug", "upgrade", "alpha"]
        expectSuccess "agent --debug upgrade outside a project" code out err
        out `shouldSatisfy` T.isInfixOf "There is no `.seihou/manifest.json` here"

    it "writes a brief for a manifest that is not JSON" $
      withSystemTempDirectory "seihou-agent-upgrade-corrupt" $ \root -> do
        fixture <- preparePortableFixture CoOwnerAppends root
        TIO.writeFile (fixture ^. #manifestPath) "{not json"
        (code, out, err) <- runIn root fixture [] ["agent", "--debug", "upgrade", "alpha"]
        expectSuccess "agent --debug upgrade on a corrupt manifest" code out err
        out `shouldSatisfy` T.isInfixOf "is not valid JSON"

    it "falls back to the built-in configuration when the configured provider is invalid" $
      withSystemTempDirectory "seihou-agent-upgrade-bad-provider" $ \root -> do
        fixture <- preparePortableFixture CoOwnerAppends root
        (code, out, err) <- runIn root fixture [] ["agent", "--provider", "not-a-provider", "--debug", "upgrade", "alpha"]
        expectSuccess "agent upgrade with an invalid provider" code out err
        out `shouldSatisfy` T.isInfixOf "fell back to the built-in default provider"

  describe "seihou agent upgrade without the provider's binary" $
    it "saves the brief, says how to use it, and exits 0" $
      withSystemTempDirectory "seihou-agent-upgrade-no-claude" $ \root -> do
        fixture <- preparePortableFixture CoOwnerAppends root
        binDirectory <- gitOnlyPath root
        (code, out, err) <- runIn root fixture [("PATH", binDirectory)] ["agent", "--provider", "claude-cli", "upgrade", "alpha"]
        expectSuccess "agent upgrade without claude" code out err
        out `shouldSatisfy` T.isInfixOf "Could not start the agent: claude is not on PATH"
        out `shouldSatisfy` T.isInfixOf "claude --append-system-prompt"
        case briefPath err of
          Nothing -> expectationFailure ("no brief path in stderr:\n" <> T.unpack err)
          Just path -> do
            doesFileExist path `shouldReturn` True
            TIO.readFile path >>= (`shouldSatisfy` T.isInfixOf "## Repair playbook")

  describe "seihou agent upgrade --check" $ do
    it "reports a healthy project ready" $
      withSystemTempDirectory "seihou-agent-upgrade-check-ready" $ \root -> do
        fixture <- preparePortableFixture CoOwnerAppends root
        (code, out, err) <- runIn root fixture [] ["agent", "upgrade", "alpha", "--check"]
        expectSuccess "--check" code out err
        out `shouldSatisfy` T.isSuffixOf "Upgrade readiness: ready\n"

    it "reports a schema-6 project not ready, and ready once seihou update has run" $
      withSystemTempDirectory "seihou-agent-upgrade-check-loop" $ \root -> do
        fixture <- preparePortableFixture CoOwnerAppendsPredatingEvidence root
        (code, out, err) <- runIn root fixture [] ["agent", "upgrade", "alpha", "--check"]
        expectSuccess "--check before the update" code out err
        out `shouldSatisfy` T.isInfixOf "Upgrade readiness: not ready (2 checks need attention)"
        out `shouldSatisfy` T.isInfixOf ".gitignore is shared with beta"

        (updateCode, updateOut, updateErr) <- runIn root fixture [] ["update", "alpha", "--json"]
        expectSuccess "update alpha" updateCode updateOut updateErr

        (afterCode, afterOut, afterErr) <- runIn root fixture [] ["agent", "upgrade", "alpha", "--check"]
        expectSuccess "--check after the update" afterCode afterOut afterErr
        afterOut `shouldSatisfy` T.isSuffixOf "Upgrade readiness: ready\n"

    it "exits 0 and is not ready outside a project" $
      withSystemTempDirectory "seihou-agent-upgrade-check-empty" $ \root -> do
        let empty = root </> "empty"
        createDirectoryIfMissing True empty
        (code, out, err) <- runBare root empty [] ["agent", "upgrade", "alpha", "--check"]
        expectSuccess "--check outside a project" code out err
        out `shouldSatisfy` T.isInfixOf "Upgrade readiness: not ready"

-- | Run the binary in the fixture's project with its configuration, the
-- portable-URL git mapping, and a private @XDG_STATE_HOME@.
runIn :: FilePath -> SharedPathFixture -> [(String, String)] -> [String] -> IO (ExitCode, Text, Text)
runIn root fixture extra =
  runWith (fixture ^. #projectRoot) (portableEnvironment root fixture <> extra <> [("XDG_STATE_HOME", root </> "state")])

-- | Run the binary in a directory that is no project at all.
runBare :: FilePath -> FilePath -> [(String, String)] -> [String] -> IO (ExitCode, Text, Text)
runBare root workDir extra =
  runWith workDir ([("XDG_CONFIG_HOME", root </> "xdg"), ("XDG_STATE_HOME", root </> "state")] <> extra)

runWith :: FilePath -> [(String, String)] -> [String] -> IO (ExitCode, Text, Text)
runWith workDir overrides args = do
  binary <- seihouBinary
  inherited <- getEnvironment
  let environment = overrides <> filter ((`notElem` map fst overrides) . fst) inherited
      command = (proc binary args) {cwd = Just workDir, env = Just environment}
  (exitCode, out, err) <- readCreateProcessWithExitCode command ""
  pure (exitCode, T.pack out, T.pack err)

-- | A directory holding only a link to @git@, to use as the whole @PATH@.
gitOnlyPath :: FilePath -> IO FilePath
gitOnlyPath root = do
  let directory = root </> "bin"
  createDirectoryIfMissing True directory
  found <- findExecutable "git"
  case found of
    Nothing -> fail "git is not on PATH"
    Just git -> createFileLink git (directory </> "git")
  pure directory

briefPath :: Text -> Maybe FilePath
briefPath err = case [T.strip rest | line <- T.lines err, Just rest <- [T.stripPrefix "Upgrade brief: " line]] of
  path : _ -> Just (T.unpack path)
  [] -> Nothing

-- | Whether the text contains a full SHA-256 digest (an application id).
hasDigest :: Text -> Bool
hasDigest = any ((>= 64) . T.length) . T.split (not . isHexDigit)

expectSuccess :: String -> ExitCode -> Text -> Text -> Expectation
expectSuccess label code out err = case code of
  ExitSuccess -> pure ()
  ExitFailure n ->
    expectationFailure
      (label <> " exited " <> show n <> "\nstdout:\n" <> T.unpack out <> "\nstderr:\n" <> T.unpack err)