-
-
Notifications
You must be signed in to change notification settings - Fork 5
[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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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"; | ||
|
||
/// <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() | ||
{ | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
@@ -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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this method can be replaced with a sequence of Either:
Or:
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) | ||
|
@@ -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) | ||
|
@@ -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) | ||
|
@@ -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(); | ||
} | ||
} |
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"); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.