Skip to content

Commit 2e6a998

Browse files
18: Upgrade runner to meet version 2 spec (exercism#104)
* Run tests with custom hspec formatter which outputs result.json v2 * Update all expected results.json files in tests * Added extra pre-compiled packages needed by injected code * Improved comment Co-authored-by: Erik Schierboom <[email protected]> * Fixed success results.json + improved run.sh - If all tests pass, hspec formatter now correctly outputs top-level success status - Implemented bash LSP hints in bin/run.sh - Added newline at end of code injection to package.yaml files * Fixed expected results in example-empty-file and example-syntax-error * Precompile bin/setup-tests executable instead of using stack runghc * Fixed bin/run.sh to run setup-tests executable correctly * Automatically cleanup code injections when running tests * Copy results.json to desired output path * Improved cleanup process --------- Co-authored-by: Erik Schierboom <[email protected]>
1 parent 417a058 commit 2e6a998

File tree

15 files changed

+397
-33
lines changed

15 files changed

+397
-33
lines changed

.gitignore

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
tests/**/results.json
2-
tests/**/*.cabal
3-
tests/**/.stack-work
4-
tests/**/stack.yaml.lock
1+
results.json
2+
*.cabal
3+
.stack-work
4+
stack.yaml.lock
5+
bin/setup-tests
6+
tests/**/HspecFormatter.hs

Dockerfile

+3
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,8 @@ COPY pre-compiled/ .
1414
RUN stack build --resolver lts-20.18 --no-terminal --test --no-run-tests
1515

1616
COPY . .
17+
18+
RUN cd ./test-setup/ && stack build setup-tests --copy-bins --local-bin-path /opt/test-runner/bin/ && cd ..
19+
1720
ENTRYPOINT ["/opt/test-runner/bin/run.sh"]
1821

bin/build-setup-tests.sh

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/usr/bin/env bash
2+
3+
# This script is just a quick way to build the setup-tests binary for local development, similar to what is done
4+
# in the Dockerfile.
5+
# It outputs the resulting executable in bin/setup-tests
6+
7+
pushd ./test-setup/ && stack build setup-tests --copy-bins --local-bin-path ../bin/ && popd

bin/run-tests.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ for test_dir in tests/*; do
2121
test_dir_path=$(realpath "${test_dir}")
2222
results_file_path="${test_dir_path}/results.json"
2323
expected_results_file_path="${test_dir_path}/expected_results.json"
24-
stack_root=$(stack path --stack-root)
24+
stack_root=$(stack --resolver lts-20.18 path --stack-root)
2525

2626
bin/run.sh "${test_dir_name}" "${test_dir_path}" "${test_dir_path}"
2727

bin/run.sh

+34-13
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,30 @@ slug="$1"
2727
input_dir="${2%/}"
2828
output_dir="${3%/}"
2929
results_file="${output_dir}/results.json"
30+
setup_tests_executable="bin/setup-tests"
3031

3132
# Create the output directory if it doesn't exist
3233
mkdir -p "${output_dir}"
3334

3435
echo "${slug}: testing..."
3536

36-
file_contents=$(< "${input_dir}/stack.yaml")
37+
stack_yml_file_contents=$(< "${input_dir}/stack.yaml")
38+
package_yml_file_contents=$(< "${input_dir}/package.yaml")
39+
tests_file_contents=$(< "${input_dir}/test/Tests.hs")
3740

3841
echo "system-ghc: true" >> "${input_dir}/stack.yaml"
3942

43+
# Run our test setup which does some code injection to modify how the tests
44+
# will run to use our custom hspec formatter that outputs results.json automatically.
45+
# We expect the setup-tests executable to be pre-built in Docker, but fallback to using runghc in case it isn't
46+
# found so that developers can continue to easily run `bin/run.sh` locally.
47+
if [ -f "${setup_tests_executable}" ]; then
48+
${setup_tests_executable} "${input_dir}"
49+
else
50+
echo "Did not find bin/setup-tests executable - using stack runghc ./test-setup/src/Main.hs instead"
51+
stack --resolver lts-20.18 runghc ./test-setup/src/Main.hs "$input_dir"
52+
fi
53+
4054
pushd "${input_dir}" > /dev/null
4155

4256
# disable -e since we expect some tests to fail
@@ -46,7 +60,12 @@ set +e
4660
# Run the tests for the provided implementation file and redirect stdout and
4761
# stderr to capture it
4862
test_output=$(stack build --resolver lts-20.18 --test --allow-different-user 2>&1)
49-
exit_code=$?
63+
64+
# Copy results.json to the output directory (only if output directory is different from
65+
# the input directory)
66+
if [ "${input_dir}/results.json" != "${results_file}" ]; then
67+
mv "${input_dir}/results.json" "${results_file}"
68+
fi
5069

5170
# re-enable original options
5271
set -$old_opts
@@ -56,27 +75,29 @@ rm -rf .stack-work
5675

5776
popd
5877

59-
# Write the results.json file based on the exit code of the command that was
60-
# just executed that tested the implementation file
61-
if [ $exit_code -eq 0 ]; then
62-
jq -n '{version: 1, status: "pass"}' > ${results_file}
63-
else
78+
# If the results.json file does not exist, it means that the tests failed to run
79+
# (usually this would be a compiler error)
80+
if ! [ -f "${results_file}" ]; then
6481
# Sanitize the output
6582
if grep -q "Registering library for " <<< "${test_output}" ; then
66-
sanitized_test_output=$(printf "${test_output}" | sed -n -E -e '1,/^Registering library for/!p')
83+
sanitized_test_output=$(printf "%s" "${test_output}" | sed -n -E -e '1,/^Registering library for/!p')
6784
elif grep -q "Building library for " <<< "${test_output}" ; then
68-
sanitized_test_output=$(printf "${test_output}" | sed -n -E -e '1,/^Building library for/!p')
85+
sanitized_test_output=$(printf "%s" "${test_output}" | sed -n -E -e '1,/^Building library for/!p')
6986
else
70-
sanitized_test_output="${test_output}"
87+
sanitized_test_output="${test_output}"
7188
fi
7289

7390
# Manually add colors to the output to help scanning the output for errors
7491
colorized_test_output=$(echo "${sanitized_test_output}" \
75-
| GREP_COLOR='01;31' grep --color=always -E -e '.*FAILED \[[0-9]+\]$|$')
92+
| GREP_COLOR='01;31' grep --color=always -E -e '.*FAILED \[[0-9]+\]$|$')
7693

77-
jq -n --arg output "${colorized_test_output}" '{version: 1, status: "fail", message: $output}' > ${results_file}
94+
jq -n --arg output "${colorized_test_output}" '{version: 2, status: "error", message: $output}' > "${results_file}"
7895
fi
7996

80-
echo "$file_contents" > "${input_dir}/stack.yaml"
97+
# Revert input directory code to it's initial state
98+
echo "$stack_yml_file_contents" > "${input_dir}/stack.yaml"
99+
echo "$package_yml_file_contents" > "${input_dir}/package.yaml"
100+
echo "$tests_file_contents" > "${input_dir}/test/Tests.hs"
101+
rm -f "${input_dir}/test/HspecFormatter.hs"
81102

82103
echo "${slug}: done"

pre-compiled/package.yaml

+5
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,9 @@ tests:
3131
source-dirs: test
3232
dependencies:
3333
- leap
34+
- aeson
35+
- aeson-pretty
36+
- bytestring
3437
- hspec
38+
- hspec-core
39+
- stm

pre-compiled/test/HspecFormatter.hs

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
-- NOTE: This file is used by the setup-tests executable (built from the test-setup/ directory).
2+
-- It's copied into the target project and is configured to be the hspec formatter using code injection.
3+
{-# LANGUAGE DeriveGeneric #-}
4+
{-# LANGUAGE OverloadedRecordDot #-}
5+
{-# LANGUAGE OverloadedStrings #-}
6+
7+
module HspecFormatter (formatter) where
8+
9+
import Data.Aeson (ToJSON, toJSON, object, encode, (.=))
10+
import Data.Aeson.Encode.Pretty (encodePretty)
11+
import qualified Data.ByteString.Lazy as BS
12+
import Data.Maybe (fromMaybe)
13+
import Control.Monad (forM_)
14+
import Control.Concurrent.STM
15+
import GHC.IO.Unsafe (unsafePerformIO)
16+
import Test.Hspec
17+
import Test.Hspec.Core.Formatters.V2
18+
import Test.Hspec.Core.Format (Format, FormatConfig, Path, Event(ItemDone, Done), FailureReason(..))
19+
import GHC.Generics (Generic)
20+
21+
data TestResultStatus = Pass | Fail | Err deriving (Eq, Show)
22+
23+
instance ToJSON TestResultStatus where
24+
toJSON Pass = "pass"
25+
toJSON Fail = "fail"
26+
toJSON Err = "error"
27+
28+
data TestResult = TestResult {
29+
name :: String,
30+
status :: TestResultStatus,
31+
message :: Maybe String
32+
} deriving (Generic, Show)
33+
34+
instance ToJSON TestResult where
35+
36+
data TestResults = TestResults {
37+
resultsStatus :: TestResultStatus,
38+
tests :: [TestResult],
39+
resultsMessage :: Maybe String,
40+
version :: Int
41+
} deriving (Generic, Show)
42+
43+
instance ToJSON TestResults where
44+
toJSON t = object [
45+
"version" .= t.version
46+
, "status" .= t.resultsStatus
47+
, "message" .= t.resultsMessage
48+
, "tests" .= t.tests
49+
]
50+
51+
results :: TVar TestResults
52+
{-# NOINLINE results #-}
53+
results = unsafePerformIO $ newTVarIO (TestResults Fail [] Nothing 2)
54+
55+
format :: Format
56+
format event = case event of
57+
ItemDone path item -> handleItemDone path item
58+
Done _ -> handleDone
59+
_ -> return ()
60+
where
61+
handleItemDone :: Path -> Item -> IO ()
62+
handleItemDone (_, requirement) item =
63+
case itemResult item of
64+
Success ->
65+
addTestResult TestResult { name = requirement, status = Pass, message = Nothing }
66+
-- NOTE: We don't expect pending tests in Exercism exercises
67+
Pending _ _ -> return ()
68+
Failure _ failureReason ->
69+
let baseResult = TestResult { name = requirement, status = Fail, message = Just "" }
70+
result = case failureReason of
71+
NoReason -> baseResult { message = Just "No reason" }
72+
Reason reason -> baseResult { message = Just reason }
73+
ExpectedButGot _ expected got ->
74+
baseResult {
75+
message = Just $ "Expected '" ++ expected ++ "' but got '" ++ got ++ "'"
76+
}
77+
Error _ exception -> baseResult { message = Just $ show exception }
78+
in addTestResult result
79+
where
80+
addTestResult tr = atomically $ modifyTVar' results (\r -> r { tests = r.tests <> [tr] })
81+
82+
handleDone :: IO ()
83+
handleDone = do
84+
resultsVal <- readTVarIO results
85+
let finalResults = if all (\t -> t.status == Pass) resultsVal.tests then resultsVal { resultsStatus = Pass } else resultsVal
86+
BS.writeFile "results.json" (encodePretty finalResults)
87+
return ()
88+
89+
90+
formatter :: FormatConfig -> IO Format
91+
formatter _config = return format

test-setup/package.yaml

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: setup-tests
2+
version: 1.0.0.0
3+
4+
dependencies:
5+
- base
6+
7+
executables:
8+
setup-tests:
9+
main: Main.hs
10+
source-dirs: src
11+
ghc-options: -Wall
12+
dependencies:
13+
- directory

test-setup/src/Main.hs

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
2+
module Main (main) where
3+
import Data.List (findIndex, isInfixOf)
4+
import Data.IORef
5+
import System.Environment (getArgs)
6+
import Control.Arrow ((>>>))
7+
import Control.Monad (when)
8+
import System.Directory (copyFile)
9+
10+
main :: IO ()
11+
main = do
12+
args <- getArgs
13+
case args of
14+
[] -> error "setup-tests expects one argument - the project directory whose code should be modified"
15+
xs -> modifyTests (head xs)
16+
17+
hspecFormatterPath :: String
18+
hspecFormatterPath = "pre-compiled/test/HspecFormatter.hs"
19+
20+
modifyTests :: String -> IO ()
21+
modifyTests inputDir = do
22+
let testFile = inputDir ++ "/test/Tests.hs"
23+
packageFile = inputDir ++ "/package.yaml"
24+
25+
testCodeRef <- readFile testFile >>= newIORef . lines
26+
27+
readIORef testCodeRef >>=
28+
(updateHspecRunnerImport >>> updateMainFunc >>> writeIORef testCodeRef)
29+
30+
-- Update the test/Tests.hs file with the new contents
31+
-- We use `when (length newTestFileData > 0)` as a trick to strictly evaluate the
32+
-- file data before trying to write to the file otherwise we get a "resouce busy (file is locked)" error
33+
newTestFileData <- readIORef testCodeRef
34+
{-# HLINT ignore "Use null" #-}
35+
when (length newTestFileData > 0) $ writeFile testFile (unlines newTestFileData)
36+
37+
-- Add aeson, aeson-pretty, bytestring, hspec-core, stm and text packages to `tests` section of
38+
-- package.yaml.
39+
-- (assumes that the tests.test.dependencies is the last item in package.yaml!)
40+
appendFile packageFile " - aeson\n - aeson-pretty\n - bytestring\n - hspec-core\n - stm\n - text\n"
41+
42+
-- Copy our custom hspec formatter into the input code directory so it can be used
43+
copyFile hspecFormatterPath (inputDir ++ "/test/HspecFormatter.hs")
44+
45+
where
46+
-- Update Test.Hspec.Runner import to add the `configFormat` import that we need
47+
-- and also add the import HspecFormatter line
48+
updateHspecRunnerImport =
49+
updateLineOfCode
50+
isHspecRunnerImport
51+
"import Test.Hspec.Runner (configFailFast, defaultConfig, hspecWith, configFormat)\nimport HspecFormatter"
52+
53+
-- Update the main function to add the configFormat option to hspec to use our custom
54+
-- formatter that outputs results.json in the necessary format.
55+
-- It also removes the configFailFast option so that we run ALL tests rather than stopping
56+
-- at the first failing.
57+
updateMainFunc =
58+
updateLineOfCode
59+
isMainFunc
60+
"main = hspecWith defaultConfig {configFormat = Just formatter} specs"
61+
62+
updateLineOfCode :: (String -> Bool) -> String -> [String] -> [String]
63+
updateLineOfCode isLineToUpdate newLine fileContents =
64+
case findIndex isLineToUpdate fileContents of
65+
Just idx -> replaceNth idx newLine fileContents
66+
Nothing -> fileContents
67+
68+
isHspecRunnerImport :: String -> Bool
69+
isHspecRunnerImport = isInfixOf "import Test.Hspec.Runner"
70+
71+
isMainFunc :: String -> Bool
72+
isMainFunc = isInfixOf "main = hspecWith"
73+
74+
replaceNth :: Int -> a -> [a] -> [a]
75+
replaceNth idx newVal list =
76+
let (first, second) = splitAt idx list
77+
in first <> (newVal : tail second)

test-setup/stack.yaml

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
system-ghc: true
2+
3+
resolver: lts-20.18
+51-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,52 @@
11
{
2-
"version": 1,
3-
"status": "fail",
4-
"message": "leap> test (suite: test)\n\n\nisLeapYear\n 2015 - year not divisible by 4 in common year [✘]\n\nFailures:\n\n test/Tests.hs:20:55: \n 1) isLeapYear 2015 - year not divisible by 4 in common year\n expected: False\n but got: True\n\n To rerun use: --match \"/isLeapYear/2015 - year not divisible by 4 in common year/\"\n\n\n1 example, 1 failure\n\nleap> Test suite test failed\n\nError: [S-7282]\n Stack failed to execute the build plan.\n \n While executing the build plan, Stack encountered the following errors:\n \n TestSuiteFailure (PackageIdentifier {pkgName = PackageName \"leap\", pkgVersion = mkVersion [1,6,0,10]}) (fromList [(\"test\",Just (ExitFailure 1))]) Nothing \"\""
5-
}
2+
"message": null,
3+
"status": "fail",
4+
"tests": [
5+
{
6+
"message": "Expected 'False' but got 'True'",
7+
"name": "2015 - year not divisible by 4 in common year",
8+
"status": "fail"
9+
},
10+
{
11+
"message": "Expected 'False' but got 'True'",
12+
"name": "1970 - year divisible by 2, not divisible by 4 in common year",
13+
"status": "fail"
14+
},
15+
{
16+
"message": "Expected 'True' but got 'False'",
17+
"name": "1996 - year divisible by 4, not divisible by 100 in leap year",
18+
"status": "fail"
19+
},
20+
{
21+
"message": "Expected 'True' but got 'False'",
22+
"name": "1960 - year divisible by 4 and 5 is still a leap year",
23+
"status": "fail"
24+
},
25+
{
26+
"message": "Expected 'False' but got 'True'",
27+
"name": "2100 - year divisible by 100, not divisible by 400 in common year",
28+
"status": "fail"
29+
},
30+
{
31+
"message": "Expected 'False' but got 'True'",
32+
"name": "1900 - year divisible by 100 but not by 3 is still not a leap year",
33+
"status": "fail"
34+
},
35+
{
36+
"message": "Expected 'True' but got 'False'",
37+
"name": "2000 - year divisible by 400 in leap year",
38+
"status": "fail"
39+
},
40+
{
41+
"message": "Expected 'True' but got 'False'",
42+
"name": "2400 - year divisible by 400 but not by 125 is still a leap year",
43+
"status": "fail"
44+
},
45+
{
46+
"message": "Expected 'False' but got 'True'",
47+
"name": "1800 - year divisible by 200, not divisible by 400 in common year",
48+
"status": "fail"
49+
}
50+
],
51+
"version": 2
52+
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": 1,
3-
"status": "fail",
2+
"version": 2,
3+
"status": "error",
44
"message": "\n/solution/src/LeapYear.hs:1:1: error:\n File name does not match module name:\n Saw: ‘Main’\n Expected: ‘LeapYear’\n\nError: [S-7282]\n Stack failed to execute the build plan.\n \n While executing the build plan, Stack encountered the following errors:\n \n [S-7011]\n While building package leap-1.6.0.10 (scroll up to its section to see the error) using:\n --verbose=1 build lib:leap test:test --ghc-options \"\"\n Process exited with code: ExitFailure 1 "
55
}

0 commit comments

Comments
 (0)