Skip to content

[wip] additional template customizations #406

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions dotnet/Cucumber.HtmlFormatter/HtmlReportSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace Cucumber.HtmlFormatter;

/// <summary>
/// Settings for HTML report generation
/// </summary>
public class HtmlReportSettings
{
private const string DEFAULT_TITLE = "Cucumber";
private const string DEFAULT_ICON = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAuMDYgMC41NiAzMi41IDM3LjEzIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPHBhdGggZD0iTS00LTFoNDB2NDBILTR6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzAwYTgxOCIKICAgICAgICAgICAgICBkPSJNMTYuNDM4LjU2M0M3LjM4Ni41NjMuMDYzIDcuODg2LjA2MyAxNi45MzhjMCA3Ljk2OCA1LjcxMiAxNC41ODkgMTMuMjUgMTYuMDYydjQuNjg4YzkuOC0xLjQ3OCAxOC40NzctOS4yNTcgMTkuMTI0LTE5LjQ3LjM5LTYuMTQ2LTIuNjc0LTEyLjQyMS03Ljg0My0xNS40NjhhMTMuNjIgMTMuNjIgMCAwIDAtMS44NzUtLjkzOGwtLjMxMy0uMTI1Yy0uMjg3LS4xMDYtLjU3Ny0uMjI1LS44NzUtLjMxMmExNi4yNDYgMTYuMjQ2IDAgMCAwLTUuMDkzLS44MTN2LjAwMXoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjZmZmIgogICAgICAgICAgICAgIGQ9Ik0xOS44MTMgNi42MjVhMS43ODcgMS43ODcgMCAwIDAtMS41NjMuNjI1Yy0uMy40LS40ODguNzg3LS42ODggMS4xODgtLjYgMS40LS40IDIuOS41IDQgMS40LS4zIDIuNTg4LTEuMTk0IDMuMTg4LTIuNTk0LjItLjQuMzEzLS45MTMuMzEzLTEuMzEzLjA2Mi0xLjA2Mi0uODE3LTEuODEtMS43NS0xLjkwNnptLTcuMjgyLjA5NGMtLjkxMy4wODctMS43ODEuODEyLTEuNzgxIDEuODEyIDAgLjQuMTEzLjkxMy4zMTMgMS4zMTMuNiAxLjQgMS44OCAyLjI5MyAzLjI4IDIuNTk0LjgtMS4xIDEuMDA3LTIuNi40MDctNC0uMi0uNC0uMzg3LS43OTQtLjY4OC0xLjA5NGExLjc1NyAxLjc1NyAwIDAgMC0xLjUzLS42MjVoLS4wMDF6TTcuNjI1IDExLjUzYy0xLjU3Ny4wODEtMi4yODEgMi4wNjMtLjk2OSAzLjA5NC40LjMuNzg4LjUxOSAxLjE4OC43MTkgMS40LjYgMy4wMTkuMzk0IDQuMjE4LS40MDYtLjMtMS4zLTEuMzE4LTIuNDk0LTIuNzE4LTMuMDk0LS41LS4yLS45MDYtLjMxMy0xLjQwNi0uMzEzLS4xMTMtLjAxMi0uMjA4LS4wMDUtLjMxMyAwem0xNS40MDYgNi4wNjNhNC41NzQgNC41NzQgMCAwIDAtMi41OTMuNzVjLjMgMS4zIDEuMzE4IDIuNDkzIDIuNzE4IDMuMDkzLjUuMi45MDcuMzEzIDEuNDA3LjMxMyAxLjguMSAyLjY4LTIuMTI1IDEuMjgtMy4xMjUtLjQtLjMtLjc4Ny0uNDg4LTEuMTg3LS42ODhhNC4zMiA0LjMyIDAgMCAwLTEuNjI1LS4zNDN6bS0xMy42NTYuMDkzYy0uNTUuMDExLTEuMS4xMi0xLjYyNS4zNDQtLjUuMi0uODg4LjQxOS0xLjE4OC43MTktMS4zIDEuMS0uNDI1IDMuMTk0IDEuMzc1IDMuMDk0LjUgMCAxLjAwNy0uMTEzIDEuNDA3LS4zMTMgMS40LS42IDIuMzk0LTEuNzkzIDIuNTk0LTMuMDkzYTQuNDc1IDQuNDc1IDAgMCAwLTIuNTYzLS43NXYtLjAwMXptNS4wNjMgMy4wNjNjLTEuNC4zLTIuNTg4IDEuMTk0LTMuMTg4IDIuNTk0LS4yLjQtLjMxMy44ODEtLjMxMyAxLjI4MS0uMSAxLjcgMi4yMiAyLjYxMyAzLjIyIDEuMzEzLjMtLjQuNDg3LS43ODguNjg3LTEuMTg4LjYtMS4zLjM5NC0yLjgtLjQwNi00em0zLjcxOC4wOTRjLS44IDEuMS0xLjAwNiAyLjYtLjQwNiA0IC4yLjQuMzg3Ljc5My42ODggMS4wOTMgMS4xIDEuMiAzLjQxMi4zMTMgMy4zMTItMS4xODcgMC0uNC0uMTEzLS45MTMtLjMxMy0xLjMxMy0uNi0xLjQtMS44OC0yLjI5My0zLjI4LTIuNTkzaC0uMDAxeiIvPgogICAgPC9nPgo8L3N2Zz4K";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be extracted to a file.

Copy link
Contributor

@mpkorstanje mpkorstanje Aug 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in #409. Just has to be read from a file.


/// <summary>
/// Gets or sets the title of the HTML report.
/// Default is "Cucumber".
/// </summary>
public string Title { get; set; } = DEFAULT_TITLE;

/// <summary>
/// Gets or sets the icon for the HTML report.
/// Default is the Cucumber icon as a base64-encoded SVG.
/// </summary>
public string Icon { get; set; } = DEFAULT_ICON;

/// <summary>
/// Gets or sets custom CSS to include in the HTML report.
/// Default is empty.
/// </summary>
public string CustomCss { get; set; } = string.Empty;

/// <summary>
/// Gets or sets custom HTML to include in the head section of the HTML report.
/// Default is empty.
/// </summary>
public string CustomHead { get; set; } = string.Empty;

/// <summary>
/// Creates a new instance of HtmlReportSettings with default values.
/// </summary>
public HtmlReportSettings()
{
}
}
120 changes: 106 additions & 14 deletions dotnet/Cucumber.HtmlFormatter/MessagesToHtmlWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,53 @@ public class MessagesToHtmlWriter : IDisposable
private readonly Action<StreamWriter, Envelope> _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;
private bool _postMessageWritten = false;
private readonly bool _isAsyncInitialized = false;

