Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The clipboard behavior can be enabled using the following:
```cs
ClipboardAccept.Enable();
```
<sup><a href='/src/Verify.ClipboardAccept.Tests/ClipboardEnabledTests.cs#L12-L14' title='Snippet source file'>snippet source</a> | <a href='#snippet-EnableClipboard' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Verify.ClipboardAccept.Tests/ClipboardEnabledTests.cs#L24-L26' title='Snippet source file'>snippet source</a> | <a href='#snippet-EnableClipboard' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down
31 changes: 25 additions & 6 deletions docs/combinations.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ public Task BuildAddressExceptionsDisabledTest()
city);
}
```
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L185-L201' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationSample_CaptureExceptionsFalse' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L214-L230' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationSample_CaptureExceptionsFalse' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down Expand Up @@ -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<object, string>();
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)
Expand Down Expand Up @@ -353,7 +372,7 @@ public class CombinationResultsConverter :
}
}
```
<sup><a href='/src/Verify/Combinations/CombinationResultsConverter.cs#L1-L166' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationResultsConverter.cs' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Verify/Combinations/CombinationResultsConverter.cs#L1-L185' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationResultsConverter.cs' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand All @@ -378,7 +397,7 @@ class CustomCombinationConverter :
string.Join(", ", keys.Select(_ => _.Value));
}
```
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L231-L240' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationSample_CustomSerializationConverter' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L260-L269' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationSample_CustomSerializationConverter' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Full control of serialization can be achieved by inheriting from `WriteOnlyJsonConverter<CombinationResults>`.
Expand All @@ -397,7 +416,7 @@ static CustomCombinationConverter customConverter = new();
public static void Init() =>
VerifierSettings.AddExtraSettings(_ => _.Converters.Insert(0, customConverter));
```
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L203-L211' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationSample_CustomSerializationModuleInitializer' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L232-L240' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationSample_CustomSerializationModuleInitializer' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down Expand Up @@ -528,5 +547,5 @@ Headers can be enabled globally:
public static void EnableIncludeHeaders() =>
CombinationSettings.IncludeHeaders();
```
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L243-L249' title='Snippet source file'>snippet source</a> | <a href='#snippet-GlobalCombinationHeader' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L272-L278' title='Snippet source file'>snippet source</a> | <a href='#snippet-GlobalCombinationHeader' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
list: Result,
1: {
target: result1,
recorded: value
}
}
29 changes: 29 additions & 0 deletions src/StaticSettingsTests/CombinationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
26 changes: 24 additions & 2 deletions src/Verify.ClipboardAccept.Tests/ClipboardEnabledTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
27 changes: 25 additions & 2 deletions src/Verify.ClipboardAccept/ClipboardAccept.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 18 additions & 5 deletions src/Verify.ClipboardAccept/ClipboardEnabled.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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() =>
Expand Down
9 changes: 7 additions & 2 deletions src/Verify.Expecto/Verifier.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace VerifyExpecto;
using System.Runtime.ExceptionServices;

namespace VerifyExpecto;

public static partial class Verifier
{
Expand Down Expand Up @@ -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;
}
});
}
Expand Down
18 changes: 14 additions & 4 deletions src/Verify.Expecto/Verifier_Directory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// <summary>
Expand Down Expand Up @@ -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
}
9 changes: 9 additions & 0 deletions src/Verify.Tests/CombinationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ public Task EnumerableTest() =>
params1,
params2);

[Fact]
public async Task EmptyListThrowsDescriptive()
{
var exception = await Assert.ThrowsAnyAsync<Exception>(
async () => await Combination()
.Verify((int _) => "x", System.Array.Empty<int>()));
Assert.Contains("empty", exception.Message);
}

public static async Task<string> TaskMethod(int param1, string param2)
{
await Task.Delay(1);
Expand Down
21 changes: 20 additions & 1 deletion src/Verify/Combinations/CombinationResultsConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<object, string>();
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)
Expand Down
13 changes: 12 additions & 1 deletion src/Verify/Combinations/CombinationRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,24 @@ public CombinationRunner(bool? captureExceptions, bool? header, List<IEnumerable
}
this.captureExceptions = captureExceptions ?? CombinationSettings.CaptureExceptionsEnabled;
this.lists = lists.Select(_ => _.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];
}

Task<CombinationResults> RunWithReturn<TReturn>(Func<object?[], Task<TReturn>> method) =>
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)
{
Expand All @@ -38,7 +49,7 @@ Task<CombinationResults> RunWithReturn<TReturn>(Func<object?[], Task<TReturn>> m
}
}

return (CombinationResult.ForValue(keys, value), value);
return (CombinationResult.ForValue(keys, value), raw);
});


Expand Down
Loading