Skip to content

dotnet: allow customizing resources in subclasses #400

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 6 commits into from
Closed
Show file tree
Hide file tree
Changes from 5 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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]
### Added
- [.Net] Added possibility to customize resources for MessagesToHtmlWriter ([#400](https://github.com/cucumber/html-formatter/pull/400))

### Changed
- Upgrade `react-components` to [23.2.0](https://github.com/cucumber/react-components/releases/tag/v23.2.0)

Expand Down
65 changes: 65 additions & 0 deletions dotnet/Cucumber.HtmlFormatter/DefaultResourceProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Reflection;

namespace Cucumber.HtmlFormatter;

/// <summary>
/// Default implementation of IResourceProvider
/// </summary>
public class DefaultResourceProvider : IResourceProvider
{
private const string TEMPLATE_RESOURCE_NAME = "index.mustache.html";
private const string CSS_RESOURCE_NAME = "main.css";
private const string JAVASCRIPT_RESOURCE_NAME = "main.js";
private readonly Assembly _assembly;
private readonly string _resourceNamespace;

public DefaultResourceProvider()
{
_assembly = typeof(MessagesToHtmlWriter).Assembly;
_resourceNamespace = "Cucumber.HtmlFormatter.Resources.";
}

/// <summary>
/// Constructor with custom assembly and namespace
/// </summary>
/// <param name="assembly">The assembly to load resources from</param>
/// <param name="resourceNamespace">The namespace prefix for resources</param>
public DefaultResourceProvider(Assembly assembly, string resourceNamespace)
{
_assembly = assembly;
_resourceNamespace = resourceNamespace;
}

/// <inheritdoc />
public string GetTemplateResource()
{
return GetResource(TEMPLATE_RESOURCE_NAME);
}

/// <inheritdoc />
public string GetCssResource()
{
return GetResource(CSS_RESOURCE_NAME);
}

/// <inheritdoc />
public string GetJavaScriptResource()
{
return GetResource(JAVASCRIPT_RESOURCE_NAME);
}

/// <summary>
/// Gets a resource from the assembly
/// </summary>
/// <param name="name">The resource name</param>
/// <returns>The resource content</returns>
protected string GetResource(string name)
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 private now I think?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

{
var resourceStream = _assembly.GetManifestResourceStream(_resourceNamespace + name);
if (resourceStream == null)
throw new InvalidOperationException($"Resource '{name}' not found in assembly '{_assembly.FullName}'");

using var reader = new StreamReader(resourceStream);
return reader.ReadToEnd();
}
}
29 changes: 29 additions & 0 deletions dotnet/Cucumber.HtmlFormatter/IResourceProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System;
using System.Threading.Tasks;
using System.IO;

namespace Cucumber.HtmlFormatter;

/// <summary>
/// Interface for providing resources to the HTML formatter
/// </summary>
public interface IResourceProvider
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.

Can we indicate that his is an experimental API? If we're planning to do a settings object, with builder I imagine use of that object would be something like,

new Setting()
  .withCssResource(...)
  .withJavascriptResource(...)
  .withEct(...)

And with sensible defaults in place if a particular method of the builder isn't called.

The IResourceProvider doesn't support those sensible defaults and so I imagine it will be phased out again too.

Copy link
Member Author

Choose a reason for hiding this comment

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

Highlighted that this is experimental

{
/// <summary>
/// Gets the HTML template
/// </summary>
/// <returns>The HTML template</returns>
string GetTemplateResource();
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure if the template is suitable for replacement. It is not actually a mustache template, it just looks like one.

There is a hard coded assumption in the code that the template contains {{css}}, {{messages}} and {{script}} and also in that order. If that ever changes, we'd break consumers of the API.

But that is acceptable if this is marked as an experimental API.

Copy link
Member Author

Choose a reason for hiding this comment

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

Currently I need this for the tweaking, but the interface is marked as experimental. But you are right, in long term, we should not need this.
The theming customization might require something, but we can solve it in other ways too - let's discuss when we talk about the customizations.


/// <summary>
/// Gets the CSS resource
/// </summary>
/// <returns>The CSS resource</returns>
string GetCssResource();

/// <summary>
/// Gets the JavaScript resource
/// </summary>
/// <returns>The JavaScript resource</returns>
string GetJavaScriptResource();
}
55 changes: 25 additions & 30 deletions dotnet/Cucumber.HtmlFormatter/MessagesToHtmlWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public class MessagesToHtmlWriter : IDisposable
private readonly Action<StreamWriter, Envelope> _streamSerializer;
private readonly string _template;
private readonly JsonInHtmlWriter _jsonInHtmlWriter;
private readonly IResourceProvider _resourceProvider;
private bool _streamClosed = false;
private bool _preMessageWritten = false;
private bool _firstMessageWritten = false;
Expand All @@ -19,59 +20,65 @@ public class MessagesToHtmlWriter : IDisposable
public MessagesToHtmlWriter(Stream stream, Action<StreamWriter, Envelope> streamSerializer) : this(new StreamWriter(stream), streamSerializer)
{
}
public MessagesToHtmlWriter(Stream stream, Func<StreamWriter, Envelope, Task> asyncStreamSerializer) : this(new StreamWriter(stream), asyncStreamSerializer) { }

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

[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 = writer;
this._streamSerializer = streamSerializer;
_writer = writer;
_streamSerializer = streamSerializer;
// Create async wrapper for sync serializer
this._asyncStreamSerializer = (w, e) =>
_asyncStreamSerializer = (w, e) =>
{
streamSerializer(w, e);
return Task.CompletedTask;
};
_template = GetResource("index.mustache.html");
_resourceProvider = new DefaultResourceProvider();
_template = _resourceProvider.GetTemplateResource();
_jsonInHtmlWriter = new JsonInHtmlWriter(writer);
_isAsyncInitialized = false;
}
public MessagesToHtmlWriter(StreamWriter writer, Func<StreamWriter, Envelope, Task> asyncStreamSerializer)

public MessagesToHtmlWriter(StreamWriter writer, Func<StreamWriter, Envelope, Task> asyncStreamSerializer, IResourceProvider? resourceProvider = null)
{
this._writer = writer;
this._asyncStreamSerializer = asyncStreamSerializer;
_writer = writer;
_asyncStreamSerializer = asyncStreamSerializer;
// 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();
_resourceProvider = resourceProvider ?? new DefaultResourceProvider();
_template = _resourceProvider.GetTemplateResource();
_jsonInHtmlWriter = new JsonInHtmlWriter(writer);
_isAsyncInitialized = true;
}

private void WritePreMessage()
{
WriteTemplateBetween(_writer, _template, null, "{{css}}");
WriteResource(_writer, "main.css");
WriteResource(_writer, _resourceProvider.GetCssResource());
WriteTemplateBetween(_writer, _template, "{{css}}", "{{messages}}");
}

private async Task WritePreMessageAsync()
{
await WriteTemplateBetweenAsync(_writer, _template, null, "{{css}}");
await WriteResourceAsync(_writer, "main.css");
await WriteResourceAsync(_writer, _resourceProvider.GetCssResource());
await WriteTemplateBetweenAsync(_writer, _template, "{{css}}", "{{messages}}");
}

private void WritePostMessage()
{
WriteTemplateBetween(_writer, _template, "{{messages}}", "{{script}}");
WriteResource(_writer, "main.js");
WriteResource(_writer, _resourceProvider.GetJavaScriptResource());
WriteTemplateBetween(_writer, _template, "{{script}}", null);
}

private async Task WritePostMessageAsync()
{
await WriteTemplateBetweenAsync(_writer, _template, "{{messages}}", "{{script}}");
await WriteResourceAsync(_writer, "main.js");
await WriteResourceAsync(_writer, _resourceProvider.GetJavaScriptResource());
await WriteTemplateBetweenAsync(_writer, _template, "{{script}}", null);
}

Expand Down Expand Up @@ -186,16 +193,14 @@ public async Task DisposeAsync()
}
}

private void WriteResource(StreamWriter writer, string v)
private void WriteResource(StreamWriter writer, string content)
{
var resource = GetResource(v);
writer.Write(resource);
writer.Write(content);
}

private async Task WriteResourceAsync(StreamWriter writer, string v)
private async Task WriteResourceAsync(StreamWriter writer, string content)
{
var resource = GetResource(v);
await writer.WriteAsync(resource);
await writer.WriteAsync(content);
}

private void WriteTemplateBetween(StreamWriter writer, string template, string? begin, string? end)
Expand All @@ -216,14 +221,4 @@ private async Task WriteTemplateBetweenAsync(StreamWriter writer, string templat
CalculateBeginAndLength(template, begin, end, out var beginIndex, out var lengthToWrite);
await writer.WriteAsync(template.Substring(beginIndex, lengthToWrite));
}

private string GetResource(string name)
{
var assembly = typeof(MessagesToHtmlWriter).Assembly;
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;
}
}
67 changes: 67 additions & 0 deletions dotnet/Cucumber.HtmlFormatterTest/DefaultResourceProviderTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using Cucumber.HtmlFormatter;

