diff --git a/docs/converter.md b/docs/converter.md index 2efb49a29..86dad8427 100644 --- a/docs/converter.md +++ b/docs/converter.md @@ -206,6 +206,43 @@ return new( +## Excluding targets + +Some converters emit the source document (for example a `pdf`, `docx`, or `xlsx`) alongside the info file and the derived targets. That source document is then committed as a `.verified.{extension}` file. Where the document is large, or where its bytes cannot be made deterministic, it can be excluded from the snapshot. The info file and the derived targets continue to verify. + +`ExcludeTargets` takes one or more extensions, and drops every matching target: + + + +```cs +[Fact] +public Task ExcludeConverterSourceTarget() => + Verify(new MemoryStream("source-document"u8.ToArray()), "excludesource") + .ExcludeTargets("excludesource"); +``` +snippet source | anchor + + +Any existing verified file for an excluded extension is then reported as pending deletion. + +To exclude an extension for every test, call `ExcludeTargets` on `VerifierSettings` at initialization: + + + +```cs +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Init() => + VerifierSettings.ExcludeTargets("pdf"); +} +``` +snippet source | anchor + + +Excluding every target of a verification is an error, since a verification requires at least one target. + + ## Shipping Converters can be shipped as NuGet packages: diff --git a/docs/mdsource/converter.source.md b/docs/mdsource/converter.source.md index 033d4917b..188d12388 100644 --- a/docs/mdsource/converter.source.md +++ b/docs/mdsource/converter.source.md @@ -70,6 +70,23 @@ If cleanup needs to occur after verification a callback can be passes to `Conver snippet: ConversionResultWithCleanup +## Excluding targets + +Some converters emit the source document (for example a `pdf`, `docx`, or `xlsx`) alongside the info file and the derived targets. That source document is then committed as a `.verified.{extension}` file. Where the document is large, or where its bytes cannot be made deterministic, it can be excluded from the snapshot. The info file and the derived targets continue to verify. + +`ExcludeTargets` takes one or more extensions, and drops every matching target: + +snippet: ExcludeTargets + +Any existing verified file for an excluded extension is then reported as pending deletion. + +To exclude an extension for every test, call `ExcludeTargets` on `VerifierSettings` at initialization: + +snippet: StaticExcludeTargets + +Excluding every target of a verification is an error, since a verification requires at least one target. + + ## Shipping Converters can be shipped as NuGet packages: diff --git a/src/ModuleInitDocs/ExcludeTargets.cs b/src/ModuleInitDocs/ExcludeTargets.cs new file mode 100644 index 000000000..2292ecb95 --- /dev/null +++ b/src/ModuleInitDocs/ExcludeTargets.cs @@ -0,0 +1,13 @@ +public class ExcludeTargets +{ + #region StaticExcludeTargets + + public static class ModuleInitializer + { + [ModuleInitializer] + public static void Init() => + VerifierSettings.ExcludeTargets("pdf"); + } + + #endregion +} diff --git a/src/StaticSettingsTests/ExcludeTargetsTests.GlobalExclusionAppliesToAllVerifications.verified.txt b/src/StaticSettingsTests/ExcludeTargetsTests.GlobalExclusionAppliesToAllVerifications.verified.txt new file mode 100644 index 000000000..c296c2eef --- /dev/null +++ b/src/StaticSettingsTests/ExcludeTargetsTests.GlobalExclusionAppliesToAllVerifications.verified.txt @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/src/StaticSettingsTests/ExcludeTargetsTests.cs b/src/StaticSettingsTests/ExcludeTargetsTests.cs new file mode 100644 index 000000000..730482b46 --- /dev/null +++ b/src/StaticSettingsTests/ExcludeTargetsTests.cs @@ -0,0 +1,20 @@ +public class ExcludeTargetsTests : + BaseTest +{ + [Fact] + public async Task GlobalExclusionAppliesToAllVerifications() + { + VerifierSettings.ExcludeTargets("excludedbin"); + Target[] targets = [new("excludedbin", new MemoryStream("binary-content"u8.ToArray()))]; + var result = await Verify(null, targets) + .DisableDiff(); + Assert.Single(result.Files); + } + + [Fact] + public void AfterVerifyHasBeenRunThrows() + { + InnerVerifier.verifyHasBeenRun = true; + Assert.Throws(() => VerifierSettings.ExcludeTargets("excludedbin")); + } +} diff --git a/src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludeConverterSourceTarget.verified.txt b/src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludeConverterSourceTarget.verified.txt new file mode 100644 index 000000000..6669c2e04 --- /dev/null +++ b/src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludeConverterSourceTarget.verified.txt @@ -0,0 +1 @@ +derived from source \ No newline at end of file diff --git a/src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludeFileConverterSourceTarget.verified.txt b/src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludeFileConverterSourceTarget.verified.txt new file mode 100644 index 000000000..fd9e33b5a --- /dev/null +++ b/src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludeFileConverterSourceTarget.verified.txt @@ -0,0 +1 @@ +the info \ No newline at end of file diff --git a/src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludeStreamTarget.verified.txt b/src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludeStreamTarget.verified.txt new file mode 100644 index 000000000..c296c2eef --- /dev/null +++ b/src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludeStreamTarget.verified.txt @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludedStreamIsDisposed.verified.txt b/src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludedStreamIsDisposed.verified.txt new file mode 100644 index 000000000..c296c2eef --- /dev/null +++ b/src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludedStreamIsDisposed.verified.txt @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/src/Verify.Tests/Converters/ExcludeTargetsTests.ExtensionMatchIsCaseInsensitive.verified.txt b/src/Verify.Tests/Converters/ExcludeTargetsTests.ExtensionMatchIsCaseInsensitive.verified.txt new file mode 100644 index 000000000..c296c2eef --- /dev/null +++ b/src/Verify.Tests/Converters/ExcludeTargetsTests.ExtensionMatchIsCaseInsensitive.verified.txt @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/src/Verify.Tests/Converters/ExcludeTargetsTests.cs b/src/Verify.Tests/Converters/ExcludeTargetsTests.cs new file mode 100644 index 000000000..fb49a206f --- /dev/null +++ b/src/Verify.Tests/Converters/ExcludeTargetsTests.cs @@ -0,0 +1,102 @@ +public class ExcludeTargetsTests +{ + // Mimics a document converter (eg Verify.PdfPig) that emits the source document + // alongside targets derived from it. + [ModuleInitializer] + public static void Init() => + VerifierSettings.RegisterStreamConverter( + "excludesource", + (_, _, _) => + new( + null, + [ + new("excludesource", new MemoryStream("source-document"u8.ToArray())), + new("txt", "derived from source") + ])); + + class SourceDocument; + + // The same shape, reached through a file converter rather than a stream converter. + [ModuleInitializer] + public static void FileConverterInit() => + VerifierSettings.RegisterFileConverter( + (_, _) => + new( + "the info", + [new("excludedbin", new MemoryStream("source-document"u8.ToArray()))])); + + static Target[] BuildTargets() => + [ + new("excludedbin", new MemoryStream("binary-content"u8.ToArray())) + ]; + + // Only the root txt target survives, so no verified file is required for the excluded extension. + [Fact] + public async Task ExcludeStreamTarget() + { + var result = await Verify(null, BuildTargets()) + .ExcludeTargets("excludedbin") + .DisableDiff(); + Assert.Single(result.Files); + } + + [Fact] + public async Task ExtensionMatchIsCaseInsensitive() + { + var result = await Verify(null, BuildTargets()) + .ExcludeTargets("EXCLUDEDBIN") + .DisableDiff(); + Assert.Single(result.Files); + } + + // The engine disposes the streams it consumes. An excluded stream never reaches it, + // so the exclusion has to dispose it instead. + [Fact] + public async Task ExcludedStreamIsDisposed() + { + var stream = new MemoryStream("binary-content"u8.ToArray()); + Target[] targets = [new("excludedbin", stream)]; + await Verify(null, targets) + .ExcludeTargets("excludedbin") + .DisableDiff(); + Assert.False(stream.CanRead); + } + + // The source document a converter emits can be excluded, keeping its derived targets. + // Were the exclusion to fail, an unexpected excludesource target would be reported as new. + #region ExcludeTargets + + [Fact] + public Task ExcludeConverterSourceTarget() => + Verify(new MemoryStream("source-document"u8.ToArray()), "excludesource") + .ExcludeTargets("excludesource"); + + #endregion + + [Fact] + public async Task ExcludeFileConverterSourceTarget() + { + var result = await Verify(new SourceDocument()) + .ExcludeTargets("excludedbin") + .DisableDiff(); + Assert.Single(result.Files); + } + + [Fact] + public async Task ExcludingAllTargetsThrows() + { + var exception = await Assert.ThrowsAsync( + () => Verify(new MemoryStream("binary-content"u8.ToArray()), "excludedbin") + .ExcludeTargets("excludedbin") + .DisableDiff()); + Assert.Contains("All targets have been excluded", exception.Message); + } + + [Fact] + public void NoExtensionsThrows() => + Assert.Throws(() => new VerifySettings().ExcludeTargets()); + + [Fact] + public void ExtensionWithPeriodThrows() => + Assert.Throws(() => new VerifySettings().ExcludeTargets(".pdf")); +} diff --git a/src/Verify/Serialization/VerifierSettings.cs b/src/Verify/Serialization/VerifierSettings.cs index 314a39e7c..93e2e96b9 100644 --- a/src/Verify/Serialization/VerifierSettings.cs +++ b/src/Verify/Serialization/VerifierSettings.cs @@ -165,6 +165,7 @@ internal static void Reset() omitContentFromException = false; encoding = new UTF8Encoding(true, true); addAttachments = true; + excludedTargets = null; GlobalScrubbers.Clear(); GlobalIgnoredParameters = null; GlobalIgnoreConstructorParameters = false; diff --git a/src/Verify/Splitters/Settings_ExcludeTargets.cs b/src/Verify/Splitters/Settings_ExcludeTargets.cs new file mode 100644 index 000000000..a4bb91b6b --- /dev/null +++ b/src/Verify/Splitters/Settings_ExcludeTargets.cs @@ -0,0 +1,68 @@ +namespace VerifyTests; + +public static partial class VerifierSettings +{ + internal static HashSet? excludedTargets; + + /// + /// Excludes every target with one of from all verifications. + /// Any existing verified file for an excluded extension is treated as pending deletion. + /// Intended for converters that emit a source document (eg pdf or docx) alongside + /// the info file and rendered pages, where committing the source document is not wanted. + /// + public static void ExcludeTargets(params string[] extensions) + { + InnerVerifier.ThrowIfVerifyHasBeenRun(); + excludedTargets = AddExcludedTargets(excludedTargets, extensions); + } + + internal static bool AnyExcludedTargets(VerifySettings settings) => + excludedTargets != null || + settings.excludedTargets != null; + + internal static bool IsTargetExcluded(VerifySettings settings, string extension) => + excludedTargets?.Contains(extension) == true || + settings.excludedTargets?.Contains(extension) == true; + + internal static HashSet AddExcludedTargets(HashSet? existing, string[] extensions) + { + if (extensions.Length == 0) + { + throw new ArgumentException("At least one extension is required.", nameof(extensions)); + } + + existing ??= new(StringComparer.OrdinalIgnoreCase); + foreach (var extension in extensions) + { + Guards.AgainstBadExtension(extension); + existing.Add(extension); + } + + return existing; + } +} + +public partial class VerifySettings +{ + internal HashSet? excludedTargets; + + /// + /// Excludes every target with one of from the current verification. + /// Any existing verified file for an excluded extension is treated as pending deletion. + /// Intended for converters that emit a source document (eg pdf or docx) alongside + /// the info file and rendered pages, where committing the source document is not wanted. + /// + public void ExcludeTargets(params string[] extensions) => + excludedTargets = VerifierSettings.AddExcludedTargets(excludedTargets, extensions); +} + +public partial class SettingsTask +{ + /// + [Pure] + public SettingsTask ExcludeTargets(params string[] extensions) + { + CurrentSettings.ExcludeTargets(extensions); + return this; + } +} diff --git a/src/Verify/Verifier/InnerVerifier_Inner.cs b/src/Verify/Verifier/InnerVerifier_Inner.cs index 221143d30..eb0c2712c 100644 --- a/src/Verify/Verifier/InnerVerifier_Inner.cs +++ b/src/Verify/Verifier/InnerVerifier_Inner.cs @@ -18,6 +18,14 @@ async Task VerifyInner(object? root, Func? cleanup, IEnumera var (extraTargets, extraCleanup) = await GetTargets(targets, doExtensionConversion); cleanup = cleanup.Then(extraCleanup); resultTargets.AddRange(extraTargets); + cleanup = RemoveExcludedTargets(resultTargets, cleanup, out var removedTargets); + if (removedTargets && + resultTargets.Count == 0) + { + await cleanup(); + throw new("All targets have been excluded by ExcludeTargets. A verification requires at least one target."); + } + var engine = new VerifyEngine( directory, settings, @@ -48,6 +56,35 @@ async Task VerifyInner(object? root, Func? cleanup, IEnumera return new(filePairs, root); } + Func RemoveExcludedTargets(List targets, Func cleanup, out bool removed) + { + removed = false; + if (!VerifierSettings.AnyExcludedTargets(settings)) + { + return cleanup; + } + + for (var index = targets.Count - 1; index >= 0; index--) + { + var target = targets[index]; + if (!VerifierSettings.IsTargetExcluded(settings, target.Extension)) + { + continue; + } + + if (target.IsStream) + { + // VerifyEngine disposes the streams it consumes, so an excluded stream never reaches it + cleanup = cleanup.Then(target.StreamData.DisposeAsyncEx); + } + + targets.RemoveAt(index); + removed = true; + } + + return cleanup; + } + async Task<(List extra, Func cleanup)> GetTargets(IEnumerable targets, bool doExtensionConversion) { List list = [..targets, ..VerifierSettings.GetFileAppenders(settings)]; diff --git a/src/Verify/VerifySettings.cs b/src/Verify/VerifySettings.cs index 006667199..19c8f964f 100644 --- a/src/Verify/VerifySettings.cs +++ b/src/Verify/VerifySettings.cs @@ -44,6 +44,11 @@ public VerifySettings(VerifySettings? settings) // (AppendFile/UseStreamComparer/etc.) would mutate a shared base settings // instance and leak across tests. appendedFiles = settings.appendedFiles?.ToList(); + if (settings.excludedTargets != null) + { + excludedTargets = new(settings.excludedTargets, StringComparer.OrdinalIgnoreCase); + } + UniqueDirectory = settings.UniqueDirectory; Directory = settings.Directory; autoVerify = settings.autoVerify;