packages feed

seihou-cli-0.13.0.0: test/Seihou/CLI/RecipeInputsE2ESpec.hs

module Seihou.CLI.RecipeInputsE2ESpec (tests) where

import Control.Monad (forM_)
import Data.Aeson qualified as Aeson
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.SeedRunE2ESpec (invoke, prepare, readManifest, success)
import Seihou.Core.Types
import Seihou.Prelude
import System.Directory qualified as D
import System.Exit (ExitCode (..))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

prepareRecipe root = do
  (binary, modul) <- prepare root
  let other = root </> ".seihou/modules/other"
      recipe = root </> ".seihou/modules/recipe-input-probe"
      body name = "let S = ../demo/schema/package.dhall in S.Module::{name=\"" <> name <> "\",version=Some \"1.0.0\",vars=[S.VarDecl::{name=\"feature\",type=\"bool\",default=Some \"false\"}],steps=[S.Step::{strategy=\"template\",src=\"value\",dest=\"" <> name <> ".txt\"}]}"
  D.createDirectoryIfMissing True (other </> "files")
  D.createDirectoryIfMissing True recipe
  TIO.writeFile (modul </> "module.dhall") (body "demo")
  TIO.writeFile (other </> "module.dhall") (body "other")
  TIO.writeFile (modul </> "files/value") "{{feature}}"
  TIO.writeFile (other </> "files/value") "{{feature}}"
  TIO.writeFile (recipe </> "recipe.dhall") "let S = ../demo/schema/package.dhall in S.Recipe::{name=\"recipe-input-probe\",version=Some \"1.0.0\",modules=[S.Dependency::{module=\"demo\"},S.Dependency::{module=\"other\"}],vars=[S.VarDecl::{name=\"feature\",type=\"bool\",required=True}],prompts=[S.Prompt::{var=\"feature\",text=\"Enable feature?\"}]}"
  pure binary

-- A harmless command records its rendered destination. Rejected runs must
-- preserve both generated bytes and this command log, including on update.
prepareOwnerPolicy root binding recipePolicy memberPolicy = do
  (binary, modul) <- prepare root
  let recipe = root </> ".seihou/modules/owner-policy"
      constraint enabled = if enabled then ",validation=Some \"^topagentnetwork\\$\"" else ""
      declaration enabled = "S.VarDecl::{name=\"git.githubOwner\",type=\"text\",default=Some \"topagentnetwork\"" <> constraint enabled <> "}"
      writeRecipe bound constrained =
        TIO.writeFile (recipe </> "recipe.dhall") $
          "let S = ../demo/schema/package.dhall in S.Recipe::{name=\"owner-policy\",version=Some \"1.0.0\",modules=[S.Dependency::{module=\"demo\""
            <> (if bound then ",vars=[{name=\"git.githubOwner\",value=\"topagentnetwork\"}]" else "")
            <> "}],vars=["
            <> declaration constrained
            <> "]}"
  D.createDirectoryIfMissing True recipe
  TIO.writeFile (modul </> "module.dhall") $
    "let S = ./schema/package.dhall in S.Module::{name=\"demo\",version=Some \"1.0.0\",vars=[" <> declaration memberPolicy <> "],steps=[S.Step::{strategy=\"template\",src=\"owner\",dest=\"owner.txt\"}],commands=[S.Command::{action=S.Command.Action.Apply \"printf '%s\\n' '{{git.githubOwner}}/probe' >> command-log\"}]}"
  TIO.writeFile (modul </> "files/owner") "{{git.githubOwner}}"
  writeRecipe binding recipePolicy
  pure (binary, writeRecipe)

