packages feed

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

module Seihou.CLI.UpgradeDiagnosisSpec (tests) where

import Control.Exception (bracket)
import Control.Lens ((&), (.~), (^.))
import Control.Monad (forM_)
import Data.ByteString.Lazy qualified as LBS
import Data.Generics.Labels ()
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.CLI.ManifestRepairOrigins (overManifestOrigins)
import Seihou.CLI.UpdateSpec (CoOwnerWriteMode (..), SharedPathFixture (..))
import Seihou.CLI.UpgradeDiagnosis
import Seihou.CLI.UpgradeFixture (portableEnvironment, preparePortableFixture, snapshotTree)
import Seihou.Core.Types (ArtifactOrigin (..), ManifestSchemaVersion (..))
import Seihou.Manifest.Types (manifestFromJSON, manifestToJSON)
import System.Directory (createDirectoryIfMissing, removeFile, renameDirectory)
import System.Environment (lookupEnv, setEnv, unsetEnv)
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.UpgradeDiagnosis" spec

spec :: Spec
spec = do
  describe "diagnoseUpgrade" $ do
    it "finds a healthy project ready" $
      withFixture CoOwnerAppends noDamage $ \_ fixture diagnose -> do
        diagnosis <- diagnose "alpha"
        let checks = readiness diagnosis
        [(check ^. #name, check ^. #status) | check <- checks, check ^. #status /= Ready] `shouldBe` []
        isReady checks `shouldBe` True
        statusOf "update-plans-cleanly" checks `shouldBe` Ready
        detailOf "update-plans-cleanly" checks `shouldSatisfy` T.isInfixOf "alpha 1.0.0 -> 2.0.0"
        detailOf "target-recorded" checks `shouldBe` "alpha"
        fixture ^. #projectRoot `shouldBe` (diagnosis ^. #projectRoot)

    it "reports a schema-6 manifest and its unknown shared path, while the update certifies it" $
      withFixture CoOwnerAppendsPredatingEvidence noDamage $ \_ _ diagnose -> do
        checks <- readiness <$> diagnose "alpha"
        statusOf "manifest-schema-current" checks `shouldBe` NeedsAttention
        detailOf "manifest-schema-current" checks `shouldSatisfy` T.isInfixOf "schema 6 is older than 9"
        statusOf "shared-evidence-known" checks `shouldBe` NeedsAttention
        detailOf "shared-evidence-known" checks `shouldBe` ".gitignore is shared with beta and its write mode is unknown"
        statusOf "update-plans-cleanly" checks `shouldBe` Ready
        statusOf "manifest-readable" checks `shouldBe` Ready
        isReady checks `shouldBe` False

    it "reports unavailable co-owner evidence with the update's error code" $
      withFixture CoOwnerAppendsPredatingEvidence evidenceUnavailable $ \_ _ diagnose -> do
        diagnosis <- diagnose "alpha"
        let checks = readiness diagnosis
        statusOf "update-plans-cleanly" checks `shouldBe` NeedsAttention
        detailOf "update-plans-cleanly" checks `shouldSatisfy` T.isInfixOf "[shared_write_evidence_unavailable]"
        ((^. #errorCode) <$> diagnosis ^. #updateDryRun) `shouldBe` ProbeOk (Just "shared_write_evidence_unavailable")

    it "reports a schema-5 manifest as needing seihou manifest upgrade" $
      withFixture CoOwnerAppends (withManifestText (T.replace "\"version\":9" "\"version\":5")) $ \_ _ diagnose -> do
        diagnosis <- diagnose "alpha"
        let checks = readiness diagnosis
        statusOf "manifest-readable" checks `shouldBe` NeedsAttention
        detailOf "manifest-readable" checks `shouldSatisfy` T.isInfixOf "seihou manifest upgrade"
        detailOf "manifest-schema-current" checks `shouldSatisfy` T.isInfixOf "schema 5"
        diagnosis ^. #manifestUpgrade `shouldSatisfy` \case
          ProbeOk _ -> True
          _ -> False
        lookup "manifest_upgrade_section" (briefSections diagnosis []) `shouldSatisfy` maybe False (T.isInfixOf "seihou manifest upgrade --dry-run")

    it "turns a missing manifest into a finding, not an exception" $
      withFixture CoOwnerAppends (\fixture -> removeFile (fixture ^. #manifestPath)) $ \_ _ diagnose -> do
        diagnosis <- diagnose "alpha"
        let checks = readiness diagnosis
        statusOf "manifest-readable" checks `shouldBe` NeedsAttention
        detailOf "manifest-readable" checks `shouldSatisfy` T.isInfixOf "does not exist"
        isReady checks `shouldBe` False
        renderReadinessReport "alpha" checks `shouldSatisfy` T.isSuffixOf "Upgrade readiness: not ready (7 checks need attention)\n"

    it "turns a manifest that is not JSON into a finding" $
      withFixture CoOwnerAppends (withManifestText (const "{not json")) $ \_ _ diagnose -> do
        checks <- readiness <$> diagnose "alpha"
        statusOf "manifest-readable" checks `shouldBe` NeedsAttention
        detailOf "manifest-readable" checks `shouldSatisfy` T.isInfixOf "not valid JSON"

    it "names a co-owner whose recorded origin is a machine-local path" $
      withFixture CoOwnerAppends localBetaOrigin $ \_ _ diagnose -> do
        checks <- readiness <$> diagnose "alpha"
        statusOf "origins-portable" checks `shouldBe` NeedsAttention
        detailOf "origins-portable" checks `shouldSatisfy` T.isInfixOf "/nonexistent/seihou-modules is recorded for beta"
        detailOf "origins-portable" checks `shouldSatisfy` T.isInfixOf "seihou manifest repair-origins"

    it "lists the recorded targets for an unknown one" $
      withFixture CoOwnerAppends noDamage $ \_ _ diagnose -> do
        diagnosis <- diagnose "nope"
        let checks = readiness diagnosis
        statusOf "target-recorded" checks `shouldBe` NeedsAttention
        detailOf "target-recorded" checks `shouldBe` "nope is not a recorded target; recorded targets: alpha, beta"
        statusOf "update-plans-cleanly" checks `shouldBe` NeedsAttention
        detailOf "update-plans-cleanly" checks `shouldSatisfy` T.isInfixOf "[target_not_found]"

    it "skips the update probe while an interrupted update waits" $
      withFixture CoOwnerAppends pendingTransaction $ \_ _ diagnose -> do
        diagnosis <- diagnose "alpha"
        let checks = readiness diagnosis
        statusOf "no-interrupted-update" checks `shouldBe` NeedsAttention
        statusOf "update-plans-cleanly" checks `shouldBe` CouldNotDetermine
        diagnosis ^. #updateDryRun `shouldSatisfy` \case
          ProbeSkipped reason -> "interrupted update" `T.isInfixOf` reason
          _ -> False

    it "writes nothing in any fixture" $
      forM_ fixtures $ \(label, mode, damage) ->
        withFixture mode damage $ \root fixture diagnose -> do
          let trees = [fixture ^. #projectRoot, fixture ^. #xdgHome]
          before <- traverse snapshotTree trees
          _ <- diagnose "alpha"
          _ <- diagnose "nope"
          after <- traverse snapshotTree trees
          (label, after) `shouldBe` (label, before)
          root `shouldSatisfy` (not . null)

  describe "renderReadinessReport" $ do
    it "ends in the exact verdict line, counting checks that could not be determined" $ do
      let checks =
            [ ReadinessCheck "manifest-readable" Ready "fine",
              ReadinessCheck "update-plans-cleanly" CouldNotDetermine "did not finish within 180 seconds"
            ]
      renderReadinessReport "alpha" checks
        `shouldBe` T.unlines
          [ "Upgrade readiness for alpha",
            "  ✓ manifest-readable     fine",
            "  ? update-plans-cleanly  did not finish within 180 seconds",
            "Upgrade readiness: not ready (1 check needs attention)"
          ]
      renderReadinessReport "alpha" (take 1 checks) `shouldSatisfy` T.isSuffixOf "Upgrade readiness: ready\n"

  describe "briefSections" $ do
    it "names applications by label and reports caller findings" $
      withFixture CoOwnerAppendsPredatingEvidence noDamage $ \_ _ diagnose -> do
        diagnosis <- diagnose "alpha"
        let sections = briefSections diagnosis ["agent configuration: fell back to claude-cli"]
            everything = T.concat (map snd sections)
        lookup "findings_section" sections `shouldSatisfy` maybe False (T.isInfixOf "fell back to claude-cli")
        lookup "shared_evidence_section" sections `shouldSatisfy` maybe False (T.isInfixOf "`.gitignore`: also owned by beta")
        lookup "manifest_section" sections `shouldSatisfy` maybe False (T.isInfixOf "schema 6")
        everything `shouldNotSatisfy` T.isInfixOf "ApplicationRef"
        everything `shouldNotSatisfy` T.isInfixOf "ModuleName"
        everything `shouldNotSatisfy` T.isInfixOf (diagnosisIdText diagnosis)
  where
    fixtures =
      [ ("healthy" :: Text, CoOwnerAppends, const (pure ())),
        ("schema 6", CoOwnerAppendsPredatingEvidence, const (pure ())),
        ("evidence unavailable", CoOwnerAppendsPredatingEvidence, evidenceUnavailable),
        ("schema 5", CoOwnerAppends, withManifestText (T.replace "\"version\":9" "\"version\":5")),
        ("not json", CoOwnerAppends, withManifestText (const "{not json")),
        ("local origin", CoOwnerAppends, localBetaOrigin),
        ("pending transaction", CoOwnerAppends, pendingTransaction)
      ]

-- | Build a portable fixture, damage it, and diagnose it with the fixture's
-- configuration and git URL mapping in the environment.
withFixture ::
  CoOwnerWriteMode ->
  (SharedPathFixture -> IO ()) ->
  (FilePath -> SharedPathFixture -> (Text -> IO UpgradeDiagnosis) -> IO a) ->
  IO a
withFixture mode damage action =
  withSystemTempDirectory "seihou-upgrade-diagnosis" $ \root -> do
    fixture <- preparePortableFixture mode root
    damage fixture
    withEnvironment (portableEnvironment root fixture) $ do
      env <- diagnosisEnvFor (fixture ^. #projectRoot)
      action root fixture (diagnoseUpgrade env)

-- | The fixture from "Seihou.CLI.UpdateE2ESpec": a schema-7 manifest that
-- still records the shared path as unknown, with beta's recorded release
-- neither installed nor published.
evidenceUnavailable :: SharedPathFixture -> IO ()
evidenceUnavailable fixture = do
  decoded <- manifestFromJSON <$> LBS.readFile (fixture ^. #manifestPath)
  manifest <- either fail pure decoded
  LBS.writeFile (fixture ^. #manifestPath) (manifestToJSON (manifest & #version .~ ManifestSchemaVersion 7))
  renameDirectory (fixture ^. #betaInstalledPath) (fixture ^. #betaInstalledPath <> "-parked")

localBetaOrigin :: SharedPathFixture -> IO ()
localBetaOrigin fixture = do
  decoded <- manifestFromJSON <$> LBS.readFile (fixture ^. #manifestPath)
  manifest <- either fail pure decoded
  let damage = \case
        RemoteOrigin _ "beta" _ -> RemoteOrigin "/nonexistent/seihou-modules" "beta" (Just "seihou-modules")
        other -> other
  LBS.writeFile (fixture ^. #manifestPath) (manifestToJSON (overManifestOrigins damage manifest))

pendingTransaction :: SharedPathFixture -> IO ()
pendingTransaction fixture = do
  let transaction = fixture ^. #projectRoot </> ".seihou" </> "transactions" </> "interrupted"
  createDirectoryIfMissing True transaction
  TIO.writeFile (transaction </> "journal.json") "{\"version\":1,\"entries\":[],\"newDirectories\":[]}"

withManifestText :: (Text -> Text) -> SharedPathFixture -> IO ()
withManifestText change fixture = do
  body <- TIO.readFile (fixture ^. #manifestPath)
  TIO.writeFile (fixture ^. #manifestPath) (change body)

statusOf :: Text -> [ReadinessCheck] -> ReadinessStatus
statusOf checkName checks = case [check ^. #status | check <- checks, check ^. #name == checkName] of
  status : _ -> status
  [] -> error ("no check named " <> T.unpack checkName)

detailOf :: Text -> [ReadinessCheck] -> Text
detailOf checkName checks = case [check ^. #detail | check <- checks, check ^. #name == checkName] of
  detail : _ -> detail
  [] -> error ("no check named " <> T.unpack checkName)

-- | Beta's full application id, which no rendering may show.
diagnosisIdText :: UpgradeDiagnosis -> Text
diagnosisIdText diagnosis = case diagnosis ^. #unknownSharedPaths of
  ProbeOk (shared : _) -> case shared ^. #otherOwners of
    owner : _ -> owner ^. #applicationId . #unApplicationId
    [] -> "no owner"
  _ -> "no shared path"

withEnvironment :: [(String, String)] -> IO a -> IO a
withEnvironment assignments action =
  bracket
    (traverse (\(key, value) -> (key,) <$> (lookupEnv key <* setEnv key value)) assignments)
    (traverse_' restore)
    (const action)
  where
    restore (key, Just previous) = setEnv key previous
    restore (key, Nothing) = unsetEnv key
    traverse_' f = mapM_ f

noDamage :: SharedPathFixture -> IO ()
noDamage _ = pure ()