// Define the points where we split the template for pre/post message
private const string CSS_MARKER = "{{css}}";
private const string MESSAGES_MARKER = "{{messages}}";
private const string SCRIPT_MARKER = "{{script}}";
private const string TITLE_MARKER = "{{title}}";
private const string ICON_MARKER = "{{icon}}";
private const string CUSTOM_CSS_MARKER = "{{custom-css}}";
private const string CUSTOM_HEAD_MARKER = "{{custom-head}}";

[Obsolete("Cucumber.HtmlFormatter moving to async only operations. Please use the MessagesToHtmlWriter(Stream, Func<StreamWriter, Envelope, Task>) constructor", false)]
public MessagesToHtmlWriter(Stream stream, Action<StreamWriter, Envelope> streamSerializer) : this(new StreamWriter(stream), streamSerializer)
public MessagesToHtmlWriter(Stream stream, Action<StreamWriter, Envelope> streamSerializer)
: this(new StreamWriter(stream), streamSerializer)
{
}

[Obsolete("Cucumber.HtmlFormatter moving to async only operations. Please use the MessagesToHtmlWriter(Stream, Func<StreamWriter, Envelope, Task>, HtmlReportSettings) constructor", false)]
public MessagesToHtmlWriter(Stream stream, Action<StreamWriter, Envelope> streamSerializer, HtmlReportSettings? settings)
: this(new StreamWriter(stream), streamSerializer, settings)
{
}

public MessagesToHtmlWriter(Stream stream, Func<StreamWriter, Envelope, Task> asyncStreamSerializer)
: this(new StreamWriter(stream), asyncStreamSerializer)
{
}