tests :: IO TestTree
tests = testSpec "Recipe inputs" $ do
  it "uses a camelCase owner binding above global config and rejects CLI violations before effects" $
    withSystemTempDirectory "owner-policy" $ \root -> do
      (binary, _) <- prepareOwnerPolicy root True True False
      _ <- success binary root ["config", "set", "git.githubOwner", "someone-else", "--global"]
      _ <- success binary root ["validate-recipe", root </> ".seihou/modules/owner-policy"]
      _ <- success binary root ["run", "owner-policy", "--dry-run"]
      D.doesPathExist (root </> "owner.txt") `shouldReturn` False
      D.doesPathExist (root </> "command-log") `shouldReturn` False
      (code, out, err) <- invoke binary root ["run", "owner-policy", "--var", "git.githubOwner=someone-else"]
      code `shouldBe` ExitFailure 1
      let message = T.pack (out <> err)
      mapM_ (\needle -> message `shouldSatisfy` T.isInfixOf needle) ["git.githubOwner", "--var", "^topagentnetwork$", "--var git.githubOwner=topagentnetwork"]
      D.doesPathExist (root </> "owner.txt") `shouldReturn` False
      D.doesPathExist (root </> "command-log") `shouldReturn` False
      D.doesPathExist (root </> ".seihou/manifest.json") `shouldReturn` False
      D.doesPathExist (root </> ".seihou/config.dhall") `shouldReturn` False
      _ <- success binary root ["run", "owner-policy"]
      TIO.readFile (root </> "owner.txt") `shouldReturn` "topagentnetwork"
      TIO.readFile (root </> "command-log") `shouldReturn` "topagentnetwork/probe\n"
      before <- LBS.readFile (root </> ".seihou/manifest.json")
      forM_ [[], ["--reconfigure"], ["--json"], ["--reconfigure", "--json"]] $ \extra -> do
        (refused, stdout, stderr) <- invoke binary root (["update", "owner-policy", "--var", "git.githubOwner=someone-else"] <> extra)
        refused `shouldBe` ExitFailure 1
        let output = T.pack (stdout <> stderr)
        output `shouldSatisfy` T.isInfixOf "git.githubOwner"
        if "--json" `elem` extra then output `shouldSatisfy` T.isInfixOf "\"code\":\"variable_errors\"" else pure ()
        LBS.readFile (root </> ".seihou/manifest.json") `shouldReturn` before
        TIO.readFile (root </> "owner.txt") `shouldReturn` "topagentnetwork"
        TIO.readFile (root </> "command-log") `shouldReturn` "topagentnetwork/probe\n"
  forM_ [True, False] $ \recipePolicy ->
    it ("revalidates historical personal owner independently; recipe policy=" <> show recipePolicy) $
      withSystemTempDirectory "owner-history" $ \root -> do
        (binary, writeRecipe) <- prepareOwnerPolicy root False False False
        _ <- success binary root ["run", "owner-policy", "--var", "git.githubOwner=someone-else"]
        if recipePolicy
          then writeRecipe False True
          else do
            let modul = root </> ".seihou/modules/demo/module.dhall"
            body <- TIO.readFile modul
            TIO.writeFile modul (T.replace "default=Some \"topagentnetwork\"" "default=Some \"topagentnetwork\",validation=Some \"^topagentnetwork\\$\"" body)
        before <- LBS.readFile (root </> ".seihou/manifest.json")
        (code, out, err) <- invoke binary root ["update", "owner-policy"]
        code `shouldBe` ExitFailure 1
        T.pack (out <> err) `shouldSatisfy` T.isInfixOf "application"
        TIO.readFile (root </> "owner.txt") `shouldReturn` "someone-else"
        TIO.readFile (root </> "command-log") `shouldReturn` "someone-else/probe\n"
        LBS.readFile (root </> ".seihou/manifest.json") `shouldReturn` before
        -- Adoption owns replacing old accepted input when the producer adds a binding.
        writeRecipe True True
        output <- success binary root ["update", "owner-policy", "--json"]
        value <- TIO.readFile (root </> "owner.txt")
        if value == "topagentnetwork" then pure () else expectationFailure (T.unpack output)
  it "validates global config against a consuming module with the original source" $
    withSystemTempDirectory "owner-consumer" $ \root -> do
      (binary, _) <- prepareOwnerPolicy root False False True
      _ <- success binary root ["config", "set", "git.githubOwner", "someone-else", "--global"]
      (code, out, err) <- invoke binary root ["run", "owner-policy"]
      code `shouldBe` ExitFailure 1
      T.pack (out <> err) `shouldSatisfy` T.isInfixOf "global config"
      D.doesPathExist (root </> "owner.txt") `shouldReturn` False
      D.doesPathExist (root </> "command-log") `shouldReturn` False

  it "refuses missing recipe decisions before writing files or manifest" $
    withSystemTempDirectory "recipe-missing" $ \root -> do
      binary <- prepareRecipe root
      (code, out, err) <- invoke binary root ["run", "recipe-input-probe"]
      code `shouldBe` ExitFailure 1
      T.pack (out <> err) `shouldSatisfy` T.isInfixOf "feature"
      D.doesPathExist (root </> "demo.txt") `shouldReturn` False
      D.doesPathExist (root </> ".seihou/manifest.json") `shouldReturn` False
  forM_ ["true", "false"] $ \choice ->
    it ("broadcasts and records " <> T.unpack choice) $
      withSystemTempDirectory "recipe-choice" $ \root -> do
        binary <- prepareRecipe root
        _ <- success binary root ["run", "recipe-input-probe", "--var", "feature=" <> T.unpack choice]
        TIO.readFile (root </> "demo.txt") `shouldReturn` choice
        TIO.readFile (root </> "other.txt") `shouldReturn` choice
        manifest <- readManifest root
        map (^. #recipeInputs) (manifest ^. #applications) `shouldBe` [Just (Map.singleton "feature" choice)]

  it "adopts an older application through ordinary preview and atomic apply" $
    withSystemTempDirectory "recipe-adoption" $ \root -> do
      binary <- prepareRecipe root
      _ <- success binary root ["run", "recipe-input-probe", "--var", "feature=false"]
      accepted <- readManifest root
      -- Construct a supported historical input, as emitted before schema 12.
      let older = accepted & #version .~ ManifestSchemaVersion 11 & #applications %~ map (\app -> app & #recipeInputs .~ Nothing)
          path = root </> ".seihou/manifest.json"
      LBS.writeFile path (Aeson.encode older)
      before <- LBS.readFile path
      _ <- success binary root ["update", "recipe-input-probe", "--dry-run", "--json"]
      LBS.readFile path `shouldReturn` before
      _ <- success binary root ["update", "recipe-input-probe", "--json"]
      adopted <- readManifest root
      map (^. #recipeInputs) (adopted ^. #applications) `shouldBe` [Just (Map.singleton "feature" "false")]
      unchanged <- success binary root ["update", "recipe-input-probe", "--json"]
      unchanged `shouldSatisfy` T.isInfixOf "\"alreadyUpToDate\":true"
  it "replays a recipe-only input after declining config saving" $
    withSystemTempDirectory "recipe-only" $ \root -> do
      binary <- prepareRecipe root
      let recipe = root </> ".seihou/modules/recipe-input-probe/recipe.dhall"
      definition <- TIO.readFile recipe
      TIO.writeFile recipe (T.replace "vars=[S.VarDecl" "vars=[S.VarDecl::{name=\"recipe.only\",type=\"bool\",required=True},S.VarDecl" definition)
      _ <- success binary root ["run", "recipe-input-probe", "--var", "feature=false", "--var", "recipe.only=true", "--no-save-prompted"]
      output <- success binary root ["update", "recipe-input-probe", "--dry-run", "--json"]
      output `shouldSatisfy` T.isInfixOf "\"alreadyUpToDate\":true"
      manifest <- readManifest root
      map (^. #recipeInputs) (manifest ^. #applications) `shouldBe` [Just (Map.fromList [("feature", "false"), ("recipe.only", "true")])]