packages feed

seihou-cli-0.11.0.0: test/Seihou/CLI/SeedRunE2ESpec.hs

module Seihou.CLI.SeedRunE2ESpec (tests, prepare, body, invoke, success, readManifest) where

import Control.Monad (forM_)
import Data.Aeson qualified as A
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.IO qualified as TIO
import Seihou.CLI.CheckoutSyncE2ESpec (copyPinnedSchema)
import Seihou.CLI.SeihouBinary (seihouBinary)
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.FilePath (takeDirectory)
import System.IO.Temp (withSystemTempDirectory)
import System.Process (CreateProcess (..), proc, readCreateProcessWithExitCode)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

invoke binary root args = do
  inherited <- getEnvironment
  let overrides = [("HOME", root </> "home"), ("XDG_CONFIG_HOME", root </> "config"), ("XDG_CACHE_HOME", root </> "cache")]
  readCreateProcessWithExitCode (proc binary args) {cwd = Just root, env = Just (overrides <> filter (\(key, _) -> key `notElem` map fst overrides) inherited)} ""

success binary root args = do
  (code, out, err) <- invoke binary root args
  unlessSuccess code (T.pack (out <> err))
  pure (T.pack (out <> err))
  where
    unlessSuccess ExitSuccess _ = pure ()
    unlessSuccess _ output = expectationFailure (unwords args <> ": " <> T.unpack output)

body lifecycle extra = "let S = ./schema/package.dhall in S.Module::{name=\"demo\", version=Some \"1.0.0\", steps=[S.Step::{strategy=\"copy\",src=\"starter\",dest=\"nested/start.txt\",lifecycle=Some \"" <> lifecycle <> "\"},S.Step::{strategy=\"copy\",src=\"license\",dest=\"LICENSE\"}] " <> extra <> "}"

prepare root = do
  binary <- seihouBinary
  cwd <- D.getCurrentDirectory
  schema <- D.canonicalizePath (takeDirectory cwd </> "schema")
  let modul = root </> ".seihou/modules/demo"
  D.createDirectoryIfMissing True (modul </> "schema")
  D.createDirectoryIfMissing True (modul </> "files")
  copyPinnedSchema schema (modul </> "schema")
  TIO.writeFile (modul </> "files/starter") "starter\n"
  TIO.writeFile (modul </> "files/license") "license\n"
  TIO.writeFile (modul </> "module.dhall") (body "seed" "")
  pure (binary, modul)

readManifest root = do
  bytes <- LBS.readFile (root </> ".seihou/manifest.json")
  case manifestFromJSON bytes of Left err -> fail err; Right manifest -> pure manifest