public MessagesToHtmlWriter(Stream stream, Func<StreamWriter, Envelope, Task> asyncStreamSerializer,
HtmlReportSettings? settings = null)
: this(new StreamWriter(stream), asyncStreamSerializer, settings)
{
}
public MessagesToHtmlWriter(Stream stream, Func<StreamWriter, Envelope, Task> asyncStreamSerializer) : this(new StreamWriter(stream), asyncStreamSerializer) { }

[Obsolete("Cucumber.HtmlFormatter moving to async only operations. Please use the MessagesToHtmlWriter(StreamWriter, Func<StreamWriter, Envelope, Task>) constructor", false)]
public MessagesToHtmlWriter(StreamWriter writer, Action<StreamWriter, Envelope> streamSerializer)
: this(writer, streamSerializer, null)
{
}

[Obsolete("Cucumber.HtmlFormatter moving to async only operations. Please use the MessagesToHtmlWriter(StreamWriter, Func<StreamWriter, Envelope, Task>, HtmlReportSettings) constructor", false)]
public MessagesToHtmlWriter(StreamWriter writer, Action<StreamWriter, Envelope> streamSerializer, HtmlReportSettings? settings)
{
this._writer = writer;
this._streamSerializer = streamSerializer;
Expand All @@ -32,47 +65,104 @@ public MessagesToHtmlWriter(StreamWriter writer, Action<StreamWriter, Envelope>
streamSerializer(w, e);
return Task.CompletedTask;
};
_settings = settings ?? new HtmlReportSettings();
_template = GetResource("index.mustache.html");
_jsonInHtmlWriter = new JsonInHtmlWriter(writer);
_isAsyncInitialized = false;
}

public MessagesToHtmlWriter(StreamWriter writer, Func<StreamWriter, Envelope, Task> asyncStreamSerializer)
: this(writer, asyncStreamSerializer, null)
{
}

public MessagesToHtmlWriter(StreamWriter writer, Func<StreamWriter, Envelope, Task> asyncStreamSerializer,
HtmlReportSettings? settings = null)
{
this._writer = writer;
this._asyncStreamSerializer = asyncStreamSerializer;
// Create sync wrapper for async serializer (will block)
this._streamSerializer = (w, e) => asyncStreamSerializer(w, e).GetAwaiter().GetResult();
this._settings = settings ?? new HtmlReportSettings();
_template = GetResource("index.mustache.html");
_jsonInHtmlWriter = new JsonInHtmlWriter(writer);
_isAsyncInitialized = true;
}

private void WritePreMessage()
{
WriteTemplateBetween(_writer, _template, null, "{{css}}");
// Process template from beginning to CSS marker
string processedTemplate = ApplySettingsToTemplate(_template);

// For backward compatibility, maintain the same order of template processing
WriteTemplateBetween(_writer, processedTemplate, null, CSS_MARKER);
WriteResource(_writer, "main.css");
WriteTemplateBetween(_writer, _template, "{{css}}", "{{messages}}");
WriteTemplateBetween(_writer, processedTemplate, CSS_MARKER, MESSAGES_MARKER);
}

private async Task WritePreMessageAsync()
{
await WriteTemplateBetweenAsync(_writer, _template, null, "{{css}}");
// Process template from beginning to CSS marker
string processedTemplate = ApplySettingsToTemplate(_template);

// For backward compatibility, maintain the same order of template processing
await WriteTemplateBetweenAsync(_writer, processedTemplate, null, CSS_MARKER);
await WriteResourceAsync(_writer, "main.css");
await WriteTemplateBetweenAsync(_writer, _template, "{{css}}", "{{messages}}");
await WriteTemplateBetweenAsync(_writer, processedTemplate, CSS_MARKER, MESSAGES_MARKER);
}

private void WritePostMessage()
{
WriteTemplateBetween(_writer, _template, "{{messages}}", "{{script}}");
// Process template from messages to end
string processedTemplate = ApplySettingsToTemplate(_template);

// For backward compatibility, maintain the same order of template processing
WriteTemplateBetween(_writer, processedTemplate, MESSAGES_MARKER, SCRIPT_MARKER);
WriteResource(_writer, "main.js");
WriteTemplateBetween(_writer, _template, "{{script}}", null);
WriteTemplateBetween(_writer, processedTemplate, SCRIPT_MARKER, null);
}

