diff --git a/CHANGELOG.md b/CHANGELOG.md index 406ccec0..756ad6a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- [.Net] Support customizations ([#409](https://github.com/cucumber/html-formatter/pull/409)) +- [Java] Support customizations ([#409](https://github.com/cucumber/html-formatter/pull/409)) + ### Changed - Upgrade `react-components` to [23.2.0](https://github.com/cucumber/react-components/releases/tag/v23.2.0) diff --git a/Makefile b/Makefile index ed933929..0c38ccbe 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ javascript_source = $(wildcard javascript/src/*) -assets = main.css main.js main.js.LICENSE.txt index.mustache.html +assets = main.css main.js main.js.LICENSE.txt index.mustache.html icon.url ruby_assets = $(addprefix ruby/assets/,$(assets)) java_assets = $(addprefix java/src/main/resources/io/cucumber/htmlformatter/,$(assets)) dotnet_assets = $(addprefix dotnet/Cucumber.HtmlFormatter/Resources/,$(assets)) @@ -15,24 +15,28 @@ clean: ## Remove javascript built module and related artifacts from java and rub rm -rf $(ruby_assets) $(java_assets) $(dotnet_assets) javascript/dist .PHONY: .clean -ruby/assets/index.mustache.html: javascript/src/index.mustache.html +ruby/assets/%: javascript/dist/src/% cp $< $@ ruby/assets/%: javascript/dist/% cp $< $@ -java/src/main/resources/io/cucumber/htmlformatter/index.mustache.html: javascript/src/index.mustache.html +java/src/main/resources/io/cucumber/htmlformatter/%: javascript/dist/src/% cp $< $@ java/src/main/resources/io/cucumber/htmlformatter/%: javascript/dist/% cp $< $@ -dotnet/Cucumber.HtmlFormatter/Resources/index.mustache.html: javascript/src/index.mustache.html +dotnet/Cucumber.HtmlFormatter/Resources/%: javascript/dist/src/% cp $< $@ dotnet/Cucumber.HtmlFormatter/Resources/%: javascript/dist/% cp $< $@ +javascript/dist/src/index.mustache.html: javascript/dist/main.js + +javascript/dist/src/icon.url: javascript/dist/main.js + javascript/dist/main.js.LICENSE.txt: javascript/dist/main.js javascript/dist/main.css: javascript/dist/main.js diff --git a/README.md b/README.md index f6f234d2..c1bc3c15 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,17 @@ This formatter is built into the following Cucumber implementations: * [cucumber-ruby](https://github.com/cucumber/cucumber-ruby/blob/main/lib/cucumber/formatter/html.rb) * [cucumber-jvm](https://github.com/cucumber/cucumber-jvm/blob/main/core/src/main/java/io/cucumber/core/plugin/HtmlFormatter.java) * [cucumber-js](https://github.com/cucumber/cucumber-js/blob/main/src/formatter/html_formatter.ts) +* [Reqnroll](https://github.com/reqnroll/Reqnroll/blob/main/Reqnroll/Formatters/Html/HtmlFormatter.cs) + +## Customizations + +_Supported by: Java and .Net_ + +The formatter can be configured with: + * A custom page title and icon + * Additional CSS to support [styling react components](https://github.com/cucumber/react-components?tab=readme-ov-file#styling). + * Additional Javascript for other customisations. + * The default Javascript and CSS can be replaced to support building custom react components. ## Contributing diff --git a/dotnet/Cucumber.HtmlFormatter/Cucumber.HtmlFormatter.snk b/dotnet/Cucumber.HtmlFormatter.snk similarity index 100% rename from dotnet/Cucumber.HtmlFormatter/Cucumber.HtmlFormatter.snk rename to dotnet/Cucumber.HtmlFormatter.snk diff --git a/dotnet/Cucumber.HtmlFormatter/Cucumber.HtmlFormatter.csproj b/dotnet/Cucumber.HtmlFormatter/Cucumber.HtmlFormatter.csproj index a82ce1aa..57de49f4 100644 --- a/dotnet/Cucumber.HtmlFormatter/Cucumber.HtmlFormatter.csproj +++ b/dotnet/Cucumber.HtmlFormatter/Cucumber.HtmlFormatter.csproj @@ -9,7 +9,7 @@ 1591 false true - Cucumber.HtmlFormatter.snk + ..\Cucumber.HtmlFormatter.snk diff --git a/dotnet/Cucumber.HtmlFormatter/HtmlReportSettings.cs b/dotnet/Cucumber.HtmlFormatter/HtmlReportSettings.cs new file mode 100644 index 00000000..a7611b87 --- /dev/null +++ b/dotnet/Cucumber.HtmlFormatter/HtmlReportSettings.cs @@ -0,0 +1,70 @@ +namespace Cucumber.HtmlFormatter; + +/// +/// Settings for HTML report generation +/// +public class HtmlReportSettings +{ + private const string DEFAULT_TITLE = "Cucumber"; + private string? _icon = null; + + /// + /// Gets or sets the title of the HTML report. + /// Default is "Cucumber". + /// + public string Title { get; set; } = DEFAULT_TITLE; + + /// + /// Gets or sets the icon for the HTML report. + /// Default is the Cucumber icon as a base64-encoded SVG. + /// + public string Icon + { + get => _icon ?? LoadDefaultIcon(); + set => _icon = value; + } + + /// + /// Gets or sets custom CSS to include in the HTML report. + /// Default is empty. + /// + public string CustomCss { get; set; } = string.Empty; + + /// + /// Gets or sets custom Javascript to include after the main Javascript section of the HTML report. + /// Default is empty. + /// + public string CustomScript { get; set; } = string.Empty; + + /// + /// Gets or sets the function that loads the main Javascript resource for the HTML report. + /// This should only be customized if you use a custom node.js project to serve the HTML report, + /// for smaller customizations you can use the and property. + /// + public Func JavascriptResourceLoader { get; set; } + + /// + /// Gets or sets the function that loads the main CSS resource for the HTML report. + /// This should only be customized if you use a custom node.js project to serve the HTML report, + /// for smaller customizations you can use the and property. + /// + public Func CssResourceLoader { get; set; } + + /// + /// Creates a new instance of HtmlReportSettings with default values. + /// + public HtmlReportSettings() + { + JavascriptResourceLoader = LoadJavascriptResource; + CssResourceLoader = LoadCssResource; + } + + private static string LoadDefaultIcon() + => MessagesToHtmlWriter.GetResource("icon.url"); + + private static string LoadJavascriptResource() + => MessagesToHtmlWriter.GetResource("main.js"); + + private static string LoadCssResource() + => MessagesToHtmlWriter.GetResource("main.css"); +} \ No newline at end of file diff --git a/dotnet/Cucumber.HtmlFormatter/InternalsVisibleTo.cs b/dotnet/Cucumber.HtmlFormatter/InternalsVisibleTo.cs new file mode 100644 index 00000000..8c8b0038 --- /dev/null +++ b/dotnet/Cucumber.HtmlFormatter/InternalsVisibleTo.cs @@ -0,0 +1,2 @@ +using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("Cucumber.HtmlFormatterTest, PublicKey=00240000048000009400000006020000002400005253413100040000010001003d3d7a4a4bb40fd4ad4b18dea92bd57f04946bbce990a7e72a406f026c0af1544510e1069718f7bdc8134fca21b4fb61d8ff139af7c19f2d855a0bf7539667334371478c323ff84e91ccb6a5bc3027fea39ca84658087b7f7f76c30af7adacb315442a0cbec817b71017f363fc4d8a751b98b6e60b00149b08c84ca984ab5ddd")] diff --git a/dotnet/Cucumber.HtmlFormatter/MessagesToHtmlWriter.cs b/dotnet/Cucumber.HtmlFormatter/MessagesToHtmlWriter.cs index 7c460b22..bb3a8e3f 100644 --- a/dotnet/Cucumber.HtmlFormatter/MessagesToHtmlWriter.cs +++ b/dotnet/Cucumber.HtmlFormatter/MessagesToHtmlWriter.cs @@ -9,6 +9,7 @@ public class MessagesToHtmlWriter : IDisposable private readonly Action _streamSerializer; private readonly string _template; private readonly JsonInHtmlWriter _jsonInHtmlWriter; + private readonly HtmlReportSettings _settings; private bool _streamClosed = false; private bool _preMessageWritten = false; private bool _firstMessageWritten = false; @@ -16,63 +17,83 @@ public class MessagesToHtmlWriter : IDisposable private readonly bool _isAsyncInitialized = false; [Obsolete("Cucumber.HtmlFormatter moving to async only operations. Please use the MessagesToHtmlWriter(Stream, Func) constructor", false)] - public MessagesToHtmlWriter(Stream stream, Action streamSerializer) : this(new StreamWriter(stream), streamSerializer) - { - } - public MessagesToHtmlWriter(Stream stream, Func asyncStreamSerializer) : this(new StreamWriter(stream), asyncStreamSerializer) { } + public MessagesToHtmlWriter(Stream stream, Action streamSerializer) + : this(new StreamWriter(stream), streamSerializer) { } + + public MessagesToHtmlWriter(Stream stream, Func asyncStreamSerializer, HtmlReportSettings? settings = null) + : this(new StreamWriter(stream), asyncStreamSerializer, settings) { } [Obsolete("Cucumber.HtmlFormatter moving to async only operations. Please use the MessagesToHtmlWriter(StreamWriter, Func) constructor", false)] public MessagesToHtmlWriter(StreamWriter writer, Action streamSerializer) { - this._writer = writer; - this._streamSerializer = streamSerializer; + _writer = writer; + _streamSerializer = streamSerializer; + _settings = new HtmlReportSettings(); // Create async wrapper for sync serializer - this._asyncStreamSerializer = (w, e) => + _asyncStreamSerializer = (w, e) => { streamSerializer(w, e); return Task.CompletedTask; }; - _template = GetResource("index.mustache.html"); + _template = LoadTemplateResource(); _jsonInHtmlWriter = new JsonInHtmlWriter(writer); _isAsyncInitialized = false; } - public MessagesToHtmlWriter(StreamWriter writer, Func asyncStreamSerializer) + + public MessagesToHtmlWriter(StreamWriter writer, Func asyncStreamSerializer, HtmlReportSettings? settings = null) { - this._writer = writer; - this._asyncStreamSerializer = asyncStreamSerializer; + _writer = writer; + _asyncStreamSerializer = asyncStreamSerializer; + _settings = settings ?? new(); // Create sync wrapper for async serializer (will block) - this._streamSerializer = (w, e) => asyncStreamSerializer(w, e).GetAwaiter().GetResult(); - _template = GetResource("index.mustache.html"); + _streamSerializer = (w, e) => asyncStreamSerializer(w, e).GetAwaiter().GetResult(); + _template = LoadTemplateResource(); _jsonInHtmlWriter = new JsonInHtmlWriter(writer); _isAsyncInitialized = true; } private void WritePreMessage() { - WriteTemplateBetween(_writer, _template, null, "{{css}}"); - WriteResource(_writer, "main.css"); - WriteTemplateBetween(_writer, _template, "{{css}}", "{{messages}}"); + WriteTemplateBetween(_writer, _template, null, "{{title}}"); + _writer.Write(_settings.Title); + WriteTemplateBetween(_writer, _template, "{{title}}", "{{icon}}"); + _writer.Write(_settings.Icon); + WriteTemplateBetween(_writer, _template, "{{icon}}", "{{css}}"); + _writer.Write(_settings.CssResourceLoader()); + WriteTemplateBetween(_writer, _template, "{{css}}", "{{custom_css}}"); + _writer.Write(_settings.CustomCss); + WriteTemplateBetween(_writer, _template, "{{custom_css}}", "{{messages}}"); } private async Task WritePreMessageAsync() { - await WriteTemplateBetweenAsync(_writer, _template, null, "{{css}}"); - await WriteResourceAsync(_writer, "main.css"); - await WriteTemplateBetweenAsync(_writer, _template, "{{css}}", "{{messages}}"); + await WriteTemplateBetweenAsync(_writer, _template, null, "{{title}}"); + await _writer.WriteAsync(_settings.Title); + await WriteTemplateBetweenAsync(_writer, _template, "{{title}}", "{{icon}}"); + await _writer.WriteAsync(_settings.Icon); + await WriteTemplateBetweenAsync(_writer, _template, "{{icon}}", "{{css}}"); + await _writer.WriteAsync(_settings.CssResourceLoader()); + await WriteTemplateBetweenAsync(_writer, _template, "{{css}}", "{{custom_css}}"); + await _writer.WriteAsync(_settings.CustomCss); + await WriteTemplateBetweenAsync(_writer, _template, "{{custom_css}}", "{{messages}}"); } private void WritePostMessage() { WriteTemplateBetween(_writer, _template, "{{messages}}", "{{script}}"); - WriteResource(_writer, "main.js"); - WriteTemplateBetween(_writer, _template, "{{script}}", null); + _writer.Write(_settings.JavascriptResourceLoader()); + WriteTemplateBetween(_writer, _template, "{{script}}", "{{custom_script}}"); + _writer.Write(_settings.CustomScript); + WriteTemplateBetween(_writer, _template, "{{custom_script}}", null); } private async Task WritePostMessageAsync() { await WriteTemplateBetweenAsync(_writer, _template, "{{messages}}", "{{script}}"); - await WriteResourceAsync(_writer, "main.js"); - await WriteTemplateBetweenAsync(_writer, _template, "{{script}}", null); + await _writer.WriteAsync(_settings.JavascriptResourceLoader()); + await WriteTemplateBetweenAsync(_writer, _template, "{{script}}", "{{custom_script}}"); + await _writer.WriteAsync(_settings.CustomScript); + await WriteTemplateBetweenAsync(_writer, _template, "{{custom_script}}", null); } public void Write(Envelope envelope) @@ -186,18 +207,6 @@ public async Task DisposeAsync() } } - private void WriteResource(StreamWriter writer, string v) - { - var resource = GetResource(v); - writer.Write(resource); - } - - private async Task WriteResourceAsync(StreamWriter writer, string v) - { - var resource = GetResource(v); - await writer.WriteAsync(resource); - } - private void WriteTemplateBetween(StreamWriter writer, string template, string? begin, string? end) { CalculateBeginAndLength(template, begin, end, out var beginIndex, out var lengthToWrite); @@ -217,7 +226,7 @@ private async Task WriteTemplateBetweenAsync(StreamWriter writer, string templat await writer.WriteAsync(template.Substring(beginIndex, lengthToWrite)); } - private string GetResource(string name) + internal static string GetResource(string name) { var assembly = typeof(MessagesToHtmlWriter).Assembly; var resourceStream = assembly.GetManifestResourceStream("Cucumber.HtmlFormatter.Resources." + name); @@ -226,4 +235,9 @@ private string GetResource(string name) var resource = new StreamReader(resourceStream).ReadToEnd(); return resource; } + + private static string LoadTemplateResource() + { + return GetResource("index.mustache.html"); + } } diff --git a/dotnet/Cucumber.HtmlFormatter/Resources/.gitignore b/dotnet/Cucumber.HtmlFormatter/Resources/.gitignore index 71cea849..ad310592 100644 --- a/dotnet/Cucumber.HtmlFormatter/Resources/.gitignore +++ b/dotnet/Cucumber.HtmlFormatter/Resources/.gitignore @@ -2,4 +2,5 @@ *.html *.js *.css -*.txt \ No newline at end of file +*.txt +*.url diff --git a/dotnet/Cucumber.HtmlFormatterTest/Cucumber.HtmlFormatterTest.csproj b/dotnet/Cucumber.HtmlFormatterTest/Cucumber.HtmlFormatterTest.csproj index 7036fd90..a073065e 100644 --- a/dotnet/Cucumber.HtmlFormatterTest/Cucumber.HtmlFormatterTest.csproj +++ b/dotnet/Cucumber.HtmlFormatterTest/Cucumber.HtmlFormatterTest.csproj @@ -5,6 +5,8 @@ latest enable enable + true + ..\Cucumber.HtmlFormatter.snk diff --git a/dotnet/Cucumber.HtmlFormatterTest/MessagesToHtmlWriterTest.cs b/dotnet/Cucumber.HtmlFormatterTest/MessagesToHtmlWriterTest.cs index 79def079..d979f45b 100644 --- a/dotnet/Cucumber.HtmlFormatterTest/MessagesToHtmlWriterTest.cs +++ b/dotnet/Cucumber.HtmlFormatterTest/MessagesToHtmlWriterTest.cs @@ -20,15 +20,16 @@ public class MessagesToHtmlWriterTest await sw.WriteAsync(s); }; - [TestMethod] - public void ItWritesOneMessageToHtml() + public async Task LegacySyncApiRendersTheSameAsAsync() { - DateTime timestamp = DateTime.UnixEpoch.AddSeconds(10).ToUniversalTime(); - Envelope envelope = Envelope.Create(new TestRunStarted(Converters.ToTimestamp(timestamp), null)); - string html = RenderAsHtml(envelope); - Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES = [{\"testRunStarted\":{\"timestamp\":{\"seconds\":10,\"nanos\":0}}}];"), - $"Expected html to contain a testRunStarted message, but instead html contained: {html}"); + Envelope sampleEnvelope = Envelope.Create(new TestRunStarted(Converters.ToTimestamp(DateTime.UnixEpoch.AddSeconds(10).ToUniversalTime()), null)); + string expectedHtml = await RenderAsHtmlAsync(sampleEnvelope); + + // ReSharper disable once MethodHasAsyncOverload + string html = RenderAsHtml(sampleEnvelope); + + Assert.AreEqual(expectedHtml, html, "Expected legacy sync API to render the same HTML as the async API."); } [TestMethod] @@ -37,22 +38,101 @@ public async Task ItWritesOneMessageToHtmlAsync() DateTime timestamp = DateTime.UnixEpoch.AddSeconds(10).ToUniversalTime(); Envelope envelope = Envelope.Create(new TestRunStarted(Converters.ToTimestamp(timestamp), null)); string html = await RenderAsHtmlAsync(envelope); - Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES = [{\"testRunStarted\":{\"timestamp\":{\"seconds\":10,\"nanos\":0}}}];"), + StringAssert.Contains(html, "window.CUCUMBER_MESSAGES = [{\"testRunStarted\":{\"timestamp\":{\"seconds\":10,\"nanos\":0}}}];", $"Expected html to contain a testRunStarted message, but instead html contained: {html}"); } [TestMethod] - public void ItWritesNoMessageToHtml() + public async Task ItWritesNoMessageToHtml() + { + string html = await RenderAsHtmlAsync(); + StringAssert.Contains(html, "window.CUCUMBER_MESSAGES = [];"); + } + + [TestMethod] + public async Task ItWritesDefaultTitle() + { + string html = await RenderAsHtmlAsync(); + StringAssert.Contains(html, "Cucumber"); + } + + [TestMethod] + public async Task ItWritesCustomTitle() + { + string html = await RenderAsHtmlAsync(new HtmlReportSettings { Title = "Custom Title" }); + StringAssert.Contains(html, "Custom Title"); + } + + [TestMethod] + public async Task ItWritesDefaultIcon() { - string html = RenderAsHtml(); - Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES = [];")); + string html = await RenderAsHtmlAsync(); + StringAssert.Contains(html, "" }); + StringAssert.Contains(html, ""); + } + + [TestMethod] + public async Task ItWritesCustomCss() + { + string html = await RenderAsHtmlAsync(new HtmlReportSettings { CustomCss = "p { color: red; }" }); + StringAssert.Contains(html, "p { color: red; }"); + } + + [TestMethod] + public async Task ItWritesCustomScript() + { + string html = await RenderAsHtmlAsync(new HtmlReportSettings() { CustomScript = "console.log(\"Hello world\");" }); + StringAssert.Contains(html, "console.log(\"Hello world\");"); + } + + [TestMethod] + public async Task ItWritesDefaultJavascriptResource() + { + var expectedJavascriptResource = MessagesToHtmlWriter.GetResource("main.js"); + + string html = await RenderAsHtmlAsync(); + StringAssert.Contains(html, expectedJavascriptResource); } + [TestMethod] + public async Task ItWritesCustomJavascriptResource() + { + string CustomJavascriptResourceLoader() => "console.log(\"Hello world\");"; + + string html = await RenderAsHtmlAsync(new HtmlReportSettings { JavascriptResourceLoader = CustomJavascriptResourceLoader }); + StringAssert.Contains(html, "console.log(\"Hello world\");"); + } + + [TestMethod] + public async Task ItWritesDefaultCssResource() + { + var expectedCssResource = MessagesToHtmlWriter.GetResource("main.css"); + + string html = await RenderAsHtmlAsync(); + StringAssert.Contains(html, expectedCssResource); + } + + [TestMethod] + public async Task ItWritesCustomCssResource() + { + string CustomCssResourceLoader() => "p { color: red; }"; + + string html = await RenderAsHtmlAsync(new HtmlReportSettings { CssResourceLoader = CustomCssResourceLoader }); + StringAssert.Contains(html, "p { color: red; }"); + } + + [TestMethod] public async Task ItWritesNoMessageToHtmlAsync() { string html = await RenderAsHtmlAsync(); - Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES = [];")); + StringAssert.Contains(html, "window.CUCUMBER_MESSAGES = [];"); } [TestMethod] @@ -153,41 +233,17 @@ public async Task ItIsIdempotentUnderFailureToCloseAsync() CollectionAssert.AreEqual(before, after); } - - [TestMethod] - public void ItWritesTwoMessagesSeparatedByAComma() - { - Envelope testRunStarted = Envelope.Create(new TestRunStarted(Converters.ToTimestamp(DateTime.UnixEpoch.AddSeconds(10).ToUniversalTime()), null)); - Envelope envelope = Envelope.Create(new TestRunFinished(null, true, Converters.ToTimestamp(DateTime.UnixEpoch.AddSeconds(15).ToUniversalTime()), null, null)); - string html = RenderAsHtml(testRunStarted, envelope); - Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES = [{\"testRunStarted\":{\"timestamp\":{\"seconds\":10,\"nanos\":0}}},{\"testRunFinished\":{\"success\":true,\"timestamp\":{\"seconds\":15,\"nanos\":0}}}];")); - } - [TestMethod] public async Task ItWritesTwoMessagesSeparatedByACommaAsync() { Envelope testRunStarted = Envelope.Create(new TestRunStarted(Converters.ToTimestamp(DateTime.UnixEpoch.AddSeconds(10).ToUniversalTime()), null)); Envelope envelope = Envelope.Create(new TestRunFinished(null, true, Converters.ToTimestamp(DateTime.UnixEpoch.AddSeconds(15).ToUniversalTime()), null, null)); string html = await RenderAsHtmlAsync(testRunStarted, envelope); - Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES = [{\"testRunStarted\":{\"timestamp\":{\"seconds\":10,\"nanos\":0}}},{\"testRunFinished\":{\"success\":true,\"timestamp\":{\"seconds\":15,\"nanos\":0}}}];")); + StringAssert.Contains(html, "window.CUCUMBER_MESSAGES = [{\"testRunStarted\":{\"timestamp\":{\"seconds\":10,\"nanos\":0}}},{\"testRunFinished\":{\"success\":true,\"timestamp\":{\"seconds\":15,\"nanos\":0}}}];"); } [TestMethod] - public void ItEscapesOpeningAngleBracket() - { - Envelope envelope = Envelope.Create(new GherkinDocument( - null, - null, - [new(new Location(0L, 0L), "")] - )); - string html = RenderAsHtml(envelope); - Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"\\x3C/script>\\x3Cscript>alert('Hello')\\x3C/script>\"}]}}];"), - $"Expected \"window.CUCUMBER_MESSAGES = [{{\\\"gherkinDocument\\\":{{\\\"comments\\\":[{{\\\"location\\\":{{\\\"line\\\":0,\\\"column\\\":0}},\\\"text\\\":\\\"\\\\x3C/script>\\\\x3Cscript>alert('Hello')\\\\x3C/script>\\\"}}]}}];" + - $"\nbut instead had: \n{html.Substring(html.IndexOf("window.CUCUMBER"))}"); - } - - [TestMethod] - public async Task ItEscapesOpeningAngleBracketAsync() + public async Task ItEscapesForwardSlashesAsync() { Envelope envelope = Envelope.Create(new GherkinDocument( null, @@ -195,7 +251,7 @@ public async Task ItEscapesOpeningAngleBracketAsync() [new(new Location(0L, 0L), "")] )); string html = await RenderAsHtmlAsync(envelope); - Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"\\x3C/script>\\x3Cscript>alert('Hello')\\x3C/script>\"}]}}];"), + StringAssert.Contains(html, "window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"\\x3C/script>\\x3Cscript>alert('Hello')\\x3C/script>\"}]}}];", $"Expected \"window.CUCUMBER_MESSAGES = [{{\\\"gherkinDocument\\\":{{\\\"comments\\\":[{{\\\"location\\\":{{\\\"line\\\":0,\\\"column\\\":0}},\\\"text\\\":\\\"\\\\x3C/script>\\\\x3Cscript>alert('Hello')\\\\x3C/script>\\\"}}]}}];" + $"\nbut instead had: \n{html.Substring(html.IndexOf("window.CUCUMBER"))}"); } @@ -216,9 +272,14 @@ private static string RenderAsHtml(params Envelope[] messages) } private static async Task RenderAsHtmlAsync(params Envelope[] messages) + { + return await RenderAsHtmlAsync(null, messages); + } + + private static async Task RenderAsHtmlAsync(HtmlReportSettings? settings, params Envelope[] messages) { MemoryStream bytes = new MemoryStream(); - await using (MessagesToHtmlWriter messagesToHtmlWriter = new MessagesToHtmlWriter(bytes, AsyncSerializer)) + await using (MessagesToHtmlWriter messagesToHtmlWriter = new MessagesToHtmlWriter(bytes, AsyncSerializer, settings)) { foreach (Envelope message in messages) { diff --git a/java/src/main/java/io/cucumber/htmlformatter/MessagesToHtmlWriter.java b/java/src/main/java/io/cucumber/htmlformatter/MessagesToHtmlWriter.java index 8165dea5..c410b682 100644 --- a/java/src/main/java/io/cucumber/htmlformatter/MessagesToHtmlWriter.java +++ b/java/src/main/java/io/cucumber/htmlformatter/MessagesToHtmlWriter.java @@ -4,6 +4,7 @@ import java.io.BufferedReader; import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -12,6 +13,7 @@ import java.io.OutputStreamWriter; import java.io.Writer; import java.nio.charset.StandardCharsets; +import java.util.function.Supplier; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Objects.requireNonNull; @@ -20,42 +22,109 @@ * Writes the message output of a test run as single page html report. */ public final class MessagesToHtmlWriter implements AutoCloseable { - private final String template; - private final Writer writer; + private final OutputStreamWriter writer; private final JsonInHtmlWriter jsonInHtmlWriter; private final Serializer serializer; + + private final String template; + private final Supplier title; + private final Supplier icon; + private final Supplier css; + private final Supplier customCss; + private final Supplier script; + private final Supplier customScript; + private boolean preMessageWritten = false; private boolean postMessageWritten = false; private boolean firstMessageWritten = false; private boolean streamClosed = false; + @Deprecated public MessagesToHtmlWriter(OutputStream outputStream, Serializer serializer) throws IOException { this( - new OutputStreamWriter( - requireNonNull(outputStream), - StandardCharsets.UTF_8), - requireNonNull(serializer) + createWriter(outputStream), + requireNonNull(serializer), + () -> createInputStream("Cucumber"), + () -> getResource("icon.url"), + () -> getResource("main.css"), + MessagesToHtmlWriter::createEmptyInputStream, + () -> getResource("main.js"), + MessagesToHtmlWriter::createEmptyInputStream ); } - - private MessagesToHtmlWriter(Writer writer, Serializer serializer) throws IOException { + private MessagesToHtmlWriter( + OutputStreamWriter writer, + Serializer serializer, + Supplier title, + Supplier icon, + Supplier css, + Supplier customCss, + Supplier script, + Supplier customScript + ) { this.writer = writer; this.jsonInHtmlWriter = new JsonInHtmlWriter(writer); this.serializer = serializer; - this.template = readResource("index.mustache.html"); + this.template = readTemplate(); + this.title = title; + this.icon = icon; + this.css = css; + this.customCss = customCss; + this.customScript = customScript; + this.script = script; + } + + private static OutputStreamWriter createWriter(OutputStream outputStream) { + return new OutputStreamWriter( + requireNonNull(outputStream), + StandardCharsets.UTF_8); + } + + private static String readTemplate() { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(baos, UTF_8))) { + InputStream resource = getResource("index.mustache.html"); + writeResource(writer, resource); + } + return new String(baos.toByteArray(), UTF_8); + } catch (IOException e) { + throw new RuntimeException("Could not read resource index.mustache.html", e); + } + } + private static InputStream createInputStream(String text) { + return new ByteArrayInputStream(text.getBytes(UTF_8)); + } + + private static InputStream createEmptyInputStream() { + return new ByteArrayInputStream(new byte[0]); + } + + private static InputStream getResource(String name) { + InputStream resource = MessagesToHtmlWriter.class.getResourceAsStream(name); + requireNonNull(resource, name + " could not be loaded"); + return resource; } private void writePreMessage() throws IOException { - writeTemplateBetween(writer, template, null, "{{css}}"); - writeResource(writer, "main.css"); - writeTemplateBetween(writer, template, "{{css}}", "{{messages}}"); + writeTemplateBetween(writer, template, null, "{{title}}"); + writeResource(writer, title); + writeTemplateBetween(writer, template, "{{title}}", "{{icon}}"); + writeResource(writer, icon); + writeTemplateBetween(writer, template, "{{icon}}", "{{css}}"); + writeResource(writer, css); + writeTemplateBetween(writer, template, "{{css}}", "{{custom_css}}"); + writeResource(writer, customCss); + writeTemplateBetween(writer, template, "{{custom_css}}", "{{messages}}"); } private void writePostMessage() throws IOException { writeTemplateBetween(writer, template, "{{messages}}", "{{script}}"); - writeResource(writer, "main.js"); - writeTemplateBetween(writer, template, "{{script}}", null); + writeResource(writer, script); + writeTemplateBetween(writer, template, "{{script}}", "{{custom_script}}"); + writeResource(writer, customScript); + writeTemplateBetween(writer, template, "{{custom_script}}", null); } /** @@ -120,9 +189,11 @@ private static void writeTemplateBetween(Writer writer, String template, String writer.write(template.substring(beginIndex, endIndex)); } - private static void writeResource(Writer writer, String name) throws IOException { - InputStream resource = MessagesToHtmlWriter.class.getResourceAsStream(name); - requireNonNull(resource, name + " could not be loaded"); + private static void writeResource(Writer writer, Supplier resource) throws IOException { + writeResource(writer, resource.get()); + } + + private static void writeResource(Writer writer, InputStream resource) throws IOException { BufferedReader reader = new BufferedReader(new InputStreamReader(resource, UTF_8)); char[] buffer = new char[1024]; for (int read = reader.read(buffer); read != -1; read = reader.read(buffer)) { @@ -130,14 +201,6 @@ private static void writeResource(Writer writer, String name) throws IOException } } - private static String readResource(String name) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(baos, UTF_8))) { - writeResource(writer, name); - } - return new String(baos.toByteArray(), UTF_8); - } - /** * Serializes a message to JSON. */ @@ -165,4 +228,134 @@ public interface Serializer { } + /** + * Creates a builder to construct this writer. + * + * @param serializer used to convert messages into json. + * @return a new builder + */ + public static Builder builder(Serializer serializer) { + return new Builder(serializer); + } + + public static final class Builder { + private final Serializer serializer; + private Supplier title = () -> createInputStream("Cucumber"); + private Supplier icon = () -> getResource("icon.url"); + private Supplier css = () -> getResource("main.css"); + private Supplier customCss = MessagesToHtmlWriter::createEmptyInputStream; + private Supplier script = () -> getResource("main.js"); + private Supplier customScript = MessagesToHtmlWriter::createEmptyInputStream; + + private Builder(Serializer serializer) { + this.serializer = requireNonNull(serializer); + } + + /** + * Sets a custom title for the report, default value "Cucumber". + * + * @param title the custom title. + * @return this builder + */ + public Builder title(String title) { + requireNonNull(title); + this.title = () -> createInputStream(title); + return this; + } + + /** + * Sets a custom icon for the report, default value the cucumber logo. + *

+ * The {@code icon} is any valid {@code href} value. + * + * @param icon a supplier for the custom icon. + * @return this builder + */ + public Builder icon(Supplier icon) { + this.icon = requireNonNull(icon); + return this; + } + + /** + * Sets a custom icon for the report, default value the cucumber logo. + *

+ * The {@code icon} is any valid {@code href} value. + * + * @param icon the custom icon. + * @return this builder + */ + public Builder icon(String icon) { + requireNonNull(icon); + return icon(() -> createInputStream(icon)); + } + + /** + * Sets default css for the report. + *

+ * The default script styles the cucumber report. Unless you are + * building your own html report you should use + * {@link #customCss(Supplier)} instead. + * + * @param css a supplier for the default css. + * @return this builder + */ + public Builder css(Supplier css) { + this.css = requireNonNull(css); + return this; + } + + /** + * Sets custom css for the report. + *

+ * The custom css is applied after the default css. + * + * @param customCss a supplier for the custom css. + * @return this builder + * @see Cucumber - React Components - Styling + */ + public Builder customCss(Supplier customCss) { + this.customCss = requireNonNull(customCss); + return this; + } + + /** + * Replaces default script for the report. + *

+ * The default script renders the cucumber messages into a report. + * Unless you are building your own html report you should use + * {@link #customScript(Supplier)} instead. + * + * @param script a supplier for the default script. + * @return this builder + */ + public Builder script(Supplier script) { + this.script = requireNonNull(script); + return this; + } + + /** + * Sets custom script for the report. + *

+ * The custom script is applied after the default script. + * + * @param customScript a supplier for the custom script. + * @return this builder + */ + public Builder customScript(Supplier customScript) { + this.customScript = requireNonNull(customScript); + return this; + } + + + /** + * Create an instance of the messages to html writer. + * + * @param out the output stream to write to + * @return a new instance of the messages to html writer. + */ + public MessagesToHtmlWriter build(OutputStream out) { + return new MessagesToHtmlWriter(createWriter(out), serializer, title, icon, css, customCss, script, customScript); + } + } + } diff --git a/java/src/main/resources/io/cucumber/htmlformatter/.gitignore b/java/src/main/resources/io/cucumber/htmlformatter/.gitignore index 71cea849..ad310592 100644 --- a/java/src/main/resources/io/cucumber/htmlformatter/.gitignore +++ b/java/src/main/resources/io/cucumber/htmlformatter/.gitignore @@ -2,4 +2,5 @@ *.html *.js *.css -*.txt \ No newline at end of file +*.txt +*.url diff --git a/java/src/test/java/io/cucumber/htmlformatter/Main.java b/java/src/test/java/io/cucumber/htmlformatter/Main.java index 62cd675b..14b53eec 100644 --- a/java/src/test/java/io/cucumber/htmlformatter/Main.java +++ b/java/src/test/java/io/cucumber/htmlformatter/Main.java @@ -21,7 +21,8 @@ public static void main(String[] args) throws IOException { in = new FileInputStream(args[0]); } try (NdjsonToMessageIterable envelopes = new NdjsonToMessageIterable(in, deserializer)) { - try (MessagesToHtmlWriter htmlWriter = new MessagesToHtmlWriter(System.out, serializer)) { + MessagesToHtmlWriter.Builder builder = MessagesToHtmlWriter.builder(serializer); + try (MessagesToHtmlWriter htmlWriter = builder.build(System.out)) { for (Envelope envelope : envelopes) { htmlWriter.write(envelope); } diff --git a/java/src/test/java/io/cucumber/htmlformatter/MessagesToHtmlWriterTest.java b/java/src/test/java/io/cucumber/htmlformatter/MessagesToHtmlWriterTest.java index 3cd6ec7e..fa69816c 100644 --- a/java/src/test/java/io/cucumber/htmlformatter/MessagesToHtmlWriterTest.java +++ b/java/src/test/java/io/cucumber/htmlformatter/MessagesToHtmlWriterTest.java @@ -10,6 +10,7 @@ import io.cucumber.messages.types.TestRunStarted; import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.time.Instant; @@ -26,6 +27,25 @@ class MessagesToHtmlWriterTest { static final Serializer serializer = Jackson.OBJECT_MAPPER::writeValue; + private static ByteArrayInputStream createInputStream(String s) { + return new ByteArrayInputStream(s.getBytes(UTF_8)); + } + + private static String renderAsHtml(Envelope... messages) throws IOException { + return renderAsHtml(MessagesToHtmlWriter.builder(serializer), messages); + } + + private static String renderAsHtml(MessagesToHtmlWriter.Builder builder, Envelope... messages) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (MessagesToHtmlWriter messagesToHtmlWriter = builder.build(bytes)) { + for (Envelope message : messages) { + messagesToHtmlWriter.write(message); + } + } + + return new String(bytes.toByteArray(), UTF_8); + } + @Test void it_writes_one_message_to_html() throws IOException { Instant timestamp = Instant.ofEpochSecond(10); @@ -41,11 +61,63 @@ void it_writes_no_message_to_html() throws IOException { assertThat(html, containsString("window.CUCUMBER_MESSAGES = [];")); } + @Test + void it_writes_default_title() throws IOException { + String html = renderAsHtml(MessagesToHtmlWriter.builder(serializer)); + assertThat(html, containsString("Cucumber")); + } + + @Test + void it_writes_custom_title() throws IOException { + String html = renderAsHtml(MessagesToHtmlWriter.builder(serializer).title("Custom Title")); + assertThat(html, containsString("Custom Title")); + } + + @Test + void it_writes_default_icon() throws IOException { + String html = renderAsHtml(MessagesToHtmlWriter.builder(serializer)); + assertThat(html, containsString("")); + } + + @Test + void it_writes_default_css() throws IOException { + String html = renderAsHtml(MessagesToHtmlWriter.builder(serializer) + .css(() -> createInputStream("p { color: red; }"))); + assertThat(html, containsString("p { color: red; }")); + } + + @Test + void it_writes_custom_css() throws IOException { + String html = renderAsHtml(MessagesToHtmlWriter.builder(serializer) + .customCss(() -> createInputStream(("p { color: red; }")))); + assertThat(html, containsString("p { color: red; }")); + } + + @Test + void it_writes_default_script() throws IOException { + String html = renderAsHtml(MessagesToHtmlWriter.builder(serializer) + .script(() -> createInputStream(("console.log(\"Hello world\");")))); + assertThat(html, containsString("console.log(\"Hello world\");")); + } + + @Test + void it_writes_custom_script() throws IOException { + String html = renderAsHtml(MessagesToHtmlWriter.builder(serializer) + .customScript(() -> createInputStream(("console.log(\"Hello world\");")))); + assertThat(html, containsString("console.log(\"Hello world\");")); + } @Test void it_throws_when_writing_after_close() throws IOException { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - MessagesToHtmlWriter messagesToHtmlWriter = new MessagesToHtmlWriter(bytes, serializer); + MessagesToHtmlWriter messagesToHtmlWriter = MessagesToHtmlWriter.builder(serializer).build(bytes); messagesToHtmlWriter.close(); assertThrows(IOException.class, () -> messagesToHtmlWriter.write(null)); } @@ -53,20 +125,20 @@ void it_throws_when_writing_after_close() throws IOException { @Test void it_can_be_closed_twice() throws IOException { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - MessagesToHtmlWriter messagesToHtmlWriter = new MessagesToHtmlWriter(bytes, serializer); + MessagesToHtmlWriter messagesToHtmlWriter = MessagesToHtmlWriter.builder(serializer).build(bytes); messagesToHtmlWriter.close(); assertDoesNotThrow(messagesToHtmlWriter::close); } @Test - void it_is_idempotent_under_failure_to_close() throws IOException { + void it_is_idempotent_under_failure_to_close() { ByteArrayOutputStream bytes = new ByteArrayOutputStream() { @Override public void close() throws IOException { throw new IOException("Can't close this"); } }; - MessagesToHtmlWriter messagesToHtmlWriter = new MessagesToHtmlWriter(bytes, serializer); + MessagesToHtmlWriter messagesToHtmlWriter = MessagesToHtmlWriter.builder(serializer).build(bytes); assertThrows(IOException.class, messagesToHtmlWriter::close); byte[] before = bytes.toByteArray(); assertDoesNotThrow(messagesToHtmlWriter::close); @@ -86,7 +158,6 @@ void it_writes_two_messages_separated_by_a_comma() throws IOException { "window.CUCUMBER_MESSAGES = [{\"testRunStarted\":{\"timestamp\":{\"seconds\":10,\"nanos\":0}}},{\"testRunFinished\":{\"success\":true,\"timestamp\":{\"seconds\":15,\"nanos\":0}}}];")); } - @Test void it_escapes_opening_angle_bracket() throws IOException { Envelope envelope = Envelope.of(new GherkinDocument( @@ -101,15 +172,4 @@ void it_escapes_opening_angle_bracket() throws IOException { assertThat(html, containsString( "window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"\\x3C/script>\\x3Cscript>alert('Hello')\\x3C/script>\"}]}}];")); } - - private static String renderAsHtml(Envelope... messages) throws IOException { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - try (MessagesToHtmlWriter messagesToHtmlWriter = new MessagesToHtmlWriter(bytes, serializer)) { - for (Envelope message : messages) { - messagesToHtmlWriter.write(message); - } - } - - return new String(bytes.toByteArray(), UTF_8); - } } diff --git a/javascript/logo.svg b/javascript/logo.svg deleted file mode 100644 index 537ee11d..00000000 --- a/javascript/logo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/javascript/package.json b/javascript/package.json index 677d89c4..89b45c23 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -15,7 +15,7 @@ "build:tsc": "tsc --build tsconfig.build.json", "build:webpack": "webpack", "build": "npm run clean && npm run build:tsc && npm run prepare && npm run build:webpack", - "prepare": "shx mkdir -p dist/src && shx cp src/*.scss dist/src && shx cp src/index.mustache.html dist/src", + "prepare": "shx mkdir -p dist/src && shx cp src/*.scss dist/src && shx cp src/index.mustache.html dist/src && shx cp src/icon.url dist/src", "test": "mocha", "prepublishOnly": "npm run build", "fix": "eslint --max-warnings 0 --fix src test && prettier --write src test", diff --git a/javascript/src/CucumberHtmlStream.ts b/javascript/src/CucumberHtmlStream.ts index 29940976..8738fb4d 100644 --- a/javascript/src/CucumberHtmlStream.ts +++ b/javascript/src/CucumberHtmlStream.ts @@ -8,13 +8,16 @@ export class CucumberHtmlStream extends Transform { private preMessageWritten = false private postMessageWritten = false private firstMessageWritten = false + /** * @param cssPath * @param jsPath + * @param iconPath */ constructor( private readonly cssPath: string = path.join(__dirname, '..', 'main.css'), - private readonly jsPath: string = path.join(__dirname, '..', 'main.js') + private readonly jsPath: string = path.join(__dirname, '..', 'main.js'), + private readonly iconPath: string = path.join(__dirname, 'icon.url') ) { super({ objectMode: true }) } @@ -44,13 +47,30 @@ export class CucumberHtmlStream extends Transform { return callback() } this.preMessageWritten = true - this.writeTemplateBetween(null, '{{css}}', (err) => { + this.writeTemplateBetween(null, '{{title}}', (err) => { if (err) return callback(err) - this.writeFile(this.cssPath, (err) => { + this.push('Cucumber') + this.writeTemplateBetween('{{title}}', '{{icon}}', (err) => { if (err) return callback(err) - this.writeTemplateBetween('{{css}}', '{{messages}}', (err) => { + this.writeFile(this.iconPath, (err) => { if (err) return callback(err) - callback() + this.writeTemplateBetween('{{icon}}', '{{css}}', (err) => { + if (err) return callback(err) + this.writeFile(this.cssPath, (err) => { + if (err) return callback(err) + this.writeTemplateBetween('{{css}}', '{{custom_css}}', (err) => { + if (err) return callback(err) + this.writeTemplateBetween( + '{{custom_css}}', + '{{messages}}', + (err) => { + if (err) return callback(err) + callback() + } + ) + }) + }) + }) }) }) }) @@ -63,7 +83,14 @@ export class CucumberHtmlStream extends Transform { if (err) return callback(err) this.writeFile(this.jsPath, (err) => { if (err) return callback(err) - this.writeTemplateBetween('{{script}}', null, callback) + this.writeTemplateBetween( + '{{script}}', + '{{custom_script}}', + (err) => { + if (err) return callback(err) + this.writeTemplateBetween('{{custom_script}}', null, callback) + } + ) }) }) }) diff --git a/javascript/src/icon.url b/javascript/src/icon.url new file mode 100644 index 00000000..9d5009c8 --- /dev/null +++ b/javascript/src/icon.url @@ -0,0 +1 @@ +data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAuMDYgMC41NiAzMi41IDM3LjEzIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPHBhdGggZD0iTS00LTFoNDB2NDBILTR6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzAwYTgxOCIKICAgICAgICAgICAgICBkPSJNMTYuNDM4LjU2M0M3LjM4Ni41NjMuMDYzIDcuODg2LjA2MyAxNi45MzhjMCA3Ljk2OCA1LjcxMiAxNC41ODkgMTMuMjUgMTYuMDYydjQuNjg4YzkuOC0xLjQ3OCAxOC40NzctOS4yNTcgMTkuMTI0LTE5LjQ3LjM5LTYuMTQ2LTIuNjc0LTEyLjQyMS03Ljg0My0xNS40NjhhMTMuNjIgMTMuNjIgMCAwIDAtMS44NzUtLjkzOGwtLjMxMy0uMTI1Yy0uMjg3LS4xMDYtLjU3Ny0uMjI1LS44NzUtLjMxMmExNi4yNDYgMTYuMjQ2IDAgMCAwLTUuMDkzLS44MTN2LjAwMXoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjZmZmIgogICAgICAgICAgICAgIGQ9Ik0xOS44MTMgNi42MjVhMS43ODcgMS43ODcgMCAwIDAtMS41NjMuNjI1Yy0uMy40LS40ODguNzg3LS42ODggMS4xODgtLjYgMS40LS40IDIuOS41IDQgMS40LS4zIDIuNTg4LTEuMTk0IDMuMTg4LTIuNTk0LjItLjQuMzEzLS45MTMuMzEzLTEuMzEzLjA2Mi0xLjA2Mi0uODE3LTEuODEtMS43NS0xLjkwNnptLTcuMjgyLjA5NGMtLjkxMy4wODctMS43ODEuODEyLTEuNzgxIDEuODEyIDAgLjQuMTEzLjkxMy4zMTMgMS4zMTMuNiAxLjQgMS44OCAyLjI5MyAzLjI4IDIuNTk0LjgtMS4xIDEuMDA3LTIuNi40MDctNC0uMi0uNC0uMzg3LS43OTQtLjY4OC0xLjA5NGExLjc1NyAxLjc1NyAwIDAgMC0xLjUzLS42MjVoLS4wMDF6TTcuNjI1IDExLjUzYy0xLjU3Ny4wODEtMi4yODEgMi4wNjMtLjk2OSAzLjA5NC40LjMuNzg4LjUxOSAxLjE4OC43MTkgMS40LjYgMy4wMTkuMzk0IDQuMjE4LS40MDYtLjMtMS4zLTEuMzE4LTIuNDk0LTIuNzE4LTMuMDk0LS41LS4yLS45MDYtLjMxMy0xLjQwNi0uMzEzLS4xMTMtLjAxMi0uMjA4LS4wMDUtLjMxMyAwem0xNS40MDYgNi4wNjNhNC41NzQgNC41NzQgMCAwIDAtMi41OTMuNzVjLjMgMS4zIDEuMzE4IDIuNDkzIDIuNzE4IDMuMDkzLjUuMi45MDcuMzEzIDEuNDA3LjMxMyAxLjguMSAyLjY4LTIuMTI1IDEuMjgtMy4xMjUtLjQtLjMtLjc4Ny0uNDg4LTEuMTg3LS42ODhhNC4zMiA0LjMyIDAgMCAwLTEuNjI1LS4zNDN6bS0xMy42NTYuMDkzYy0uNTUuMDExLTEuMS4xMi0xLjYyNS4zNDQtLjUuMi0uODg4LjQxOS0xLjE4OC43MTktMS4zIDEuMS0uNDI1IDMuMTk0IDEuMzc1IDMuMDk0LjUgMCAxLjAwNy0uMTEzIDEuNDA3LS4zMTMgMS40LS42IDIuMzk0LTEuNzkzIDIuNTk0LTMuMDkzYTQuNDc1IDQuNDc1IDAgMCAwLTIuNTYzLS43NXYtLjAwMXptNS4wNjMgMy4wNjNjLTEuNC4zLTIuNTg4IDEuMTk0LTMuMTg4IDIuNTk0LS4yLjQtLjMxMy44ODEtLjMxMyAxLjI4MS0uMSAxLjcgMi4yMiAyLjYxMyAzLjIyIDEuMzEzLjMtLjQuNDg3LS43ODguNjg3LTEuMTg4LjYtMS4zLjM5NC0yLjgtLjQwNi00em0zLjcxOC4wOTRjLS44IDEuMS0xLjAwNiAyLjYtLjQwNiA0IC4yLjQuMzg3Ljc5My42ODggMS4wOTMgMS4xIDEuMiAzLjQxMi4zMTMgMy4zMTItMS4xODcgMC0uNC0uMTEzLS45MTMtLjMxMy0xLjMxMy0uNi0xLjQtMS44OC0yLjI5My0zLjI4LTIuNTkzaC0uMDAxeiIvPgogICAgPC9nPgo8L3N2Zz4K \ No newline at end of file diff --git a/javascript/src/index.mustache.html b/javascript/src/index.mustache.html index 9005521d..80572ab6 100644 --- a/javascript/src/index.mustache.html +++ b/javascript/src/index.mustache.html @@ -1,13 +1,16 @@ - Cucumber + {{title}} - + +

@@ -18,5 +21,8 @@ + - \ No newline at end of file + diff --git a/javascript/test/acceptance.spec.ts b/javascript/test/acceptance.spec.ts index 9da40afc..a5598e8a 100644 --- a/javascript/test/acceptance.spec.ts +++ b/javascript/test/acceptance.spec.ts @@ -24,7 +24,8 @@ test.beforeAll(async () => { new NdjsonToMessageStream(), new CucumberHtmlStream( path.join(__dirname, '../dist/main.css'), - path.join(__dirname, '../dist/main.js') + path.join(__dirname, '../dist/main.js'), + path.join(__dirname, '../dist/src/icon.url') ), fs.createWriteStream(outputFile) ) diff --git a/ruby/assets/.gitignore b/ruby/assets/.gitignore index 71cea849..ad310592 100644 --- a/ruby/assets/.gitignore +++ b/ruby/assets/.gitignore @@ -2,4 +2,5 @@ *.html *.js *.css -*.txt \ No newline at end of file +*.txt +*.url diff --git a/ruby/lib/cucumber/html_formatter/assets_loader.rb b/ruby/lib/cucumber/html_formatter/assets_loader.rb index eb60d948..ac71f4af 100644 --- a/ruby/lib/cucumber/html_formatter/assets_loader.rb +++ b/ruby/lib/cucumber/html_formatter/assets_loader.rb @@ -8,6 +8,10 @@ def template read_asset('index.mustache.html') end + def icon + read_asset('icon.url') + end + def css read_asset('main.css') end diff --git a/ruby/lib/cucumber/html_formatter/formatter.rb b/ruby/lib/cucumber/html_formatter/formatter.rb index 24a162e0..3d7243aa 100644 --- a/ruby/lib/cucumber/html_formatter/formatter.rb +++ b/ruby/lib/cucumber/html_formatter/formatter.rb @@ -45,18 +45,24 @@ def write_post_message def pre_message [ - template_writer.write_between(nil, '{{css}}'), + template_writer.write_between(nil, '{{title}}'), + 'Cucumber', + template_writer.write_between('{{title}}', '{{icon}}'), + AssetsLoader.icon, + template_writer.write_between('{{icon}}', '{{css}}'), AssetsLoader.css, - template_writer.write_between('{{css}}', '{{messages}}') - ].join("\n") + template_writer.write_between('{{css}}', '{{custom_css}}'), + template_writer.write_between('{{custom_css}}', '{{messages}}') + ].join("") end def post_message [ template_writer.write_between('{{messages}}', '{{script}}'), AssetsLoader.script, - template_writer.write_between('{{script}}', nil) - ].join("\n") + template_writer.write_between('{{script}}', '{{custom_script}}'), + template_writer.write_between('{{custom_script}}', nil) + ].join("") end def template_writer diff --git a/ruby/spec/cucumber/html_formatter/formatter_spec.rb b/ruby/spec/cucumber/html_formatter/formatter_spec.rb index c9bfcea7..eabf87ef 100644 --- a/ruby/spec/cucumber/html_formatter/formatter_spec.rb +++ b/ruby/spec/cucumber/html_formatter/formatter_spec.rb @@ -9,22 +9,24 @@ before do allow(Cucumber::HTMLFormatter::AssetsLoader) .to receive_messages( - template: '{{css}}{{messages}}{{script}}', - css: '', - script: "" + template: "{{title}}\n{{icon}}\n{{css}}\n{{custom_css}}\n{{messages}}\n{{script}}\n{{custom_script}}\n", + icon: 'https://example.org/icon.svg', + css: 'div { color: red }', + script: "alert('Hi');" ) end describe '#process_messages' do let(:message) { Cucumber::Messages::Envelope.new(pickle: Cucumber::Messages::Pickle.new(id: 'some-random-uid')) } let(:expected_report) do - <<~REPORT.strip - - - - #{message.to_json} - - + <<~REPORT.lstrip + Cucumber + https://example.org/icon.svg + div { color: red } + + #{message.to_json} + alert('Hi'); + REPORT end @@ -39,14 +41,14 @@ it 'outputs the content of the template up to {{messages}}' do formatter.write_pre_message - expect(out.string).to eq("\n\n\n") + expect(out.string).to eq("Cucumber\nhttps://example.org/icon.svg\ndiv { color: red }\n\n") end it 'does not write the content twice' do formatter.write_pre_message formatter.write_pre_message - expect(out.string).to eq("\n\n\n") + expect(out.string).to eq("Cucumber\nhttps://example.org/icon.svg\ndiv { color: red }\n\n") end end @@ -90,7 +92,7 @@ it 'outputs the template end' do formatter.write_post_message - expect(out.string).to eq("\n\n") + expect(out.string).to eq("\nalert('Hi');\n\n") end end end