mirror of
https://github.com/crytic/echidna.git
synced 2026-10-12 02:12:32 +00:00
Merge branch 'master' into dev-sym-exec-fixes
This commit is contained in:
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
env:
|
||||
# Tag for cache invalidation
|
||||
CACHE_VERSION: v9
|
||||
CACHE_VERSION: v10
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
shell: bash
|
||||
container: "{\"image\": \"elopeztob/alpine-haskell-stack-echidna:ghc-9.8.4\", \"options\": \"--user 1001\"}"
|
||||
- os: macos-13 # x86_64 macOS
|
||||
- os: macos-15-intel # x86_64 macOS
|
||||
shell: bash
|
||||
- os: windows-latest
|
||||
shell: msys2 {0}
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
name: Linux (aarch64)
|
||||
tuple: aarch64-linux
|
||||
timeout: 180
|
||||
- os: macos-13
|
||||
- os: macos-15-intel
|
||||
name: macOS (x86_64)
|
||||
tuple: x86_64-macos
|
||||
- os: macos-14
|
||||
|
||||
+13
-5
@@ -4,22 +4,24 @@ import Control.Concurrent (newChan)
|
||||
import Control.Monad.Catch (MonadThrow(..))
|
||||
import Control.Monad.ST (RealWorld)
|
||||
import Data.IORef (newIORef)
|
||||
import Data.List (find)
|
||||
import Data.List (find, nub)
|
||||
import Data.List.NonEmpty (NonEmpty)
|
||||
import Data.List.NonEmpty qualified as NE
|
||||
import Data.Map.Strict qualified as Map
|
||||
import Data.Maybe (mapMaybe)
|
||||
import Data.Set qualified as Set
|
||||
import System.FilePath ((</>))
|
||||
|
||||
import EVM (cheatCode)
|
||||
import EVM.ABI (AbiValue(AbiAddress))
|
||||
import EVM.Dapp (dappInfo)
|
||||
import EVM.Solidity (BuildOutput(..), Contracts(Contracts))
|
||||
import EVM.Solidity (BuildOutput(..), Contracts(Contracts), Method(..), Mutability(..), SolcContract(..))
|
||||
import EVM.Types hiding (Env)
|
||||
|
||||
import Echidna.ABI
|
||||
import Echidna.Onchain as Onchain
|
||||
import Echidna.Output.Corpus
|
||||
import Echidna.SourceMapping (findSrcForReal)
|
||||
import Echidna.SourceAnalysis.Slither
|
||||
import Echidna.Solidity
|
||||
import Echidna.SymExec.Symbolic (forceAddr)
|
||||
@@ -72,7 +74,6 @@ prepareContract cfg solFiles buildOutput selectedContract seed = do
|
||||
|
||||
-- deploy contracts
|
||||
vm <- loadSpecified env mainContract contracts
|
||||
|
||||
let
|
||||
deployedAddresses = Set.fromList $ AbiAddress . forceAddr <$> Map.keys vm.env.contracts
|
||||
constants = enhanceConstants slitherInfo
|
||||
@@ -80,14 +81,21 @@ prepareContract cfg solFiles buildOutput selectedContract seed = do
|
||||
<> extremeConstants
|
||||
<> staticAddresses solConf
|
||||
<> deployedAddresses
|
||||
|
||||
deployedSolcContracts = nub $ mapMaybe (findSrcForReal env.dapp) $ Map.elems vm.env.contracts
|
||||
nonViewPureSigs = concatMap (mapMaybe (\ (Method {name, inputs, mutability}) ->
|
||||
case mutability of
|
||||
View -> Nothing
|
||||
Pure -> Nothing
|
||||
Payable -> Just (name, map snd inputs)
|
||||
NonPayable -> Just (name, map snd inputs))
|
||||
. Map.elems . (\ (SolcContract {abiMap}) -> abiMap)) deployedSolcContracts
|
||||
dict = mkGenDict env.cfg.campaignConf.dictFreq
|
||||
-- make sure we don't use cheat codes to form fuzzing call sequences
|
||||
(Set.delete (AbiAddress $ forceAddr cheatCode) constants)
|
||||
Set.empty
|
||||
seed
|
||||
(returnTypes contracts)
|
||||
|
||||
nonViewPureSigs
|
||||
pure (vm, env, dict)
|
||||
|
||||
loadInitialCorpus :: Env -> IO [(FilePath, [Tx])]
|
||||
|
||||
+43
-21
@@ -1,6 +1,6 @@
|
||||
module Echidna.ABI where
|
||||
|
||||
import Control.Monad (liftM2, liftM3, foldM, replicateM)
|
||||
import Control.Monad (liftM2, liftM3, foldM, replicateM, zipWithM)
|
||||
import Control.Monad.Random.Strict (MonadRandom, join, getRandom, getRandoms, getRandomR, uniform, fromList)
|
||||
import Control.Monad.Random.Strict qualified as Random
|
||||
import Data.Binary.Put (runPut, putWord32be)
|
||||
@@ -121,6 +121,8 @@ data GenDict = GenDict
|
||||
-- ^ Return types of any methods we scrape return values from
|
||||
, dictValues :: !(Set W256)
|
||||
-- ^ A set of int/uint constants for better performance
|
||||
, callbackSigs :: ![SolSignature]
|
||||
-- ^ A list of callback signatures (for generating random callbacks)
|
||||
}
|
||||
|
||||
hashMapBy
|
||||
@@ -142,6 +144,7 @@ mkGenDict
|
||||
-> Set SolCall -- ^ A list of complete 'SolCall's to mutate
|
||||
-> Int -- ^ A default seed
|
||||
-> (Text -> Maybe AbiType) -- ^ A return value typing rule
|
||||
-> [SolSignature]
|
||||
-> GenDict
|
||||
mkGenDict mutationChance abiValues solCalls seed typingRule =
|
||||
GenDict mutationChance
|
||||
@@ -152,7 +155,7 @@ mkGenDict mutationChance abiValues solCalls seed typingRule =
|
||||
(mkDictValues abiValues)
|
||||
|
||||
emptyDict :: GenDict
|
||||
emptyDict = mkGenDict 0 Set.empty Set.empty 0 (const Nothing)
|
||||
emptyDict = mkGenDict 0 Set.empty Set.empty 0 (const Nothing) []
|
||||
|
||||
mkDictValues :: Set AbiValue -> Set W256
|
||||
mkDictValues =
|
||||
@@ -382,31 +385,50 @@ pregenAbiAdds = map (AbiAddress . fromIntegral) pregenAdds
|
||||
-- | Synthesize a random 'AbiValue' given its 'AbiType'. Requires a dictionary.
|
||||
-- Only produce lists with number of elements in the range [1, 32]
|
||||
genAbiValueM :: MonadRandom m => GenDict -> AbiType -> m AbiValue
|
||||
genAbiValueM genDict = genWithDict genDict genDict.constants $ \case
|
||||
AbiUIntType n -> fixAbiUInt n . fromInteger <$> getRandomUint n
|
||||
AbiIntType n -> fixAbiInt n . fromInteger <$> getRandomInt n
|
||||
AbiAddressType -> rElem $ NE.fromList pregenAbiAdds
|
||||
AbiBoolType -> AbiBool <$> getRandom
|
||||
AbiBytesType n -> AbiBytes n . BS.pack . take n <$> getRandoms
|
||||
AbiBytesDynamicType -> liftM2 (\n -> AbiBytesDynamic . BS.pack . take n)
|
||||
(getRandomR (1, 32)) getRandoms
|
||||
AbiStringType -> liftM2 (\n -> AbiString . BS.pack . take n)
|
||||
(getRandomR (1, 32)) getRandoms
|
||||
AbiArrayDynamicType t -> fmap (AbiArrayDynamic t) $ getRandomR (1, 32)
|
||||
>>= flip V.replicateM (genAbiValueM genDict t)
|
||||
AbiArrayType n t -> AbiArray n t <$> V.replicateM n (genAbiValueM genDict t)
|
||||
AbiTupleType v -> AbiTuple <$> traverse (genAbiValueM genDict) v
|
||||
AbiFunctionType -> liftM2 (\n -> AbiString . BS.pack . take n)
|
||||
(getRandomR (1, 32)) getRandoms
|
||||
genAbiValueM genDict = genAbiValueM' genDict "" 0
|
||||
|
||||
genAbiValueM' :: MonadRandom m => GenDict -> Text -> Int -> AbiType -> m AbiValue
|
||||
genAbiValueM' genDict funcName depth t =
|
||||
let go = \case
|
||||
AbiUIntType n -> fixAbiUInt n . fromInteger <$> getRandomUint n
|
||||
AbiIntType n -> fixAbiInt n . fromInteger <$> getRandomInt n
|
||||
AbiAddressType -> rElem $ NE.fromList pregenAbiAdds
|
||||
AbiBoolType -> AbiBool <$> getRandom
|
||||
AbiBytesType n -> AbiBytes n . BS.pack . take n <$> getRandoms
|
||||
AbiBytesDynamicType ->
|
||||
let
|
||||
filteredSigs = filter ((/= funcName) . fst) genDict.callbackSigs
|
||||
in if null filteredSigs || depth >= 2 then
|
||||
liftM2 (\n -> AbiBytesDynamic . BS.pack . take n)
|
||||
(getRandomR (1, 32)) getRandoms
|
||||
else
|
||||
join $ Random.weighted
|
||||
[ (do
|
||||
sig@(_, types) <- uniform filteredSigs
|
||||
params <- V.fromList <$> mapM (genAbiValueM' genDict "" $ depth + 1) types
|
||||
pure $ AbiBytesDynamic (abiCalldata (encodeSig sig) params), 9)
|
||||
, (liftM2 (\n -> AbiBytesDynamic . BS.pack . take n)
|
||||
(getRandomR (1, 8)) getRandoms, 1)
|
||||
]
|
||||
AbiStringType -> liftM2 (\n -> AbiString . BS.pack . take n)
|
||||
(getRandomR (1, 32)) getRandoms
|
||||
AbiArrayDynamicType t' -> fmap (AbiArrayDynamic t') $ getRandomR (1, 32)
|
||||
>>= flip V.replicateM (genAbiValueM' genDict funcName (depth + 1) t')
|
||||
AbiArrayType n t' -> AbiArray n t' <$> V.replicateM n (genAbiValueM' genDict funcName (depth + 1) t')
|
||||
AbiTupleType v -> AbiTuple <$> traverse (genAbiValueM' genDict funcName (depth + 1)) v
|
||||
AbiFunctionType -> liftM2 (\n -> AbiString . BS.pack . take n)
|
||||
(getRandomR (1, 32)) getRandoms
|
||||
in genWithDict genDict genDict.constants go t
|
||||
|
||||
-- | Given a 'SolSignature', generate a random 'SolCall' with that signature,
|
||||
-- possibly with a dictionary.
|
||||
genAbiCallM :: MonadRandom m => GenDict -> SolSignature -> m SolCall
|
||||
genAbiCallM genDict abi = do
|
||||
genAbiCallM genDict (name, types) = do
|
||||
let genVals = zipWithM (flip (genAbiValueM' genDict name)) types (repeat 0)
|
||||
solCall <- genWithDict genDict
|
||||
genDict.wholeCalls
|
||||
(traverse $ traverse (genAbiValueM genDict))
|
||||
abi
|
||||
(const ((name,) <$> genVals))
|
||||
(name, types)
|
||||
mutateAbiCall solCall
|
||||
|
||||
-- | Given a list of 'SolSignature's, generate a random 'SolCall' for one,
|
||||
|
||||
@@ -98,6 +98,7 @@ instance FromJSON EConfigWithUsage where
|
||||
<*> v ..:? "coverageDir" ..!= Nothing
|
||||
<*> v ..:? "mutConsts" ..!= defaultMutationConsts
|
||||
<*> v ..:? "coverageFormats" ..!= [Txt,Html,Lcov]
|
||||
<*> v ..:? "coverageExcludes" ..!= []
|
||||
<*> v ..:? "workers"
|
||||
<*> v ..:? "server"
|
||||
<*> v ..:? "symExec" ..!= False
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
module Echidna.Output.Foundry (foundryTest) where
|
||||
|
||||
import Data.Aeson (Value(..), object, (.=))
|
||||
import Data.List (elemIndex, nub)
|
||||
import Data.Maybe (fromMaybe, mapMaybe)
|
||||
import Data.Text (Text, unpack)
|
||||
import Data.Text.Encoding (decodeUtf8)
|
||||
import qualified Data.Text.Lazy as TL
|
||||
import Data.Text.Lazy (fromStrict)
|
||||
import Data.Vector as V hiding ((++), map, zipWith, elemIndex, mapMaybe)
|
||||
import EVM.ABI (AbiValue(..))
|
||||
import EVM.Types (W256, Addr)
|
||||
import Numeric (showHex)
|
||||
import Text.Mustache (Template, substituteValue, toMustache)
|
||||
import Text.Mustache.Compile (embedTemplate)
|
||||
|
||||
import Echidna.Types.Test (EchidnaTest(..), TestType(..))
|
||||
import Echidna.Types.Tx (Tx(..), TxCall(..))
|
||||
|
||||
template :: Template
|
||||
template = $(embedTemplate ["lib/Echidna/Output/assets"] "foundry.mustache")
|
||||
|
||||
-- | Generate a Foundry test from an EchidnaTest result.
|
||||
foundryTest :: Maybe Text -> EchidnaTest -> TL.Text
|
||||
foundryTest mContractName test =
|
||||
case test.testType of
|
||||
AssertionTest{} ->
|
||||
let testData = createTestData mContractName test
|
||||
in fromStrict $ substituteValue template (toMustache testData)
|
||||
_ -> ""
|
||||
|
||||
-- | Create an Aeson Value from test data for the Mustache template.
|
||||
createTestData :: Maybe Text -> EchidnaTest -> Value
|
||||
createTestData mContractName test =
|
||||
let
|
||||
senders = nub $ map (.src) test.reproducer
|
||||
actors = zipWith actorObject senders [1..]
|
||||
repro = mapMaybe (foundryTx senders) test.reproducer
|
||||
cName = fromMaybe "YourContract" mContractName
|
||||
in
|
||||
object
|
||||
[ "testName" .= ("Test" :: Text)
|
||||
, "contractName" .= cName
|
||||
, "actors" .= actors
|
||||
, "reproducer" .= repro
|
||||
]
|
||||
|
||||
-- | Create a JSON object for an actor.
|
||||
actorObject :: Addr -> Int -> Value
|
||||
actorObject sender i = object
|
||||
[ "name" .= ("USER" ++ show i :: String)
|
||||
, "address" .= formatAddr sender
|
||||
]
|
||||
|
||||
-- | Format an address for Solidity.
|
||||
formatAddr :: Addr -> String
|
||||
formatAddr addr = "address(0x" ++ showHex (fromIntegral addr :: W256) "" ++ ")"
|
||||
|
||||
-- | Generate a single transaction line for the reproducer.
|
||||
foundryTx :: [Addr] -> Tx -> Maybe Value
|
||||
foundryTx senders tx =
|
||||
case tx.call of
|
||||
SolCall (name, args) ->
|
||||
let
|
||||
(time, blocks) = tx.delay
|
||||
senderName =
|
||||
case elemIndex tx.src senders of
|
||||
Just i -> "USER" ++ show (i + 1)
|
||||
Nothing -> formatAddr tx.src
|
||||
prelude =
|
||||
(if time > 0 || blocks > 0 then " _delay(" ++ show time ++ ", " ++ show blocks ++ ");\n" else "") ++
|
||||
" _setUpActor(" ++ senderName ++ ");"
|
||||
call = " Target." ++ unpack name ++ "(" ++ foundryArgs (map abiValueToString args) ++ ");"
|
||||
in Just $ object ["prelude" .= prelude, "call" .= call]
|
||||
_ -> Nothing
|
||||
|
||||
-- | Format arguments for a Solidity call.
|
||||
foundryArgs :: [String] -> String
|
||||
foundryArgs [] = ""
|
||||
foundryArgs [x] = x
|
||||
foundryArgs (x:xs) = x ++ ", " ++ foundryArgs xs
|
||||
|
||||
-- | Convert an AbiValue to its string representation for Solidity.
|
||||
abiValueToString :: AbiValue -> String
|
||||
abiValueToString (AbiUInt _ w) = show w
|
||||
abiValueToString (AbiInt _ w) = show w
|
||||
abiValueToString (AbiAddress a) = "address(0x" ++ showHex (fromIntegral a :: W256) "" ++ ")"
|
||||
abiValueToString (AbiBool b) = if b then "true" else "false"
|
||||
abiValueToString (AbiBytes _ bs) = "hex\"" ++ unpack (decodeUtf8 bs) ++ "\""
|
||||
abiValueToString (AbiString s) = show s
|
||||
abiValueToString (AbiTuple vs) = "(" ++ foundryArgs (map abiValueToString (V.toList vs)) ++ ")"
|
||||
abiValueToString _ = ""
|
||||
+184
-27
@@ -1,4 +1,5 @@
|
||||
{-# LANGUAGE ViewPatterns #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
module Echidna.Output.Source where
|
||||
|
||||
@@ -6,10 +7,13 @@ import Prelude hiding (writeFile)
|
||||
|
||||
import Control.Monad (unless)
|
||||
import Data.ByteString qualified as BS
|
||||
import Text.Mustache (substituteValue, toMustache)
|
||||
import Text.Mustache.Compile (embedTemplate)
|
||||
import Text.Mustache.Types (Template, Value(..))
|
||||
import Data.Foldable
|
||||
import Data.List (nub, sort)
|
||||
import Data.List.NonEmpty qualified as NE
|
||||
import Data.Maybe (fromMaybe, mapMaybe)
|
||||
import Data.Maybe (fromMaybe, mapMaybe, catMaybes)
|
||||
import Data.Map (Map)
|
||||
import Data.Map qualified as Map
|
||||
import Data.Sequence qualified as Seq
|
||||
@@ -18,11 +22,13 @@ import Data.Text (Text, pack)
|
||||
import Data.Text qualified as T
|
||||
import Data.Text.Encoding (decodeUtf8)
|
||||
import Data.Text.IO (writeFile)
|
||||
import Data.Time (getCurrentTime, formatTime, defaultTimeLocale)
|
||||
import Data.Vector qualified as V
|
||||
import Data.Vector.Unboxed qualified as VU
|
||||
import HTMLEntities.Text qualified as HTML
|
||||
import System.Directory (createDirectoryIfMissing)
|
||||
import System.FilePath ((</>))
|
||||
import System.FilePath ((</>), splitDirectories, joinPath, takeDirectory)
|
||||
import System.FilePath.Glob qualified as Glob
|
||||
import Text.Printf (printf)
|
||||
|
||||
import EVM.Dapp (srcMapCodePos, DappInfo(..))
|
||||
@@ -34,6 +40,19 @@ import Echidna.Types.Coverage (OpIx, unpackTxResults, FrozenCoverageMap, Coverag
|
||||
import Echidna.Types.Tx (TxResult(..))
|
||||
import Echidna.SourceAnalysis.Slither (AssertLocation(..), assertLocationList, SlitherInfo(..))
|
||||
|
||||
-- | Embedded template with partials for coverage reports
|
||||
coverageTemplate :: Template
|
||||
coverageTemplate = $(embedTemplate ["lib/Echidna/Output/assets"] "coverage.mustache")
|
||||
|
||||
-- | Filter files based on exclude patterns, using relative paths from common prefix
|
||||
filterExcludedFiles :: [Text] -> FilePath -> [(FilePath, V.Vector Text)] -> [(FilePath, V.Vector Text)]
|
||||
filterExcludedFiles excludePatterns commonPrefix allFiles =
|
||||
let patterns = map (Glob.compile . T.unpack) excludePatterns
|
||||
isExcluded filePath =
|
||||
let relativePath = makeRelativePath commonPrefix filePath
|
||||
in any (`Glob.match` relativePath) patterns
|
||||
in filter (not . isExcluded . fst) allFiles
|
||||
|
||||
saveCoverages
|
||||
:: Env
|
||||
-> Int
|
||||
@@ -43,8 +62,10 @@ saveCoverages
|
||||
-> IO ()
|
||||
saveCoverages env seed d sc cs = do
|
||||
let fileTypes = env.cfg.campaignConf.coverageFormats
|
||||
coverageExcludes = env.cfg.campaignConf.coverageExcludes
|
||||
projectName = env.cfg.projectName
|
||||
coverage <- mergeCoverageMaps env.dapp env.coverageRefInit env.coverageRefRuntime
|
||||
mapM_ (\ty -> saveCoverage ty seed d sc cs coverage) fileTypes
|
||||
mapM_ (\ty -> saveCoverage ty seed d sc cs coverage projectName coverageExcludes) fileTypes
|
||||
|
||||
saveCoverage
|
||||
:: CoverageFileType
|
||||
@@ -53,11 +74,15 @@ saveCoverage
|
||||
-> SourceCache
|
||||
-> [SolcContract]
|
||||
-> FrozenCoverageMap
|
||||
-> Maybe Text
|
||||
-> [Text]
|
||||
-> IO ()
|
||||
saveCoverage fileType seed d sc cs covMap = do
|
||||
saveCoverage fileType seed d sc cs covMap projectName excludePatterns = do
|
||||
let extension = coverageFileExtension fileType
|
||||
fn = d </> "covered." <> show seed <> extension
|
||||
cc = ppCoveredCode fileType sc cs covMap
|
||||
currentTime <- getCurrentTime
|
||||
let timestamp = T.pack $ formatTime defaultTimeLocale "%B %d, %Y at %H:%M:%S UTC" currentTime
|
||||
cc = ppCoveredCode fileType sc cs covMap projectName timestamp excludePatterns
|
||||
createDirectoryIfMissing True d
|
||||
writeFile fn cc
|
||||
|
||||
@@ -67,42 +92,40 @@ coverageFileExtension Html = ".html"
|
||||
coverageFileExtension Txt = ".txt"
|
||||
|
||||
-- | Pretty-print the covered code
|
||||
ppCoveredCode :: CoverageFileType -> SourceCache -> [SolcContract] -> FrozenCoverageMap -> Text
|
||||
ppCoveredCode fileType sc cs s | null s = "Coverage map is empty"
|
||||
| otherwise =
|
||||
let
|
||||
-- List of covered lines during the fuzzing campaign
|
||||
covLines = srcMapCov sc s cs
|
||||
-- Collect all the possible lines from all the files
|
||||
allFiles = (\(path, src) -> (path, V.fromList (decodeUtf8 <$> BS.split 0xa src))) <$> Map.elems sc.files
|
||||
-- Excludes lines such as comments or blanks
|
||||
runtimeLinesMap = buildRuntimeLinesMap sc cs
|
||||
ppCoveredCode :: CoverageFileType -> SourceCache -> [SolcContract] -> FrozenCoverageMap -> Maybe Text -> Text -> [Text] -> Text
|
||||
ppCoveredCode fileType sc cs s projectName timestamp excludePatterns
|
||||
| null s = "Coverage map is empty"
|
||||
| Html <- fileType = htmlTemplate filteredFiles runtimeLinesMap covLines projectName timestamp commonPrefix
|
||||
| otherwise = let
|
||||
-- Pretty print individual file coverage
|
||||
ppFile (srcPath, srcLines) =
|
||||
let runtimeLines = fromMaybe mempty $ Map.lookup srcPath runtimeLinesMap
|
||||
marked = markLines fileType srcLines runtimeLines (fromMaybe Map.empty (Map.lookup srcPath covLines))
|
||||
in T.unlines (changeFileName srcPath : changeFileLines (V.toList marked))
|
||||
-- Text to add to top of the file
|
||||
topHeader = case fileType of
|
||||
Lcov -> "TN:\n"
|
||||
Html -> "<style> code { white-space: pre-wrap; display: block; background-color: #eee; }" <>
|
||||
".e { background-color: #afa; }" <> -- executed
|
||||
".r { background-color: #ffa; }" <> -- reverted
|
||||
".u { background-color: #faa; }" <> -- unexecuted
|
||||
".n { background-color: #eee; }" <> -- neutral
|
||||
"</style>"
|
||||
Txt -> ""
|
||||
-- ^ Text to add to top of the file
|
||||
-- Alter file name
|
||||
changeFileName (T.pack -> fn) = case fileType of
|
||||
Lcov -> "SF:" <> fn
|
||||
Html -> "<b>" <> HTML.text fn <> "</b>"
|
||||
Txt -> fn
|
||||
-- ^ Alter file name, in the case of html turning it into bold text
|
||||
-- Alter file contents
|
||||
changeFileLines ls = case fileType of
|
||||
Lcov -> ls ++ ["end_of_record"]
|
||||
Html -> "<code>" : ls ++ ["", "</code>","<br />"]
|
||||
Txt -> ls
|
||||
-- ^ Alter file contents, in the case of html encasing it in <code> and adding a line break
|
||||
in topHeader <> T.unlines (map ppFile allFiles)
|
||||
in topHeader <> T.unlines (map ppFile filteredFiles)
|
||||
where
|
||||
-- List of covered lines during the fuzzing campaign
|
||||
covLines = srcMapCov sc s cs
|
||||
-- Collect all the possible lines from all the files
|
||||
allFiles = (\(path, src) -> (path, V.fromList (decodeUtf8 <$> BS.split 0xa src))) <$> Map.elems sc.files
|
||||
-- Find common path prefix for filtering
|
||||
commonPrefix = findCommonPathPrefix (map fst allFiles)
|
||||
-- Filter out excluded files using relative paths
|
||||
filteredFiles = filterExcludedFiles excludePatterns commonPrefix allFiles
|
||||
-- Excludes lines such as comments or blanks
|
||||
runtimeLinesMap = buildRuntimeLinesMap sc cs
|
||||
|
||||
-- | Mark one particular line, from a list of lines, keeping the order of them
|
||||
markLines :: CoverageFileType -> V.Vector Text -> S.Set Int -> Map Int [TxResult] -> V.Vector Text
|
||||
@@ -217,3 +240,137 @@ checkAssertionReached covLines assert =
|
||||
warnAssertNotReached =
|
||||
putStrLn $ "WARNING: assertion at file: " ++ assert.filenameRelative
|
||||
++ " starting at line: " ++ show (NE.head assert.assertLines) ++ " was never reached"
|
||||
|
||||
-- | Find the common path prefix among all file paths
|
||||
findCommonPathPrefix :: [FilePath] -> FilePath
|
||||
findCommonPathPrefix [] = ""
|
||||
findCommonPathPrefix [path] = takeDirectory path
|
||||
findCommonPathPrefix paths =
|
||||
let pathComponents = map splitDirectories paths
|
||||
commonComponents = foldl1 commonPrefix pathComponents
|
||||
in joinPath commonComponents
|
||||
where
|
||||
commonPrefix [] _ = []
|
||||
commonPrefix _ [] = []
|
||||
commonPrefix (x:xs) (y:ys)
|
||||
| x == y = x : commonPrefix xs ys
|
||||
| otherwise = []
|
||||
|
||||
-- | Convert absolute path to relative path given a base directory
|
||||
makeRelativePath :: FilePath -> FilePath -> FilePath
|
||||
makeRelativePath basePath filePath =
|
||||
let baseComponents = splitDirectories basePath
|
||||
fileComponents = splitDirectories filePath
|
||||
in maybe filePath joinPath (stripPrefix baseComponents fileComponents)
|
||||
where
|
||||
stripPrefix [] ys = Just ys
|
||||
stripPrefix _ [] = Nothing
|
||||
stripPrefix (x:xs) (y:ys)
|
||||
| x == y = stripPrefix xs ys
|
||||
| otherwise = Nothing
|
||||
|
||||
-- | Generate modern HTML coverage report using mustache template
|
||||
htmlTemplate :: [(FilePath, V.Vector Text)] -> Map FilePath (S.Set Int) -> Map FilePath (Map Int [TxResult]) -> Maybe Text -> Text -> FilePath -> Text
|
||||
htmlTemplate allFiles runtimeLinesMap covLines projectName timestamp commonPrefix =
|
||||
substituteValue coverageTemplate $ buildTemplateContext allFiles runtimeLinesMap covLines projectName timestamp commonPrefix
|
||||
|
||||
-- | Build the context object for the mustache template
|
||||
buildTemplateContext :: [(FilePath, V.Vector Text)] -> Map FilePath (S.Set Int) -> Map FilePath (Map Int [TxResult]) -> Maybe Text -> Text -> FilePath -> Value
|
||||
buildTemplateContext allFiles runtimeLinesMap covLines projectName timestamp commonPrefix =
|
||||
let
|
||||
totalFiles = length allFiles
|
||||
(totalLines, totalCoveredLines, totalActiveLines) = calculateTotalStats allFiles runtimeLinesMap covLines
|
||||
coveragePercentage = if totalActiveLines == 0 then 0 else (totalCoveredLines * 100) `div` totalActiveLines
|
||||
|
||||
-- Build title with optional project name
|
||||
title = case projectName of
|
||||
Just name -> "Echidna Coverage Report - " <> name
|
||||
Nothing -> "Echidna Coverage Report"
|
||||
|
||||
filesData = map (buildFileContext runtimeLinesMap covLines commonPrefix) allFiles
|
||||
in toMustache $ (Map.fromList :: [(Text, Value)] -> Map Text Value)
|
||||
[ ("title", toMustache title)
|
||||
, ("totalFiles", toMustache $ T.pack $ show totalFiles)
|
||||
, ("totalLines", toMustache $ T.pack $ show totalLines)
|
||||
, ("totalCoveredLines", toMustache $ T.pack $ show totalCoveredLines)
|
||||
, ("totalActiveLines", toMustache $ T.pack $ show totalActiveLines)
|
||||
, ("coveragePercentage", toMustache $ T.pack $ printf "%.1f" (fromIntegral coveragePercentage :: Double))
|
||||
, ("coverageColor", toMustache $ getCoverageColorHsl coveragePercentage)
|
||||
, ("timestamp", toMustache timestamp)
|
||||
, ("files", toMustache filesData)
|
||||
]
|
||||
|
||||
-- | Build context for a single file
|
||||
buildFileContext :: Map FilePath (S.Set Int) -> Map FilePath (Map Int [TxResult]) -> FilePath -> (FilePath, V.Vector Text) -> Value
|
||||
buildFileContext runtimeLinesMap covLines commonPrefix (srcPath, srcLines) =
|
||||
let
|
||||
runtimeLines = fromMaybe mempty $ Map.lookup srcPath runtimeLinesMap
|
||||
covered = fromMaybe Map.empty (Map.lookup srcPath covLines)
|
||||
activeLines = S.size runtimeLines
|
||||
coveredLines = length $ filter (`Map.member` covered) (S.toList runtimeLines)
|
||||
coveragePercentage = if activeLines == 0 then 0 else (coveredLines * 100) `div` activeLines
|
||||
|
||||
-- Use relative path for display
|
||||
displayPath = makeRelativePath commonPrefix srcPath
|
||||
fileId = T.pack $ map (\c -> if c `elem` ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" :: String) then c else '_') displayPath
|
||||
|
||||
linesData = V.toList $ V.imap (buildLineContext runtimeLines covered) srcLines
|
||||
in toMustache $ (Map.fromList :: [(Text, Value)] -> Map Text Value)
|
||||
[ ("fileId", toMustache fileId)
|
||||
, ("filePath", toMustache $ T.pack displayPath)
|
||||
, ("activeLines", toMustache $ T.pack $ show activeLines)
|
||||
, ("coveredLines", toMustache $ T.pack $ show coveredLines)
|
||||
, ("coveragePercentage", toMustache $ T.pack $ printf "%.1f" (fromIntegral coveragePercentage :: Double))
|
||||
, ("coverageColor", toMustache $ getCoverageColorHsl coveragePercentage)
|
||||
, ("coverageColorAlpha", toMustache $ getCoverageColorAlpha coveragePercentage)
|
||||
, ("lines", toMustache linesData)
|
||||
]
|
||||
|
||||
-- | Build context for a single line of code
|
||||
buildLineContext :: S.Set Int -> Map Int [TxResult] -> Int -> Text -> Value
|
||||
buildLineContext runtimeLines covered lineIndex codeLine =
|
||||
let
|
||||
lineNum = lineIndex + 1
|
||||
results = fromMaybe [] (Map.lookup lineNum covered)
|
||||
isActive = lineNum `S.member` runtimeLines
|
||||
isCovered = not (null results)
|
||||
rowClass
|
||||
| not isActive = Nothing
|
||||
| isCovered = Just ("row-line-covered" :: Text)
|
||||
| otherwise = Just ("row-line-uncovered" :: Text)
|
||||
|
||||
in toMustache $ (Map.fromList :: [(Text, Value)] -> Map Text Value) $ catMaybes
|
||||
[ Just ("lineNumber", toMustache $ T.pack $ show lineNum)
|
||||
, Just ("sourceCode", toMustache codeLine)
|
||||
, fmap (\v -> ("rowClass", toMustache v)) rowClass
|
||||
]
|
||||
|
||||
-- | Calculate total statistics across all files
|
||||
calculateTotalStats :: [(FilePath, V.Vector Text)] -> Map FilePath (S.Set Int) -> Map FilePath (Map Int [TxResult]) -> (Int, Int, Int)
|
||||
calculateTotalStats allFiles runtimeLinesMap covLines =
|
||||
let
|
||||
fileStats (srcPath, srcLines) =
|
||||
let runtimeLines = fromMaybe mempty $ Map.lookup srcPath runtimeLinesMap
|
||||
covered = fromMaybe Map.empty (Map.lookup srcPath covLines)
|
||||
fileTotalLines = V.length srcLines
|
||||
activeLines = S.size runtimeLines
|
||||
coveredLines = length $ filter (`Map.member` covered) (S.toList runtimeLines)
|
||||
in (fileTotalLines, coveredLines, activeLines)
|
||||
|
||||
allStats = map fileStats allFiles
|
||||
totalLines = sum $ map (\(t,_,_) -> t) allStats
|
||||
totalCoveredLines = sum $ map (\(_,c,_) -> c) allStats
|
||||
totalActiveLines = sum $ map (\(_,_,a) -> a) allStats
|
||||
in (totalLines, totalCoveredLines, totalActiveLines)
|
||||
|
||||
-- | Get HSL color based on coverage percentage
|
||||
getCoverageColorHsl :: Int -> Text
|
||||
getCoverageColorHsl percentage
|
||||
| percentage < 50 = "hsl(" <> T.pack (show (percentage * 12 `div` 10)) <> ", 90%, 50%)"
|
||||
| otherwise = "hsl(" <> T.pack (show (60 + ((percentage - 50) * 12 `div` 10))) <> ", 90%, 45%)"
|
||||
|
||||
-- | Get HSL color with alpha based on coverage percentage
|
||||
getCoverageColorAlpha :: Int -> Text
|
||||
getCoverageColorAlpha percentage
|
||||
| percentage < 50 = "hsla(" <> T.pack (show (percentage * 12 `div` 10)) <> ", 90%, 50%, 0.15)"
|
||||
| otherwise = "hsla(" <> T.pack (show (60 + ((percentage - 50) * 12 `div` 10))) <> ", 90%, 45%, 0.15)"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{title}}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
{{>styles.mustache}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<span class="logo-text">{{title}}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="split-panel" id="file-split-panel">
|
||||
<!-- File Explorer -->
|
||||
<div id="file-explorer">
|
||||
<div class="file-explorer-header">
|
||||
<h3 class="file-explorer-title">Files</h3>
|
||||
</div>
|
||||
<div class="file-search">
|
||||
<input type="text" id="file-search-input" placeholder="Search files..." />
|
||||
<button id="file-search-clear" title="Clear search">×</button>
|
||||
</div>
|
||||
<div class="file-explorer-content">
|
||||
<ul id="file-tree-root" class="file-tree"></ul>
|
||||
<div id="search-no-results" class="search-no-results" style="display: none;">No matching files found</div>
|
||||
</div>
|
||||
<div id="file-explorer-resizer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main panel -->
|
||||
<div id="main-view-panel">
|
||||
<!-- Overview -->
|
||||
<div class="stats-container">
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">Files</div>
|
||||
<div class="stat-value">{{totalFiles}}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">Total Lines</div>
|
||||
<div class="stat-value">{{totalLines}}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">Coverage</div>
|
||||
<div class="stat-value">{{coveragePercentage}}%</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" style="width: {{coveragePercentage}}%; background-color: {{coverageColor}}"></div>
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem">
|
||||
<span class="stat-badge">{{totalCoveredLines}} / {{totalActiveLines}} lines</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">Actions</div>
|
||||
<div class="button-group">
|
||||
<button class="btn btn-secondary btn-sm" onclick="setAllSourceFilesCollapsed(false)">Expand All</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="setAllSourceFilesCollapsed(true)">Collapse All</button>
|
||||
</div>
|
||||
<div class="button-group" style="margin-top: 0.5rem">
|
||||
<button class="btn btn-secondary btn-sm" onclick="setEmptySourceFilesCollapsed(false)">Expand Empty</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="setEmptySourceFilesCollapsed(true)">Collapse Empty</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual file coverage -->
|
||||
{{#files}}
|
||||
<div class="source-file" id="{{fileId}}" data-file-path="{{filePath}}" data-lines-active="{{activeLines}}" data-lines-covered="{{coveredLines}}">
|
||||
<div class="source-file-header">
|
||||
<div class="file-info">
|
||||
<span class="coverage-badge" style="background-color: {{coverageColorAlpha}}; color: {{coverageColor}}">
|
||||
{{coveragePercentage}}%
|
||||
</span>
|
||||
<span class="file-name">{{filePath}}</span>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="chevron-icon">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="source-file-content">
|
||||
<div class="source-file-stats">
|
||||
<span>Lines covered: <strong>{{coveredLines}} / {{activeLines}}</strong> ({{coveragePercentage}}%)</span>
|
||||
</div>
|
||||
<div class="code-container">
|
||||
<table class="code-coverage-table">
|
||||
{{#lines}}
|
||||
<tr{{#rowClass}} class="{{rowClass}}"{{/rowClass}}>
|
||||
<td class="row-line-number">{{lineNumber}}</td>
|
||||
<td class="row-source"><pre>{{sourceCode}}</pre></td>
|
||||
</tr>
|
||||
{{/lines}}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/files}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<div>Report generated by <a href="https://github.com/crytic/echidna" target="_blank">Echidna</a> on {{timestamp}}</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script>
|
||||
{{>scripts.mustache}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: Unlicense
|
||||
pragma solidity ^0.8.0;
|
||||
|
||||
import "forge-std/Test.sol";
|
||||
|
||||
contract {{testName}} is Test {
|
||||
{{#actors}}
|
||||
address constant {{name}} = {{address}};
|
||||
{{/actors}}
|
||||
|
||||
// TODO: Replace with your actual contract instance
|
||||
{{contractName}} Target;
|
||||
|
||||
function setUp() public {
|
||||
// TODO: Initialize your contract here
|
||||
Target = new {{contractName}}();
|
||||
}
|
||||
|
||||
function test_replay() public {
|
||||
{{#reproducer}}
|
||||
{{{prelude}}}
|
||||
{{{call}}}
|
||||
{{/reproducer}}
|
||||
}
|
||||
|
||||
function _setUpActor(address actor) internal {
|
||||
vm.startPrank(actor);
|
||||
// Add any additional actor setup here if needed
|
||||
}
|
||||
|
||||
function _delay(uint256 timeInSeconds, uint256 numBlocks) internal {
|
||||
vm.warp(block.timestamp + timeInSeconds);
|
||||
vm.roll(block.number + numBlocks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
// Initialize the application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initSourceFileExpansion();
|
||||
initializeFileTree();
|
||||
initResizer();
|
||||
});
|
||||
|
||||
// Initialize source file expansion behavior
|
||||
function initSourceFileExpansion() {
|
||||
document.querySelectorAll('.source-file').forEach(file => {
|
||||
const header = file.querySelector('.source-file-header');
|
||||
header.addEventListener('click', () => {
|
||||
file.classList.toggle('expanded');
|
||||
const chevron = header.querySelector('.chevron-icon');
|
||||
chevron.style.transform = file.classList.contains('expanded') ? 'rotate(180deg)' : 'rotate(0)';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize the resizer functionality
|
||||
function initResizer() {
|
||||
const fileExplorer = document.getElementById('file-explorer');
|
||||
const resizer = document.getElementById('file-explorer-resizer');
|
||||
let startX, startWidth;
|
||||
|
||||
// Handle mousedown on the resizer
|
||||
resizer.addEventListener('mousedown', e => {
|
||||
startX = e.clientX;
|
||||
startWidth = parseInt(window.getComputedStyle(fileExplorer).width, 10);
|
||||
|
||||
// Add event listeners for resizing
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// Prevent text selection during resize
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
});
|
||||
|
||||
// Handle mousemove for resizing
|
||||
function handleMouseMove(e) {
|
||||
const newWidth = startWidth + (e.clientX - startX);
|
||||
|
||||
// Apply width constraints
|
||||
if (newWidth >= 200 && newWidth <= 500) {
|
||||
fileExplorer.style.width = newWidth + 'px';
|
||||
fileExplorer.style.flex = `0 0 ${newWidth}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mouseup to end resizing
|
||||
function handleMouseUp() {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get color based on coverage percentage
|
||||
function getCoverageColor(percentage) {
|
||||
return percentage < 50
|
||||
? `hsl(${percentage * 1.2}, 90%, 50%)`
|
||||
: `hsl(${60 + (percentage - 50) * 1.2}, 90%, 45%)`;
|
||||
}
|
||||
|
||||
// Helper function for transparent background with coverage color
|
||||
function getCoverageColorAlpha(percentage) {
|
||||
return percentage < 50
|
||||
? `hsla(${percentage * 1.2}, 90%, 50%, 0.15)`
|
||||
: `hsla(${60 + (percentage - 50) * 1.2}, 90%, 45%, 0.15)`;
|
||||
}
|
||||
|
||||
// Build file tree structure from file paths
|
||||
function buildFileTree() {
|
||||
const root = { name: '', type: 'directory', children: {}, path: '' };
|
||||
const sourceFiles = document.getElementsByClassName("source-file");
|
||||
|
||||
// First pass: build the tree structure
|
||||
for (let i = 0; i < sourceFiles.length; i++) {
|
||||
const filePath = sourceFiles[i].dataset.filePath;
|
||||
const pathParts = filePath.split(/[\/\\]+/);
|
||||
let current = root;
|
||||
let currentPath = '';
|
||||
|
||||
for (let j = 0; j < pathParts.length; j++) {
|
||||
const part = pathParts[j];
|
||||
if (part === '') continue;
|
||||
|
||||
currentPath += (currentPath ? '/' : '') + part;
|
||||
|
||||
if (j === pathParts.length - 1) {
|
||||
// This is a file
|
||||
current.children[part] = {
|
||||
name: part,
|
||||
type: 'file',
|
||||
path: filePath,
|
||||
element: sourceFiles[i],
|
||||
coveredLines: parseInt(sourceFiles[i].dataset.linesCovered),
|
||||
activeLines: parseInt(sourceFiles[i].dataset.linesActive)
|
||||
};
|
||||
} else {
|
||||
// This is a directory
|
||||
if (!current.children[part]) {
|
||||
current.children[part] = {
|
||||
name: part,
|
||||
type: 'directory',
|
||||
children: {},
|
||||
path: currentPath
|
||||
};
|
||||
}
|
||||
current = current.children[part];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: calculate directory coverage
|
||||
calculateDirectoryCoverage(root);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
// Calculate coverage for directories by aggregating file coverage
|
||||
function calculateDirectoryCoverage(node) {
|
||||
if (node.type === 'file') {
|
||||
return {
|
||||
coveredLines: node.coveredLines,
|
||||
activeLines: node.activeLines
|
||||
};
|
||||
}
|
||||
|
||||
let totalCovered = 0;
|
||||
let totalActive = 0;
|
||||
|
||||
for (const childName in node.children) {
|
||||
const child = node.children[childName];
|
||||
const { coveredLines, activeLines } = calculateDirectoryCoverage(child);
|
||||
totalCovered += coveredLines;
|
||||
totalActive += activeLines;
|
||||
}
|
||||
|
||||
node.coveredLines = totalCovered;
|
||||
node.activeLines = totalActive;
|
||||
|
||||
return {
|
||||
coveredLines: totalCovered,
|
||||
activeLines: totalActive
|
||||
};
|
||||
}
|
||||
|
||||
// Render the file tree to the DOM
|
||||
function renderFileTree(node, parentElement, isRoot = false) {
|
||||
const list = isRoot ? parentElement : document.createElement('ul');
|
||||
|
||||
// Sort children: directories first, then files, both alphabetically
|
||||
const sortedChildren = Object.values(node.children).sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
for (const child of sortedChildren) {
|
||||
const li = document.createElement('li');
|
||||
li.className = child.type;
|
||||
|
||||
// Create item container
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'file-item';
|
||||
|
||||
// Create toggle button for directories
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'file-toggle';
|
||||
itemDiv.appendChild(toggle);
|
||||
|
||||
// Create file/folder icon
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'file-icon';
|
||||
itemDiv.appendChild(icon);
|
||||
|
||||
// Create name span
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'file-name';
|
||||
nameSpan.textContent = child.name;
|
||||
itemDiv.appendChild(nameSpan);
|
||||
|
||||
// Add coverage indicator
|
||||
if (child.activeLines > 0) {
|
||||
const coverage = document.createElement('span');
|
||||
coverage.className = 'file-coverage';
|
||||
const percentage = Math.round((child.coveredLines / child.activeLines) * 100);
|
||||
coverage.style.backgroundColor = getCoverageColor(percentage);
|
||||
coverage.title = `${child.coveredLines}/${child.activeLines} lines covered (${percentage}%)`;
|
||||
itemDiv.appendChild(coverage);
|
||||
}
|
||||
|
||||
li.appendChild(itemDiv);
|
||||
|
||||
// Handle click events
|
||||
if (child.type === 'directory') {
|
||||
toggle.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
li.classList.toggle('expanded');
|
||||
});
|
||||
|
||||
itemDiv.addEventListener('click', () => {
|
||||
li.classList.toggle('expanded');
|
||||
});
|
||||
|
||||
// Recursively render children
|
||||
renderFileTree(child, li);
|
||||
} else {
|
||||
// Add click handler for files
|
||||
itemDiv.addEventListener('click', () => {
|
||||
// Deactivate all other items
|
||||
document.querySelectorAll('.file-item.active').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
|
||||
// Activate this item
|
||||
itemDiv.classList.add('active');
|
||||
|
||||
// Navigate to the file
|
||||
const fileElement = child.element;
|
||||
fileElement.scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
// Expand the file if it's collapsed
|
||||
if (!fileElement.classList.contains('expanded')) {
|
||||
fileElement.querySelector('.source-file-header').click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
list.appendChild(li);
|
||||
}
|
||||
|
||||
if (!isRoot) {
|
||||
parentElement.appendChild(list);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the file tree
|
||||
function initializeFileTree() {
|
||||
const fileTreeRoot = document.getElementById('file-tree-root');
|
||||
const fileTree = buildFileTree();
|
||||
renderFileTree(fileTree, fileTreeRoot, true);
|
||||
|
||||
// Expand the first level directories by default
|
||||
document.querySelectorAll('#file-tree-root > li.directory').forEach(dir => {
|
||||
dir.classList.add('expanded');
|
||||
});
|
||||
|
||||
// Initialize search functionality
|
||||
initializeFileSearch();
|
||||
}
|
||||
|
||||
// Initialize file search functionality
|
||||
function initializeFileSearch() {
|
||||
const searchInput = document.getElementById('file-search-input');
|
||||
const clearButton = document.getElementById('file-search-clear');
|
||||
const noResultsMessage = document.getElementById('search-no-results');
|
||||
|
||||
// Debounce function to limit how often the search is performed during typing
|
||||
function debounce(func, delay) {
|
||||
let timeout;
|
||||
return function() {
|
||||
const context = this;
|
||||
const args = arguments;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(context, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
// Function to highlight text that matches the search term
|
||||
function highlightText(element, searchTerm) {
|
||||
const text = element.textContent;
|
||||
|
||||
if (!searchTerm) {
|
||||
// Clear any existing highlights
|
||||
element.textContent = text;
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerSearchTerm = searchTerm.toLowerCase();
|
||||
|
||||
if (lowerText.includes(lowerSearchTerm)) {
|
||||
const startIndex = lowerText.indexOf(lowerSearchTerm);
|
||||
const endIndex = startIndex + lowerSearchTerm.length;
|
||||
|
||||
const beforeMatch = text.substring(0, startIndex);
|
||||
const match = text.substring(startIndex, endIndex);
|
||||
const afterMatch = text.substring(endIndex);
|
||||
|
||||
element.innerHTML = beforeMatch + '<span class="search-highlight">' + match + '</span>' + afterMatch;
|
||||
return true;
|
||||
}
|
||||
|
||||
element.textContent = text;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Function to filter the file tree based on search term
|
||||
function filterFileTree(searchTerm) {
|
||||
const fileTreeItems = document.querySelectorAll('.file-tree li');
|
||||
let hasVisibleItems = false;
|
||||
|
||||
if (searchTerm === '') {
|
||||
// If search is cleared, show everything and reset expansion state
|
||||
fileTreeItems.forEach(item => {
|
||||
const nameElement = item.querySelector('.file-name');
|
||||
highlightText(nameElement, ''); // Clear highlights
|
||||
item.style.display = '';
|
||||
|
||||
// Only expand first level by default
|
||||
if (item.classList.contains('directory')) {
|
||||
// Check if this is a direct child of the root file tree
|
||||
if (item.parentElement.id === 'file-tree-root') {
|
||||
item.classList.add('expanded');
|
||||
} else {
|
||||
item.classList.remove('expanded');
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Two-pass filtering for search
|
||||
|
||||
// First pass: hide everything and find matches
|
||||
const matchingItems = [];
|
||||
fileTreeItems.forEach(item => {
|
||||
const nameElement = item.querySelector('.file-name');
|
||||
const isMatch = highlightText(nameElement, searchTerm);
|
||||
item.style.display = 'none'; // Hide everything initially
|
||||
|
||||
if (isMatch) {
|
||||
matchingItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Second pass: show matching items and all their ancestor directories
|
||||
matchingItems.forEach(item => {
|
||||
hasVisibleItems = true;
|
||||
|
||||
// Show the matching item
|
||||
item.style.display = '';
|
||||
|
||||
// Show and expand all parent directories
|
||||
let parent = item.parentElement.closest('li.directory');
|
||||
while (parent) {
|
||||
parent.style.display = '';
|
||||
parent.classList.add('expanded');
|
||||
parent = parent.parentElement.closest('li.directory');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show/hide the "no results" message
|
||||
noResultsMessage.style.display = searchTerm && !hasVisibleItems ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Debounced search function to avoid excessive filtering while typing
|
||||
const debouncedSearch = debounce(filterFileTree, 200);
|
||||
|
||||
// Search input event
|
||||
searchInput.addEventListener('input', function() {
|
||||
debouncedSearch(this.value.trim());
|
||||
});
|
||||
|
||||
// Clear button event
|
||||
clearButton.addEventListener('click', function() {
|
||||
searchInput.value = '';
|
||||
filterFileTree('');
|
||||
searchInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Button click handler for expanding/collapsing all files
|
||||
function setAllSourceFilesCollapsed(collapsed) {
|
||||
document.querySelectorAll('.source-file').forEach(file => {
|
||||
if ((collapsed && file.classList.contains('expanded')) ||
|
||||
(!collapsed && !file.classList.contains('expanded'))) {
|
||||
file.querySelector('.source-file-header').click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Button click handler for expanding/collapsing empty files
|
||||
function setEmptySourceFilesCollapsed(collapsed) {
|
||||
document.querySelectorAll('.source-file').forEach(file => {
|
||||
if (parseInt(file.dataset.linesActive) <= 0) {
|
||||
if ((collapsed && file.classList.contains('expanded')) ||
|
||||
(!collapsed && !file.classList.contains('expanded'))) {
|
||||
file.querySelector('.source-file-header').click();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
:root {
|
||||
--primary: #6366f1;
|
||||
--primary-hover: #4f46e5;
|
||||
--primary-light: rgba(99,102,241,0.1);
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
--danger: #ef4444;
|
||||
--gray-50: #f9fafb;
|
||||
--gray-100: #f3f4f6;
|
||||
--gray-200: #e5e7eb;
|
||||
--gray-400: #9ca3af;
|
||||
--gray-500: #6b7280;
|
||||
--gray-600: #4b5563;
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1f2937;
|
||||
--surface: #fff;
|
||||
--text: var(--gray-800);
|
||||
--text-light: var(--gray-500);
|
||||
--radius: 0.375rem;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
--success-rgb: 34,197,94;
|
||||
--warning-rgb: 234,179,8;
|
||||
--danger-rgb: 239,68,68;
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', -apple-system, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--gray-50);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
header {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
padding: 1rem 1.5rem;
|
||||
z-index: 10;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1400px;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #B92B3D;
|
||||
border-color: #B92B3D;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #9e2434;
|
||||
border-color: #9e2434;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Split panel layout */
|
||||
.split-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#file-split-panel {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* File explorer panel */
|
||||
#file-explorer {
|
||||
flex: 0 0 260px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--gray-200);
|
||||
padding: 0;
|
||||
position: relative;
|
||||
min-width: 200px;
|
||||
max-width: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden; /* to not show horizontal scrollbar with resizer */
|
||||
}
|
||||
|
||||
#file-explorer-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -5px;
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
background: linear-gradient(to right, transparent, rgba(0,0,0,0.05), transparent);
|
||||
}
|
||||
|
||||
#file-explorer-resizer:hover {
|
||||
background: linear-gradient(to right, transparent, rgba(0,0,0,0.1), transparent);
|
||||
}
|
||||
|
||||
.file-explorer-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.file-search {
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-search input {
|
||||
flex: 1;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-search input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px var(--primary-light);
|
||||
}
|
||||
|
||||
#file-search-clear {
|
||||
margin-left: 0.5rem;
|
||||
color: var(--gray-500);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
#file-search-clear:hover {
|
||||
background: var(--gray-100);
|
||||
}
|
||||
|
||||
.search-highlight {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-no-results {
|
||||
padding: 1rem;
|
||||
font-style: italic;
|
||||
color: var(--gray-500);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-explorer-content {
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.file-explorer-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--gray-600);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Main view panel */
|
||||
#main-view-panel {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 1.5rem;
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
/* Stats cards */
|
||||
.stats-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
height: 0.5rem;
|
||||
background: var(--gray-200);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
/* File tree */
|
||||
.file-tree {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.file-tree li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.file-tree ul {
|
||||
list-style: none;
|
||||
padding-left: 1.25rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--gray-100);
|
||||
}
|
||||
|
||||
.file-item.active {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.file-toggle {
|
||||
width: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 0.5rem;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-coverage {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* File tree icons */
|
||||
.directory > .file-item > .file-icon:before { content: "📁"; }
|
||||
.directory.expanded > .file-item > .file-icon:before { content: "📂"; }
|
||||
.file > .file-item > .file-icon:before { content: "📄"; }
|
||||
.directory > .file-item > .file-toggle:before { content: "▶"; }
|
||||
.directory.expanded > .file-item > .file-toggle:before { content: "▼"; }
|
||||
.file > .file-item > .file-toggle { visibility: hidden; }
|
||||
.directory > ul { display: none; }
|
||||
.directory.expanded > ul { display: block; }
|
||||
|
||||
/* Source files */
|
||||
.source-file {
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.source-file-header {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #12181E;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.source-file-header:hover {
|
||||
background: #1e2832;
|
||||
}
|
||||
|
||||
.source-file-header .file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.source-file-header .file-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.source-file-header .coverage-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.source-file-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.source-file.expanded .source-file-content {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.source-file-stats {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Code styling */
|
||||
.code-container {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.code-coverage-table {
|
||||
border-collapse: collapse;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
tab-size: 4;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.code-coverage-table pre {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.row-line-number {
|
||||
user-select: none;
|
||||
text-align: right;
|
||||
padding: 0 0.75rem;
|
||||
width: 1%;
|
||||
min-width: 2.5rem;
|
||||
color: var(--gray-400);
|
||||
background: var(--gray-100);
|
||||
border-right: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.row-reverted-status {
|
||||
user-select: none;
|
||||
text-align: center;
|
||||
width: 1%;
|
||||
min-width: 2.25rem;
|
||||
max-width: fit-content;
|
||||
padding: 0 0.25rem;
|
||||
color: var(--gray-600);
|
||||
background: var(--gray-100);
|
||||
border-right: 1px solid var(--gray-200);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-source {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.row-line-covered {
|
||||
background: rgba(var(--success-rgb), 0.1);
|
||||
}
|
||||
|
||||
.row-line-uncovered {
|
||||
background: rgba(var(--danger-rgb), 0.1);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--gray-200);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.stats-container { grid-template-columns: 1fr; }
|
||||
#file-explorer { display: none; }
|
||||
}
|
||||
@@ -83,6 +83,9 @@ findSrcByMetadata contr dapp = find compareMetadata (snd <$> Map.elems dapp.solc
|
||||
(RuntimeCode (ConcreteRuntimeCode c)) -> Just c
|
||||
(RuntimeCode (SymbolicRuntimeCode c)) -> Just $ BS.pack $ mapMaybe maybeLitByteSimp $ V.toList c
|
||||
|
||||
findSrcForReal :: DappInfo -> Contract -> Maybe SolcContract
|
||||
findSrcForReal dapp contr = findSrc contr dapp <|> findSrcByMetadata contr dapp
|
||||
|
||||
getBytecodeMetadata :: ByteString -> ByteString
|
||||
getBytecodeMetadata bs =
|
||||
let stripCandidates = flip BS.breakSubstring bs <$> knownBzzrPrefixes in
|
||||
|
||||
@@ -22,6 +22,7 @@ import Data.Vector qualified as V
|
||||
import EVM (ceilDiv, initialContract, loadContract, resetState)
|
||||
import EVM.ABI (abiValueType)
|
||||
import EVM.FeeSchedule (FeeSchedule(..))
|
||||
import EVM.Transaction (setupTx)
|
||||
import EVM.Types hiding (Env, Gas, VMOpts(timestamp, gasprice))
|
||||
|
||||
import Echidna.ABI
|
||||
@@ -88,7 +89,7 @@ genTx world deployedContracts = do
|
||||
fmap (forceAddr addr,) . snd <$> lookupUsingCodehash env.codehashMap c env.dapp sigMap
|
||||
|
||||
genDelay :: MonadRandom m => W256 -> Set W256 -> m W256
|
||||
genDelay mv ds = do
|
||||
genDelay mv ds =
|
||||
join $ oftenUsually fromDict randValue
|
||||
where randValue = fromIntegral <$> getRandomR (1 :: Integer, fromIntegral mv)
|
||||
fromDict = (`mod` (mv + 1)) <$> rElem' ds
|
||||
@@ -191,8 +192,12 @@ setupTx tx@Tx{call} = fromEVM $ do
|
||||
modify' $ \vm ->
|
||||
let intrinsicGas = txGasCost vm.block.schedule isCreate calldata
|
||||
burned = min intrinsicGas vm.state.gas
|
||||
-- This might not be 100% accurate (it does not revert the balance transfers)
|
||||
-- but Echidna resets the vm on revert so it should not make a difference
|
||||
reversionState = EVM.Transaction.setupTx vm.tx.origin vm.block.coinbase vm.tx.gasprice vm.tx.gaslimit vm.env.contracts
|
||||
in vm & #state % #gas %!~ subtract burned
|
||||
& #burned %!~ (+ burned)
|
||||
& #tx % #txReversion !~ reversionState
|
||||
where
|
||||
incrementBalance = #env % #contracts % ix (LitAddr tx.dst) % #balance %= (\v -> Lit $ forceWord v + tx.value)
|
||||
encode (n, vs) = abiCalldata (encodeSig (n, abiValueType <$> vs)) $ V.fromList vs
|
||||
|
||||
@@ -37,6 +37,8 @@ data CampaignConf = CampaignConf
|
||||
-- ^ Mutation constants for fuzzing
|
||||
, coverageFormats :: [CoverageFileType]
|
||||
-- ^ List of file formats to save coverage reports
|
||||
, coverageExcludes :: [Text]
|
||||
-- ^ List of glob patterns for files/directories to exclude from coverage reports
|
||||
, workers :: Maybe Word8
|
||||
-- ^ Number of fuzzing workers
|
||||
, serverPort :: Maybe Word16
|
||||
|
||||
@@ -53,11 +53,13 @@ library:
|
||||
- exceptions
|
||||
- extra
|
||||
- filepath
|
||||
- Glob
|
||||
- hashable
|
||||
- html-conduit
|
||||
- html-entities
|
||||
- http-conduit
|
||||
- ListLike
|
||||
- mustache
|
||||
- optics
|
||||
- optics-core
|
||||
- process
|
||||
@@ -95,6 +97,7 @@ executables:
|
||||
- with-utf8
|
||||
ghc-options:
|
||||
- -threaded
|
||||
- -rtsopts
|
||||
- '"-with-rtsopts=-A64m -N"'
|
||||
when:
|
||||
- condition: (os(linux) || os(windows)) && flag(static)
|
||||
|
||||
+21
-1
@@ -16,6 +16,7 @@ import Data.Maybe (fromMaybe, isJust)
|
||||
import Data.Set qualified as Set
|
||||
import Data.Text (Text)
|
||||
import Data.Text qualified as T
|
||||
import Data.Text.Lazy qualified as TL
|
||||
import Data.Time.Clock.System (getSystemTime, systemSeconds)
|
||||
import Data.Version (showVersion)
|
||||
import Data.Word (Word8, Word16, Word64)
|
||||
@@ -38,13 +39,14 @@ import Echidna.Campaign (isSuccessful)
|
||||
import Echidna.Config
|
||||
import Echidna.Onchain qualified as Onchain
|
||||
import Echidna.Output.Corpus
|
||||
import Echidna.Output.Foundry
|
||||
import Echidna.Output.Source
|
||||
import Echidna.Solidity (compileContracts)
|
||||
import Echidna.Test (reproduceTest, validateTestMode)
|
||||
import Echidna.Types.Campaign
|
||||
import Echidna.Types.Config
|
||||
import Echidna.Types.Solidity
|
||||
import Echidna.Types.Test (TestMode, EchidnaTest(..))
|
||||
import Echidna.Types.Test (TestMode, EchidnaTest(..), TestType(..), TestState(..))
|
||||
import Echidna.UI
|
||||
import Echidna.UI.Report (ppFailWithTraces, ppTestName)
|
||||
import Echidna.Utility (measureIO)
|
||||
@@ -101,6 +103,24 @@ main = withUtf8 $ withCP65001 $ do
|
||||
corpus <- readIORef env.corpusRef
|
||||
saveTxs env (dir </> "coverage") (snd <$> Set.toList corpus)
|
||||
|
||||
let isLargeOrSolved Solved = True
|
||||
isLargeOrSolved (Large _) = True
|
||||
isLargeOrSolved _ = False
|
||||
measureIO cfg.solConf.quiet "Saving foundry reproducers" $ do
|
||||
let foundryDir = dir </> "foundry"
|
||||
liftIO $ createDirectoryIfMissing True foundryDir
|
||||
forM_ tests $ \test ->
|
||||
case (test.testType, test.state) of
|
||||
(AssertionTest{}, state) | isLargeOrSolved state ->
|
||||
do
|
||||
let
|
||||
reproducerHash = (show . abs . hash) test.reproducer
|
||||
fileName = foundryDir </> "Test." ++ reproducerHash <.> "sol"
|
||||
content = foundryTest cliSelectedContract test
|
||||
liftIO $ writeFile fileName (TL.unpack content)
|
||||
_ -> pure ()
|
||||
|
||||
|
||||
-- save coverage reports
|
||||
let coverageDir = cfg.campaignConf.coverageDir <|> cfg.campaignConf.corpusDir
|
||||
case coverageDir of
|
||||
|
||||
@@ -50,4 +50,6 @@ valuesTests = testGroup "Value extraction tests"
|
||||
[ ("getItem passed", solved "getItem") ]
|
||||
, testContract' "values/events.sol" Nothing Nothing (Just "values/events.yaml") False FuzzWorker
|
||||
[ ("check passed", solved "check") ]
|
||||
, testContract' "values/callback.sol" Nothing Nothing (Just "values/callback.yaml") False FuzzWorker
|
||||
[ ("echidna_callback_executed passed", solved "echidna_callback_executed") ]
|
||||
]
|
||||
|
||||
@@ -82,6 +82,10 @@ corpusDir: null
|
||||
coverageDir: null
|
||||
# list of file formats to save coverage reports in; default is all possible formats
|
||||
coverageFormats: ["txt","html","lcov"]
|
||||
# list of glob patterns for files to exclude from coverage reports
|
||||
# patterns are matched against relative paths from the common project root
|
||||
# examples: ["lib/**/*.sol", "test/mocks/**/*"]
|
||||
coverageExcludes: []
|
||||
# constants for corpus mutations (for experimentation only)
|
||||
mutConsts: [1, 1, 1, 1]
|
||||
# maximum value to send to payable functions
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.0;
|
||||
|
||||
contract Test {
|
||||
bool internal callbackExecuted = false;
|
||||
|
||||
// The function we want to encode and pass to the callback
|
||||
function transfer(address to, uint256 value) public {
|
||||
// Ensure that only the contract itself can call this function
|
||||
require(msg.sender == address(this), "transfer can only be called by the contract");
|
||||
callbackExecuted = true;
|
||||
}
|
||||
|
||||
// The function that receives the encoded calldata
|
||||
function callback(bytes calldata data) public {
|
||||
address(this).call(data);
|
||||
}
|
||||
|
||||
// The Echidna property to test the callback
|
||||
function echidna_callback_executed() public view returns (bool) {
|
||||
return !callbackExecuted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
seqLen: 1
|
||||
filterFunctions:
|
||||
- Test.transfer
|
||||
Reference in New Issue
Block a user