private async Task WritePostMessageAsync()
{
await WriteTemplateBetweenAsync(_writer, _template, "{{messages}}", "{{script}}");
// Process template from messages to end
string processedTemplate = ApplySettingsToTemplate(_template);

// For backward compatibility, maintain the same order of template processing
await WriteTemplateBetweenAsync(_writer, processedTemplate, MESSAGES_MARKER, SCRIPT_MARKER);
await WriteResourceAsync(_writer, "main.js");
await WriteTemplateBetweenAsync(_writer, _template, "{{script}}", null);
await WriteTemplateBetweenAsync(_writer, processedTemplate, SCRIPT_MARKER, null);
}

private string ApplySettingsToTemplate(string template)
Copy link
Contributor

@mpkorstanje mpkorstanje Aug 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this method can be replaced with a sequence of WriteTemplateBetweenAsync calls.

Either:

  • The template is effectively a constant, so we don't have to check if it a template variable should be replaced.

Or:

  • The template is not a constant, then the repeated .Replace calls are prone to accidental injection. For example the title is set to {{script}}.

So I think it would be better to consider the template to be constant. The alternative leads to problems that can only be solved by using a mustache template engine which we are trying to avoid to keep the dependencies small.

edit: I'm actually okay if you want to use a template engine. But would be good to do that explicitly and not reinvent the wheel too much. πŸ˜…

{
// Apply all custom settings to the template
var result = template;

// Apply title placeholder if it exists in the template
if (template.Contains(TITLE_MARKER))
{
result = result.Replace(TITLE_MARKER, _settings.Title);
}

// Apply icon placeholder if it exists in the template
if (template.Contains(ICON_MARKER))
{
result = result.Replace(ICON_MARKER, _settings.Icon);
}

// Apply custom CSS placeholder if it exists in the template
if (template.Contains(CUSTOM_CSS_MARKER))
{
result = result.Replace(CUSTOM_CSS_MARKER, _settings.CustomCss);
}

// Apply custom head placeholder if it exists in the template
if (template.Contains(CUSTOM_HEAD_MARKER))
{
result = result.Replace(CUSTOM_HEAD_MARKER, _settings.CustomHead);
}

return result;
}

public void Write(Envelope envelope)
Expand Down Expand Up @@ -201,7 +291,8 @@ private async Task WriteResourceAsync(StreamWriter writer, string v)
private void WriteTemplateBetween(StreamWriter writer, string template, string? begin, string? end)
{
CalculateBeginAndLength(template, begin, end, out var beginIndex, out var lengthToWrite);
writer.Write(template.Substring(beginIndex, lengthToWrite));
string section = template.Substring(beginIndex, lengthToWrite);
writer.Write(section);
}

private static void CalculateBeginAndLength(string template, string? begin, string? end, out int beginIndex, out int lengthToWrite)
Expand All @@ -214,7 +305,8 @@ private static void CalculateBeginAndLength(string template, string? begin, stri
private async Task WriteTemplateBetweenAsync(StreamWriter writer, string template, string? begin, string? end)
{
CalculateBeginAndLength(template, begin, end, out var beginIndex, out var lengthToWrite);
await writer.WriteAsync(template.Substring(beginIndex, lengthToWrite));
string section = template.Substring(beginIndex, lengthToWrite);
await writer.WriteAsync(section);
}

private string GetResource(string name)
Expand All @@ -223,7 +315,7 @@ private string GetResource(string name)
var resourceStream = assembly.GetManifestResourceStream("Cucumber.HtmlFormatter.Resources." + name);
if (resourceStream == null)
throw new InvalidOperationException($"Resource '{name}' not found in assembly '{assembly.FullName}'");
var resource = new StreamReader(resourceStream).ReadToEnd();
return resource;
using var reader = new StreamReader(resourceStream);
return reader.ReadToEnd();
}
}
117 changes: 117 additions & 0 deletions dotnet/Cucumber.HtmlFormatterTest/HtmlReportSettingsTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Cucumber.HtmlFormatter;
using Io.Cucumber.Messages.Types;
using Cucumber.Messages;

