diff --git a/docs/converter.md b/docs/converter.md
index af35c3ee2..2efb49a29 100644
--- a/docs/converter.md
+++ b/docs/converter.md
@@ -146,6 +146,45 @@ await VerifyFile("sample.tif");
+### Text extensions
+
+A stream converter can also be registered against a text extension. This is useful when a text document needs derived targets, for example rendering html to an image for visual verification.
+
+The text target is scrubbed before being passed to the converter, so any derived targets (for example a rendered image) reflect the scrubbed content.
+
+For a custom text extension, register it as text via `FileExtensions.AddTextExtension`. Built-in text extensions (for example `html` or `csv`) do not require this.
+
+
+
+```cs
+// "texttoconvert" is a custom text extension, so register it as text first.
+// For built-in text extensions (eg html or csv) this step is not required.
+FileExtensions.AddTextExtension("texttoconvert");
+
+// The input text is scrubbed before being passed to the converter, so any
+// derived targets (eg a rendered image) reflect the scrubbed content.
+VerifierSettings.RegisterStreamConverter(
+ "texttoconvert",
+ async (_, stream, _) =>
+ new(
+ null,
+ [
+ new("texttoconvert", await stream.ReadStringBuilderWithFixedLines()),
+ new("txt", "derived from text")
+ ]));
+```
+snippet source | anchor
+
+
+
+
+```cs
+Verify("the source text", "texttoconvert");
+```
+snippet source | anchor
+
+
+
### Cleanup
If cleanup needs to occur after verification a callback can be passes to `ConversionResult`:
diff --git a/docs/mdsource/converter.source.md b/docs/mdsource/converter.source.md
index ba559678d..033d4917b 100644
--- a/docs/mdsource/converter.source.md
+++ b/docs/mdsource/converter.source.md
@@ -50,6 +50,19 @@ snippet: RegisterStreamConverterExtension
snippet: FileConverterExtensionVerify
+### Text extensions
+
+A stream converter can also be registered against a text extension. This is useful when a text document needs derived targets, for example rendering html to an image for visual verification.
+
+The text target is scrubbed before being passed to the converter, so any derived targets (for example a rendered image) reflect the scrubbed content.
+
+For a custom text extension, register it as text via `FileExtensions.AddTextExtension`. Built-in text extensions (for example `html` or `csv`) do not require this.
+
+snippet: RegisterStreamConverterTextExtension
+
+snippet: TextExtensionConverterVerify
+
+
### Cleanup
If cleanup needs to occur after verification a callback can be passes to `ConversionResult`:
diff --git a/src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitter.verified.texttoconvert b/src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitter.verified.texttoconvert
new file mode 100644
index 000000000..925839fe6
--- /dev/null
+++ b/src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitter.verified.texttoconvert
@@ -0,0 +1 @@
+the source text
\ No newline at end of file
diff --git a/src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitter.verified.txt b/src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitter.verified.txt
new file mode 100644
index 000000000..f925c4ac5
--- /dev/null
+++ b/src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitter.verified.txt
@@ -0,0 +1 @@
+derived from text
\ No newline at end of file
diff --git a/src/Verify.Tests/Converters/ExtensionConverterTests.cs b/src/Verify.Tests/Converters/ExtensionConverterTests.cs
index 47615ae24..f0c992b7b 100644
--- a/src/Verify.Tests/Converters/ExtensionConverterTests.cs
+++ b/src/Verify.Tests/Converters/ExtensionConverterTests.cs
@@ -1,5 +1,36 @@
public class ExtensionConverterTests
{
+ [ModuleInitializer]
+ public static void TextSplitterInit()
+ {
+ #region RegisterStreamConverterTextExtension
+
+ // "texttoconvert" is a custom text extension, so register it as text first.
+ // For built-in text extensions (eg html or csv) this step is not required.
+ FileExtensions.AddTextExtension("texttoconvert");
+
+ // The input text is scrubbed before being passed to the converter, so any
+ // derived targets (eg a rendered image) reflect the scrubbed content.
+ VerifierSettings.RegisterStreamConverter(
+ "texttoconvert",
+ async (_, stream, _) =>
+ new(
+ null,
+ [
+ new("texttoconvert", await stream.ReadStringBuilderWithFixedLines()),
+ new("txt", "derived from text")
+ ]));
+
+ #endregion
+ }
+
+ // a conversion splitter registered against a text extension
+ [Fact]
+ public Task TextSplitter() =>
+ #region TextExtensionConverterVerify
+ Verify("the source text", "texttoconvert");
+ #endregion
+
[ModuleInitializer]
public static void RecursiveInit() =>
VerifierSettings.RegisterStreamConverter(
diff --git a/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.GetExtensions.verified.txt b/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.GetExtensions.verified.txt
index fa9ccb15e..3956841f0 100644
--- a/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.GetExtensions.verified.txt
+++ b/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.GetExtensions.verified.txt
@@ -6,6 +6,7 @@
nuspec,
props,
staticComparerExt,
+ texttoconvert,
txt,
xml
]
\ No newline at end of file
diff --git a/src/Verify/Compare/Png/PngSsimComparer.cs b/src/Verify/Compare/Png/PngSsimComparer.cs
index 1086ba79b..1630c67a0 100644
--- a/src/Verify/Compare/Png/PngSsimComparer.cs
+++ b/src/Verify/Compare/Png/PngSsimComparer.cs
@@ -13,7 +13,8 @@ internal static Task Compare(Stream received, Stream verified, do
var receivedImage = PngDecoder.Decode(received);
var verifiedImage = PngDecoder.Decode(verified);
- if (receivedImage.Width != verifiedImage.Width || receivedImage.Height != verifiedImage.Height)
+ if (receivedImage.Width != verifiedImage.Width ||
+ receivedImage.Height != verifiedImage.Height)
{
return Task.FromResult(CompareResult.NotEqual(
$"PNG dimensions differ. Received: {receivedImage.Width}x{receivedImage.Height}, Verified: {verifiedImage.Width}x{verifiedImage.Height}"));
diff --git a/src/Verify/Splitters/Settings_Extension.cs b/src/Verify/Splitters/Settings_Extension.cs
index 1577b6174..c166f70a2 100644
--- a/src/Verify/Splitters/Settings_Extension.cs
+++ b/src/Verify/Splitters/Settings_Extension.cs
@@ -23,11 +23,6 @@ public static void RegisterStreamConverter(
{
InnerVerifier.ThrowIfVerifyHasBeenRun();
Guards.AgainstBadExtension(extension);
- if (FileExtensions.IsTextExtension(extension))
- {
- throw new("RegisterStreamConverter is only supported for non-text extensions");
- }
-
extensionConverters[extension] = conversion;
}
}
\ No newline at end of file
diff --git a/src/Verify/Verifier/InnerVerifier_Inner.cs b/src/Verify/Verifier/InnerVerifier_Inner.cs
index 4259a81f2..221143d30 100644
--- a/src/Verify/Verifier/InnerVerifier_Inner.cs
+++ b/src/Verify/Verifier/InnerVerifier_Inner.cs
@@ -52,46 +52,54 @@ async Task VerifyInner(object? root, Func? cleanup, IEnumera
{
List list = [..targets, ..VerifierSettings.GetFileAppenders(settings)];
var cleanup = () => Task.CompletedTask;
- if (doExtensionConversion)
- {
- var result = new List();
- foreach (var target in list)
- {
- if (!target.PerformConversion ||
- !VerifierSettings.HasStreamConverter(target.Extension))
- {
- result.Add(target);
- continue;
- }
-
- var (info, converted, itemCleanup) = await DoExtensionConversion(target.Extension, target.StreamData, null, target.Name);
- cleanup = cleanup.Then(itemCleanup);
- if (info != null)
- {
- result.Add(
- new(
- settings.TxtOrJson,
- JsonFormatter.AsJson(
- settings,
- counter,
- info)));
- }
-
- result.AddRange(converted);
- }
- list = result;
+ // When doExtensionConversion is false the targets have already been run through
+ // conversion and scrubbing (the only caller is the post-conversion stream path),
+ // so pass them through untouched to avoid double scrubbing.
+ if (!doExtensionConversion)
+ {
+ return (list, cleanup);
}
+ var result = new List();
foreach (var target in list)
{
- if (target.TryGetStringBuilder(out var builder))
+ if (!target.PerformConversion ||
+ !VerifierSettings.HasStreamConverter(target.Extension))
{
- ApplyScrubbers.ApplyForExtension(target.Extension, builder, settings, counter);
+ Scrub(target);
+ result.Add(target);
+ continue;
}
+
+ var (info, converted, itemCleanup) = await DoExtensionConversion(target, null);
+ cleanup = cleanup.Then(itemCleanup);
+ if (info != null)
+ {
+ Target infoTarget = new(
+ settings.TxtOrJson,
+ JsonFormatter.AsJson(
+ settings,
+ counter,
+ info));
+ Scrub(infoTarget);
+ result.Add(infoTarget);
+ }
+
+ // converted targets are scrubbed within DoExtensionConversion
+ result.AddRange(converted);
}
- return (list, cleanup);
+ return (result, cleanup);
+ }
+
+ // Scrubs a text target in place. Stream (binary) targets are left untouched.
+ void Scrub(in Target target)
+ {
+ if (target.TryGetStringBuilder(out var builder))
+ {
+ ApplyScrubbers.ApplyForExtension(target.Extension, builder, settings, counter);
+ }
}
bool TryGetRootTarget(object? root,bool ignoreNullRoot, [NotNullWhen(true)] out Target? target)
diff --git a/src/Verify/Verifier/InnerVerifier_Stream.cs b/src/Verify/Verifier/InnerVerifier_Stream.cs
index 7c66fb9c5..91a3550e4 100644
--- a/src/Verify/Verifier/InnerVerifier_Stream.cs
+++ b/src/Verify/Verifier/InnerVerifier_Stream.cs
@@ -90,7 +90,8 @@ public async Task VerifyStream(Stream? stream, string extension, o
{
if (VerifierSettings.HasStreamConverter(extension))
{
- var (newInfo, converted, cleanup) = await DoExtensionConversion(extension, stream, info, null);
+ var initial = await GetTarget(stream, extension);
+ var (newInfo, converted, cleanup) = await DoExtensionConversion(initial, info);
return await VerifyInner(newInfo, cleanup, converted, false, true);
}
@@ -122,10 +123,15 @@ static async Task GetTarget(Stream stream, string extension)
return new(extension, stream);
}
- //TODO: possibly pass in the target here
- async Task<(object? info, List targets, Func cleanup)> DoExtensionConversion(string extension, Stream stream, object? info, string? name)
+ async Task<(object? info, List targets, Func cleanup)> DoExtensionConversion(Target initial, object? info)
{
- var cleanup = stream.DisposeAsyncEx;
+ var cleanup = () => Task.CompletedTask;
+ // the source stream of a stream target is owned here, so dispose it once consumed
+ if (initial.IsStream)
+ {
+ cleanup = cleanup.Then(initial.StreamData.DisposeAsyncEx);
+ }
+
var infos = new List