packages feed

salmon-ops-recipes-0.2.0.0: test/Test/HttpServerFlagSpec.hs

{- | @salmon-ops@'s @http-server@ cabal flag: on by default, and off it must
still build, without warp, warp-tls or wai anywhere in the library's plan.

The default suite can only see the default build (this suite imports
"Salmon.Actions.Serve.Http" itself), so the flag-off side is opt-in: set
@SALMON_TEST_NO_HTTP_SERVER_BUILD=1@ and it builds the library with
@-http-server@ into a temporary @--builddir@, from the project root, and
reads the plan that build wrote. It costs a full compile of @salmon-ops@
(about two minutes when the store already has the dependencies), which is
why it is not in the default run. Without the variable it skips loudly.
-}
module Test.HttpServerFlagSpec (tests) where

import Control.Monad (unless)
import Data.Aeson (Value (..))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KeyMap
import Data.Foldable (toList)
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as Text
import System.Directory (doesFileExist, getCurrentDirectory)
import System.Environment (lookupEnv)
import System.Exit (ExitCode (..))
import System.FilePath (takeDirectory, (</>))
import System.IO (hPutStrLn, stderr)
import System.Process (CreateProcess (..), proc, readCreateProcessWithExitCode)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)

import qualified Salmon.Actions.Serve.Http as Http
import qualified Salmon.Actions.Serve.HttpBind as HttpBind
import qualified Salmon.Builtin.CommandLine as CLI

import Test.Harness (withTempDir)

tests :: TestTree
tests =
    testGroup
        "salmon-ops http-server flag"
        [ testCase "the default build has the HTTP server" $
            assertBool "httpServerAvailable" CLI.httpServerAvailable
        , testCase "Serve.Http re-exports the listener configuration it moved out" $
            assertEqual "defaultSessionPolicy" HttpBind.defaultSessionPolicy Http.defaultSessionPolicy
        , testCase "the refusal names the flag and both options" $ do
            let msg = Text.pack CLI.httpServerUnavailable
            mapM_ (\w -> assertBool (Text.unpack w) (w `Text.isInfixOf` msg)) ["-http-server", "--http ", "--http-tcp"]
        , testCase "built with -http-server: no warp, warp-tls or wai in the library's plan (opt-in)" withoutHttpServer
        ]

optInEnv :: String
optInEnv = "SALMON_TEST_NO_HTTP_SERVER_BUILD"

withoutHttpServer :: IO ()
withoutHttpServer = do
    requested <- lookupEnv optInEnv
    case requested of
        Just v | not (null v) -> run
        _ -> hPutStrLn stderr ("SKIPPED: set " <> optInEnv <> "=1 to build salmon-ops with -http-server and check its plan")
  where
    run = do
        root <- projectRoot =<< getCurrentDirectory
        withTempDir $ \builddir -> do
            let args = ["build", "salmon-ops:lib:salmon-ops", "--constraint=salmon-ops -http-server", "--builddir=" <> builddir]
            (code, out, err) <- readCreateProcessWithExitCode (proc "cabal" args){cwd = Just root, close_fds = True} ""
            unless (code == ExitSuccess) $
                assertFailure ("cabal " <> unwords args <> " failed:\n" <> lastLines out <> lastLines err)
            plan <- Aeson.eitherDecodeFileStrict (builddir </> "cache" </> "plan.json")
            case plan of
                Left e -> assertFailure ("plan.json: " <> e)
                Right v -> do
                    let names = libraryClosure v
                    assertBool "the closure was found at all" (Set.member "salmon-core" names)
                    mapM_
                        (\p -> assertBool (Text.unpack p <> " is in salmon-ops -http-server's plan") (not (Set.member p names)))
                        ["warp", "warp-tls", "wai"]
    lastLines = unlines . reverse . take 40 . reverse . lines

-- | The directory holding @cabal.project@, from here upwards.
projectRoot :: FilePath -> IO FilePath
projectRoot dir = do
    found <- doesFileExist (dir </> "cabal.project")
    if found
        then pure dir
        else
            let up = takeDirectory dir
             in if up == dir then fail "no cabal.project above the test's directory" else projectRoot up

{- | Package names reachable from @salmon-ops@'s library in a cabal
@plan.json@: the whole project's plan lists every local package, some of
which (this suite) depend on warp themselves, so only the library's own
closure says anything.
-}
libraryClosure :: Value -> Set.Set Text
libraryClosure plan = go Set.empty roots
  where
    units = [u | Object o <- [plan], Just (Array us) <- [KeyMap.lookup "install-plan" o], Object u <- toList us]
    byId = Map.fromList [(i, u) | u <- units, Just (String i) <- [KeyMap.lookup "id" u]]
    str k u = case KeyMap.lookup (Key.fromText k) u of
        Just (String s) -> Just s
        _ -> Nothing
    depsOf u = case KeyMap.lookup "depends" u of
        Just (Array ds) -> [d | String d <- toList ds]
        _ -> case KeyMap.lookup "components" u of
            Just (Object cs) -> concat [[d | String d <- toList ds] | Object c <- KeyMap.elems cs, Just (Array ds) <- [KeyMap.lookup "depends" c]]
            _ -> []
    roots =
        concat
            [ depsOf u
            | u <- units
            , str "pkg-name" u == Just "salmon-ops"
            , str "component-name" u == Just "lib"
            ]
    go seen [] = Set.fromList [n | i <- Set.toList seen, Just u <- [Map.lookup i byId], Just n <- [str "pkg-name" u]]
    go seen (i : rest)
        | Set.member i seen = go seen rest
        | otherwise = go (Set.insert i seen) (maybe [] depsOf (Map.lookup i byId) <> rest)