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);
});