packages feed

wireform-proto-0.1.0.0: test-integration/Test/Plugin.hs

module Test.Plugin (pluginTests) where

import Control.Concurrent (forkIO)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Monad (when)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import System.Directory (findExecutable)
import System.IO (hClose, hSetBinaryMode)
import System.Process (CreateProcess (..), StdStream (..), createProcess, proc, waitForProcess)
import Test.Tasty
import Test.Tasty.HUnit

import Proto.CodeGen
import Proto (decodeMessage)
import Proto (encodeMessage)
import Proto.Google.Protobuf.Compiler.Plugin
import Proto.IDL.AST (ProtoFile)
import Proto.IDL.Descriptor (astToFileDescriptor, fileDescriptorToAST)
import Proto.IDL.Parser (parseProtoFile)
import Proto.IDL.Parser.Resolver (ResolvedProto (..))


testProtoSrc :: Text
testProtoSrc =
  T.unlines
    [ "syntax = \"proto3\";"
    , "package test.plugin;"
    , ""
    , "enum Status {"
    , "  STATUS_UNSPECIFIED = 0;"
    , "  STATUS_ACTIVE = 1;"
    , "  STATUS_BANNED = 2;"
    , "}"
    , ""
    , "message Person {"
    , "  string name = 1;"
    , "  int32  age  = 2;"
    , "  Status status = 3;"
    , "  map<string,int32> scores = 4;"
    , "}"
    , ""
    , "message Team {"
    , "  string name = 1;"
    , "  repeated Person members = 2;"
    , "}"
    ]

testProtoPath :: FilePath
testProtoPath = "test/plugin/fixture.proto"


parseFixture :: IO ProtoFile
parseFixture =
  case parseProtoFile testProtoPath testProtoSrc of
    Left e -> assertFailure ("parse failed: " <> show e) >> error "unreachable"
    Right pf -> pure pf


pluginTests :: TestTree
pluginTests =
  testGroup
    "protoc-gen-wireform plugin"
    [ descriptorRoundTripTests
    , binaryPluginTest
    ]


{- | The plugin path goes:

  parse → FileDescriptorProto (astToFileDescriptor)
        → ProtoFile (fileDescriptorToAST)
        → generateModuleText

  The direct path (loadProto / Proto.Setup) goes:

  parse → generateModuleText

  These must produce identical output for the plugin to be a
  transparent substitute for the other code-generation entry points.
-}
descriptorRoundTripTests :: TestTree
descriptorRoundTripTests =
  testGroup
    "Descriptor round-trip (direct == via FileDescriptorProto)"
    [ testCase "enum and message structure preserved" $ do
        pf <- parseFixture
        let reg = buildTypeRegistry defaultGenerateOpts [(testProtoPath, ResolvedProto pf testProtoPath Map.empty)]
            directCode = generateModuleText defaultGenerateOpts reg testProtoPath pf
            fdp = astToFileDescriptor testProtoPath pf
            roundPf = fileDescriptorToAST fdp
            -- Use a registry built from the round-tripped file since its
            -- type names must match what the codegen will address.
            roundReg = buildTypeRegistry defaultGenerateOpts [(testProtoPath, ResolvedProto roundPf testProtoPath Map.empty)]
            roundCode = generateModuleText defaultGenerateOpts roundReg testProtoPath roundPf
        -- FileDescriptorProto stores messages and enums in separate lists so
        -- the top-level declaration order differs from the parsed source (all
        -- messages first, then all enums). Haskell modules are order-
        -- independent, so we compare sets of declarations rather than exact
        -- text. The key check is that the same data types and instances are
        -- present regardless of order.
        let directDecls = extractDecls directCode
            roundDecls  = extractDecls roundCode
        directDecls @?= roundDecls

    , testCase "expected declarations present" $ do
        pf <- parseFixture
        let reg = buildTypeRegistry defaultGenerateOpts [(testProtoPath, ResolvedProto pf testProtoPath Map.empty)]
            code = generateModuleText defaultGenerateOpts reg testProtoPath pf
        assertBool "data Person"  (T.isInfixOf "data Person"  code)
        assertBool "data Team"    (T.isInfixOf "data Team"    code)
        assertBool "data Status"  (T.isInfixOf "data Status"  code)
        assertBool "StatusActive" (T.isInfixOf "StatusActive" code)
        assertBool "MessageEncode instance for Person"  (T.isInfixOf "MessageEncode Person"  code)
        assertBool "MessageDecode instance for Person"  (T.isInfixOf "MessageDecode Person"  code)
        assertBool "map field (Map)" (T.isInfixOf "Map." code)

    , testCase "round-tripped code also has expected declarations" $ do
        pf <- parseFixture
        let fdp = astToFileDescriptor testProtoPath pf
            roundPf = fileDescriptorToAST fdp
            reg = buildTypeRegistry defaultGenerateOpts [(testProtoPath, ResolvedProto roundPf testProtoPath Map.empty)]
            code = generateModuleText defaultGenerateOpts reg testProtoPath roundPf
        assertBool "data Person (round-tripped)"  (T.isInfixOf "data Person"  code)
        assertBool "data Team (round-tripped)"    (T.isInfixOf "data Team"    code)
        assertBool "data Status (round-tripped)"  (T.isInfixOf "data Status"  code)
    ]