namespace Cucumber.HtmlFormatterTest;

[TestClass]
public class DefaultResourceProviderTest
{
private DefaultResourceProvider _resourceProvider = null!;

[TestInitialize]
public void Setup()
{
_resourceProvider = new DefaultResourceProvider();
}

[TestMethod]
public void GetTemplateResource_ReturnsNonEmptyString()
{
// Act
var template = _resourceProvider.GetTemplateResource();

// Assert
Assert.IsNotNull(template, "Template resource should not be null");
Assert.IsTrue(!string.IsNullOrWhiteSpace(template), "Template resource should not be empty");
Assert.IsTrue(template.Contains("{{css}}"), "Template should contain CSS placeholder");
Assert.IsTrue(template.Contains("{{messages}}"), "Template should contain messages placeholder");
Assert.IsTrue(template.Contains("{{script}}"), "Template should contain script placeholder");
}

[TestMethod]
public void GetCssResource_ReturnsNonEmptyString()
{
// Act
var css = _resourceProvider.GetCssResource();

// Assert
Assert.IsNotNull(css, "CSS resource should not be null");
Assert.IsTrue(!string.IsNullOrWhiteSpace(css), "CSS resource should not be empty");
}

[TestMethod]
public void GetJavaScriptResource_ReturnsNonEmptyString()
{
// Act
var javascript = _resourceProvider.GetJavaScriptResource();

// Assert
Assert.IsNotNull(javascript, "JavaScript resource should not be null");
Assert.IsTrue(!string.IsNullOrWhiteSpace(javascript), "JavaScript resource should not be empty");
}

[TestMethod]
public void AllResources_LoadSuccessfully()
{
// This test verifies that all resources can be loaded in sequence without errors

// Act & Assert - if any of these throw an exception, the test will fail
var template = _resourceProvider.GetTemplateResource();
Assert.IsNotNull(template, "Template resource should not be null");

var css = _resourceProvider.GetCssResource();
Assert.IsNotNull(css, "CSS resource should not be null");

var javascript = _resourceProvider.GetJavaScriptResource();
Assert.IsNotNull(javascript, "JavaScript resource should not be null");
}
}