heni-0.1.0.0: src/Test/Heni/Internal/Codec.hs
-- | Explicit version-one wire vocabulary. No Generic-derived compatibility.
module Test.Heni.Internal.Codec where
import Control.Monad (unless)
import Data.Aeson
import Data.Aeson.Decoding.ByteString (bsToTokens)
import Data.Aeson.Decoding.Tokens
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (Parser)
import Data.ByteString qualified as BS
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Test.Heni.Case (validateCase)
import Test.Heni.Command (validateCommand)
import Test.Heni.Internal.Model
import Test.Heni.Result (summarizeRun)
-- | Validate the original token stream before Aeson's map conversion can erase
-- duplicate keys. Escaped spellings are compared as decoded keys at every depth.
decodeJSON :: BS.ByteString -> Either String Value
decodeJSON bytes = do
leftover <- checkTokens 0 (bsToTokens bytes)
unless (BS.all (`elem` [9, 10, 13, 32]) leftover) (Left "trailing non-whitespace after JSON value")
eitherDecodeStrict bytes
checkTokens :: Int -> Tokens k String -> Either String k
checkTokens depth _ | depth > 128 = Left "JSON nesting exceeds 128 levels"
checkTokens _ (TkLit _ k) = Right k
checkTokens _ (TkText _ k) = Right k
checkTokens _ (TkNumber _ k) = Right k
checkTokens depth (TkArrayOpen array) = checkArray (depth + 1) array
checkTokens depth (TkRecordOpen record) = checkRecord (depth + 1) Set.empty record
checkTokens _ (TkErr message) = Left message
checkArray :: Int -> TkArray k String -> Either String k
checkArray depth (TkItem value) = checkTokens depth value >>= checkArray depth
checkArray _ (TkArrayEnd k) = Right k
checkArray _ (TkArrayErr message) = Left message
checkRecord :: Int -> Set.Set Key -> TkRecord k String -> Either String k
checkRecord depth seen (TkPair key value)
| key `Set.member` seen = Left ("duplicate JSON object key: " ++ Key.toString key)
| otherwise = checkTokens depth value >>= checkRecord depth (Set.insert key seen)
checkRecord _ _ (TkRecordEnd k) = Right k
checkRecord _ _ (TkRecordErr message) = Left message
strictObject :: String -> [Key] -> (Object -> Parser a) -> Value -> Parser a
strictObject label allowed f = withObject label $ \o -> do
unless (all (`elem` allowed) (KM.keys o)) (fail (label ++ ": unknown keys " ++ show (filter (`notElem` allowed) (KM.keys o))))
f o
checked :: (Show e) => Either e a -> Parser a
checked = either (fail . show) pure
enumValue :: (Eq a) => [(Text, a)] -> a -> Value
enumValue table a = case [name | (name, b) <- table, a == b] of
name : _ -> String name
[] -> error "internal wire enum table is incomplete"
enumParser :: [(Text, a)] -> Value -> Parser a
enumParser table = withText "enum" (\name -> maybe (fail ("unknown value: " ++ Text.unpack name)) pure (lookup name table))
stageTable :: [(Text, Stage)]
stageTable = [("setup", Setup), ("build", Build), ("test", Test), ("replay", Replay), ("teardown", Teardown)]
verdicts :: [(Text, Verdict)]
verdicts = [("caught", Caught), ("survived", Survived), ("invalid-mutation", InvalidMutation), ("uncovered", Uncovered), ("unexpected-build-failure", UnexpectedBuildFailure), ("wrong-failure", WrongFailure), ("timed-out", TimedOut), ("infrastructure-error", InfrastructureError), ("not-run", NotRun)]
detections :: [(Text, DetectionKind)]
detections = [("semantic-named", SemanticNamed), ("generic-diagnostic", GenericDiagnostic), ("expected-build-rejection", ExpectedBuildRejection), ("explicit-crash", ExplicitCrash), ("control-passed", ControlPassed)]
statuses :: [(Text, RunStatus)]
statuses = [("passed", RunPassed), ("failed", RunFailed), ("untrustworthy", RunUntrustworthy), ("interrupted", RunInterrupted)]
nullable :: (a -> Value) -> Maybe a -> Value
nullable = maybe Null
parseNullable :: (Value -> Parser a) -> Value -> Parser (Maybe a)
parseNullable _ Null = pure Nothing
parseNullable f v = Just <$> f v
commandJSON :: Command -> Value
commandJSON Command {executable, arguments, sensitiveArguments, workingDirectory, environment, budget, output = OutputPolicy {limitBytes}} =
object
[ "executable" .= executable,
"arguments" .= arguments,
"sensitiveArguments" .= Set.toAscList sensitiveArguments,
"workingDirectory" .= fmap targetPath workingDirectory,
"environment" .= object [Key.fromString k .= v | (k, v) <- environment],
"timeoutMicroseconds" .= budgetMicroseconds budget,
"outputLimitBytes" .= limitBytes
]
parseCommand :: Value -> Parser Command
parseCommand = strictObject "command" ["executable", "arguments", "sensitiveArguments", "workingDirectory", "environment", "timeoutMicroseconds", "outputLimitBytes"] $ \o -> do
executable <- o .: "executable"
arguments <- o .:? "arguments" .!= []
sensitiveArguments <- o .:? "sensitiveArguments" .!= [] >>= uniqueSet
relative <- o .:? "workingDirectory"
workingDirectory <- traverse (checked . mkRelativeTarget) relative
env <- o .:? "environment" .!= KM.empty
environment <- mapM (\(k, v) -> (,) (Key.toString k) <$> parseJSON v) (KM.toList env)
budget <- o .: "timeoutMicroseconds" >>= checked . mkStageBudget
output <- OutputPolicy <$> o .:? "outputLimitBytes" .!= 1048576
let command = Command {executable, arguments, sensitiveArguments, workingDirectory, environment, budget, output}
checked (validateCommand command)
pure command
editJSON :: Edit -> Value
editJSON (Replace p old new n) = object ["kind" .= String "replace", "path" .= targetPath p, "old" .= old, "new" .= new, "occurrences" .= n]
editJSON (Create p content) = object ["kind" .= String "create", "path" .= targetPath p, "content" .= content]
editJSON (Delete p content) = object ["kind" .= String "delete", "path" .= targetPath p, "expected" .= content]
editJSON (Patch content) = object ["kind" .= String "patch", "content" .= content]
editJSON (CustomEdit key) = object ["kind" .= String "custom", "key" .= key]
parseEdit :: Value -> Parser Edit
parseEdit value =
withObject
"edit"
( \o ->
o .: "kind" >>= \(kind :: Text) -> case kind of
"replace" -> strictObject "replace" ["kind", "path", "old", "new", "occurrences"] (\x -> Replace <$> (x .: "path" >>= checked . mkRelativeTarget) <*> x .: "old" <*> x .: "new" <*> x .:? "occurrences" .!= 1) value
"create" -> strictObject "create" ["kind", "path", "content"] (\x -> Create <$> (x .: "path" >>= checked . mkRelativeTarget) <*> x .: "content") value
"delete" -> strictObject "delete" ["kind", "path", "expected"] (\x -> Delete <$> (x .: "path" >>= checked . mkRelativeTarget) <*> x .: "expected") value
"patch" -> strictObject "patch" ["kind", "content"] (\x -> Patch <$> x .: "content") value
"custom" -> strictObject "custom" ["kind", "key"] (\x -> CustomEdit <$> x .: "key") value
_ -> fail "unknown edit kind"
)
value
expectationJSON :: Expectation -> Value
expectationJSON (NamedTests ids policy) = object ["kind" .= String "named-tests", "failures" .= Set.toAscList ids, "replayRequired" .= (policy == RequireReplay)]
expectationJSON (Diagnostic stage code witness) = object ["kind" .= String "diagnostic", "stage" .= enumValue stageTable stage, "exitCode" .= code, "outputContains" .= witness]
expectationJSON (BuildRejection code witness) = object ["kind" .= String "build-rejection", "exitCode" .= code, "outputContains" .= witness]
expectationJSON (CrashProbe signal probe) = object ["kind" .= String "crash", "signal" .= signal, "probe" .= probe]
expectationJSON Control = object ["kind" .= String "control"]
parseExpectation :: Value -> Parser Expectation
parseExpectation value =
withObject
"expectation"
( \o ->
o .: "kind" >>= \(kind :: Text) -> case kind of
"named-tests" -> strictObject "named-tests" ["kind", "failures", "replayRequired"] (\x -> NamedTests <$> (x .: "failures" >>= uniqueSet) <*> ((\b -> if b then RequireReplay else NoReplay) <$> (x .:? "replayRequired" .!= False))) value
"diagnostic" -> strictObject "diagnostic" ["kind", "stage", "exitCode", "outputContains"] (\x -> Diagnostic <$> (x .: "stage" >>= enumParser stageTable) <*> x .: "exitCode" <*> x .: "outputContains") value
"build-rejection" -> strictObject "build-rejection" ["kind", "exitCode", "outputContains"] (\x -> BuildRejection <$> x .: "exitCode" <*> x .: "outputContains") value
"crash" -> strictObject "crash" ["kind", "signal", "probe"] (\x -> CrashProbe <$> x .: "signal" <*> x .: "probe") value
"control" -> strictObject "control" ["kind"] (const (pure Control)) value
_ -> fail "unknown expectation kind"
)
value
uniqueSet :: (Ord a) => [a] -> Parser (Set.Set a)
uniqueSet xs = let s = Set.fromList xs in if Set.size s == length xs then pure s else fail "duplicate set member"
caseJSON :: CaseDefinition -> Value
caseJSON CaseDefinition {caseId, description, tags, edits, build, test, selector, expectation, evidenceMode} =
object
[ "id" .= caseIdText caseId,
"description" .= description,
"tags" .= Set.toAscList tags,
"edits" .= map editJSON edits,
"build" .= commandIdText build,
"test" .= fmap commandIdText test,
"selector" .= selector,
"expect" .= expectationJSON expectation,
"adapter" .= (case evidenceMode of GenericCommands -> Null; StructuredTestsV1 -> String "heni-v1")
]
parseCase :: Value -> Parser CaseDefinition
parseCase = strictObject "case" ["id", "description", "tags", "edits", "build", "test", "selector", "expect", "adapter"] $ \o -> do
caseId <- o .: "id" >>= checked . mkCaseId
description <- o .: "description"
tags <- o .:? "tags" .!= [] >>= uniqueSet
edits <- o .:? "edits" .!= [] >>= mapM parseEdit
build <- o .: "build" >>= checked . mkCommandId
test <- o .:? "test" >>= traverse (checked . mkCommandId)
selector <- o .:? "selector"
expectation <- o .: "expect" >>= parseExpectation
evidenceMode <-
o .:? "adapter" >>= \case
Nothing -> pure GenericCommands
Just ("heni-v1" :: Text) -> pure StructuredTestsV1
Just _ -> fail "unsupported adapter; expected heni-v1"
let definition = CaseDefinition {caseId, description, tags, edits, build, test, selector, expectation, evidenceMode}
checked (validateCase definition)
pure definition
outcomeJSON :: ProcessOutcome -> Value
outcomeJSON (Exited code) = object ["kind" .= String "exit", "code" .= code]
outcomeJSON (Signaled signal) = object ["kind" .= String "signal", "signal" .= signal]
outcomeJSON Timeout = object ["kind" .= String "timeout"]
outcomeJSON Cancelled = object ["kind" .= String "cancelled"]
outcomeJSON (LaunchFailed message) = object ["kind" .= String "launch-failed", "message" .= message]
parseOutcome :: Value -> Parser ProcessOutcome
parseOutcome value =
withObject
"outcome"
( \o ->
o .: "kind" >>= \(kind :: Text) -> case kind of
"exit" -> strictObject "exit" ["kind", "code"] (\x -> do n <- x .: "code"; if n < 0 then fail "negative normal exit" else pure (Exited n)) value
"signal" -> strictObject "signal" ["kind", "signal"] (\x -> do n <- x .: "signal"; if n <= 0 then fail "nonpositive signal" else pure (Signaled n)) value
"timeout" -> strictObject "timeout" ["kind"] (const (pure Timeout)) value
"cancelled" -> strictObject "cancelled" ["kind"] (const (pure Cancelled)) value
"launch-failed" -> strictObject "launch-failed" ["kind", "message"] (\x -> LaunchFailed <$> x .: "message") value
_ -> fail "unknown process outcome"
)
value
selectionJSON :: SelectionEvidence -> Value
selectionJSON SelectionEvidence {discovered, selected, executed, executedIds, failedIds, skippedIds, setupErrors} =
object
[ "discovered" .= discovered,
"selected" .= selected,
"executed" .= executed,
"executedIds" .= Set.toAscList executedIds,
"failedIds" .= Set.toAscList failedIds,
"skippedIds" .= Set.toAscList skippedIds,
"setupErrors" .= setupErrors
]
parseSelection :: Value -> Parser SelectionEvidence
parseSelection = strictObject "selection" ["discovered", "selected", "executed", "executedIds", "failedIds", "skippedIds", "setupErrors"] $ \o ->
SelectionEvidence
<$> o .: "discovered"
<*> o .: "selected"
<*> o .: "executed"
<*> (o .: "executedIds" >>= uniqueSet)
<*> (o .: "failedIds" >>= uniqueSet)
<*> (o .: "skippedIds" >>= uniqueSet)
<*> o .: "setupErrors"
replayJSON :: ReplayEvidence -> Value
replayJSON ReplayEvidence {token, generator, failures} = object ["token" .= token, "generator" .= generator, "failures" .= Set.toAscList failures]
parseReplay :: Value -> Parser ReplayEvidence
parseReplay = strictObject "replay" ["token", "generator", "failures"] $ \o -> ReplayEvidence <$> o .: "token" <*> o .: "generator" <*> (o .: "failures" >>= uniqueSet)
stageJSON :: StageEvidence -> Value
stageJSON StageEvidence {stage, outcome, transcript, truncated, selection, replay, durationSeconds, logPaths} =
object
[ "stage" .= enumValue stageTable stage,
"outcome" .= outcomeJSON outcome,
"witnesses" .= transcript,
"evidenceUnavailable" .= truncated,
"selection" .= selectionJSON selection,
"replay" .= nullable replayJSON replay,
"durationSeconds" .= durationSeconds,
"logPaths" .= logPaths
]
parseStage :: Value -> Parser StageEvidence
parseStage = strictObject "stage" ["stage", "outcome", "witnesses", "evidenceUnavailable", "selection", "replay", "durationSeconds", "logPaths"] $ \o -> do
stage <- o .: "stage" >>= enumParser stageTable
outcome <- o .: "outcome" >>= parseOutcome
transcript <- o .: "witnesses"
truncated <- o .: "evidenceUnavailable"
selection <- o .: "selection" >>= parseSelection
replay <- o .: "replay" >>= parseNullable parseReplay
durationSeconds <- o .: "durationSeconds"
unless (durationSeconds >= 0 && not (isInfinite durationSeconds || isNaN durationSeconds)) (fail "invalid duration")
logPaths <- o .: "logPaths"
mapM_ (checked . mkRelativeTarget) logPaths
pure StageEvidence {stage, outcome, transcript, truncated, selection, replay, durationSeconds, logPaths}
baselineJSON :: BaselineEvidence -> Value
baselineJSON BaselinePassed = object ["kind" .= String "passed"]
baselineJSON BaselineUnavailable = object ["kind" .= String "unavailable"]
baselineJSON (BaselineFailed message) = object ["kind" .= String "failed", "message" .= message]
parseBaseline :: Value -> Parser BaselineEvidence
parseBaseline = strictObject "baseline" ["kind", "message"] $ \o ->
o .: "kind" >>= \(kind :: Text) -> case kind of
"passed" -> pure BaselinePassed
"unavailable" -> pure BaselineUnavailable
"failed" -> BaselineFailed <$> o .: "message"
_ -> fail "unknown baseline status"
mutationJSON :: MutationEvidence -> Value
mutationJSON Changed = object ["kind" .= String "changed"]
mutationJSON Unchanged = object ["kind" .= String "unchanged"]
mutationJSON (EditRejected message) = object ["kind" .= String "rejected", "message" .= message]
parseMutation :: Value -> Parser MutationEvidence
parseMutation = strictObject "mutation" ["kind", "message"] $ \o ->
o .: "kind" >>= \(kind :: Text) -> case kind of
"changed" -> pure Changed
"unchanged" -> pure Unchanged
"rejected" -> EditRejected <$> o .: "message"
_ -> fail "unknown mutation status"
evidenceJSON :: CaseEvidence -> Value
evidenceJSON CaseEvidence {baseline, mutation, stages, infrastructureErrors, cleanupErrors, stopped} =
object
[ "baseline" .= baselineJSON baseline,
"mutation" .= mutationJSON mutation,
"stages" .= map stageJSON stages,
"infrastructureErrors" .= infrastructureErrors,
"cleanupErrors" .= cleanupErrors,
"stopped" .= stopped
]
parseEvidence :: Value -> Parser CaseEvidence
parseEvidence = strictObject "case evidence" ["baseline", "mutation", "stages", "infrastructureErrors", "cleanupErrors", "stopped"] $ \o ->
CaseEvidence
<$> (o .: "baseline" >>= parseBaseline)
<*> (o .: "mutation" >>= parseMutation)
<*> (o .: "stages" >>= mapM parseStage)
<*> o .: "infrastructureErrors"
<*> o .: "cleanupErrors"
<*> o .: "stopped"
caseResultJSON :: CaseResult -> Value
caseResultJSON CaseResult {caseId, verdict, detection, semantic, control, reason, evidence} =
object
[ "id" .= caseIdText caseId,
"verdict" .= enumValue verdicts verdict,
"detection" .= nullable (enumValue detections) detection,
"semantic" .= semantic,
"control" .= control,
"reason" .= reason,
"evidence" .= evidenceJSON evidence
]
parseCaseResult :: Value -> Parser CaseResult
parseCaseResult = strictObject "case result" ["id", "verdict", "detection", "semantic", "control", "reason", "evidence"] $ \o ->
CaseResult
<$> (o .: "id" >>= checked . mkCaseId)
<*> (o .: "verdict" >>= enumParser verdicts)
<*> (o .: "detection" >>= parseNullable (enumParser detections))
<*> o .: "semantic"
<*> o .: "control"
<*> o .: "reason"
<*> (o .: "evidence" >>= parseEvidence)
failureJSON :: RunFailure -> Value
failureJSON (BaselineFailure ids why) = object ["kind" .= String "baseline", "affected" .= map caseIdText ids, "message" .= why]
failureJSON (ControlFailure i why) = object ["kind" .= String "control", "id" .= caseIdText i, "message" .= why]
failureJSON (RunInfrastructure why) = object ["kind" .= String "infrastructure", "message" .= why]
failureJSON (Interrupted why) = object ["kind" .= String "interrupted", "message" .= why]
parseFailure :: Value -> Parser RunFailure
parseFailure = strictObject "run failure" ["kind", "affected", "id", "message"] $ \o ->
o .: "kind" >>= \(kind :: Text) -> case kind of
"baseline" -> BaselineFailure <$> (o .: "affected" >>= mapM (checked . mkCaseId)) <*> o .: "message"
"control" -> ControlFailure <$> (o .: "id" >>= checked . mkCaseId) <*> o .: "message"
"infrastructure" -> RunInfrastructure <$> o .: "message"
"interrupted" -> Interrupted <$> o .: "message"
_ -> fail "unknown run failure"
scoreJSON :: SemanticScore -> Value
scoreJSON (SemanticScore caught total complete) = object ["caught" .= caught, "total" .= total, "complete" .= complete]
parseScore :: Value -> Parser SemanticScore
parseScore = strictObject "score" ["caught", "total", "complete"] $ \o -> SemanticScore <$> o .: "caught" <*> o .: "total" <*> o .: "complete"
resultJSON :: RunResult -> Value
resultJSON RunResult {selectedIds, results, failures, status, score} =
object
[ "selectedIds" .= map caseIdText selectedIds,
"cases" .= map caseResultJSON results,
"failures" .= map failureJSON failures,
"status" .= enumValue statuses status,
"semanticScore" .= nullable scoreJSON score
]
parseResult :: Value -> Parser RunResult
parseResult = strictObject "run result" ["selectedIds", "cases", "failures", "status", "semanticScore"] $ \o -> do
selected <- o .: "selectedIds" >>= mapM (checked . mkCaseId) >>= checked . mkSelectedCases
cases <- o .: "cases" >>= mapM parseCaseResult
failures <- o .: "failures" >>= mapM parseFailure
status <- o .: "status" >>= enumParser statuses
score <- o .: "semanticScore" >>= parseNullable parseScore
result <- checked (summarizeRun selected cases failures)
let RunResult {status = actualStatus, score = actualScore} = result
unless (actualStatus == status && actualScore == score) (fail "stored summary contradicts selected ledger")
pure result