namespace Cucumber.HtmlFormatterTest;

[TestClass]
public class HtmlReportSettingsTest
{
private static readonly Func<StreamWriter, Envelope, Task> AsyncSerializer = async (sw, e) =>
{
var s = NdjsonSerializer.Serialize(e);
await sw.WriteAsync(s);
};

[TestMethod]
public void DefaultSettings_ProperlyInitialized()
{
// Arrange & Act
var settings = new HtmlReportSettings();

// Assert
Assert.AreEqual("Cucumber", settings.Title, "Default title should be 'Cucumber'");
Assert.IsTrue(settings.Icon.StartsWith("data:image/svg+xml;base64,"), "Default icon should be a base64 SVG");
Assert.AreEqual(string.Empty, settings.CustomCss, "Default custom CSS should be empty");
Assert.AreEqual(string.Empty, settings.CustomHead, "Default custom head should be empty");
}

[TestMethod]
public void CustomSettings_AppliedToSimpleTemplate()
{
// Setup a mock template with placeholders
var settings = new HtmlReportSettings
{
Title = "Custom Title",
Icon = "custom-icon-url",
CustomCss = ".custom { color: red; }",
CustomHead = "<meta name=\"custom\" content=\"test\">"
};

// Create a simple template that contains the placeholders
string simpleTemplate = "<!DOCTYPE html><html><head><title>{{title}}</title><link rel=\"icon\" href=\"{{icon}}\"><style>{{custom-css}}</style>{{custom-head}}</head><body>{{messages}}<script>{{script}}</script></body></html>";

// Apply replacements
string processed = simpleTemplate
.Replace("{{title}}", settings.Title)
.Replace("{{icon}}", settings.Icon)
.Replace("{{custom-css}}", settings.CustomCss)
.Replace("{{custom-head}}", settings.CustomHead);

// Verify
StringAssert.Contains(processed, "Custom Title", "Processed template should contain the custom title");
StringAssert.Contains(processed, "custom-icon-url", "Processed template should contain the custom icon URL");
StringAssert.Contains(processed, ".custom { color: red; }", "Processed template should contain the custom CSS");
StringAssert.Contains(processed, "<meta name=\"custom\" content=\"test\">", "Processed template should contain the custom head content");
}

[TestMethod]
public void MessagesToHtmlWriter_WithCustomSettings_CreatesInstance()
{
// This test ensures we can create an instance without exceptions
var settings = new HtmlReportSettings
{
Title = "Custom Title",
Icon = "custom-icon-url",
CustomCss = ".custom { color: red; }",
CustomHead = "<meta name=\"custom\" content=\"test\">"
};

// Act - just creating the instance and writing a message should not throw
using var stream = new MemoryStream();
using var writer = new MessagesToHtmlWriter(stream, AsyncSerializer, settings);

// Assert - if we got here without exceptions, the test passes
Assert.IsNotNull(writer, "MessagesToHtmlWriter should be created without exceptions");
}

[TestMethod]
public async Task MessagesToHtmlWriter_WithCustomSettings_WritesMessageAsync()
{
// This test ensures we can write messages with custom settings
var settings = new HtmlReportSettings
{
Title = "Custom Title",
Icon = "custom-icon-url",
CustomCss = ".custom { color: red; }",
CustomHead = "<meta name=\"custom\" content=\"test\">"
};

// Act - create the writer and write a simple message
var stream = new MemoryStream();
await using (var writer = new MessagesToHtmlWriter(stream, AsyncSerializer, settings))
{
DateTime timestamp = DateTime.UnixEpoch.AddSeconds(10).ToUniversalTime();
await writer.WriteAsync(Envelope.Create(new TestRunStarted(Converters.ToTimestamp(timestamp), null)));
}

// Get the output as a string
string html = Encoding.UTF8.GetString(stream.ToArray());

// Assert - verify the output contains the expected message
Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES"),
"HTML should contain a message window variable");

// If the template contains the placeholders, the custom values should be present
// But we can't guarantee the actual template has these placeholders
if (html.Contains("Custom Title"))
{
StringAssert.Contains(html, "Custom Title", "HTML should contain the custom title");
}
}
}
Loading
Loading