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 @@
1591falsetrue
- 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 @@
latestenableenable
+ 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.
+ *