wireform-proto-0.2.0.0: test-conformance/Driver.hs
{-# LANGUAGE OverloadedStrings #-}
{- | Tasty driver for the upstream protobuf conformance suite.
The suite is a separate test target ('protobuf-conformance-test')
because it depends on an external binary (the upstream
@conformance_test_runner@ from
<https://github.com/protocolbuffers/protobuf>) that we can't
ship through Hackage.
== How the suite finds the runner
1. If the @CONFORMANCE_TEST_RUNNER@ environment variable points
at an executable, that's used.
2. Otherwise, look for the runner under
@dist-newstyle/conformance/conformance_test_runner@. The
helper script @scripts\/build-conformance-runner.sh@ clones
+ builds the upstream tree to that location.
3. Otherwise, the suite skips with an instructive message.
The skip path keeps the test green in environments without a
C++ toolchain or network (CI sandboxes, fresh dev VMs, etc.).
A non-skipped run rebuilds @wireform-conformance-runner@ via
@cabal list-bin@ and pipes it to the upstream runner.
== What \"pass\" means
The upstream runner emits one summary line of the form
@
CONFORMANCE TEST BEGIN ====================================
...
CONFORMANCE SUITE PASSED: ... successes, ... skipped.
@
(or @FAILED@). We assert on the @PASSED@ marker, capturing the
runner's stderr into the test failure when the assertion
fires so the failure list is visible without re-running.
A small @failure_list_proto3.txt@ file lists tests we
knowingly don't support (JSON conformance for messages whose
schema we omit; JSPB; TEXT_FORMAT). The runner's
@--failure_list@ flag treats those as expected failures so the
net assertion is on regressions rather than absolute coverage.
-}
module Main (main) where
import Control.Exception (IOException, try)
import Data.ByteString.Char8 qualified as BS8
import Data.Maybe qualified
import System.Directory (doesFileExist, executable, getPermissions)
import System.Environment (lookupEnv)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO (hPutStrLn, stderr)
import System.Process (
CreateProcess (..),
StdStream (..),
createProcess,
proc,
waitForProcess,
)
import Test.Syd
main :: IO ()
main = sydTest =<< buildTree
buildTree :: IO Spec
buildTree = do
mRunner <- locateUpstreamRunner
case mRunner of
Nothing ->
pure
( describe "protobuf-conformance" $
sequence_
[ it "skipped (no upstream runner)" $ do
hPutStrLn stderr ""
hPutStrLn stderr instructions
]
)
Just runner ->
pure
( describe "protobuf-conformance" $
sequence_
[it ("upstream runner: " <> runner) (runConformance runner)]
)
{- | Probe order: @CONFORMANCE_TEST_RUNNER@ env var first, then
the helper-script's default install path.
-}
locateUpstreamRunner :: IO (Maybe FilePath)
locateUpstreamRunner = do
envPath <- lookupEnv "CONFORMANCE_TEST_RUNNER"
case envPath of
Just p -> ifExists p
Nothing -> ifExists defaultRunnerPath
defaultRunnerPath :: FilePath
defaultRunnerPath =
"dist-newstyle" </> "conformance" </> "conformance_test_runner"
ifExists :: FilePath -> IO (Maybe FilePath)
ifExists p = do
exists <- doesFileExist p
if not exists
then pure Nothing
else do
perm <- getPermissions p
pure (if executable perm then Just p else Nothing)
instructions :: String
instructions =
unlines
[ "wireform-proto conformance suite skipped."
, ""
, "To enable, build the upstream protobuf conformance_test_runner:"
, ""
, " bash wireform-proto/test-conformance/scripts/build-conformance-runner.sh"
, ""
, "or point CONFORMANCE_TEST_RUNNER at a pre-built copy and re-run"
, " cabal test wireform-proto:protobuf-conformance-test"
]
{- | Locate the freshly-built @wireform-conformance-runner@
binary via @cabal list-bin@. This is more reliable than
guessing dist-newstyle paths (which differ by GHC version,
platform, and project file), and it'll refuse to run if the
binary isn't built.
-}
locateWireformRunner :: IO (Either String FilePath)
locateWireformRunner = do
res <- try @IOException $ do
(_, _, _, ph) <-
createProcess
(proc "cabal" ["build", "wireform-proto:exe:wireform-conformance-runner"])
{ std_out = Inherit
, std_err = Inherit
}
code <- waitForProcess ph
case code of
ExitSuccess -> pure ()
_ -> error "cabal build wireform-conformance-runner failed"
(_, mout, _, ph2) <-
createProcess
(proc "cabal" ["list-bin", "wireform-proto:exe:wireform-conformance-runner"])
{ std_out = CreatePipe
, std_err = Inherit
}
code2 <- waitForProcess ph2
case code2 of
ExitSuccess -> pure ()
_ -> error "cabal list-bin failed"
case mout of
Nothing -> error "cabal list-bin produced no output"
Just h -> do
bs <- BS8.hGetContents h
case lines (BS8.unpack bs) of
(path : _) -> pure path
[] -> error "cabal list-bin returned empty"
pure
( case res of
Left e -> Left (show e)
Right p -> Right p
)
runConformance :: FilePath -> IO ()
runConformance runner = do
eRunnerBin <- locateWireformRunner
case eRunnerBin of
Left e -> expectationFailure ("could not build wireform-conformance-runner: " <> e)
Right wireformBin -> do
failureList <- failureListPath
let args =
[ "--enforce_recommended"
, "--failure_list"
, failureList
, wireformBin
]
hPutStrLn stderr ("> " <> runner <> " " <> unwords args)
-- Capture stderr so we can salvage a real pass/fail
-- decision from the summary line. The runner exits non-zero
-- on "doesn't exist" entries in the failure list (which fire
-- when one iteration of the suite skips a test that another
-- iteration's failure list references). Those are noise; the
-- actual signal is "N unexpected failures" in the summary.
(_, _, Just hErr, ph) <-
createProcess
(proc runner args)
{ std_out = Inherit
, std_err = CreatePipe
}
errBytes <- BS8.hGetContents hErr
hPutStrLn stderr (BS8.unpack errBytes)
code <- waitForProcess ph
let unexpectedFailures = parseUnexpectedFailures errBytes
case (code, unexpectedFailures) of
(ExitSuccess, _) -> pure ()
(ExitFailure _, Just 0) ->
-- Exit non-zero is the runner complaining about
-- "doesn't exist" entries in the failure list; that's
-- a stale-list issue, not a wireform regression.
hPutStrLn
stderr
"upstream runner exited non-zero but reported 0 unexpected failures; \
\treating as PASS."
(ExitFailure n, _) ->
expectationFailure
( "upstream conformance_test_runner exited with code "
<> show n
<> "; failures listed above. See "
<> failureList
<> " to add expected failures."
)
{- | Parse the runner's @CONFORMANCE SUITE (PASSED|FAILED): N successes, ...,
M unexpected failures.@ summary line out of stderr. Returns
'Nothing' if the line isn't found (treat as a failure to be safe).
-}
parseUnexpectedFailures :: BS8.ByteString -> Maybe Int
parseUnexpectedFailures bs =
let summaryLine =
Data.Maybe.listToMaybe
[l | l <- BS8.lines bs, BS8.isInfixOf (BS8.pack "unexpected failures") l]
in case summaryLine of
Nothing -> Nothing
Just l ->
-- Extract the integer immediately preceding " unexpected failures".
let parts = BS8.split ',' l
uf = filter (BS8.isInfixOf (BS8.pack "unexpected failures")) parts
in case uf of
(x : _) -> case BS8.words x of
(n : _) -> Just (read (BS8.unpack n))
_ -> Nothing
_ -> Nothing
failureListPath :: IO FilePath
failureListPath = do
envPath <- lookupEnv "CONFORMANCE_FAILURE_LIST"
case envPath of
Just p -> pure p
Nothing -> pickDefaultFailureList
{- | The default failure-list path is hairier than it looks
because cabal's test runner doesn't fix the working
directory in any consistent way across versions. Try several
candidates in order of likelihood.
-}
pickDefaultFailureList :: IO FilePath
pickDefaultFailureList = do
let candidates =
[ "wireform-proto" </> "test-conformance" </> "failure_list_proto3.txt"
, "test-conformance" </> "failure_list_proto3.txt"
, ".." </> "test-conformance" </> "failure_list_proto3.txt"
]
first [] = pure (head candidates) -- give up; runner will say
first (c : cs) = do
ex <- doesFileExist c
if ex then pure c else first cs
first candidates