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(); if (info != null) { @@ -135,7 +141,7 @@ static async Task GetTarget(Stream stream, string extension) var targets = new List(); var queue = new Queue(); - queue.Enqueue(new(extension, stream, name)); + queue.Enqueue(initial); while (queue.Count > 0) { @@ -143,11 +149,29 @@ static async Task GetTarget(Stream stream, string extension) if (!VerifierSettings.TryGetStreamConverter(target.Extension, out var conversion)) { + // terminal target: scrub text before it is finalized + Scrub(target); targets.Add(target); continue; } - var targetStream = target.StreamData; + // scrub text before conversion so derived targets (eg rendered images) reflect the scrubbed content + Scrub(target); + + Stream targetStream; + if (target.IsStream) + { + targetStream = target.StreamData; + } + else + { + // a text target is fed to the converter as a utf8 stream + target.TryGetStringBuilder(out var builder); + var memory = new MemoryStream(Encoding.UTF8.GetBytes(builder!.ToString())); + cleanup = cleanup.Then(memory.DisposeAsyncEx); + targetStream = memory; + } + var result = await conversion(target.Name, targetStream, settings.Context); if (result.Cleanup != null) { @@ -163,7 +187,8 @@ static async Task GetTarget(Stream stream, string extension) foreach (var resultTarget in resultTargets) { - // if the same extension is returned. no need to re process + // if the same extension is returned. no need to re process. + // its content derives from the already scrubbed input, so it is not scrubbed again if (resultTarget.Extension == target.Extension) { targets.Add(resultTarget);