diff --git a/.github/workflows/crossBrowserTesting.yml b/.github/workflows/crossBrowserTesting.yml
index 89ebf10e..f1db7652 100644
--- a/.github/workflows/crossBrowserTesting.yml
+++ b/.github/workflows/crossBrowserTesting.yml
@@ -116,11 +116,11 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: Screenplay JSON reports ${{ matrix.browserName }}_${{ matrix.browserVersion }}_${{ matrix.os }}_${{ matrix.osVersion }}
- path: Tests/CSF.Screenplay.Selenium.Tests/**/ScreenplayReport_*.json
+ path: Tests/CSF.Screenplay.Selenium.Tests/**/ScreenplayReport.json
- name: Convert Screenplay reports to HTML
continue-on-error: true
run: |
- for report in $(find Tests/CSF.Screenplay.Selenium.Tests/ -type f -name "ScreenplayReport_*.json")
+ for report in $(find Tests/CSF.Screenplay.Selenium.Tests/ -type f -name "ScreenplayReport.json")
do
reportDir=$(dirname "$report")
outputFile="$reportDir/ScreenplayReport.html"
diff --git a/.github/workflows/dotnetCi.yml b/.github/workflows/dotnetCi.yml
index 1695f40f..912d64fe 100644
--- a/.github/workflows/dotnetCi.yml
+++ b/.github/workflows/dotnetCi.yml
@@ -165,11 +165,11 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: Screenplay JSON reports
- path: Tests/**/ScreenplayReport_*.json
+ path: Tests/**/ScreenplayReport.json
- name: Convert Screenplay reports to HTML
continue-on-error: true
run: |
- for report in $(find Tests/ -type f -name "ScreenplayReport_*.json")
+ for report in $(find Tests/ -type f -name "ScreenplayReport.json")
do
reportDir=$(dirname "$report")
outputFile="$reportDir/ScreenplayReport.html"
diff --git a/CSF.Screenplay.Abstractions/Reporting/IGetsReportPath.cs b/CSF.Screenplay.Abstractions/Reporting/IGetsReportPath.cs
index 6ee81c59..fbd61b91 100644
--- a/CSF.Screenplay.Abstractions/Reporting/IGetsReportPath.cs
+++ b/CSF.Screenplay.Abstractions/Reporting/IGetsReportPath.cs
@@ -6,7 +6,7 @@ namespace CSF.Screenplay.Reporting
public interface IGetsReportPath
{
///
- /// Gets the path to which the report should be written.
+ /// Gets the directory path to which the report files should be written.
///
///
///
diff --git a/CSF.Screenplay.Abstractions/Reporting/ReportPathProviderExtensions.cs b/CSF.Screenplay.Abstractions/Reporting/ReportPathProviderExtensions.cs
new file mode 100644
index 00000000..a170f846
--- /dev/null
+++ b/CSF.Screenplay.Abstractions/Reporting/ReportPathProviderExtensions.cs
@@ -0,0 +1,34 @@
+using System.IO;
+
+namespace CSF.Screenplay.Reporting
+{
+ ///
+ /// Extension methods for .
+ ///
+ public static class ReportPathProviderExtensions
+ {
+ const string reportFilename = "ScreenplayReport.json";
+
+ ///
+ /// Gets the file path to which the report JSON file should be written.
+ ///
+ ///
+ ///
+ /// If the returned path is then Screenplay's reporting functionality should be disabled and no report should be written.
+ /// Otherwise, implementations of this interface should return an absolute file system path to which the report JSON file should be written.
+ /// This path must be writable by the executing process.
+ ///
+ ///
+ /// Reporting could be disabled if either the Screenplay Options report path is or a whitespace-only string, or if the path
+ /// indicated by those options is not writable.
+ ///
+ ///
+ /// The report file path.
+ public static string GetReportFilePath(this IGetsReportPath provider)
+ {
+ var directoryPath = provider.GetReportPath();
+ if(directoryPath is null) return null;
+ return Path.Combine(directoryPath, reportFilename);
+ }
+ }
+}
diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/webpack.config.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/webpack.config.js
index 05da7cb7..d77fdd87 100644
--- a/CSF.Screenplay.JsonToHtmlReport.Template/src/webpack.config.js
+++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/webpack.config.js
@@ -25,10 +25,7 @@ module.exports = {
]
},
optimization: {
- minimizer: [
- `...`,
- new CssMinimizerPlugin()
- ]
+ minimizer: [new CssMinimizerPlugin(), '...']
},
plugins: [
new HtmlWebpackPlugin({
diff --git a/CSF.Screenplay.JsonToHtmlReport/AssetEmbedder.cs b/CSF.Screenplay.JsonToHtmlReport/AssetEmbedder.cs
new file mode 100644
index 00000000..a69505f7
--- /dev/null
+++ b/CSF.Screenplay.JsonToHtmlReport/AssetEmbedder.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using CSF.Screenplay.ReportModel;
+using System.Linq;
+using System.IO;
+
+namespace CSF.Screenplay.JsonToHtmlReport
+{
+ ///
+ /// Default implementation of .
+ ///
+ public class AssetEmbedder : IEmbedsReportAssets
+ {
+ ///
+ public async Task EmbedReportAssetsAsync(ScreenplayReport report, ReportConverterOptions options)
+ {
+ if (report is null)
+ throw new ArgumentNullException(nameof(report));
+ if (options is null)
+ throw new ArgumentNullException(nameof(options));
+
+ var applicableExtensions = options.GetEmbeddedFileExtensions();
+ var ignoreExtension = options.ShouldEmbedAllFileTypes();
+ var maxSizeKb = options.EmbeddedFileSizeThresholdKb;
+
+ var allAssets = GetAssets(report);
+ foreach(var asset in allAssets)
+ await EmbedAssetIfApplicable(asset, maxSizeKb, applicableExtensions, ignoreExtension);
+ }
+
+ static IEnumerable GetAssets(ScreenplayReport report)
+ {
+ return from performance in report.Performances
+ from performable in GetAllPerformables(performance)
+ from asset in performable.Assets
+ select asset;
+ }
+
+ static IEnumerable GetAllPerformables(PerformanceReport performance)
+ {
+ var open = new List();
+ var closed = new List();
+ open.AddRange(performance.Reportables.OfType());
+
+ while(open.Count > 0)
+ {
+ var current = open.First();
+ open.RemoveAt(0);
+ closed.Add(current);
+ open.AddRange(current.Reportables.OfType());
+ }
+
+ return closed;
+ }
+
+ static Task EmbedAssetIfApplicable(PerformableAsset asset,
+ int maxSizeKb,
+ IReadOnlyCollection applicableExtensions,
+ bool ignoreExtension)
+ {
+ if(!ShouldEmbed(asset, maxSizeKb, applicableExtensions, ignoreExtension)) return Task.CompletedTask;
+ return EmbedAssetIfApplicableAsync(asset);
+ }
+
+ static bool ShouldEmbed(PerformableAsset asset,
+ int maxSizeKb,
+ IReadOnlyCollection applicableExtensions,
+ bool ignoreExtension)
+ {
+ if(string.IsNullOrWhiteSpace(asset.FilePath)) return false;
+
+ var info = new FileInfo(asset.FilePath);
+ var fileSizeBytes = info.Length;
+ var extension = info.Extension;
+
+ return fileSizeBytes <= (maxSizeKb * 1000)
+ && (ignoreExtension || applicableExtensions.Contains(extension));
+ }
+
+#if !NETSTANDARD2_0 && !NET462
+ static async Task EmbedAssetIfApplicableAsync(PerformableAsset asset)
+ {
+ var bytes = await File.ReadAllBytesAsync(asset.FilePath).ConfigureAwait(false);
+#else
+ static Task EmbedAssetIfApplicableAsync(PerformableAsset asset)
+ {
+ var bytes = File.ReadAllBytes(asset.FilePath);
+#endif
+
+ asset.FileData = Convert.ToBase64String(bytes);
+ asset.FilePath = null;
+#if NETSTANDARD2_0 || NET462
+ return Task.CompletedTask;
+#endif
+ }
+ }
+}
\ No newline at end of file
diff --git a/CSF.Screenplay.JsonToHtmlReport/CSF.Screenplay.JsonToHtmlReport.csproj b/CSF.Screenplay.JsonToHtmlReport/CSF.Screenplay.JsonToHtmlReport.csproj
index b08e548a..d7c73c03 100644
--- a/CSF.Screenplay.JsonToHtmlReport/CSF.Screenplay.JsonToHtmlReport.csproj
+++ b/CSF.Screenplay.JsonToHtmlReport/CSF.Screenplay.JsonToHtmlReport.csproj
@@ -4,7 +4,7 @@
- netcoreapp3.1;net462;netstandard2.0;net6.0;net8.0
+ net462;netstandard2.0;net6.0;net8.0ExeLibraryNU1903,NU1902
@@ -18,7 +18,7 @@
-
+
@@ -26,6 +26,7 @@
false
+
diff --git a/CSF.Screenplay.JsonToHtmlReport/IEmbedsReportAssets.cs b/CSF.Screenplay.JsonToHtmlReport/IEmbedsReportAssets.cs
new file mode 100644
index 00000000..9d0c4397
--- /dev/null
+++ b/CSF.Screenplay.JsonToHtmlReport/IEmbedsReportAssets.cs
@@ -0,0 +1,20 @@
+using System.Threading.Tasks;
+using CSF.Screenplay.ReportModel;
+
+namespace CSF.Screenplay.JsonToHtmlReport
+{
+ ///
+ /// A service which reworks a , embedding assets within the report.
+ ///
+ public interface IEmbedsReportAssets
+ {
+ ///
+ /// Processes the specified , converting external file assets to embedded assets,
+ /// where they meet the criteria specified by the specified .
+ ///
+ /// A Screenplay report
+ /// A set of options for converting a report to HTML
+ /// A task which completes when the process is done.
+ Task EmbedReportAssetsAsync(ScreenplayReport report, ReportConverterOptions options);
+ }
+}
\ No newline at end of file
diff --git a/CSF.Screenplay.JsonToHtmlReport/ReportConverter.cs b/CSF.Screenplay.JsonToHtmlReport/ReportConverter.cs
index 016cfbd4..8462d10f 100644
--- a/CSF.Screenplay.JsonToHtmlReport/ReportConverter.cs
+++ b/CSF.Screenplay.JsonToHtmlReport/ReportConverter.cs
@@ -1,6 +1,8 @@
using System;
using System.IO;
using System.Threading.Tasks;
+using CSF.Screenplay.ReportModel;
+using CSF.Screenplay.Reporting;
namespace CSF.Screenplay.JsonToHtmlReport
{
@@ -10,26 +12,35 @@ namespace CSF.Screenplay.JsonToHtmlReport
public class ReportConverter : IConvertsReportJsonToHtml
{
readonly IGetsHtmlTemplate templateReader;
+ readonly IEmbedsReportAssets assetsEmbedder;
+ readonly IDeserializesReport reportReader;
+ readonly ISerializesReport reportWriter;
///
public async Task ConvertAsync(ReportConverterOptions options)
{
var report = await ReadReport(options.ReportPath).ConfigureAwait(false);
+ await assetsEmbedder.EmbedReportAssetsAsync(report, options).ConfigureAwait(false);
+ var reportJson = await GetModifiedReportJsonAsync(report).ConfigureAwait(false);
var template = await templateReader.ReadTemplate().ConfigureAwait(false);
- var assembledTemplate = template.Replace("", report);
+ var assembledTemplate = template.Replace("", reportJson);
await WriteReport(options.OutputPath, assembledTemplate).ConfigureAwait(false);
}
- static async Task ReadReport(string path)
+ async Task ReadReport(string path)
{
using (var stream = File.OpenRead(path))
- using (var reader = new StreamReader(stream))
- {
- return await reader.ReadToEndAsync().ConfigureAwait(false);
- }
+ return await reportReader.DeserializeAsync(stream).ConfigureAwait(false);
+ }
+
+ async Task GetModifiedReportJsonAsync(ScreenplayReport report)
+ {
+ var stream = await reportWriter.SerializeAsync(report).ConfigureAwait(false);
+ using(var textReader = new StreamReader(stream))
+ return await textReader.ReadToEndAsync().ConfigureAwait(false);
}
- static async Task WriteReport(string path, string report)
+ async Task WriteReport(string path, string report)
{
using (var stream = File.Create(path))
using (var writer = new StreamWriter(stream))
@@ -42,9 +53,18 @@ static async Task WriteReport(string path, string report)
/// Initializes a new instance of the class.
///
/// The template reader used to get the HTML template.
- public ReportConverter(IGetsHtmlTemplate templateReader)
+ /// A service which embeds asset data into the JSON report.
+ /// A report deserializer
+ /// A report serializer
+ public ReportConverter(IGetsHtmlTemplate templateReader,
+ IEmbedsReportAssets assetsEmbedder,
+ IDeserializesReport reportReader,
+ ISerializesReport reportWriter)
{
this.templateReader = templateReader ?? throw new ArgumentNullException(nameof(templateReader));
+ this.assetsEmbedder = assetsEmbedder ?? throw new ArgumentNullException(nameof(assetsEmbedder));
+ this.reportReader = reportReader ?? throw new ArgumentNullException(nameof(reportReader));
+ this.reportWriter = reportWriter ?? throw new ArgumentNullException(nameof(reportWriter));
}
}
}
\ No newline at end of file
diff --git a/CSF.Screenplay.JsonToHtmlReport/ReportConverterOptions.cs b/CSF.Screenplay.JsonToHtmlReport/ReportConverterOptions.cs
index 9e3caaca..c6e057d2 100644
--- a/CSF.Screenplay.JsonToHtmlReport/ReportConverterOptions.cs
+++ b/CSF.Screenplay.JsonToHtmlReport/ReportConverterOptions.cs
@@ -1,3 +1,7 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
namespace CSF.Screenplay.JsonToHtmlReport
{
///
@@ -14,5 +18,73 @@ public class ReportConverterOptions
/// Gets or sets the file system path where the HTML report will be saved.
///
public string OutputPath { get; set; } = "ScreenplayReport.html";
+
+ ///
+ /// Gets or sets a threshold (in Kilobytes) for files which should be embedded into the report.
+ ///
+ ///
+ ///
+ /// By default, the report converter will attempt to embed file data into the HTML report file.
+ /// This is desirable because it means that the report file is likely to be portable as a single file, even when
+ /// . Any asset files of supported file extensions are
+ /// embedded into the HTML file if their file size (in kilobytes) is less than or equal to this value.
+ ///
+ ///
+ /// The default value for this property is 500 (500 kilobytes, half a megabyte). Setting this value to zero
+ /// or a negative number will disable embedding of files into the report.
+ /// Setting this value to an arbitrarily high number (such as 1000000, meaning a gigabyte) will cause all files to be
+ /// embedded.
+ ///
+ ///
+ /// The supported file extensions are listed in the option property .
+ ///
+ ///
+ public int EmbeddedFileSizeThresholdKb { get; set; } = 500;
+
+ ///
+ /// Gets or sets a comma-separated list of file extensions which are supported for embedding into report.
+ ///
+ ///
+ ///
+ /// By default, the report converter will attempt to embed file data into the HTML report file.
+ /// This is desirable because it means that the report file is likely to be portable as a single file, even when
+ /// . Any asset files with a size less than or equal to the threshold
+ /// are embedded into the HTML file if their file extension is amongst those listed in this property.
+ ///
+ ///
+ /// The default value for this property is jpg,jpeg,png,gif,webp,svg,mp4,mov,avi,wmv,mkv,webm.
+ /// These are common image and video file types, seen on the web.
+ /// Note that the wildcard *, if included anywhere this property value, denotes that files of all (any) extension
+ /// should be embedded into the report.
+ /// Setting this value to an empty string will disable embedding of files into the report.
+ ///
+ ///
+ /// The file-size threshold for files which may be embedded into the report is controlled by the option property
+ /// .
+ ///
+ ///
+ public string EmbeddedFileExtensions { get; set; } = "jpg,jpeg,png,gif,webp,svg,mp4,mov,avi,wmv,mkv,webm";
+
+ ///
+ /// Gets a collection of file extensions (including the leading period) which should be embedded into HTML reports.
+ ///
+ /// A collection of the extensions to embed
+ public IReadOnlyCollection GetEmbeddedFileExtensions()
+ {
+ if(string.IsNullOrWhiteSpace(EmbeddedFileExtensions)) return Array.Empty();
+ return EmbeddedFileExtensions.Split(',').Select(x => string.Concat(".", x.Trim())).ToArray();
+ }
+
+ ///
+ /// Gets a value indicating whether all file types (regardless of extension) should be embedded.
+ ///
+ ///
+ ///
+ /// This method returns if contains the character *.
+ /// Note that a file must still have a size equal to or less than to be embedded.
+ ///
+ ///
+ /// if all file types are to be embedded; if not.
+ public bool ShouldEmbedAllFileTypes() => EmbeddedFileExtensions.Contains('*');
}
}
\ No newline at end of file
diff --git a/CSF.Screenplay.JsonToHtmlReport/ServiceRegistrations.cs b/CSF.Screenplay.JsonToHtmlReport/ServiceRegistrations.cs
index a6d24289..ae2bee18 100644
--- a/CSF.Screenplay.JsonToHtmlReport/ServiceRegistrations.cs
+++ b/CSF.Screenplay.JsonToHtmlReport/ServiceRegistrations.cs
@@ -1,3 +1,4 @@
+using CSF.Screenplay.Reporting;
using Microsoft.Extensions.DependencyInjection;
namespace CSF.Screenplay.JsonToHtmlReport
@@ -19,8 +20,12 @@ public static class ServiceRegistrations
/// The service collection to which the services will be added.
public static void RegisterServices(IServiceCollection services)
{
- services.AddTransient();
- services.AddTransient();
+ services
+ .AddTransient()
+ .AddTransient()
+ .AddTransient()
+ .AddTransient()
+ .AddTransient();
}
}
}
\ No newline at end of file
diff --git a/CSF.Screenplay/CSF.Screenplay.csproj b/CSF.Screenplay/CSF.Screenplay.csproj
index 061a62ad..3b6f17e9 100644
--- a/CSF.Screenplay/CSF.Screenplay.csproj
+++ b/CSF.Screenplay/CSF.Screenplay.csproj
@@ -16,6 +16,10 @@
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
diff --git a/CSF.Screenplay/ReportModel/PerformableAsset.cs b/CSF.Screenplay/ReportModel/PerformableAsset.cs
index 75d8b2b9..b1a321cd 100644
--- a/CSF.Screenplay/ReportModel/PerformableAsset.cs
+++ b/CSF.Screenplay/ReportModel/PerformableAsset.cs
@@ -1,3 +1,5 @@
+using System.IO;
+
namespace CSF.Screenplay.ReportModel
{
///
@@ -5,19 +7,45 @@ namespace CSF.Screenplay.ReportModel
///
///
///
- /// Assets are files which are saved to disk, containing arbitrary information, recorded by a performable.
+ /// Assets are typically files which are saved to disk, containing arbitrary information, recorded by a performable.
/// This might be a screenshot, some generated content or diagnostic information. Its real content is arbitrary and
/// down to the implementation.
- /// An asset is described here by a file path and an optional human-readable summary.
+ ///
+ ///
+ /// Every asset has one or two properties in common, the and optionally .
+ /// The data for the asset file is then described by one of two ways:
+ ///
+ ///
+ /// The content of the asset is located on disk at a path indicated by
+ /// The content of the asset is embedded within this model as base64-encoded text, within
+ ///
+ ///
+ /// Typically when reports are first created, for expedience, assets are recorded to disk as files.
+ /// However, for portability, it is often useful to embed the asset data within the report so that the entire report may be transported as a single file.
///
///
public class PerformableAsset
{
+ ///
+ /// Gets the content type (aka MIME type) of the asset.
+ ///
+ public string ContentType { get; set; }
+
///
/// Gets or sets a full/absolute path to the asset file.
///
public string FilePath { get; set; }
+ ///
+ /// Gets or sets base64-encoded data which contains the data for the asset.
+ ///
+ public string FileData { get; set; }
+
+ ///
+ /// Gets or sets the name of the asset file, including its extension.
+ ///
+ public string FileName { get; set; }
+
///
/// Gets or sets an optional human-readable summary of what this asset represents. This should be one sentence at most, suitable
/// for display in a UI tool-tip.
diff --git a/CSF.Screenplay/Reporting/AssetPathProvider.cs b/CSF.Screenplay/Reporting/AssetPathProvider.cs
index fb793b5f..9edcf50b 100644
--- a/CSF.Screenplay/Reporting/AssetPathProvider.cs
+++ b/CSF.Screenplay/Reporting/AssetPathProvider.cs
@@ -1,5 +1,5 @@
using System;
-using System.Globalization;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using CSF.Screenplay.Performances;
@@ -36,6 +36,7 @@ namespace CSF.Screenplay.Reporting
///
public class AssetPathProvider : IGetsAssetFilePath
{
+ static readonly List invalidFilenameChars = Path.GetInvalidFileNameChars().Select(x => x.ToString()).ToList();
readonly IGetsReportPath reportPathProvider;
readonly IPerformance performance;
int assetNumber = 1;
@@ -55,14 +56,12 @@ public string GetAssetFilePath(string baseName)
var performanceId = performance.NamingHierarchy.LastOrDefault()?.Identifier ?? performance.PerformanceIdentity.ToString();
var sanitisedPerformanceId = RemoveInvalidFilenameChars(performanceId);
var sanitisedBaseFilename = RemoveInvalidFilenameChars(baseFilename);
- var filename = $"{GetTimestamp()}_{sanitisedPerformanceId}_{assetNumber++:000}_{sanitisedBaseFilename}";
- return Path.Combine(Path.GetDirectoryName(reportPath), filename);
+ var filename = $"{sanitisedPerformanceId}_{assetNumber++:000}_{sanitisedBaseFilename}";
+ return Path.Combine(reportPath, filename);
}
static string RemoveInvalidFilenameChars(string input)
- => Path.GetInvalidFileNameChars().Select(c => c.ToString()).Aggregate(input, (current, c) => current.Replace(c, string.Empty));
-
- static string GetTimestamp() => DateTime.UtcNow.ToString("yyyy-MM-ddTHHmmssZ", CultureInfo.InvariantCulture);
+ => invalidFilenameChars.Aggregate(input, (current, c) => current.Replace(c, string.Empty));
///
/// Initializes a new instance of the class.
diff --git a/CSF.Screenplay/Reporting/ContentTypeProvider.cs b/CSF.Screenplay/Reporting/ContentTypeProvider.cs
new file mode 100644
index 00000000..72b6860c
--- /dev/null
+++ b/CSF.Screenplay/Reporting/ContentTypeProvider.cs
@@ -0,0 +1,11 @@
+namespace CSF.Screenplay.Reporting
+{
+ ///
+ /// Implementation of which makes use of the MimeTypes NuGet package.
+ ///
+ public class ContentTypeProvider : IGetsContentType
+ {
+ ///
+ public string GetContentType(string fileName) => MimeTypes.GetMimeType(fileName);
+ }
+}
\ No newline at end of file
diff --git a/CSF.Screenplay/Reporting/IGetsContentType.cs b/CSF.Screenplay/Reporting/IGetsContentType.cs
new file mode 100644
index 00000000..60c96af2
--- /dev/null
+++ b/CSF.Screenplay/Reporting/IGetsContentType.cs
@@ -0,0 +1,15 @@
+namespace CSF.Screenplay.Reporting
+{
+ ///
+ /// An object which can get the MIME type for a given filename.
+ ///
+ public interface IGetsContentType
+ {
+ ///
+ /// Gets the content type (aka MIME type) for a specified filename.
+ ///
+ /// The filename
+ /// The content type
+ string GetContentType(string fileName);
+ }
+}
\ No newline at end of file
diff --git a/CSF.Screenplay/Reporting/ISerializesReport.cs b/CSF.Screenplay/Reporting/ISerializesReport.cs
new file mode 100644
index 00000000..42602827
--- /dev/null
+++ b/CSF.Screenplay/Reporting/ISerializesReport.cs
@@ -0,0 +1,19 @@
+using System.IO;
+using System.Threading.Tasks;
+using CSF.Screenplay.ReportModel;
+
+namespace CSF.Screenplay.Reporting
+{
+ ///
+ /// An object which serializes a Screenplay report into a stream.
+ ///
+ public interface ISerializesReport
+ {
+ ///
+ /// Serializes a Screenplay report into a stream asynchronously.
+ ///
+ /// A Screenplay report.
+ /// A task that represents the asynchronous operation. The task result contains the serialized report stream.
+ Task SerializeAsync(ScreenplayReport report);
+ }
+}
\ No newline at end of file
diff --git a/CSF.Screenplay/Reporting/JsonScreenplayReportReader.cs b/CSF.Screenplay/Reporting/JsonScreenplayReportReader.cs
index 27784d23..0f208591 100644
--- a/CSF.Screenplay/Reporting/JsonScreenplayReportReader.cs
+++ b/CSF.Screenplay/Reporting/JsonScreenplayReportReader.cs
@@ -8,17 +8,30 @@ namespace CSF.Screenplay.Reporting
{
///
- /// Implementation of that deserializes a Screenplay report from a JSON stream.
+ /// Implementation of and
+ /// which serializes and/or deserializes a Screenplay report to/from a JSON stream.
///
- public class JsonScreenplayReportReader : IDeserializesReport
+ public class ScreenplayReportSerializer : IDeserializesReport, ISerializesReport
{
///
public async Task DeserializeAsync(Stream stream)
{
- if (stream == null)
+ if (stream is null)
throw new ArgumentNullException(nameof(stream));
- return await JsonSerializer.DeserializeAsync(stream);
+ return await JsonSerializer.DeserializeAsync(stream).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task SerializeAsync(ScreenplayReport report)
+ {
+ if (report is null)
+ throw new ArgumentNullException(nameof(report));
+
+ var stream = new BufferedStream(new MemoryStream());
+ await JsonSerializer.SerializeAsync(stream, report).ConfigureAwait(false);
+ stream.Position = 0;
+ return stream;
}
}
}
\ No newline at end of file
diff --git a/CSF.Screenplay/Reporting/PerformanceReportBuilder.cs b/CSF.Screenplay/Reporting/PerformanceReportBuilder.cs
index d618c6e1..44b9398b 100644
--- a/CSF.Screenplay/Reporting/PerformanceReportBuilder.cs
+++ b/CSF.Screenplay/Reporting/PerformanceReportBuilder.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using CSF.Screenplay.Performables;
using CSF.Screenplay.Performances;
@@ -22,6 +23,7 @@ public class PerformanceReportBuilder
readonly Stack performableStack = new Stack();
readonly IGetsValueFormatter valueFormatterProvider;
readonly IFormatsReportFragment formatter;
+ readonly IGetsContentType contentTypeProvider;
///
/// Gets a value indicating whether or not this builder has a 'current' performable that it is building.
@@ -187,7 +189,17 @@ public void BeginPerformable(object performable, Actor actor, string performance
/// The file path to the asset
/// The human readable summary of the asset
public void RecordAssetForCurrentPerformable(string assetPath, string assetSummary)
- => CurrentPerformable.Assets.Add(new PerformableAsset { FilePath = assetPath, FileSummary = assetSummary });
+ {
+ var fileName = Path.GetFileName(assetPath);
+ var asset = new PerformableAsset
+ {
+ FilePath = assetPath,
+ FileSummary = assetSummary,
+ FileName = fileName,
+ ContentType = contentTypeProvider.GetContentType(fileName),
+ };
+ CurrentPerformable.Assets.Add(asset);
+ }
///
/// Enriches the current performable with information about its result.
@@ -245,7 +257,7 @@ public void RecordFailureForCurrentPerformable(Exception exception)
performableStack.Pop();
}
-#endregion
+ #endregion
///
/// Initialises a new instance of .
@@ -253,17 +265,19 @@ public void RecordFailureForCurrentPerformable(Exception exception)
/// The naming hierarchy of the performance; see
/// A value formatter factory
/// A report-fragment formatter
+ /// A content type provider service
/// If any parameter is .
public PerformanceReportBuilder(List namingHierarchy,
IGetsValueFormatter valueFormatterProvider,
- IFormatsReportFragment formatter)
+ IFormatsReportFragment formatter,
+ IGetsContentType contentTypeProvider)
{
if (namingHierarchy is null)
throw new ArgumentNullException(nameof(namingHierarchy));
this.valueFormatterProvider = valueFormatterProvider ?? throw new ArgumentNullException(nameof(valueFormatterProvider));
this.formatter = formatter ?? throw new ArgumentNullException(nameof(formatter));
-
+ this.contentTypeProvider = contentTypeProvider ?? throw new ArgumentNullException(nameof(contentTypeProvider));
report = new PerformanceReport
{
NamingHierarchy = namingHierarchy.ToList(),
diff --git a/CSF.Screenplay/Reporting/ReportPathProvider.cs b/CSF.Screenplay/Reporting/ReportPathProvider.cs
index eb378b9d..7db689c0 100644
--- a/CSF.Screenplay/Reporting/ReportPathProvider.cs
+++ b/CSF.Screenplay/Reporting/ReportPathProvider.cs
@@ -15,7 +15,7 @@ namespace CSF.Screenplay.Reporting
///
///
/// If is a relative path then it is combined with the current working directory to form an
- /// absolute path, thus (if does not return null), its return value will always be an absolute path.
+ /// absolute path. Thus, if does not return null, its return value will always be an absolute path.
///
///
/// Because of the caching functionality, this class is stateful and should be used as a singleton.
@@ -28,14 +28,18 @@ public class ReportPathProvider : IGetsReportPath
bool hasCachedReportPath;
string cachedReportPath;
+ readonly object syncRoot = new object();
///
public string GetReportPath()
{
- if(!hasCachedReportPath)
+ lock(syncRoot)
{
- cachedReportPath = ShouldEnableReporting(out var reportPath) ? reportPath : null;
- hasCachedReportPath = true;
+ if(!hasCachedReportPath)
+ {
+ cachedReportPath = ShouldEnableReporting(out var reportPath) ? reportPath : null;
+ hasCachedReportPath = true;
+ }
}
return cachedReportPath;
diff --git a/CSF.Screenplay/Reporting/WritePermissionTester.cs b/CSF.Screenplay/Reporting/WritePermissionTester.cs
index edf664e5..5b1f0577 100644
--- a/CSF.Screenplay/Reporting/WritePermissionTester.cs
+++ b/CSF.Screenplay/Reporting/WritePermissionTester.cs
@@ -9,7 +9,7 @@ namespace CSF.Screenplay.Reporting
public class WritePermissionTester : ITestsPathForWritePermissions
{
///
- /// Gets a value indicating whether or not the current process has write permission to the specified file path.
+ /// Gets a value indicating whether or not the current process has write permission to the specified path.
///
///
///
diff --git a/CSF.Screenplay/ScreenplayOptions.cs b/CSF.Screenplay/ScreenplayOptions.cs
index 649d7c77..f2865602 100644
--- a/CSF.Screenplay/ScreenplayOptions.cs
+++ b/CSF.Screenplay/ScreenplayOptions.cs
@@ -61,24 +61,27 @@ public sealed class ScreenplayOptions
};
///
- /// Gets a file system path at which a Screenplay report file will be written.
+ /// Gets a file system directory path at which a Screenplay report will be written.
///
///
///
/// As a executes each , it accumulates data relating to those performances, via its reporting
- /// mechanism. This information is then written to a JSON-formatted report file, which is saved at the path specified by this property.
- /// Once the Screenplay has completed this file may be inspected, converted into a different format and otherwise used to learn-about and diagnose the
+ /// mechanism. This information is then written to a JSON-formatted report file, which is saved into a directory specified by this property.
+ /// Once the Screenplay has completed the file may be inspected, converted into a different format and otherwise used to learn-about and diagnose the
/// Screenplay.
///
///
- /// If this value is set to a relative file path, then it will be relative to the current working directory.
- /// If using Screenplay with a software testing integration, then this directory might not be easily determined.
+ /// This value must indicate a directory, and not a file path, as a Screenplay Report may comprise of many files.
+ /// If this value is set to a relative path, then it will be relative to the current working directory.
+ /// If using Screenplay with a software testing integration, then the current working directory might not be easily determined.
+ /// It is strongly recommended that each Screenplay run should create its own directory, so files for the same report are kept together.
+ /// As such, it is advised that the directory name should contain some form of time-based value which will differ upon each run.
///
///
- /// The default value for this property is a relative file path in the current working directory, using the filename ScreenplayReport_[timestamp].json
- /// where [timestamp] is replaced by the current UTC date & time in a format which is similar to ISO 8601, except that the : characters separating
- /// the hours, minutes and second are omitted. This is because they are typically not legal filename characters. A sample of a Screenplay Report filename using
- /// this default path is ScreenplayReport_2024-10-04T192345Z.json.
+ /// The default value for this property is a relative directory path in the current working directory, using the format ScreenplayReport_[timestamp].
+ /// The [timestamp] portion is replaced by the current UTC date & time in a format which is similar to ISO 8601, except that the : characters
+ /// separating the hours, minutes and second are omitted. This is because they are typically not legal path characters. A sample of a Screenplay Report path using
+ /// this convention is ScreenplayReport_2024-10-04T192345Z.
///
///
/// If this property is set to , or an empty/whitespace-only string, or if the path is not writable, then the reporting functionality
@@ -88,7 +91,7 @@ public sealed class ScreenplayOptions
/// At runtime, do not read this value directly; instead use an implementation of service to get the report path.
///
///
- public string ReportPath { get; set; } = $"ScreenplayReport_{DateTime.UtcNow.ToString("yyyy-MM-ddTHHmmssZ", CultureInfo.InvariantCulture)}.json";
+ public string ReportPath { get; set; } = $"ScreenplayReport_{DateTime.UtcNow.ToString("yyyy-MM-ddTHHmmssZ", CultureInfo.InvariantCulture)}";
///
/// An optional callback/action which exposes the various which may be subscribed-to in order to be notified
diff --git a/CSF.Screenplay/ScreenplayServiceCollectionExtensions.cs b/CSF.Screenplay/ScreenplayServiceCollectionExtensions.cs
index 7264a480..66b32186 100644
--- a/CSF.Screenplay/ScreenplayServiceCollectionExtensions.cs
+++ b/CSF.Screenplay/ScreenplayServiceCollectionExtensions.cs
@@ -48,9 +48,10 @@ public static IServiceCollection AddScreenplay(this IServiceCollection services)
.AddSingleton()
.AddSingleton(s =>
{
- var reportPath = s.GetRequiredService().GetReportPath();
+ var reportPath = s.GetRequiredService().GetReportFilePath();
if(reportPath is null) return new NoOpReporter();
+ Directory.CreateDirectory(Path.GetDirectoryName(reportPath));
var stream = File.Create(reportPath);
return ActivatorUtilities.CreateInstance(s, stream);
});
@@ -66,6 +67,7 @@ public static IServiceCollection AddScreenplay(this IServiceCollection services)
.AddTransient()
.AddTransient()
.AddTransient()
+ .AddTransient()
.AddTransient()
.AddTransient()
.AddTransient()
diff --git a/Tests/CSF.Screenplay.Tests/OptionsAttribute.cs b/Tests/CSF.Screenplay.Tests/OptionsAttribute.cs
new file mode 100644
index 00000000..f2199756
--- /dev/null
+++ b/Tests/CSF.Screenplay.Tests/OptionsAttribute.cs
@@ -0,0 +1,42 @@
+using System.Linq;
+using System.Reflection;
+using AutoFixture;
+using AutoFixture.Kernel;
+using Microsoft.Extensions.Options;
+
+namespace CSF.Screenplay;
+
+public class OptionsAttribute : CustomizeAttribute
+{
+ public override ICustomization GetCustomization(ParameterInfo parameter)
+ {
+ if(!parameter.ParameterType.IsGenericType || parameter.ParameterType.GetGenericTypeDefinition() != typeof(IOptions<>))
+ throw new ArgumentException($"The parameter type must be a generic type of {nameof(IOptions