packages feed

seihou-cli-0.6.0.0: test/Seihou/CLI/SharedManifestE2ESpec.hs

-- | The initiative's headline claim, driven end to end through the real
-- binary: two developers can share @.seihou\/manifest.json@ safely.
--
-- Four mechanisms have to hold together for that to be true — the manifest
-- records portable origins, every command resolves them locally, seihou
-- refuses to generate from an artifact older than the manifest records, and a
-- manifest written by an older seihou can be converted. Each has its own unit
-- tests. This spec is what stops them from drifting apart.
module Seihou.CLI.SharedManifestE2ESpec (tests) where

import Control.Lens ((^.))
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.TwoDeveloperFixture
  ( TwoDeveloperFixture (..),
    gitCommitAll,
    gitStatus,
    installModuleVersion,
    moduleSourceUrl,
    prepareTwoDeveloperFixture,
    resetWorkingTree,
    runSeihouAs,
    seihouBinary,
  )
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "shared manifest across developers" spec

spec :: Spec
spec = do
  it "records a portable manifest and refuses a stale developer" $
    withGeneratedProject "seihou-shared-refuse" $ \binary fixture -> do
      manifest <- TIO.readFile (fixture ^. #manifestPath)
      manifest `shouldSatisfy` T.isInfixOf "\"kind\":\"remote\""
      manifest `shouldSatisfy` T.isInfixOf moduleSourceUrl
      manifest `shouldNotSatisfy` T.isInfixOf (T.pack (fixture ^. #homeA))
      manifest `shouldNotSatisfy` T.isInfixOf (T.pack (fixture ^. #homeB))

      (code, out, err) <- runSeihouAs binary fixture (fixture ^. #homeB) ["run", "demo"]
      code `shouldSatisfy` (/= ExitSuccess)
      let reported = out <> err
      reported `shouldSatisfy` T.isInfixOf "2.0.0"
      reported `shouldSatisfy` T.isInfixOf "1.4.0"
      reported `shouldSatisfy` T.isInfixOf "seihou upgrade"

      -- The assertion that matters: an exit code proves the command reported
      -- failure, only an unchanged working tree proves it did not write first.
      gitStatus fixture `shouldReturn` ""

  it "proceeds under --allow-downgrade and says so" $
    withGeneratedProject "seihou-shared-allow" $ \binary fixture -> do
      (code, out, err) <-
        runSeihouAs binary fixture (fixture ^. #homeB) ["run", "demo", "--allow-downgrade"]
      expectSuccess "developer B's deliberate downgrade" code out err

      -- A deliberate downgrade must still be visible; silently honouring the
      -- flag would hide exactly the change the guard exists to make legible.
      let reported = out <> err
      reported `shouldSatisfy` T.isInfixOf "2.0.0"
      reported `shouldSatisfy` T.isInfixOf "1.4.0"

      manifest <- TIO.readFile (fixture ^. #manifestPath)
      manifest `shouldSatisfy` T.isInfixOf "\"version\":\"1.4.0\""
      TIO.readFile (fixture ^. #projectFile) `shouldReturnSatisfy` T.isInfixOf "demo 1.4.0"

  it "succeeds once the stale developer upgrades locally" $
    withGeneratedProject "seihou-shared-upgrade" $ \binary fixture -> do
      resetWorkingTree fixture
      installModuleVersion (fixture ^. #homeB) (fixture ^. #moduleName) "2.0.0"

      generated <- TIO.readFile (fixture ^. #projectFile)
      (code, out, err) <- runSeihouAs binary fixture (fixture ^. #homeB) ["run", "demo"]
      expectSuccess "developer B's run after upgrading" code out err

      -- Regenerating from the same version with the same inputs produces the
      -- same bytes, so the generated file is untouched. The manifest is
      -- rewritten regardless, because every run stamps a fresh 'generatedAt'
      -- and 'appliedAt' — that is the only path git reports.
      status <- gitStatus fixture
      map T.strip (T.lines status) `shouldBe` ["M .seihou/manifest.json"]
      TIO.readFile (fixture ^. #projectFile) `shouldReturn` generated
      manifest <- TIO.readFile (fixture ^. #manifestPath)
      manifest `shouldSatisfy` T.isInfixOf "\"version\":\"2.0.0\""

  it "rejects, upgrades, and then accepts a legacy manifest" $
    withGeneratedProject "seihou-shared-legacy" $ \binary fixture -> do
      TIO.writeFile (fixture ^. #manifestPath) legacyManifest
      gitCommitAll fixture "test: commit a manifest from an older seihou"

      (statusCode, statusOut, statusErr) <- runSeihouAs binary fixture (fixture ^. #homeB) ["status"]
      statusCode `shouldSatisfy` (/= ExitSuccess)
      let rejected = statusOut <> statusErr
      rejected `shouldSatisfy` T.isInfixOf "schema version 5"
      rejected `shouldSatisfy` T.isInfixOf "seihou manifest upgrade"

      before <- LBS.readFile (fixture ^. #manifestPath)
      (dryCode, dryOut, dryErr) <-
        runSeihouAs binary fixture (fixture ^. #homeB) ["manifest", "upgrade", "--dry-run"]
      expectSuccess "the dry-run upgrade" dryCode dryOut dryErr
      dryOut `shouldSatisfy` T.isInfixOf foreignPath
      dryOut `shouldSatisfy` T.isInfixOf ("remote " <> moduleSourceUrl)
      LBS.readFile (fixture ^. #manifestPath) `shouldReturn` before

      (upgradeCode, upgradeOut, upgradeErr) <-
        runSeihouAs binary fixture (fixture ^. #homeB) ["manifest", "upgrade"]
      expectSuccess "the upgrade" upgradeCode upgradeOut upgradeErr
      manifest <- TIO.readFile (fixture ^. #manifestPath)
      manifest `shouldSatisfy` T.isInfixOf "\"kind\":\"remote\""
      manifest `shouldNotSatisfy` T.isInfixOf "someone-else"

      (afterCode, afterOut, afterErr) <- runSeihouAs binary fixture (fixture ^. #homeB) ["status"]
      expectSuccess "status after the upgrade" afterCode afterOut afterErr

-- | Developer A, on 2.0.0, generates the project and commits. Developer B has
-- 1.4.0 installed. Every scenario starts here.
withGeneratedProject :: String -> (FilePath -> TwoDeveloperFixture -> IO ()) -> IO ()
withGeneratedProject label action =
  withSystemTempDirectory label $ \root -> do
    fixture <- prepareTwoDeveloperFixture root "2.0.0" "1.4.0"
    binary <- seihouBinary
    (code, out, err) <- runSeihouAs binary fixture (fixture ^. #homeA) ["run", "demo"]
    expectSuccess "developer A's run" code out err
    gitCommitAll fixture "feat: apply demo 2.0.0"
    action binary fixture

-- | The absolute path a third machine — neither developer's — recorded, the
-- kind of value a schema-5 manifest is full of.
foreignPath :: Text
foreignPath = "/Users/someone-else/.config/seihou/installed/demo"

-- | A manifest as a released seihou wrote them: schema 5, with the module's
-- source recorded as somebody else's absolute path.
--
-- It records 1.4.0, which is what developer B has installed, so the upgrade's
-- own guard is satisfied and the scenario exercises the conversion rather than
-- the refusal.
legacyManifest :: Text
legacyManifest =
  T.concat
    [ "{\"version\":5",
      ",\"generatedAt\":\"2026-07-01T12:00:00Z\"",
      ",\"modules\":[{\"name\":\"demo\",\"source\":\"",
      foreignPath,
      "\",\"version\":\"1.4.0\",\"appliedAt\":\"2026-07-01T12:00:00Z\"}]",
      ",\"variables\":{},\"files\":{},\"applications\":[],\"blueprintMigrations\":[]}"
    ]

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

shouldReturnSatisfy :: IO Text -> (Text -> Bool) -> Expectation
shouldReturnSatisfy action predicate = action >>= (`shouldSatisfy` predicate)