From ac0a8d147ed3d47e428c6f3ce133397623a7f18a Mon Sep 17 00:00:00 2001 From: Micah Hahn Date: Thu, 31 Jul 2025 16:25:34 -0500 Subject: [PATCH 1/9] Add `equalToFile` to Expect --- src/Elm/Kernel/Test.js | 24 ++++++++++++++++++++++++ src/Expect.elm | 21 ++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/Elm/Kernel/Test.js b/src/Elm/Kernel/Test.js index 1ef03633..f277e82c 100644 --- a/src/Elm/Kernel/Test.js +++ b/src/Elm/Kernel/Test.js @@ -5,6 +5,7 @@ import Result exposing (Err, Ok) */ +const fs = require('fs'); function _Test_runThunk(thunk) { @@ -16,3 +17,26 @@ function _Test_runThunk(thunk) return __Result_Err(err.toString()); } } + +function _Test_readFile(filePath) +{ + try { + return __Result_Ok(fs.readFileSync(filePath, { encoding: 'utf8' })); + } + catch (err) + { + return __Result_Err(err.toString()) + } +} + +var _Test_writeFile = F2(function(filePath, contents) +{ + try { + fs.writeFileSync(filePath, contents); + return __Result_Ok(__Utils_Tuple0); + } + catch (err) + { + return __Result_Err(err.toString()); + } +}) \ No newline at end of file diff --git a/src/Expect.elm b/src/Expect.elm index 4c925a02..df4aee2c 100644 --- a/src/Expect.elm +++ b/src/Expect.elm @@ -3,7 +3,7 @@ module Expect exposing , lessThan, atMost, greaterThan, atLeast , FloatingPointTolerance(..), within, notWithin , ok, err, equalLists, equalDicts, equalSets - , pass, fail, onFail + , pass, fail, onFail, equalToFile ) {-| A library to create `Expectation`s, which describe a claim to be tested. @@ -575,6 +575,25 @@ equalSets expected actual = in reportCollectionFailure "Expect.equalSets" expected actual missingKeys extraKeys +readFile : String -> Result String String +readFile = Elm.Kernel.Test.readFile + +writeFile : String -> String -> Result String () +writeFile = Elm.Kernel.Test.writeFile + +equalToFile : String -> String -> Expectation +equalToFile filePath actual = + case readFile filePath of + Err _ -> + case writeFile filePath actual of + Err q -> + fail q + + Ok _ -> + pass + + Ok contents -> + equateWith ("equalToFile \'" ++ filePath ++ "\'") (==) contents actual {-| Always passes. From f90579b2825896eaf91a8e05c7adfcb3d6f76daf Mon Sep 17 00:00:00 2001 From: Micah Hahn Date: Fri, 1 Aug 2025 14:33:42 -0500 Subject: [PATCH 2/9] Harden our file access API --- src/Elm/Kernel/Test.js | 58 +++++++++++++++++++++++++++++++++++++----- src/Expect.elm | 39 +++++++++++++++++++++------- src/File.elm | 15 +++++++++++ 3 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 src/File.elm diff --git a/src/Elm/Kernel/Test.js b/src/Elm/Kernel/Test.js index f277e82c..e8d4cb3f 100644 --- a/src/Elm/Kernel/Test.js +++ b/src/Elm/Kernel/Test.js @@ -1,12 +1,11 @@ /* import Elm.Kernel.Utils exposing (Tuple0) +import File exposing (FileNotFound, GeneralFileError, IsDirectory, PathEscapesDirectory) import Result exposing (Err, Ok) */ -const fs = require('fs'); - function _Test_runThunk(thunk) { try { @@ -18,25 +17,70 @@ function _Test_runThunk(thunk) } } + +const fs = require('fs'); +const path = require('path'); + 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(fs.readFileSync(filePath, { encoding: 'utf8' })); + return __Result_Ok(fs.readFileSync(fullPath, { encoding: 'utf8' })); } catch (err) { - return __Result_Err(err.toString()) + if (err.code == "ENOENT"){ + return __Result_Err(__File_FileNotFound); + } + else { + return __Result_Err(__File_GeneralFileError(err.toString())); + } } } var _Test_writeFile = F2(function(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 "tests" directory + const testsPath = path.resolve("tests"); + const fullPath = path.resolve(testsPath, filePath); + + if (!fullPath.startsWith(testsPath)) + { + return __Result_Err(__File_PathEscapesDirectory); + } + + const fullDir = path.dirname(fullPath); + + if (!fs.existsSync(fullDir)) + { + // Can this make a nested directory? + fs.mkdirSync(fullDir, {recursive: true}); + } + try { - fs.writeFileSync(filePath, contents); + fs.writeFileSync(fullPath, contents); return __Result_Ok(__Utils_Tuple0); } catch (err) - { - return __Result_Err(err.toString()); + { + return __Result_Err(__File_GeneralFileError(err.toString())); } }) \ No newline at end of file diff --git a/src/Expect.elm b/src/Expect.elm index df4aee2c..30e0fa7a 100644 --- a/src/Expect.elm +++ b/src/Expect.elm @@ -42,6 +42,9 @@ 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 @@ -104,6 +107,7 @@ Another example is comparing values that are on either side of zero. `0.0001` is import Dict exposing (Dict) import Set exposing (Set) +import File import Test.Distribution import Test.Expectation import Test.Internal as Internal @@ -575,23 +579,40 @@ equalSets expected actual = in reportCollectionFailure "Expect.equalSets" expected actual missingKeys extraKeys -readFile : String -> Result String String -readFile = Elm.Kernel.Test.readFile -writeFile : String -> String -> Result String () -writeFile = Elm.Kernel.Test.writeFile +{-| 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 = - case readFile filePath of - Err _ -> - case writeFile filePath actual of - Err q -> - fail q + case File.readFile filePath of + Err File.FileNotFound -> + case File.writeFile filePath actual of + Err (File.GeneralFileError fileError) -> + Test.Expectation.fail { description = "Expect.equalToFile encountered a general file error: " ++ fileError, reason = Custom } + + -- This case should be impossible non general file errors should have been surfaced in the call to `readFile` above. + Err _ -> + Test.Expectation.fail { description = "Expect.equalToFile encountered an unexpected error", reason = Custom } Ok _ -> 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 contents -> equateWith ("equalToFile \'" ++ filePath ++ "\'") (==) contents actual diff --git a/src/File.elm b/src/File.elm new file mode 100644 index 00000000..69cd0ad0 --- /dev/null +++ b/src/File.elm @@ -0,0 +1,15 @@ +module File exposing (readFile, writeFile, FileError(..)) + +import Elm.Kernel.Test + +type FileError + = FileNotFound + | IsDirectory + | PathEscapesDirectory + | GeneralFileError String + +readFile : String -> Result FileError String +readFile = Elm.Kernel.Test.readFile + +writeFile : String -> String -> Result FileError () +writeFile = Elm.Kernel.Test.writeFile \ No newline at end of file From 1ec5b4d158d704fe8dfa18021ae592f65c4fb258 Mon Sep 17 00:00:00 2001 From: Micah Hahn Date: Fri, 1 Aug 2025 15:11:42 -0500 Subject: [PATCH 3/9] Add pretty printer to query --- src/Test/Html/Query.elm | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 From a84a18ba8d6510f2d7ead9d46c3dcbf874a2e5ec Mon Sep 17 00:00:00 2001 From: Micah Hahn Date: Thu, 23 Oct 2025 15:09:03 -0500 Subject: [PATCH 4/9] No need to check `existsSync`, `mkdirSync` works just fine if the directory already exists --- src/Elm/Kernel/Test.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Elm/Kernel/Test.js b/src/Elm/Kernel/Test.js index e8d4cb3f..5e3badab 100644 --- a/src/Elm/Kernel/Test.js +++ b/src/Elm/Kernel/Test.js @@ -69,11 +69,8 @@ var _Test_writeFile = F2(function(filePath, contents) const fullDir = path.dirname(fullPath); - if (!fs.existsSync(fullDir)) - { - // Can this make a nested directory? - fs.mkdirSync(fullDir, {recursive: true}); - } + // Note that this does not throw an error if the directory exists + fs.mkdirSync(fullDir, {recursive: true}); try { fs.writeFileSync(fullPath, contents); From c9e645c098dc0e6bf0be913aa9cbb5386bb97376 Mon Sep 17 00:00:00 2001 From: Micah Hahn Date: Thu, 23 Oct 2025 15:13:00 -0500 Subject: [PATCH 5/9] Add "node:" prefix to imports --- src/Elm/Kernel/Test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Elm/Kernel/Test.js b/src/Elm/Kernel/Test.js index 5e3badab..513d2995 100644 --- a/src/Elm/Kernel/Test.js +++ b/src/Elm/Kernel/Test.js @@ -18,8 +18,8 @@ function _Test_runThunk(thunk) } -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); function _Test_readFile(filePath) { From d7bac452b3d3c755f2ee49bdff95fc351546e52d Mon Sep 17 00:00:00 2001 From: Micah Hahn Date: Mon, 27 Oct 2025 13:41:29 -0500 Subject: [PATCH 6/9] Write to a temp file if there are diffs and output instructions for how to compare --- src/Elm/Kernel/Test.js | 34 ++++++++++++++++++++------- src/Expect.elm | 52 +++++++++++++++++++++++++++++++----------- src/File.elm | 40 ++++++++++++++++++++++++++++---- 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/src/Elm/Kernel/Test.js b/src/Elm/Kernel/Test.js index 513d2995..921ed308 100644 --- a/src/Elm/Kernel/Test.js +++ b/src/Elm/Kernel/Test.js @@ -1,6 +1,6 @@ /* -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) @@ -20,6 +20,8 @@ function _Test_runThunk(thunk) const fs = require('node:fs'); const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); function _Test_readFile(filePath) { @@ -38,7 +40,7 @@ function _Test_readFile(filePath) } try { - return __Result_Ok(fs.readFileSync(fullPath, { encoding: 'utf8' })); + return __Result_Ok(__Utils_Tuple2(fullPath, fs.readFileSync(fullPath, { encoding: 'utf8' }))); } catch (err) { @@ -51,18 +53,17 @@ function _Test_readFile(filePath) } } -var _Test_writeFile = F2(function(filePath, contents) +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 "tests" directory - const testsPath = path.resolve("tests"); - const fullPath = path.resolve(testsPath, filePath); + // Protect against writing files above the root directory + const fullPath = path.resolve(root, filePath); - if (!fullPath.startsWith(testsPath)) + if (!fullPath.startsWith(root)) { return __Result_Err(__File_PathEscapesDirectory); } @@ -74,10 +75,27 @@ var _Test_writeFile = F2(function(filePath, contents) try { fs.writeFileSync(fullPath, contents); - return __Result_Ok(__Utils_Tuple0); + 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); +}) + +var tempDir = null; +var _Test_writeTempFile = F2(function(filePath, contents) +{ + if (tempDir === null) + { + tempDir = os.tmpdir() + "/" + crypto.randomUUID(); + fs.mkdirSync(tempDir); + } + + return WriteFile(tempDir, filePath, contents); }) \ No newline at end of file diff --git a/src/Expect.elm b/src/Expect.elm index 30e0fa7a..1fe1fe5c 100644 --- a/src/Expect.elm +++ b/src/Expect.elm @@ -3,7 +3,8 @@ module Expect exposing , lessThan, atMost, greaterThan, atLeast , FloatingPointTolerance(..), within, notWithin , ok, err, equalLists, equalDicts, equalSets - , pass, fail, onFail, equalToFile + , equalToFile + , pass, fail, onFail ) {-| A library to create `Expectation`s, which describe a claim to be tested. @@ -42,10 +43,12 @@ 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. @@ -106,8 +109,8 @@ Another example is comparing values that are on either side of zero. `0.0001` is -} import Dict exposing (Dict) -import Set exposing (Set) import File +import Set exposing (Set) import Test.Distribution import Test.Expectation import Test.Internal as Internal @@ -580,7 +583,7 @@ 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. +{-| 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. @@ -591,30 +594,53 @@ All file paths are scoped to be within the "tests/" directory. -} equalToFile : String -> String -> Expectation equalToFile filePath actual = - case File.readFile filePath of + case File.readFile filePath of Err File.FileNotFound -> - case File.writeFile filePath actual of - Err (File.GeneralFileError fileError) -> + case File.writeFile filePath actual of + Err (File.GeneralFileError fileError) -> Test.Expectation.fail { description = "Expect.equalToFile encountered a general file error: " ++ fileError, reason = Custom } -- This case should be impossible non general file errors should have been surfaced in the call to `readFile` above. - Err _ -> - Test.Expectation.fail { description = "Expect.equalToFile encountered an unexpected error", reason = Custom } + Err _ -> + Test.Expectation.fail { description = "Expect.equalToFile encountered an unexpected error", reason = Custom } Ok _ -> pass - Err File.IsDirectory -> + Err File.IsDirectory -> Test.Expectation.fail { description = "Expect.equalToFile was given a directory instead of a file", reason = Custom } - Err File.PathEscapesDirectory -> + 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) -> + Err (File.GeneralFileError fileError) -> Test.Expectation.fail { description = "Expect.equalToFile encountered a general file error: " ++ fileError, reason = Custom } - Ok contents -> - equateWith ("equalToFile \'" ++ filePath ++ "\'") (==) contents actual + Ok ( existingAbsolutePath, contents ) -> + if actual == contents then + pass + + else + case File.writeTempFile filePath actual of + Ok newAbsolutePath -> + let + message = + [ "The contents of \"" ++ filePath ++ "\" changed!" + , "To compare run: git diff --no-index " ++ existingAbsolutePath ++ " " ++ newAbsolutePath + ] + + messageWithVisualDiff = + if String.endsWith ".html" filePath then + message ++ [ "To visually compare run: open file://" ++ existingAbsolutePath ++ " file://" ++ newAbsolutePath ] + + else + message + in + Test.Expectation.fail { description = String.join "\n\n" messageWithVisualDiff, reason = Custom } + + _ -> + Test.Expectation.fail { description = "Expect.equalToFile encountered an unexpected error", reason = Custom } + {-| Always passes. diff --git a/src/File.elm b/src/File.elm index 69cd0ad0..3eeb1eb4 100644 --- a/src/File.elm +++ b/src/File.elm @@ -1,15 +1,45 @@ -module File exposing (readFile, writeFile, FileError(..)) +module File exposing (AbsolutePath, FileError(..), RelativePath, readFile, writeFile, writeTempFile) import Elm.Kernel.Test + type FileError = FileNotFound | IsDirectory | PathEscapesDirectory | GeneralFileError String -readFile : String -> Result FileError String -readFile = Elm.Kernel.Test.readFile -writeFile : String -> String -> Result FileError () -writeFile = Elm.Kernel.Test.writeFile \ No newline at end of file +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 + + +{-| Write the contents of the second argument to the file path in the first argument relative to a temp directory + +Returns the absolute file path if successful. + +-} +writeTempFile : RelativePath -> String -> Result FileError AbsolutePath +writeTempFile = + Elm.Kernel.Test.writeTempFile From d8fa6ea7193988f9968cd6fdd632e867d6fab007 Mon Sep 17 00:00:00 2001 From: Micah Hahn Date: Mon, 27 Oct 2025 14:50:34 -0500 Subject: [PATCH 7/9] Add OVERWRITE_GOLDEN_FILES behavior --- src/Elm/Kernel/Test.js | 12 +++++- src/Expect.elm | 83 ++++++++++++++++++++++++------------------ src/File.elm | 9 ++++- 3 files changed, 67 insertions(+), 37 deletions(-) diff --git a/src/Elm/Kernel/Test.js b/src/Elm/Kernel/Test.js index 921ed308..730b06c5 100644 --- a/src/Elm/Kernel/Test.js +++ b/src/Elm/Kernel/Test.js @@ -21,6 +21,7 @@ function _Test_runThunk(thunk) const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); +const process = require('node:process'); const crypto = require('node:crypto'); function _Test_readFile(filePath) @@ -98,4 +99,13 @@ var _Test_writeTempFile = F2(function(filePath, contents) } return WriteFile(tempDir, filePath, contents); -}) \ No newline at end of file +}) + +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 1fe1fe5c..542ea520 100644 --- a/src/Expect.elm +++ b/src/Expect.elm @@ -594,52 +594,65 @@ All file paths are scoped to be within the "tests/" directory. -} equalToFile : String -> String -> Expectation equalToFile filePath actual = - case File.readFile filePath of - Err File.FileNotFound -> + let + 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 } - -- This case should be impossible non general file errors should have been surfaced in the call to `readFile` above. - Err _ -> - Test.Expectation.fail { description = "Expect.equalToFile encountered an unexpected error", reason = Custom } - Ok _ -> pass + in + if File.overwriteGoldenFiles () then + writeGoldenFile () - Err File.IsDirectory -> - Test.Expectation.fail { description = "Expect.equalToFile was given a directory instead of a file", reason = Custom } + else + case File.readFile filePath of + Err File.FileNotFound -> + writeGoldenFile () - Err File.PathEscapesDirectory -> - Test.Expectation.fail { description = "Expect.equalToFile was given a path that would escape the tests/ directory", reason = Custom } + Err File.IsDirectory -> + Test.Expectation.fail { description = "Expect.equalToFile was given a directory instead of a file", reason = Custom } - Err (File.GeneralFileError fileError) -> - Test.Expectation.fail { description = "Expect.equalToFile encountered a general file error: " ++ fileError, reason = Custom } + Err File.PathEscapesDirectory -> + Test.Expectation.fail { description = "Expect.equalToFile was given a path that would escape the tests/ directory", reason = Custom } - Ok ( existingAbsolutePath, contents ) -> - if actual == contents then - pass + Err (File.GeneralFileError fileError) -> + Test.Expectation.fail { description = "Expect.equalToFile encountered a general file error: " ++ fileError, reason = Custom } - else - case File.writeTempFile filePath actual of - Ok newAbsolutePath -> - let - message = - [ "The contents of \"" ++ filePath ++ "\" changed!" - , "To compare run: git diff --no-index " ++ existingAbsolutePath ++ " " ++ newAbsolutePath - ] - - messageWithVisualDiff = - if String.endsWith ".html" filePath then - message ++ [ "To visually compare run: open file://" ++ existingAbsolutePath ++ " file://" ++ newAbsolutePath ] - - else - message - in - Test.Expectation.fail { description = String.join "\n\n" messageWithVisualDiff, reason = Custom } - - _ -> - Test.Expectation.fail { description = "Expect.equalToFile encountered an unexpected error", reason = Custom } + Ok ( existingAbsolutePath, contents ) -> + if actual == contents then + pass + + else + case File.writeTempFile filePath 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. diff --git a/src/File.elm b/src/File.elm index 3eeb1eb4..a0aa1f76 100644 --- a/src/File.elm +++ b/src/File.elm @@ -1,4 +1,4 @@ -module File exposing (AbsolutePath, FileError(..), RelativePath, readFile, writeFile, writeTempFile) +module File exposing (AbsolutePath, FileError(..), RelativePath, overwriteGoldenFiles, readFile, writeFile, writeTempFile) import Elm.Kernel.Test @@ -43,3 +43,10 @@ Returns the absolute file path if successful. writeTempFile : RelativePath -> String -> Result FileError AbsolutePath writeTempFile = Elm.Kernel.Test.writeTempFile + + +{-| Checks the OVERWRITE\_GOLDEN\_FILES environment variable +-} +overwriteGoldenFiles : () -> Bool +overwriteGoldenFiles = + Elm.Kernel.Test.overwriteGoldenFiles From dd21c676701a7f6da794b06725bbb7ed6a3c169b Mon Sep 17 00:00:00 2001 From: Micah Hahn Date: Wed, 5 Nov 2025 11:27:57 -0600 Subject: [PATCH 8/9] Write a ".failed" file next to the golden file instead of in temp --- src/Elm/Kernel/Test.js | 33 +++++++++++++++++++-------------- src/Expect.elm | 11 ++++++++++- src/File.elm | 12 +----------- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/Elm/Kernel/Test.js b/src/Elm/Kernel/Test.js index 730b06c5..76ff00d4 100644 --- a/src/Elm/Kernel/Test.js +++ b/src/Elm/Kernel/Test.js @@ -20,9 +20,7 @@ function _Test_runThunk(thunk) const fs = require('node:fs'); const path = require('node:path'); -const os = require('node:os'); const process = require('node:process'); -const crypto = require('node:crypto'); function _Test_readFile(filePath) { @@ -69,6 +67,25 @@ function WriteFile(root, filePath, contents) 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 @@ -89,18 +106,6 @@ var _Test_writeFile = F2(function(filePath, contents) return WriteFile(path.resolve("tests"), filePath, contents); }) -var tempDir = null; -var _Test_writeTempFile = F2(function(filePath, contents) -{ - if (tempDir === null) - { - tempDir = os.tmpdir() + "/" + crypto.randomUUID(); - fs.mkdirSync(tempDir); - } - - return WriteFile(tempDir, filePath, contents); -}) - var overwriteGoldenFiles = null; function _Test_overwriteGoldenFiles(unused) { diff --git a/src/Expect.elm b/src/Expect.elm index 542ea520..1e6704a1 100644 --- a/src/Expect.elm +++ b/src/Expect.elm @@ -635,7 +635,16 @@ equalToFile filePath actual = pass else - case File.writeTempFile filePath actual of + 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" + in + case File.writeFile failedFilePath actual of Ok newAbsolutePath -> let message = diff --git a/src/File.elm b/src/File.elm index a0aa1f76..bd913e43 100644 --- a/src/File.elm +++ b/src/File.elm @@ -1,4 +1,4 @@ -module File exposing (AbsolutePath, FileError(..), RelativePath, overwriteGoldenFiles, readFile, writeFile, writeTempFile) +module File exposing (AbsolutePath, FileError(..), RelativePath, overwriteGoldenFiles, readFile, writeFile) import Elm.Kernel.Test @@ -35,16 +35,6 @@ writeFile = Elm.Kernel.Test.writeFile -{-| Write the contents of the second argument to the file path in the first argument relative to a temp directory - -Returns the absolute file path if successful. - --} -writeTempFile : RelativePath -> String -> Result FileError AbsolutePath -writeTempFile = - Elm.Kernel.Test.writeTempFile - - {-| Checks the OVERWRITE\_GOLDEN\_FILES environment variable -} overwriteGoldenFiles : () -> Bool From 5f2caf68d47c9ad354dda72bc17858b3e9ac788e Mon Sep 17 00:00:00 2001 From: Micah Hahn Date: Thu, 6 Nov 2025 09:37:18 -0600 Subject: [PATCH 9/9] Bring the file deletion into Elm land --- src/Elm/Kernel/Test.js | 30 ++++++++++++++++++++++++++++++ src/Expect.elm | 23 +++++++++++++---------- src/File.elm | 6 +++++- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/Elm/Kernel/Test.js b/src/Elm/Kernel/Test.js index 76ff00d4..7c80a1e3 100644 --- a/src/Elm/Kernel/Test.js +++ b/src/Elm/Kernel/Test.js @@ -106,6 +106,36 @@ 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) { diff --git a/src/Expect.elm b/src/Expect.elm index 1e6704a1..4f31c46f 100644 --- a/src/Expect.elm +++ b/src/Expect.elm @@ -595,6 +595,14 @@ 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 -> @@ -611,7 +619,11 @@ equalToFile filePath actual = Test.Expectation.fail { description = "Expect.equalToFile encountered a general file error: " ++ fileError, reason = Custom } Ok _ -> - pass + -- 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 () @@ -635,15 +647,6 @@ equalToFile filePath actual = pass else - 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" - in case File.writeFile failedFilePath actual of Ok newAbsolutePath -> let diff --git a/src/File.elm b/src/File.elm index bd913e43..5bce01b5 100644 --- a/src/File.elm +++ b/src/File.elm @@ -1,4 +1,4 @@ -module File exposing (AbsolutePath, FileError(..), RelativePath, overwriteGoldenFiles, readFile, writeFile) +module File exposing (AbsolutePath, FileError(..), RelativePath, overwriteGoldenFiles, readFile, writeFile, deleteFile) import Elm.Kernel.Test @@ -34,6 +34,10 @@ 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 -}