packages feed

seihou-cli-0.16.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,
    installModuleVersionFrom,
    moduleSourceUrl,
    prepareTwoDeveloperFixture,
    resetWorkingTree,
    runSeihouAs,
    seihouBinary,
  )
import System.Directory (doesFileExist, removeDirectoryRecursive)
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

  describe "equivalent public GitHub transport spellings" $ do
    it "status accepts an SSH-recorded manifest against an HTTPS install without writing" $
      withTransportProject "seihou-shared-ssh-https" githubSsh githubHttps $ \binary fixture -> do
        out <- statusWithoutWrites binary fixture
        out `shouldNotSatisfy` T.isInfixOf "Artifacts that differ"
        out `shouldNotSatisfy` T.isInfixOf "this project records"

    it "status accepts an HTTPS-recorded manifest against an SSH install without writing" $
      withTransportProject "seihou-shared-https-ssh" githubHttps githubSsh $ \binary fixture -> do
        out <- statusWithoutWrites binary fixture
        out `shouldNotSatisfy` T.isInfixOf "Artifacts that differ"

    it "status still reports a genuinely different repository" $
      withTransportProject "seihou-shared-other-repo" githubSsh "https://github.com/shinzui/other-modules.git" $ \binary fixture -> do
        out <- statusWithoutWrites binary fixture
        out `shouldSatisfy` T.isInfixOf "Artifacts that differ"
        out `shouldSatisfy` T.isInfixOf "this project records"
        out `shouldSatisfy` T.isInfixOf "other-modules"

    it "status still reports a different owner" $
      withTransportProject "seihou-shared-other-owner" githubSsh "https://github.com/someone/seihou-modules.git" $ \binary fixture -> do
        out <- statusWithoutWrites binary fixture
        out `shouldSatisfy` T.isInfixOf "this project records"

    it "status keeps the missing-producer notice when nothing is installed" $
      withTransportProject "seihou-shared-empty-cache" githubSsh githubHttps $ \binary fixture -> do
        removeDirectoryRecursive (fixture ^. #homeB </> "seihou" </> "installed" </> "demo")
        out <- statusWithoutWrites binary fixture
        out `shouldSatisfy` T.isInfixOf "Remote producer cache: 1 not cached locally"
        out `shouldNotSatisfy` T.isInfixOf "Artifacts that differ"

    it "run still refuses an older version from an equivalent spelling" $
      withSystemTempDirectory "seihou-shared-ssh-stale" $ \root -> do
        fixture <- prepareTwoDeveloperFixture root "2.0.0" "1.4.0"
        installModuleVersionFrom githubSsh (fixture ^. #homeA) "demo" "2.0.0"
        installModuleVersionFrom githubHttps (fixture ^. #homeB) "demo" "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 over SSH"
        (staleCode, staleOut, staleErr) <- runSeihouAs binary fixture (fixture ^. #homeB) ["run", "demo"]
        staleCode `shouldSatisfy` (/= ExitSuccess)
        (staleOut <> staleErr) `shouldSatisfy` T.isInfixOf "1.4.0"
        gitStatus fixture `shouldReturn` ""

githubSsh :: Text
githubSsh = "git@github.com:shinzui/seihou-modules.git"

githubHttps :: Text
githubHttps = "https://github.com/shinzui/seihou-modules.git"

-- | Models existing state rather than repairing it: developer A installed
-- @demo@ 2.0.0 recording @recordedUrl@ and generated the project with it;
-- developer B has the same version installed recording @installedUrl@.
withTransportProject :: String -> Text -> Text -> (FilePath -> TwoDeveloperFixture -> IO ()) -> IO ()
withTransportProject label recordedUrl installedUrl action =
  withSystemTempDirectory label $ \root -> do
    fixture <- prepareTwoDeveloperFixture root "2.0.0" "2.0.0"
    installModuleVersionFrom recordedUrl (fixture ^. #homeA) "demo" "2.0.0"
    installModuleVersionFrom installedUrl (fixture ^. #homeB) "demo" "2.0.0"
    binary <- seihouBinary
    (code, out, err) <- runSeihouAs binary fixture (fixture ^. #homeA) ["run", "demo"]
    expectSuccess "developer A's run" code out err
    manifest <- TIO.readFile (fixture ^. #manifestPath)
    manifest `shouldSatisfy` T.isInfixOf recordedUrl
    gitCommitAll fixture "feat: apply demo 2.0.0"
    action binary fixture

-- | Run developer B's ordinary @seihou status@ twice and prove it wrote
-- nothing: the project is clean and the manifest and installed metadata keep
-- their bytes. Returns the first run's combined output.
statusWithoutWrites :: FilePath -> TwoDeveloperFixture -> IO Text
statusWithoutWrites binary fixture = do
  let originFile = fixture ^. #homeB </> "seihou" </> "installed" </> "demo" </> ".seihou-origin.json"
  manifestBefore <- LBS.readFile (fixture ^. #manifestPath)
  originBefore <- readIfPresent originFile
  (code, out, err) <- runSeihouAs binary fixture (fixture ^. #homeB) ["status"]
  expectSuccess "developer B's status" code out err
  (againCode, againOut, againErr) <- runSeihouAs binary fixture (fixture ^. #homeB) ["status"]
  expectSuccess "developer B's repeated status" againCode againOut againErr
  againOut `shouldBe` out
  gitStatus fixture `shouldReturn` ""
  LBS.readFile (fixture ^. #manifestPath) `shouldReturn` manifestBefore
  readIfPresent originFile `shouldReturn` originBefore
  pure (out <> err)
  where
    readIfPresent path = do
      present <- doesFileExist path
      if present then Just <$> LBS.readFile path else pure Nothing

-- | 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)