From ffcf8049cb3d3d6892b71cf2ef5b0032f995a387 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 11 Jul 2026 10:42:22 +1000 Subject: [PATCH 1/2] Add ExcludeTargets to omit targets by extension Converters that emit a source document (pdf, docx, xlsx, pptx) alongside the info file and derived renders force that document to be committed as a .verified file. ExcludeTargets drops every target of a given extension, keeping the rest of the snapshot. Exposed globally on VerifierSettings and per-verification on VerifySettings and SettingsTask. Applied at the single funnel all converters pass through, so it covers both stream and file converters. Excluded stream targets are disposed (the engine never receives them); excluding every target throws. --- docs/converter.md | 37 +++++++ docs/mdsource/converter.source.md | 17 +++ src/ModuleInitDocs/ExcludeTargets.cs | 13 +++ ...sionAppliesToAllVerifications.verified.txt | 1 + .../ExcludeTargetsTests.cs | 20 ++++ ....ExcludeConverterSourceTarget.verified.txt | 1 + ...ludeFileConverterSourceTarget.verified.txt | 1 + ...getsTests.ExcludeStreamTarget.verified.txt | 1 + ...ests.ExcludedStreamIsDisposed.verified.txt | 1 + ...tensionMatchIsCaseInsensitive.verified.txt | 1 + .../Converters/ExcludeTargetsTests.cs | 102 ++++++++++++++++++ src/Verify/Serialization/VerifierSettings.cs | 1 + .../Splitters/Settings_ExcludeTargets.cs | 68 ++++++++++++ src/Verify/Verifier/InnerVerifier_Inner.cs | 37 +++++++ src/Verify/VerifySettings.cs | 5 + 15 files changed, 306 insertions(+) create mode 100644 src/ModuleInitDocs/ExcludeTargets.cs create mode 100644 src/StaticSettingsTests/ExcludeTargetsTests.GlobalExclusionAppliesToAllVerifications.verified.txt create mode 100644 src/StaticSettingsTests/ExcludeTargetsTests.cs create mode 100644 src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludeConverterSourceTarget.verified.txt create mode 100644 src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludeFileConverterSourceTarget.verified.txt create mode 100644 src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludeStreamTarget.verified.txt create mode 100644 src/Verify.Tests/Converters/ExcludeTargetsTests.ExcludedStreamIsDisposed.verified.txt create mode 100644 src/Verify.Tests/Converters/ExcludeTargetsTests.ExtensionMatchIsCaseInsensitive.verified.txt create mode 100644 src/Verify.Tests/Converters/ExcludeTargetsTests.cs create mode 100644 src/Verify/Splitters/Settings_ExcludeTargets.cs diff --git a/docs/converter.md b/docs/converter.md index 2efb49a29f..6b3dd3748d 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 033d4917b5..188d123884 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 0000000000..2292ecb951 --- /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 0000000000..c296c2eef5 --- /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 0000000000..730482b462 --- /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 0000000000..6669c2e04e --- /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 0000000000..fd9e33b5a0 --- /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 0000000000..c296c2eef5 --- /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 0000000000..c296c2eef5 --- /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 0000000000..c296c2eef5 --- /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 0000000000..fb49a206f6 --- /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 314a39e7c2..93e2e96b9a 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 0000000000..a4bb91b6b3 --- /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 221143d305..eb0c2712cf 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 0066671994..19c8f964f1 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; From b7f35e554019dc050a038b366cd9f88777ef5652 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 11 Jul 2026 00:43:25 +0000 Subject: [PATCH 2/2] Docs changes --- docs/converter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/converter.md b/docs/converter.md index 6b3dd3748d..86dad8427d 100644 --- a/docs/converter.md +++ b/docs/converter.md @@ -220,7 +220,7 @@ public Task ExcludeConverterSourceTarget() => Verify(new MemoryStream("source-document"u8.ToArray()), "excludesource") .ExcludeTargets("excludesource"); ``` -snippet source | anchor +snippet source | anchor Any existing verified file for an excluded extension is then reported as pending deletion.