import Dictionary from "Dictionary" import FilePath from "FilePath" import { always, equals, ifElse, when } from "Function" import IO from "IO" import List from "List" import Math from "Math" import { Just, Nothing, fromMaybe } from "Maybe" import Monad from "Monad" import {} from "Number" import Process from "Process" import { pShow } from "Show" import String from "String" import Terminal from "Terminal" import Tuple from "Tuple" import Wish from "Wish" moveCursorUpCode :: Integer -> String moveCursorUpCode = (lines) => `\x1b[${show(lines)}A` IS_COLOR_ENABLED :: Boolean IS_COLOR_ENABLED = do { noColor = Process.getEnv("NO_COLOR") return noColor == Just("") || noColor == Nothing } PREFIX_RUNS :: String PREFIX_RUNS = IS_COLOR_ENABLED ? Terminal.ansiColor([Terminal.ansi.FGBlack, Terminal.ansi.BGBrightYellow], " RUNS ") : "RUNS " PREFIX_PASS :: String PREFIX_PASS = IS_COLOR_ENABLED ? Terminal.ansiColor([Terminal.ansi.FGBlack, Terminal.ansi.BGBrightGreen], " PASS ") : "PASS " PREFIX_FAIL :: String PREFIX_FAIL = IS_COLOR_ENABLED ? Terminal.ansiColor([Terminal.ansi.FGBlack, Terminal.ansi.BGBrightRed], " FAIL ") : "FAIL " // CHAR_CHECK :: String // CHAR_CHECK = "✓" CHAR_CROSS :: String CHAR_CROSS = "×" EMPTY_REPORT :: TestReport EMPTY_REPORT = TestReport("", 0, 0, 0) CWD :: String CWD = Process.getCurrentWorkingDirectory() // Result collector // SuiteResult(total, succeeded, failed) type SuiteResult = SuiteResult(Integer, Integer, Integer) type TestResult = Success(String) | Failure(String, String) isSuccess :: TestResult -> Boolean isSuccess = (res) => where(res) { Success(_) => true _ => false } makeResultCollector :: {} -> { getResults :: {} -> Dictionary String SuiteResult, setResults :: Dictionary String SuiteResult -> {}, } makeResultCollector = () => { results = {{}} getResults = () => results setResults = (newResults) => { results := newResults } return { getResults, setResults } } collector :: { getResults :: {} -> Dictionary String SuiteResult, setResults :: Dictionary String SuiteResult -> {}, } collector = makeResultCollector() // Test API export type AssertionError = AssertionError(String, String) | Error(String) | ErrorWithMessage(String) | NotImplemented // Contains the accumulated String to be displayed, // the amount of tests run, the amount of successful tests, // and the amount of failed tests type TestReport = TestReport(String, Integer, Integer, Integer) getMessage :: TestReport -> String getMessage = where { TestReport(str, _, _, _) => str } getTotal :: TestReport -> Integer getTotal = where { TestReport(_, total, _, _) => total } getSuccessCount :: TestReport -> Integer getSuccessCount = where { TestReport(_, _, success, _) => success } getFailureCount :: TestReport -> Integer getFailureCount = where { TestReport(_, _, _, failed) => failed } resultToReport :: TestResult -> TestReport resultToReport = (result) => where(result) { Failure(_, message) => TestReport(message, 1, 0, 1) Success(_) => TestReport("", 1, 1, 0) } mergeReports :: TestReport -> TestReport -> TestReport mergeReports = (t1, t2) => TestReport( getMessage(t1) ++ getMessage(t2), getTotal(t1) + getTotal(t2), getSuccessCount(t1) + getSuccessCount(t2), getFailureCount(t1) + getFailureCount(t2), ) assertEquals :: (Show a, Eq a) => a -> a -> Wish.Wish AssertionError {} export assertEquals = (actual, expected) => actual == expected ? of({}) : Wish.bad(AssertionError(pShow(expected), pShow(actual))) indent :: Integer -> String -> String indent = (amount, x) => pipe( String.lines, map( (line) => pipe( spaces, mappend($, line), )(amount), ), String.unlines, )(x) renderValue :: (String -> String) -> String -> String renderValue = (colorize, value) => pipe( indent(4), (s) => IS_COLOR_ENABLED ? colorize(s) : s, )(value) renderAssertionError :: String -> AssertionError -> String renderAssertionError = (description, assertionError) => where(assertionError) { AssertionError(expected, actual) => IO.red(`${CHAR_CROSS} ${description}`) ++ "\n expected:\n" ++ renderValue(Terminal.text.brightGreen, expected) ++ "\n actual:\n" ++ renderValue(Terminal.text.brightRed, actual) ++ "\n" ErrorWithMessage(message) => IS_COLOR_ENABLED ? IO.red(`${CHAR_CROSS} ${message}\n`) : `${CHAR_CROSS} ${message}\n` Error(err) => IS_COLOR_ENABLED ? IO.red(`${CHAR_CROSS} ${show(err)}`) : `${CHAR_CROSS} ${show(err)}` NotImplemented => IS_COLOR_ENABLED ? IO.red(`${CHAR_CROSS} not implemented`) : `${CHAR_CROSS} not implemented` } test :: String -> (String -> Wish.Wish AssertionError {}) -> Wish.Wish TestResult TestResult export test = (description, testFunction) => pipe( testFunction, bimap( pipe( renderAssertionError(description), Failure(description), ), always(Success(description)), ), )(description) generateReportSuiteEndMessage :: List (Wish.Wish e a) -> String generateReportSuiteEndMessage = pipe( List.length, ifElse(equals(0), always("No test found\n\n"), always("")), ) prepareSuitePath :: Boolean -> String -> String prepareSuitePath = (colorful, suitePath) => { cwdParts = FilePath.splitPath(CWD) partIndex = 0 return pipe( FilePath.splitPath, List.dropWhile( (part) => { justPart = pipe( FilePath.dropTrailingPathSeparator, Just, )(part) justCwdPart = pipe( List.nth(partIndex), map(FilePath.dropTrailingPathSeparator), )(cwdParts) drop = justPart == justCwdPart partIndex := partIndex + 1 return drop }, ), (parts) => { path = pipe( List.init, FilePath.joinPath, mappend($, "/"), when(always(colorful && IS_COLOR_ENABLED), IO.grey), )(parts) fileName = pipe( List.last, fromMaybe(""), )(parts) return path ++ fileName }, )(suitePath) } spaces :: Integer -> String spaces = (amount) => pipe( List.repeat(' '), String.fromList, )(amount) printSuiteResults :: Dictionary String SuiteResult -> {} printSuiteResults = (results) => { pipe( Dictionary.toList, map( where { #[suitePath, SuiteResult(total, success, failed)] => do { counts = `${show(success + failed)}/${show(total)}` preparedSuitePath = prepareSuitePath(true, suitePath) coloredCounts = if (IS_COLOR_ENABLED) { failed > 0 ? Terminal.text.brightRed(counts) : Terminal.text.brightGreen(counts) } else { counts } prefix = total == success + failed ? failed > 0 ? PREFIX_FAIL : PREFIX_PASS : PREFIX_RUNS return #[`${prefix} ${preparedSuitePath}`, coloredCounts] } }, ), ( (prepared) => { firsts = map(Tuple.fst, prepared) longest = List.reduce( (biggest, input) => Math.max(biggest, String.length(input)), 0, firsts, ) map( where { #[start, counts] => IO.putLine(`${start}${spaces(longest - String.length(start))} [${counts}]`) }, prepared, ) } ), )(results) IO.putLine("") } failSuite :: String -> {} failSuite = (suitePath) => { pipe( Dictionary.update( where { SuiteResult(total, success, failed) => SuiteResult(total, success, total - success) }, suitePath, ), collector.setResults, )(collector.getResults()) if (IS_COLOR_ENABLED) do { IO.put(moveCursorUpCode(Dictionary.length(collector.getResults()) + 1)) printSuiteResults(collector.getResults()) } } updateSuiteResult :: String -> TestResult -> TestResult updateSuiteResult = (suitePath, result) => { pipe( Dictionary.update( where { SuiteResult(total, success, failed) => isSuccess(result) ? SuiteResult(total, success + 1, failed) : SuiteResult(total, success, failed + 1) }, suitePath, ), collector.setResults, )(collector.getResults()) if (IS_COLOR_ENABLED) do { IO.put(moveCursorUpCode(Dictionary.length(collector.getResults()) + 1)) printSuiteResults(collector.getResults()) } return result } runTestSuite :: (Show e, Show f) => String -> ({} -> Wish e a) -> ({} -> Wish f b) -> List (Wish TestResult TestResult) -> Wish {} TestReport runTestSuite = (suitePath, beforeAll, afterAll, testsInSuite) => pipe( (tests) => { pipe( Dictionary.insert(suitePath, SuiteResult(List.length(tests), 0, 0)), collector.setResults, )(collector.getResults()) return tests }, map( Wish.bichain( pipe( updateSuiteResult(suitePath), resultToReport, of, ), pipe( updateSuiteResult(suitePath), resultToReport, of, ), ), ), Wish.parallel, (testsWish) => Monad.andDo( testsWish, Wish.mapRej( (err) => IO.red(`${CHAR_CROSS} suite failed in beforeAll:\n${show(err)}`), beforeAll(), ), ), (testsWish) => do { result <- testsWish _ <- Wish.mapRej( (err) => IO.red(`${CHAR_CROSS} suite failed in afterAll:\n${show(err)}`), afterAll(), ) return of(result) }, map( pipe( List.reduce(mergeReports, EMPTY_REPORT), where { TestReport(msg, total, success, failed) => (total == 0 || failed > 0) ? TestReport( `${suitePath}\n${msg}${generateReportSuiteEndMessage(testsInSuite)}`, total, success, failed, ) : TestReport("", total, success, failed) }, ), ), Wish.chainRej( (err) => { failSuite(suitePath) return of( TestReport( `${suitePath}\n${err}\n${generateReportSuiteEndMessage(testsInSuite)}`, List.length(testsInSuite), 0, List.length(testsInSuite), ), ) }, ), )(testsInSuite) runAllTestSuites :: ( Show e, Show f ) => List #[String, {} -> Wish e a, {} -> Wish f b, List (Wish TestResult TestResult)] -> {} export runAllTestSuites = (testSuites) => pipe( map( where { #[path, beforeAll, afterAll, tests] => runTestSuite(path, beforeAll, afterAll, tests) }, ), (suites) => { if (IS_COLOR_ENABLED) { printSuiteResults(collector.getResults()) } return suites }, Wish.parallel, map(List.reduce(mergeReports, EMPTY_REPORT)), Wish.fulfill( () => ({}), (report) => { if (!IS_COLOR_ENABLED) { printSuiteResults(collector.getResults()) } IO.put(getMessage(report)) IO.putLine( `Test suites: ${show(List.length(testSuites))} tests: ${show(getTotal(report))} passed: ${ show(getSuccessCount(report)) } failed: ${show(getFailureCount(report))}`, ) if (getFailureCount(report) > 0) { Process.exit(1) } }, ), (run) => run({}), )(testSuites)