diff --git a/docs/clipboard.md b/docs/clipboard.md index 907e7e422..7dfe597d4 100644 --- a/docs/clipboard.md +++ b/docs/clipboard.md @@ -25,7 +25,7 @@ The clipboard behavior can be enabled using the following: ```cs ClipboardAccept.Enable(); ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/combinations.md b/docs/combinations.md index ef70c7013..ebcd5c37f 100644 --- a/docs/combinations.md +++ b/docs/combinations.md @@ -175,7 +175,7 @@ public Task BuildAddressExceptionsDisabledTest() city); } ``` -snippet source | anchor +snippet source | anchor @@ -215,13 +215,32 @@ public class CombinationResultsConverter : var keyValues = new string[items.Count, keysLength]; + // Keys repeat across rows (a column only has as many distinct values as + // its input list), so cache the computed name per distinct key value. + var nameCache = new Dictionary(); + string? nullName = null; + for (var itemIndex = 0; itemIndex < items.Count; itemIndex++) { var item = items[itemIndex]; for (var keyIndex = 0; keyIndex < keysLength; keyIndex++) { var key = item.Keys[keyIndex]; - var name = VerifierSettings.GetNameForParameter(key, writer.Counter, pathFriendly: false); + string name; + if (key == null) + { + name = nullName ??= VerifierSettings.GetNameForParameter(null, writer.Counter, pathFriendly: false); + } + else if (nameCache.TryGetValue(key, out var cached)) + { + name = cached; + } + else + { + name = VerifierSettings.GetNameForParameter(key, writer.Counter, pathFriendly: false); + nameCache[key] = name; + } + keyValues[itemIndex, keyIndex] = name; var currentKeyLength = maxKeyLengths[keyIndex]; if (name.Length > currentKeyLength) @@ -353,7 +372,7 @@ public class CombinationResultsConverter : } } ``` -snippet source | anchor +snippet source | anchor @@ -378,7 +397,7 @@ class CustomCombinationConverter : string.Join(", ", keys.Select(_ => _.Value)); } ``` -snippet source | anchor +snippet source | anchor Full control of serialization can be achieved by inheriting from `WriteOnlyJsonConverter`. @@ -397,7 +416,7 @@ static CustomCombinationConverter customConverter = new(); public static void Init() => VerifierSettings.AddExtraSettings(_ => _.Converters.Insert(0, customConverter)); ``` -snippet source | anchor +snippet source | anchor @@ -528,5 +547,5 @@ Headers can be enabled globally: public static void EnableIncludeHeaders() => CombinationSettings.IncludeHeaders(); ``` -snippet source | anchor +snippet source | anchor diff --git a/src/StaticSettingsTests/CombinationTests.AfterCallbackRawValueWhenRecording.verified.txt b/src/StaticSettingsTests/CombinationTests.AfterCallbackRawValueWhenRecording.verified.txt new file mode 100644 index 000000000..7a59a8541 --- /dev/null +++ b/src/StaticSettingsTests/CombinationTests.AfterCallbackRawValueWhenRecording.verified.txt @@ -0,0 +1,7 @@ +{ + list: Result, + 1: { + target: result1, + recorded: value + } +} \ No newline at end of file diff --git a/src/StaticSettingsTests/CombinationTests.cs b/src/StaticSettingsTests/CombinationTests.cs index 3b960c137..71eea270f 100644 --- a/src/StaticSettingsTests/CombinationTests.cs +++ b/src/StaticSettingsTests/CombinationTests.cs @@ -79,6 +79,35 @@ await Verify(new .UseMethodName("CallbackResults"); } + [Fact] + public async Task AfterCallbackRawValueWhenRecording() + { + object? afterResult = null; + CombinationSettings.UseCallbacks( + _ => Task.CompletedTask, + (_, result) => + { + afterResult = result; + return Task.CompletedTask; + }, + (_, _) => Task.CompletedTask); + + int[] list = [1]; + Recording.Start(); + await Combination() + .Verify( + (int param1) => + { + Recording.Add("recorded", "value"); + return $"result{param1}"; + }, + list); + + // With recording active the callback must still receive the raw method + // result, not the internal InfoBuilder wrapper. + Assert.Equal("result1", afterResult); + } + [Fact] public async Task ExceptionCallbacksTest() { diff --git a/src/Verify.ClipboardAccept.Tests/ClipboardEnabledTests.cs b/src/Verify.ClipboardAccept.Tests/ClipboardEnabledTests.cs index e2604f524..826460158 100644 --- a/src/Verify.ClipboardAccept.Tests/ClipboardEnabledTests.cs +++ b/src/Verify.ClipboardAccept.Tests/ClipboardEnabledTests.cs @@ -4,16 +4,38 @@ public void ParseEnvironmentVariable() { Assert.False(ClipboardEnabled.ParseEnvironmentVariable(null)); + Assert.False(ClipboardEnabled.ParseEnvironmentVariable("")); Assert.False(ClipboardEnabled.ParseEnvironmentVariable("false")); + Assert.False(ClipboardEnabled.ParseEnvironmentVariable("0")); + Assert.False(ClipboardEnabled.ParseEnvironmentVariable("no")); Assert.True(ClipboardEnabled.ParseEnvironmentVariable("true")); + Assert.True(ClipboardEnabled.ParseEnvironmentVariable("1")); + Assert.True(ClipboardEnabled.ParseEnvironmentVariable("yes")); + Assert.True(ClipboardEnabled.ParseEnvironmentVariable("ON")); } + [Fact] + public void ParseEnvironmentVariable_unknownDoesNotThrow() => + // Runs from a static constructor, so an unrecognized value must not throw + // (which would poison the type with a TypeInitializationException). + Assert.False(ClipboardEnabled.ParseEnvironmentVariable("foo")); + void EnableClipboard() => #region EnableClipboard ClipboardAccept.Enable(); #endregion [Fact] - public Task ParseEnvironmentVariable_failure() => - Throws(() => ClipboardEnabled.ParseEnvironmentVariable("foo")); + public void EscapePath_Posix() + { + // Inside double quotes, shell metacharacters must be escaped so paths + // containing them are not expanded. + Assert.Equal(@"\$HOME/x", ClipboardAccept.EscapePath("$HOME/x", windows: false)); + Assert.Equal(@"a\`b", ClipboardAccept.EscapePath("a`b", windows: false)); + Assert.Equal("plain/path", ClipboardAccept.EscapePath("plain/path", windows: false)); + } + + [Fact] + public void EscapePath_Windows() => + Assert.Equal(@"C:\dir\file", ClipboardAccept.EscapePath(@"C:\dir\file", windows: true)); } \ No newline at end of file diff --git a/src/Verify.ClipboardAccept/ClipboardAccept.cs b/src/Verify.ClipboardAccept/ClipboardAccept.cs index dc5ebd5c8..45bb0b1c2 100644 --- a/src/Verify.ClipboardAccept/ClipboardAccept.cs +++ b/src/Verify.ClipboardAccept/ClipboardAccept.cs @@ -11,6 +11,7 @@ public static class ClipboardAccept static string moveCommand; static string deleteCommand; + static readonly bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); public static void Enable() { @@ -26,7 +27,29 @@ static Task AppendMove(FilePair file, string? receivedText, bool autoVerify) return Task.CompletedTask; } - return Append(string.Format(moveCommand, file.ReceivedPath, file.VerifiedPath)); + return Append(string.Format(moveCommand, EscapePath(file.ReceivedPath), EscapePath(file.VerifiedPath))); + } + + static string EscapePath(string path) => + EscapePath(path, isWindows); + + internal static string EscapePath(string path, bool windows) + { + if (windows) + { + // Windows filenames cannot contain '"', so the surrounding double + // quotes in the command are sufficient. '%' expansion is left as-is + // since it cannot be reliably suppressed at an interactive prompt. + return path; + } + + // The path is substituted inside double quotes; escape the characters the + // shell still interprets there so a path containing $ or ` is not expanded. + return path + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("$", "\\$") + .Replace("`", "\\`"); } static ClipboardAccept() @@ -67,7 +90,7 @@ internal static Task AppendDelete(string verified, bool autoVerify) { return Task.CompletedTask; } - return Append(string.Format(deleteCommand, verified)); + return Append(string.Format(deleteCommand, EscapePath(verified))); } static async Task Append(string command) diff --git a/src/Verify.ClipboardAccept/ClipboardEnabled.cs b/src/Verify.ClipboardAccept/ClipboardEnabled.cs index 6a8925910..0055740ab 100644 --- a/src/Verify.ClipboardAccept/ClipboardEnabled.cs +++ b/src/Verify.ClipboardAccept/ClipboardEnabled.cs @@ -12,17 +12,30 @@ static ClipboardEnabled() public static bool ParseEnvironmentVariable(string? disabledText) { - if (disabledText is null) + if (string.IsNullOrWhiteSpace(disabledText)) { return false; } - if (bool.TryParse(disabledText, out var disabled)) + // Parse leniently and never throw: this runs from a static constructor, + // so throwing would poison the type and surface as a TypeInitializationException + // that masks the actual snapshot diff on every failing test. + switch (disabledText!.Trim().ToLowerInvariant()) { - return disabled; + case "true": + case "1": + case "yes": + case "on": + return true; + case "false": + case "0": + case "no": + case "off": + return false; + default: + Trace.WriteLine($"Could not convert `Verify_DisableClipboard` environment variable to a bool. Value: {disabledText}. Treating as not disabled."); + return false; } - - throw new($"Could not convert `Verify_DisableClipboard` environment variable to a bool. Value: {disabledText}"); } public static bool IsEnabled() => diff --git a/src/Verify.Expecto/Verifier.cs b/src/Verify.Expecto/Verifier.cs index a19fd17ed..a4edd87bb 100644 --- a/src/Verify.Expecto/Verifier.cs +++ b/src/Verify.Expecto/Verifier.cs @@ -1,4 +1,6 @@ -namespace VerifyExpecto; +using System.Runtime.ExceptionServices; + +namespace VerifyExpecto; public static partial class Verifier { @@ -55,7 +57,10 @@ static SettingsTask Verify( catch (TargetInvocationException exception) when (exception.InnerException != null) { - throw exception.InnerException!; + // Preserve the original stack trace instead of resetting it + // with a bare `throw exception.InnerException`. + ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); + throw; } }); } diff --git a/src/Verify.Expecto/Verifier_Directory.cs b/src/Verify.Expecto/Verifier_Directory.cs index c7a0b18e3..1da1f450b 100644 --- a/src/Verify.Expecto/Verifier_Directory.cs +++ b/src/Verify.Expecto/Verifier_Directory.cs @@ -39,8 +39,13 @@ public static SettingsTask VerifyDirectory( VerifySettings? settings = null, object? info = null, FileScrubber? fileScrubber = null, - [CallerFilePath] string sourceFile = "") => - VerifyDirectory(name, path.FullName, include, pattern, options, settings, info, fileScrubber, sourceFile); + [CallerFilePath] string sourceFile = "") + { + // Capture the caller here rather than delegating to the string overload, + // otherwise GetCallingAssembly would resolve to Verify.Expecto. + var assembly = Assembly.GetCallingAssembly()!; + return Verify(settings, assembly, sourceFile, name, _ => _.VerifyDirectory(path.FullName, include, pattern, options, info, fileScrubber), true); + } #else /// @@ -77,8 +82,13 @@ public static SettingsTask VerifyDirectory( VerifySettings? settings = null, object? info = null, FileScrubber? fileScrubber = null, - [CallerFilePath] string sourceFile = "") => - VerifyDirectory(name, path.FullName, include, pattern, option, settings, info, fileScrubber, sourceFile); + [CallerFilePath] string sourceFile = "") + { + // Capture the caller here rather than delegating to the string overload, + // otherwise GetCallingAssembly would resolve to Verify.Expecto. + var assembly = Assembly.GetCallingAssembly()!; + return Verify(settings, assembly, sourceFile, name, _ => _.VerifyDirectory(path.FullName, include, pattern, option, info, fileScrubber), true); + } #endif } \ No newline at end of file diff --git a/src/Verify.Tests/CombinationTests.cs b/src/Verify.Tests/CombinationTests.cs index 3b1e17a0c..0f893543e 100644 --- a/src/Verify.Tests/CombinationTests.cs +++ b/src/Verify.Tests/CombinationTests.cs @@ -31,6 +31,15 @@ public Task EnumerableTest() => params1, params2); + [Fact] + public async Task EmptyListThrowsDescriptive() + { + var exception = await Assert.ThrowsAnyAsync( + async () => await Combination() + .Verify((int _) => "x", System.Array.Empty())); + Assert.Contains("empty", exception.Message); + } + public static async Task TaskMethod(int param1, string param2) { await Task.Delay(1); diff --git a/src/Verify/Combinations/CombinationResultsConverter.cs b/src/Verify/Combinations/CombinationResultsConverter.cs index 522dc8ff2..6094398f4 100644 --- a/src/Verify/Combinations/CombinationResultsConverter.cs +++ b/src/Verify/Combinations/CombinationResultsConverter.cs @@ -27,13 +27,32 @@ public override void Write(VerifyJsonWriter writer, CombinationResults results) var keyValues = new string[items.Count, keysLength]; + // Keys repeat across rows (a column only has as many distinct values as + // its input list), so cache the computed name per distinct key value. + var nameCache = new Dictionary(); + string? nullName = null; + for (var itemIndex = 0; itemIndex < items.Count; itemIndex++) { var item = items[itemIndex]; for (var keyIndex = 0; keyIndex < keysLength; keyIndex++) { var key = item.Keys[keyIndex]; - var name = VerifierSettings.GetNameForParameter(key, writer.Counter, pathFriendly: false); + string name; + if (key == null) + { + name = nullName ??= VerifierSettings.GetNameForParameter(null, writer.Counter, pathFriendly: false); + } + else if (nameCache.TryGetValue(key, out var cached)) + { + name = cached; + } + else + { + name = VerifierSettings.GetNameForParameter(key, writer.Counter, pathFriendly: false); + nameCache[key] = name; + } + keyValues[itemIndex, keyIndex] = name; var currentKeyLength = maxKeyLengths[keyIndex]; if (name.Length > currentKeyLength) diff --git a/src/Verify/Combinations/CombinationRunner.cs b/src/Verify/Combinations/CombinationRunner.cs index f51bf2c1c..d5fa396e3 100644 --- a/src/Verify/Combinations/CombinationRunner.cs +++ b/src/Verify/Combinations/CombinationRunner.cs @@ -19,6 +19,14 @@ public CombinationRunner(bool? captureExceptions, bool? header, List _.ToArray()).ToArray(); + for (var index = 0; index < this.lists.Length; index++) + { + if (this.lists[index].Length == 0) + { + throw new($"Combinations requires every list to contain at least one item. The list at index {index} is empty."); + } + } + indices = new int[lists.Count]; } @@ -26,6 +34,9 @@ Task RunWithReturn(Func> m InnerRun(async keys => { object? value = await method(keys); + // Preserve the raw method result for the after-callbacks; only the + // serialized CombinationResult should be wrapped in InfoBuilder. + var raw = value; var paused = Recording.IsPaused(); if (Recording.IsRecording() || paused) { @@ -38,7 +49,7 @@ Task RunWithReturn(Func> m } } - return (CombinationResult.ForValue(keys, value), value); + return (CombinationResult.ForValue(keys, value), raw); });