tests :: IO TestTree
tests = testSpec "seed run end-to-end" $ do
  it "creates once, preserves forced edits and deliberate deletion without parent resurrection" $
    withSystemTempDirectory "seed-run" $ \root -> do
      (binary, _) <- prepare root
      first <- success binary root ["run", "demo"]
      first `shouldSatisfy` T.isInfixOf "create once nested/start.txt"
      manifest <- readManifest root
      Map.member "nested/start.txt" (manifest ^. #seeds) `shouldBe` True
      Map.member "nested/start.txt" (manifest ^. #files) `shouldBe` False
      TIO.writeFile (root </> "nested/start.txt") "project changes\n"
      _ <- success binary root ["run", "demo", "--force"]
      TIO.readFile (root </> "nested/start.txt") `shouldReturn` "project changes\n"
      D.removeFile (root </> "nested/start.txt")
      D.removeDirectory (root </> "nested")
      deleted <- success binary root ["run", "demo", "--force"]
      deleted `shouldSatisfy` T.isInfixOf "keep deleted nested/start.txt"
      D.doesPathExist (root </> "nested") `shouldReturn` False
  it "keeps a pre-existing regular file without force and makes previews immutable" $
    withSystemTempDirectory "seed-existing" $ \root -> do
      (binary, _) <- prepare root
      D.createDirectory (root </> "nested")
      TIO.writeFile (root </> "nested/start.txt") "already mine\n"
      _ <- success binary root ["run", "demo", "--dry-run"]
      D.doesPathExist (root </> ".seihou/manifest.json") `shouldReturn` False
      D.doesPathExist (root </> "LICENSE") `shouldReturn` False
      _ <- success binary root ["run", "demo"]
      TIO.readFile (root </> "nested/start.txt") `shouldReturn` "already mine\n"
      manifest <- readManifest root
      fmap (^. #outcome) (Map.lookup "nested/start.txt" (manifest ^. #seeds)) `shouldBe` Just SeedFoundExisting
      before <- BS.readFile (root </> ".seihou/manifest.json")
      _ <- success binary root ["run", "demo", "--diff"]
      BS.readFile (root </> ".seihou/manifest.json") `shouldReturn` before
  it "refuses both lifecycle transitions before bytes or applied metadata advance" $
    withSystemTempDirectory "seed-transition" $ \root -> do
      (binary, modul) <- prepare root
      TIO.writeFile (modul </> "module.dhall") (body "managed" "")
      _ <- success binary root ["run", "demo", "--force"]
      before <- BS.readFile (root </> ".seihou/manifest.json")
      TIO.writeFile (modul </> "module.dhall") (body "seed" "")
      (code, _, err) <- invoke binary root ["run", "demo", "--force"]
      code `shouldBe` ExitFailure 1
      BS.readFile (root </> ".seihou/manifest.json") `shouldReturn` before
      -- Start the reverse direction in another project so no fixture edits
      -- silently perform the transition the public command must refuse.
      withSystemTempDirectory "seed-reverse" $ \other -> do
        (otherBinary, otherModule) <- prepare other
        _ <- success otherBinary other ["run", "demo"]
        old <- BS.readFile (other </> ".seihou/manifest.json")
        TIO.writeFile (other </> "nested/start.txt") "mine\n"
        TIO.writeFile (otherModule </> "module.dhall") (body "managed" "")
        (refused, _, _) <- invoke otherBinary other ["run", "demo", "--force"]
        refused `shouldBe` ExitFailure 1
        BS.readFile (other </> ".seihou/manifest.json") `shouldReturn` old
        TIO.readFile (other </> "nested/start.txt") `shouldReturn` "mine\n"
  it "refuses directories and dangling links without writing managed siblings" $
    forM_ [False, True] $ \linked -> withSystemTempDirectory "seed-occupied" $ \root -> do
      (binary, _) <- prepare root
      D.createDirectory (root </> "nested")
      if linked then D.createFileLink "missing" (root </> "nested/start.txt") else D.createDirectory (root </> "nested/start.txt")
      (code, _, _) <- invoke binary root ["run", "demo", "--force"]
      code `shouldBe` ExitFailure 1
      D.doesPathExist (root </> "LICENSE") `shouldReturn` False
      D.doesPathExist (root </> ".seihou/manifest.json") `shouldReturn` False

  it "records co-seeders in one application and adds another application without changing the supplier" $
    withSystemTempDirectory "seed-coowners" $ \root -> do
      (binary, modul) <- prepare root
      let child = root </> ".seihou/modules/child"
      D.createDirectoryIfMissing True (child </> "files")
      TIO.writeFile (child </> "files/starter") "child supplies first\n"
      TIO.writeFile (child </> "module.dhall") "let S = ../demo/schema/package.dhall in S.Module::{name=\"child\", version=Some \"1.0.0\", vars=[S.VarDecl::{name=\"slot\",type=\"text\",default=Some \"base\"}], steps=[S.Step::{strategy=\"copy\",src=\"starter\",dest=\"nested/start.txt\",lifecycle=Some \"seed\"}]}"
      TIO.writeFile (modul </> "module.dhall") (body "seed" ", dependencies=[S.Dependency::{module=\"child\",vars=[{name=\"slot\",value=\"one\"}]},S.Dependency::{module=\"child\",vars=[{name=\"slot\",value=\"two\"}]}]")
      _ <- success binary root ["run", "demo"]
      first <- readManifest root
      let Just firstReceipt = Map.lookup "nested/start.txt" (first ^. #seeds)
      case firstReceipt ^. #ownership of
        KnownSeedOwners owners -> owners `shouldSatisfy` ((== [3]) . map length . Map.elems)
        _ -> expectationFailure "expected exact owners"
      firstReceipt ^. #moduleName . #unModuleName `shouldSatisfy` T.isPrefixOf "child#"
      TIO.readFile (root </> "nested/start.txt") `shouldReturn` "child supplies first\n"
      let other = root </> ".seihou/modules/other"
      D.createDirectoryIfMissing True (other </> "files")
      D.copyFile (modul </> "files/starter") (other </> "files/starter")
      D.copyFile (modul </> "files/license") (other </> "files/license")
      TIO.writeFile (other </> "module.dhall") (T.replace "./schema/package.dhall" "../demo/schema/package.dhall" (T.replace "name=\"demo\"" "name=\"other\"" (body "seed" "")))
      TIO.writeFile (root </> "nested/start.txt") "project bytes\n"
      _ <- success binary root ["run", "other", "--force"]
      shared <- readManifest root
      let Just sharedReceipt = Map.lookup "nested/start.txt" (shared ^. #seeds)
      sharedReceipt ^. #moduleName `shouldBe` firstReceipt ^. #moduleName
      sharedReceipt ^. #settledAt `shouldBe` firstReceipt ^. #settledAt
      case sharedReceipt ^. #ownership of
        KnownSeedOwners owners -> Map.size owners `shouldBe` 2
        _ -> expectationFailure "expected exact owners"
      TIO.readFile (root </> "nested/start.txt") `shouldReturn` "project bytes\n"
  it "retires false-condition claims and permits seeding after final receipt retirement" $
    withSystemTempDirectory "seed-condition" $ \root -> do
      (binary, modul) <- prepare root
      let conditional = T.replace "lifecycle=Some \"seed\"" "lifecycle=Some \"seed\",when=Some \"Eq enabled true\"" (body "seed" ",vars=[S.VarDecl::{name=\"enabled\",type=\"bool\",default=Some \"true\"}]")
      TIO.writeFile (modul </> "module.dhall") conditional
      _ <- success binary root ["run", "demo"]
      TIO.writeFile (root </> "nested/start.txt") "mine\n"
      _ <- success binary root ["run", "demo", "--var", "enabled=false"]
      retired <- readManifest root
      retired ^. #seeds `shouldBe` Map.empty
      TIO.readFile (root </> "nested/start.txt") `shouldReturn` "mine\n"
      D.removeFile (root </> "nested/start.txt")
      _ <- success binary root ["run", "demo", "--var", "enabled=true"]
      TIO.readFile (root </> "nested/start.txt") `shouldReturn` "starter\n"
  it "uses the same seed handling for an agent blueprint baseline" $
    withSystemTempDirectory "seed-agent" $ \root -> do
      (binary, _) <- prepare root
      let blueprint = root </> ".seihou/modules/seed-blueprint"
      D.createDirectoryIfMissing True blueprint
      TIO.writeFile (blueprint </> "blueprint.dhall") "let S = ../demo/schema/package.dhall in S.Blueprint::{name=\"seed-blueprint\",version=Some \"1.0.0\",prompt=\"Describe the accepted baseline.\", baseModules = [ S.Dependency::{ module = \"demo\" } ]}"
      _ <- success binary root ["agent", "--debug", "run", "seed-blueprint", "--force"]
      first <- readManifest root
      Map.member "nested/start.txt" (first ^. #seeds) `shouldBe` True
      TIO.writeFile (root </> "nested/start.txt") "agent project changes\n"
      _ <- success binary root ["agent", "--debug", "run", "seed-blueprint", "--force"]
      TIO.readFile (root </> "nested/start.txt") `shouldReturn` "agent project changes\n"
      D.removeFile (root </> "nested/start.txt")
      _ <- success binary root ["agent", "--debug", "run", "seed-blueprint", "--force"]
      D.doesPathExist (root </> "nested/start.txt") `shouldReturn` False
  it "reports receipts without drift and retires a seed-only owner without a removal specification" $
    withSystemTempDirectory "seed-remove" $ \root -> do
      (binary, modul) <- prepare root
      let seedOnly = T.replace ",S.Step::{strategy=\"copy\",src=\"license\",dest=\"LICENSE\"}" "" (body "seed" "")
      TIO.writeFile (modul </> "module.dhall") seedOnly
      _ <- success binary root ["run", "demo"]
      TIO.writeFile (root </> "nested/start.txt") "mine\n"
      status <- success binary root ["status"]
      status `shouldSatisfy` T.isInfixOf "Seed files: 1"
      diff <- success binary root ["diff"]
      diff `shouldSatisfy` T.isInfixOf "1 seed files not compared"
      diff `shouldSatisfy` (not . T.isInfixOf "nested/start.txt")
      before <- BS.readFile (root </> ".seihou/manifest.json")
      preview <- success binary root ["remove", "demo", "--dry-run"]
      preview `shouldSatisfy` T.isInfixOf "Keep nested/start.txt"
      BS.readFile (root </> ".seihou/manifest.json") `shouldReturn` before
      removed <- success binary root ["remove", "demo", "--force"]
      removed `shouldSatisfy` T.isInfixOf "Retired 1 seed claims"
      TIO.readFile (root </> "nested/start.txt") `shouldReturn` "mine\n"
      retired <- readManifest root
      retired ^. #seeds `shouldBe` Map.empty
      D.removeFile (root </> "nested/start.txt")
      _ <- success binary root ["run", "demo"]
      TIO.readFile (root </> "nested/start.txt") `shouldReturn` "starter\n"