packages feed

hurl-workbench-core-0.1.0.0: test/HurlWorkbench/Run/SelectionTest.hs

module HurlWorkbench.Run.SelectionTest (tests) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.STM (atomically, modifyTVar', newEmptyTMVarIO, newTVarIO, putTMVar, readTVar, readTVarIO, takeTMVar, writeTVar)
import Control.Exception (bracket)
import Data.Bits ((.&.))
import Data.ByteString qualified as ByteString
import Data.Generics.Labels ()
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Version (makeVersion)
import HurlWorkbench.Hurl.Format
import HurlWorkbench.Hurl.Run
import HurlWorkbench.Parameter.Resolve
import HurlWorkbench.Prelude
import HurlWorkbench.Run.Artifact
import HurlWorkbench.Run.Batch
import HurlWorkbench.Run.FileRoot
import HurlWorkbench.Run.Prepare
import HurlWorkbench.Run.Selection
import HurlWorkbench.Workspace.Context
import HurlWorkbench.Workspace.Discover (WorkspaceSource (..))
import HurlWorkbench.Workspace.Error (renderWorkspaceError)
import HurlWorkbench.Workspace.Types
import HurlWorkbench.Workspace.Validate
import System.Environment (lookupEnv, setEnv, unsetEnv)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import System.Posix.Files
  ( accessModes,
    fileMode,
    getFileStatus,
    ownerExecuteMode,
    ownerReadMode,
    ownerWriteMode,
    unionFileModes,
  )
import System.Posix.Types qualified
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertFailure, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "run selection and preparation"
    [ testCase "explicit roots survive matrix preparation and artifact overlap is refused" $
        withSystemTempDirectory "matrix-file-root" $ \directory -> do
          root <- validateFileRoot directory (Just directory) >>= either (assertFailure . show) pure
          prepared <- preparedMatrixAt (Just root)
          for_ prepared $ \ready -> do
            ready ^. #fileRoot @?= root
            buildBatchCase TestMode CaptureRunOutput [] ready ^. #request . #fileRoot @?= root
          prepareResponseArtifacts (directory </> "nested") True prepared >>= \case
            Left _ -> pure ()
            Right _ -> assertFailure "artifact preparation accepted input overlap",
      testCase "expands matrix cases in declaration order with case-over-recipe layers" $ do
        workspace <- fullWorkspace
        case resolveSelection workspace (SelectMatrix (MatrixName "property-page-sizes")) of
          Left err -> assertFailure (Text.unpack (renderSelectionError err))
          Right expanded -> do
            let runs = NonEmpty.toList expanded
            map (view #displayName) runs @?= ["property-page-sizes/small", "property-page-sizes/large"]
            map (view #artifactStem) runs @?= ["property-page-sizes" </> "small", "property-page-sizes" </> "large"]
            map (view #safety) runs @?= [Classified ReadOnly, Classified ReadOnly]
            map (map (view #source) . view #bindingLayers) runs
              @?= [ [CommittedBinding "matrix \"property-page-sizes\" case \"small\"", CommittedBinding "recipe \"top-properties\""],
                    [CommittedBinding "matrix \"property-page-sizes\" case \"large\"", CommittedBinding "recipe \"top-properties\""]
                  ],
      testCase "applies runtime, file, matrix, recipe, environment, and default precedence" $
        withSystemTempDirectory "hurl-workbench-layered-bindings" $ \directory -> do
          workspace <- fullWorkspace
          expanded <- case resolveSelection workspace (SelectMatrix (MatrixName "property-page-sizes")) of
            Left err -> assertFailure (Text.unpack (renderSelectionError err))
            Right value -> pure (NonEmpty.head value)
          let layers = expanded ^. #bindingLayers
              top = ParameterName "top"
              clientId = ParameterName "client_id"
              recipeOnly = drop 1 layers
              topValue result = hurlValueLiteralText <$> Map.lookup top (result ^. #variables)
          resolveParametersWithLayers workspace (Set.singleton top) [] emptyBindingInput >>= requireBindings >>= ((@?= Just "5") . topValue)
          resolveParametersWithLayers workspace (Set.singleton top) recipeOnly emptyBindingInput >>= requireBindings >>= ((@?= Just "10") . topValue)
          resolveParametersWithLayers workspace (Set.singleton top) layers emptyBindingInput >>= requireBindings >>= ((@?= Just "1") . topValue)
          ByteString.writeFile (directory </> "values.env") "top=20\n"
          let fileInput = emptyBindingInput {variableFiles = [directory </> "values.env"]}
          resolveParametersWithLayers workspace (Set.singleton top) layers fileInput >>= requireBindings >>= ((@?= Just "20") . topValue)
          let explicitInput = fileInput {plainOverrides = Map.singleton top (literal "30")}
          resolveParametersWithLayers workspace (Set.singleton top) layers explicitInput >>= requireBindings >>= ((@?= Just "30") . topValue)
          let committedClient = BindingLayer (CommittedBinding "recipe test") (Map.singleton clientId (literal "committed-id"))
          resolveParametersWithLayers workspace (Set.singleton clientId) [committedClient] emptyBindingInput >>= requireBindings >>= \resolved ->
            (hurlValueLiteralText <$> Map.lookup clientId (resolved ^. #variables)) @?= Just "committed-id",
      testCase "prepares every matrix case before building the shared request shape" $
        withEnvironment [("PREPARE_SELECTION_SECRET", "fixture-secret")] $ do
          workspace <- fullWorkspace
          let input =
                emptyBindingInput
                  { plainOverrides = Map.singleton (ParameterName "client_id") (literal "fixture-id"),
                    secretEnvironmentOverrides = Map.singleton (ParameterName "client_secret") "PREPARE_SELECTION_SECRET"
                  }
          prepared <-
            prepareSelection
              trueCapabilities
              workspace
              input
              defaultHurlOptions
              (SelectMatrix (MatrixName "property-page-sizes"))
          case prepared of
            Left errors -> assertFailure (Text.unpack (Text.unlines (map renderPreparationError (toList errors))))
            Right runs -> do
              map (view #displayName) (NonEmpty.toList runs) @?= ["property-page-sizes/small", "property-page-sizes/large"]
              map (fmap hurlValueLiteralText . Map.lookup (ParameterName "top") . view #variables . view #bindings) (NonEmpty.toList runs)
                @?= [Just "1", Just "100"]
              let batchCase = buildBatchCase TestMode CaptureRunOutput [] (NonEmpty.head runs)
              batchCase ^. #name @?= "property-page-sizes/small"
              batchCase ^. #request . #mode @?= TestMode,
      testCase "bounds concurrency and returns results in declaration order" $ do
        cases <- syntheticCases ["slow", "fast", "middle"]
        active <- newTVarIO (0 :: Int)
        maximumActive <- newTVarIO (0 :: Int)
        let runner = HurlRunner $ \request -> do
              atomically $ do
                modifyTVar' active (+ 1)
                current <- readTVar active
                observed <- readTVar maximumActive
                writeTVar maximumActive (max current observed)
              threadDelay (if requestPath request == Just "slow" then 150000 else 20000)
              atomically (modifyTVar' active (subtract 1))
              pure (Right (RunResult ExitSuccess 0 (Just (CapturedRunOutput "" ""))))
        result <- runBatch runner (BatchOptions (positive 2) False) cases
        readTVarIO maximumActive >>= (@?= 2)
        map (view #name) (toList (result ^. #cases)) @?= ["slow", "fast", "middle"]
        map (view #outcome) (toList (result ^. #cases)) @?= replicate 3 CasePassed
        result ^. #selectedExitCode @?= ExitSuccess,
      testCase "keeps one hundred scheduled cases within the requested job limit" $ do
        let names = ["case-" <> Text.pack (show number) | number <- [1 .. 100 :: Int]]
        cases <- syntheticCases names
        active <- newTVarIO (0 :: Int)
        maximumActive <- newTVarIO (0 :: Int)
        let runner = HurlRunner $ \_request -> do
              atomically $ do
                modifyTVar' active (+ 1)
                current <- readTVar active
                modifyTVar' maximumActive (max current)
              threadDelay 1000
              atomically (modifyTVar' active (subtract 1))
              pure (Right (RunResult ExitSuccess 0 Nothing))
        result <- runBatch runner (BatchOptions (positive 7) False) cases
        readTVarIO maximumActive >>= (@?= 7)
        map (view #name) (toList (result ^. #cases)) @?= names
        map (view #outcome) (toList (result ^. #cases)) @?= replicate 100 CasePassed,
      testCase "fail-fast stops queue scheduling and marks untouched cases skipped" $ do
        cases <- syntheticCases ["first", "active", "third", "fourth", "fifth"]
        starts <- newTVarIO (0 :: Int)
        activeStarted <- newEmptyTMVarIO
        failurePublished <- newEmptyTMVarIO
        let runner = HurlRunner $ \request -> do
              atomically (modifyTVar' starts (+ 1))
              if requestPath request == Just "first"
                then atomically (takeTMVar activeStarted) >> pure (Right (RunResult (ExitFailure 7) 0 Nothing))
                else do
                  atomically (putTMVar activeStarted ())
                  atomically (takeTMVar failurePublished)
                  pure (Right (RunResult ExitSuccess 0 Nothing))
            observe caseIndex _ = when (caseIndex == 0) (atomically (putTMVar failurePublished ()))
        result <- runBatchObserved runner (BatchOptions (positive 2) True) cases observe
        readTVarIO starts >>= (@?= 2)
        map (view #outcome) (toList (result ^. #cases))
          @?= [ CaseFailed (ExitFailure 7),
                CasePassed,
                CaseSkipped FailFastTriggered,
                CaseSkipped FailFastTriggered,
                CaseSkipped FailFastTriggered
              ]
        result ^. #selectedExitCode @?= ExitFailure 7,
      testCase "rejects non-positive worker counts" $ do
        mkPositiveInt 0 @?= Left (NonPositiveJobs 0)
        positiveIntValue (positive 1) @?= 1,
      testCase "prepares collision-checked owner-only response artifacts" $
        withSystemTempDirectory "hurl-workbench-artifacts" $ \directory -> do
          prepared <- preparedMatrix
          let outputDirectory = directory </> "responses"
              smallPath = outputDirectory </> "property-page-sizes" </> "small.response"
              expected = smallPath :| [outputDirectory </> "property-page-sizes" </> "large.response"]
          prepareResponseArtifacts outputDirectory False prepared >>= \case
            Left errors -> assertFailure (Text.unpack (Text.unlines (map renderArtifactError (toList errors))))
            Right paths -> do
              paths @?= expected
              traverse filePermissions expected >>= (@?= (ownerFileMode :| [ownerFileMode]))
              directoryPermissions outputDirectory >>= (@?= ownerDirectoryMode)
              directoryPermissions (outputDirectory </> "property-page-sizes") >>= (@?= ownerDirectoryMode)
          ByteString.writeFile smallPath "keep-me"
          prepareResponseArtifacts outputDirectory False prepared >>= \case
            Left errors -> map renderArtifactError (toList errors) @?= map (renderArtifactError . ArtifactAlreadyExists) (toList expected)
            Right _ -> assertFailure "expected existing artifacts to be rejected"
          ByteString.readFile smallPath >>= (@?= "keep-me")
          prepareResponseArtifacts outputDirectory True prepared >>= \case
            Left errors -> assertFailure (Text.unpack (Text.unlines (map renderArtifactError (toList errors))))
            Right _ -> ByteString.readFile smallPath >>= (@?= "")
    ]

trueCapabilities :: HurlfmtCapabilities
trueCapabilities = HurlfmtCapabilities (HurlfmtExecutable "true") (makeVersion [8, 0, 1])

literal :: Text -> HurlValueLiteral
literal value = either (error . show) id (mkHurlValueLiteral value)

requireBindings :: Either BindingError ResolvedBindings -> IO ResolvedBindings
requireBindings = either (assertFailure . Text.unpack . renderBindingError) pure

positive :: Int -> PositiveInt
positive value = either (error . show) id (mkPositiveInt value)

syntheticCases :: [Text] -> IO (NonEmpty BatchCase)
syntheticCases names = do
  prepared <- preparedMatrix
  let base = NonEmpty.head prepared
      makeCase name =
        let original = buildBatchCase ClientMode (ResponseFile (Text.unpack name)) [] base
         in BatchCase
              { name,
                artifactStem = original ^. #artifactStem,
                request = original ^. #request
              }
  maybe (assertFailure "synthetic case list must be non-empty") pure (nonEmpty (map makeCase names))

preparedMatrix :: IO (NonEmpty PreparedRun)
preparedMatrix = preparedMatrixAt Nothing

preparedMatrixAt :: Maybe FileRoot -> IO (NonEmpty PreparedRun)
preparedMatrixAt selected =
  withSystemTempDirectory "hurl-workbench-batch-bindings" $ \directory -> do
    let secretsPath = directory </> "secrets.env"
    ByteString.writeFile secretsPath "client_secret=fixture-secret\n"
    workspace <- fullWorkspace
    let input =
          emptyBindingInput
            { plainOverrides = Map.singleton (ParameterName "client_id") (literal "fixture-id"),
              secretFiles = [secretsPath]
            }
    let prepare = case selected of
          Nothing -> prepareSelectionWith (\_ _ -> pure (Right ()))
          Just root -> prepareSelectionAtFileRootWith root (\_ _ -> pure (Right ()))
    prepare trueCapabilities workspace input defaultHurlOptions (SelectMatrix (MatrixName "property-page-sizes")) >>= \case
      Left errors -> assertFailure (Text.unpack (Text.unlines (map renderPreparationError (toList errors))))
      Right prepared -> pure prepared

requestPath :: RunRequest -> Maybe FilePath
requestPath request = case request ^. #outputPolicy of
  ResponseFile path -> Just path
  _ -> Nothing

filePermissions :: FilePath -> IO System.Posix.Types.FileMode
filePermissions path = (.&. accessModes) . fileMode <$> getFileStatus path

directoryPermissions :: FilePath -> IO System.Posix.Types.FileMode
directoryPermissions = filePermissions

ownerFileMode :: System.Posix.Types.FileMode
ownerFileMode = ownerReadMode `unionFileModes` ownerWriteMode

ownerDirectoryMode :: System.Posix.Types.FileMode
ownerDirectoryMode = ownerFileMode `unionFileModes` ownerExecuteMode

fullWorkspace :: IO ValidatedWorkspace
fullWorkspace = do
  loaded <- loadWorkspaceContext (ExplicitWorkspace "test/fixtures/workspaces/full/hurl-workbench.dhall")
  context <- either (assertFailure . Text.unpack . renderWorkspaceError) pure loaded
  validateWorkspace context >>= either (assertFailure . Text.unpack . Text.unlines . map renderValidationIssue . toList) pure

withEnvironment :: [(String, String)] -> IO a -> IO a
withEnvironment values action = bracket save restore (const applyAndRun)
  where
    save = traverse (\(name, _value) -> (name,) <$> lookupEnv name) values
    restore previous = for_ previous $ \(name, old) -> maybe (unsetEnv name) (setEnv name) old
    applyAndRun = traverse_ (uncurry setEnv) values >> action