{- | End-to-end test for the 'protoc-gen-wireform' binary.

Constructs a 'CodeGeneratorRequest' from the test fixture, sends it
to the plugin binary on stdin, reads the 'CodeGeneratorResponse'
from stdout, and asserts the generated file content matches what
'generateModuleText' produces directly.

Skipped when the binary is not found in PATH. Run
@cabal build protoc-gen-wireform@ first, then add the binary to PATH
or point PATH at @$(cabal list-bin protoc-gen-wireform)@.
-}
binaryPluginTest :: TestTree
binaryPluginTest = testCase "End-to-end: binary stdin→stdout round-trip" $ do
  mBin <- findExecutable "protoc-gen-wireform"
  case mBin of
    Nothing ->
      putStrLn
        "\n  (skipped: protoc-gen-wireform not in PATH;\
        \ run 'cabal build wireform-proto:protoc-gen-wireform' then add to PATH)"
    Just binPath -> runBinaryTest binPath


runBinaryTest :: FilePath -> IO ()
runBinaryTest binPath = do
  pf <- parseFixture
  let fdp = astToFileDescriptor testProtoPath pf
      req =
        defaultCodeGeneratorRequest
          { codeGeneratorRequestFileToGenerate = V.singleton (T.pack testProtoPath)
          , codeGeneratorRequestProtoFile = V.singleton fdp
          }
      reqBytes = encodeMessage req
      reg = buildTypeRegistry defaultGenerateOpts [(testProtoPath, ResolvedProto pf testProtoPath Map.empty)]
      expected = generateModuleText defaultGenerateOpts reg testProtoPath pf

  resp <- invokePlugin binPath reqBytes

  case codeGeneratorResponseError resp of
    Just e -> assertFailure ("Plugin returned error: " <> T.unpack e)
    Nothing -> pure ()

  let files = V.toList (codeGeneratorResponseFile resp)
  case files of
    [] -> assertFailure "Plugin produced no output files"
    (f : _) -> do
      let actual = fromMaybe "" (codeGeneratorResponseFileContent f)
          expectedDecls = extractDecls expected
          actualDecls   = extractDecls actual
      when (expectedDecls /= actualDecls) $ do
        let diff = diffLines
              (T.unlines expectedDecls)
              (T.unlines actualDecls)
        assertFailure ("Plugin declarations differ from generateModuleText:\n" <> T.unpack diff)


invokePlugin :: FilePath -> ByteString -> IO CodeGeneratorResponse
invokePlugin binPath reqBytes = do
  let cp =
        (proc binPath [])
          { std_in = CreatePipe
          , std_out = CreatePipe
          , std_err = Inherit
          }
  (Just inH, Just outH, Nothing, ph) <- createProcess cp
  hSetBinaryMode inH True
  hSetBinaryMode outH True
  -- Read stdout concurrently with writing stdin to prevent pipe buffer deadlock.
  resultVar <- newEmptyMVar
  _ <- forkIO $ do
    out <- BS.hGetContents outH
    putMVar resultVar out
  BS.hPut inH reqBytes
  hClose inH
  respBytes <- takeMVar resultVar
  _ <- waitForProcess ph
  case decodeMessage respBytes of
    Left err -> assertFailure ("Plugin response decode failed: " <> show err) >> error "unreachable"
    Right resp -> pure resp


-- | Extract the set of top-level declarations from generated Haskell source.
-- Lines starting with @data @, @newtype @, @type @, @instance @, or
-- @defaultFoo@ are collected and sorted. This normalises declaration
-- order differences while still catching missing or wrong declarations.
extractDecls :: Text -> [Text]
extractDecls src =
  let ls = T.lines src
      isDecl l =
        any (`T.isPrefixOf` l)
          ["data ", "newtype ", "type ", "instance ", "default"]
  in sort (filter isDecl ls)
  where
    sort = foldr insertSorted []
    insertSorted x [] = [x]
    insertSorted x (y : ys)
      | x <= y = x : y : ys
      | otherwise = y : insertSorted x ys


diffLines :: Text -> Text -> Text
diffLines expected actual =
  let expLines = T.lines expected
      actLines = T.lines actual
      pairs = zip3 [(1 :: Int) ..] (padTo (max (length expLines) (length actLines)) expLines) (padTo (max (length expLines) (length actLines)) actLines)
      diffs = filter (\(_, e, a) -> e /= a) pairs
      showDiff (n, e, a) = T.pack (show n) <> ":\n  expected: " <> e <> "\n  actual:   " <> a
  in if null diffs
       then "(no line differences found)"
       else T.intercalate "\n" (fmap showDiff (take 20 diffs))
            <> if length diffs > 20
                 then "\n... (" <> T.pack (show (length diffs - 20)) <> " more differing lines)"
                 else ""


padTo :: Int -> [Text] -> [Text]
padTo n xs = xs <> replicate (n - length xs) ""