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;