diff --git a/src/Elm/Kernel/Test.js b/src/Elm/Kernel/Test.js index 1ef03633..7c80a1e3 100644 --- a/src/Elm/Kernel/Test.js +++ b/src/Elm/Kernel/Test.js @@ -1,11 +1,11 @@ /* -import Elm.Kernel.Utils exposing (Tuple0) +import Elm.Kernel.Utils exposing (Tuple0, Tuple2) +import File exposing (FileNotFound, GeneralFileError, IsDirectory, PathEscapesDirectory) import Result exposing (Err, Ok) */ - function _Test_runThunk(thunk) { try { @@ -16,3 +16,131 @@ function _Test_runThunk(thunk) return __Result_Err(err.toString()); } } + + +const fs = require('node:fs'); +const path = require('node:path'); +const process = require('node:process'); + +function _Test_readFile(filePath) +{ + // Test for this early as `resolve` will strip training slashes + if (filePath.slice(-1) == path.sep) { + return __Result_Err(__File_IsDirectory); + } + + // Protect against reading files above the "tests" directory + const testsPath = path.resolve("tests"); + const fullPath = path.resolve(testsPath, filePath); + + if (!fullPath.startsWith(testsPath)) + { + return __Result_Err(__File_PathEscapesDirectory); + } + + try { + return __Result_Ok(__Utils_Tuple2(fullPath, fs.readFileSync(fullPath, { encoding: 'utf8' }))); + } + catch (err) + { + if (err.code == "ENOENT"){ + return __Result_Err(__File_FileNotFound); + } + else { + return __Result_Err(__File_GeneralFileError(err.toString())); + } + } +} + +function WriteFile(root, filePath, contents) +{ + // Test for this early as `resolve` will strip training slashes + if (filePath.slice(-1) == path.sep) { + return __Result_Err(__File_IsDirectory); + } + + // Protect against writing files above the root directory + const fullPath = path.resolve(root, filePath); + + if (!fullPath.startsWith(root)) + { + return __Result_Err(__File_PathEscapesDirectory); + } + + // Remove failed file if it exists + var failedPath = null; + if (!fullPath.endsWith(".failed.html") && fullPath.endsWith(".html")) + failedPath = fullPath.slice(0, -5) + ".failed.html"; + else if (!fullPath.endsWith(".failed")) + failedPath = fullPath + ".failed"; + + if (failedPath) + { + try + { + fs.unlinkSync(failedPath); + } + catch (error) + { + // Ignore failure if file doesn't exist + } + } + + const fullDir = path.dirname(fullPath); + + // Note that this does not throw an error if the directory exists + fs.mkdirSync(fullDir, {recursive: true}); + + try { + fs.writeFileSync(fullPath, contents); + return __Result_Ok(fullPath); + } + catch (err) + { + return __Result_Err(__File_GeneralFileError(err.toString())); + } +} + +var _Test_writeFile = F2(function(filePath, contents) +{ + return WriteFile(path.resolve("tests"), filePath, contents); +}) + +function _Test_deleteFile(filePath) +{ + // Test for this early as `resolve` will strip training slashes + if (filePath.slice(-1) == path.sep) { + return __Result_Err(__File_IsDirectory); + } + + // Protect against deleting files above the "tests" directory + const testsPath = path.resolve("tests"); + const fullPath = path.resolve(testsPath, filePath); + + if (!fullPath.startsWith(testsPath)) + { + return __Result_Err(__File_PathEscapesDirectory); + } + + try { + fs.unlinkSync(fullPath); + } + catch (err) + { + if (err.code == "ENOENT"){ + return __Result_Err(__File_FileNotFound); + } + else { + return __Result_Err(__File_GeneralFileError(err.toString())); + } + } +} + +var overwriteGoldenFiles = null; +function _Test_overwriteGoldenFiles(unused) +{ + if (overwriteGoldenFiles === null) + overwriteGoldenFiles = process.env.OVERWRITE_GOLDEN_FILES == '1'; + + return overwriteGoldenFiles; +} \ No newline at end of file diff --git a/src/Expect.elm b/src/Expect.elm index 4c925a02..4f31c46f 100644 --- a/src/Expect.elm +++ b/src/Expect.elm @@ -3,6 +3,7 @@ module Expect exposing , lessThan, atMost, greaterThan, atLeast , FloatingPointTolerance(..), within, notWithin , ok, err, equalLists, equalDicts, equalSets + , equalToFile , pass, fail, onFail ) @@ -43,6 +44,11 @@ or both. For an in-depth look, see our [Guide to Floating Point Comparison](#gui @docs ok, err, equalLists, equalDicts, equalSets +## Golden Files + +@docs equalToFile + + ## Customizing These functions will let you build your own expectations. @@ -103,6 +109,7 @@ Another example is comparing values that are on either side of zero. `0.0001` is -} import Dict exposing (Dict) +import File import Set exposing (Set) import Test.Distribution import Test.Expectation @@ -576,6 +583,90 @@ equalSets expected actual = reportCollectionFailure "Expect.equalSets" expected actual missingKeys extraKeys +{-| Tests the a String is equal to the contents of the file stored at the file path. + +If the file does not exist, it will be created and this test will pass. + +If the file does exist, then this test will pass if its contents are equal to the actual string. + +All file paths are scoped to be within the "tests/" directory. + +-} +equalToFile : String -> String -> Expectation +equalToFile filePath actual = + let + failedFilePath = + -- Be careful to make the failed final extension .html so that browsers can render it + if String.endsWith ".html" filePath then + String.dropRight (String.length ".html") filePath ++ ".failed.html" + + else + filePath ++ ".failed" + + writeGoldenFile () = + case File.writeFile filePath actual of + Err File.FileNotFound -> + -- Impossible + pass + + Err File.IsDirectory -> + Test.Expectation.fail { description = "Expect.equalToFile was given a directory instead of a file", reason = Custom } + + Err File.PathEscapesDirectory -> + Test.Expectation.fail { description = "Expect.equalToFile was given a path that would escape the tests/ directory", reason = Custom } + + Err (File.GeneralFileError fileError) -> + Test.Expectation.fail { description = "Expect.equalToFile encountered a general file error: " ++ fileError, reason = Custom } + + Ok _ -> + -- If we have successully written the golden file we can delete the failure file. + -- We don't really care if this fails, if nothing else the user can delete the file themselves + case File.deleteFile failedFilePath of + _ -> + pass + in + if File.overwriteGoldenFiles () then + writeGoldenFile () + + else + case File.readFile filePath of + Err File.FileNotFound -> + writeGoldenFile () + + Err File.IsDirectory -> + Test.Expectation.fail { description = "Expect.equalToFile was given a directory instead of a file", reason = Custom } + + Err File.PathEscapesDirectory -> + Test.Expectation.fail { description = "Expect.equalToFile was given a path that would escape the tests/ directory", reason = Custom } + + Err (File.GeneralFileError fileError) -> + Test.Expectation.fail { description = "Expect.equalToFile encountered a general file error: " ++ fileError, reason = Custom } + + Ok ( existingAbsolutePath, contents ) -> + if actual == contents then + pass + + else + case File.writeFile failedFilePath actual of + Ok newAbsolutePath -> + let + message = + [ Just <| "The contents of \"" ++ filePath ++ "\" changed!" + , Just <| "To compare run: git diff --no-index " ++ existingAbsolutePath ++ " " ++ newAbsolutePath + , if String.endsWith ".html" filePath then + Just <| "To visually compare run: open file://" ++ existingAbsolutePath ++ " file://" ++ newAbsolutePath + + else + Nothing + , Just <| "To accept these changes delete \"" ++ filePath ++ "\" or specify OVERWRITE_GOLDEN_FILES=1 when running elm-test" + ] + in + Test.Expectation.fail { description = String.join "\n\n" (List.filterMap identity message), reason = Custom } + + _ -> + Test.Expectation.fail { description = "Expect.equalToFile encountered an unexpected error", reason = Custom } + + {-| Always passes. import Json.Decode exposing (decodeString, int) diff --git a/src/File.elm b/src/File.elm new file mode 100644 index 00000000..5bce01b5 --- /dev/null +++ b/src/File.elm @@ -0,0 +1,46 @@ +module File exposing (AbsolutePath, FileError(..), RelativePath, overwriteGoldenFiles, readFile, writeFile, deleteFile) + +import Elm.Kernel.Test + + +type FileError + = FileNotFound + | IsDirectory + | PathEscapesDirectory + | GeneralFileError String + + +type alias RelativePath = + String + + +type alias AbsolutePath = + String + + +{-| Read the contents of the filePath relative to "tests/" +-} +readFile : RelativePath -> Result FileError ( AbsolutePath, String ) +readFile = + Elm.Kernel.Test.readFile + + +{-| Write the contents of the second argument to the file path in the first argument relative to "tests/" + +Returns the absolute file path if successful. + +-} +writeFile : RelativePath -> String -> Result FileError AbsolutePath +writeFile = + Elm.Kernel.Test.writeFile + +{-| Delete the file specified in filePath relative to "tests/" -} +deleteFile : RelativePath -> Result FileError () +deleteFile = + Elm.Kernel.Test.deleteFile + +{-| Checks the OVERWRITE\_GOLDEN\_FILES environment variable +-} +overwriteGoldenFiles : () -> Bool +overwriteGoldenFiles = + Elm.Kernel.Test.overwriteGoldenFiles diff --git a/src/Test/Html/Query.elm b/src/Test/Html/Query.elm index 8c57f325..562d40af 100644 --- a/src/Test/Html/Query.elm +++ b/src/Test/Html/Query.elm @@ -2,6 +2,7 @@ module Test.Html.Query exposing ( Single, Multiple, fromHtml , find, findAll, children, first, index, keep , count, contains, has, hasNot, each + , prettyPrintSingle ) {-| Querying HTML structure. @@ -18,6 +19,10 @@ module Test.Html.Query exposing @docs count, contains, has, hasNot, each +## Debugging + +@docs prettyPrintSingle + -} import Expect exposing (Expectation) @@ -496,3 +501,16 @@ each : (Single msg -> Expectation) -> Multiple msg -> Expectation each check (Internal.Multiple showTrace query) = Internal.expectAll check query |> failWithQuery showTrace "Query.each" query + +{-| Pretty prints the result of a query as HTML if successful -} +prettyPrintSingle : Single msg -> Result String String +prettyPrintSingle (Internal.Single _ query) = + case Internal.traverse query of + Ok [ element ] -> + Ok <| Internal.prettyPrint element + + Ok results -> + Err <| "Query.prettyPrintSingle expected exactly one result from query, but found " ++ String.fromInt (List.length results) + + Err queryError -> + Err <| "Query.prettyPrintSingle " ++ Internal.queryErrorToString queryError \ No newline at end of file