From 973d2fdb3e35508fa7997fdce2fa04f43b977afe Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Thu, 26 Feb 2026 21:10:20 +0000 Subject: [PATCH 1/7] WIP #280 - Move asset files and reports into a folder --- .github/workflows/crossBrowserTesting.yml | 4 +- .github/workflows/dotnetCi.yml | 4 +- .../Reporting/IGetsReportPath.cs | 2 +- .../Reporting/ReportPathProviderExtensions.cs | 34 ++++++++++ CSF.Screenplay/Reporting/AssetPathProvider.cs | 11 ++-- .../Reporting/ReportPathProvider.cs | 12 ++-- .../Reporting/WritePermissionTester.cs | 2 +- CSF.Screenplay/ScreenplayOptions.cs | 23 ++++--- .../ScreenplayServiceCollectionExtensions.cs | 3 +- .../CSF.Screenplay.Tests/OptionsAttribute.cs | 42 ++++++++++++ .../Reporting/AssetPathProviderTests.cs | 64 +++++++++++++++++++ .../Reporting/ReportPathProviderTests.cs | 62 ++++++++++++++++++ 12 files changed, 236 insertions(+), 27 deletions(-) create mode 100644 CSF.Screenplay.Abstractions/Reporting/ReportPathProviderExtensions.cs create mode 100644 Tests/CSF.Screenplay.Tests/OptionsAttribute.cs create mode 100644 Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs create mode 100644 Tests/CSF.Screenplay.Tests/Reporting/ReportPathProviderTests.cs 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/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/ReportPathProvider.cs b/CSF.Screenplay/Reporting/ReportPathProvider.cs index eb378b9d..7a065602 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; + 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..c5b97a37 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); }); 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)}", nameof(parameter)); + + var genericType = parameter.ParameterType.GetGenericArguments().Single(); + var customizationType = typeof(OptionsCustomization<>).MakeGenericType(genericType); + return (ICustomization) Activator.CreateInstance(customizationType, parameter)!; + } +} + +public class OptionsCustomization(ParameterInfo param) : ICustomization where T : class, new() +{ + public void Customize(IFixture fixture) + { + var builder = new FilteringSpecimenBuilder(new OptionsSpecimenBuilder(), + new ParameterSpecification(param.ParameterType, param.Name)); + fixture.Customizations.Insert(0, builder); + } +} + +public class OptionsSpecimenBuilder : ISpecimenBuilder where T : class, new() +{ + public object Create(object request, ISpecimenContext context) + { + if(request is not ParameterInfo paramRequest || paramRequest.ParameterType != typeof(IOptions)) + return new NoSpecimen(); + + var options = new T(); + return Mock.Of>(x => x.Value == options); + } +} \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs b/Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs new file mode 100644 index 00000000..3e584189 --- /dev/null +++ b/Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs @@ -0,0 +1,64 @@ +using System.IO; + +namespace CSF.Screenplay.Reporting; + +[TestFixture, Parallelizable] +public class AssetPathProviderTests +{ + [Test, AutoMoqData] + public void GetAssetFilePathShouldThrowIfBaseNameIsNull(AssetPathProvider sut) + { + Assert.That(() => sut.GetAssetFilePath(null), Throws.ArgumentException); + } + + [Test, AutoMoqData] + public void GetAssetFilePathShouldThrowIfBaseNameIsEmpty(AssetPathProvider sut) + { + Assert.That(() => sut.GetAssetFilePath(null), Throws.ArgumentException); + } + + [Test, AutoMoqData] + public void GetAssetFilePathShouldThrowIfBaseNameIsADirectory(AssetPathProvider sut) + { + Assert.That(() => sut.GetAssetFilePath("aDirectory" + Path.DirectorySeparatorChar), Throws.ArgumentException); + } + + [Test, AutoMoqData] + public void GetAssetFilePathShouldReturnNullIfReportPathIsNull([Frozen] IGetsReportPath reportPathProvider, AssetPathProvider sut) + { + Mock.Get(reportPathProvider).Setup(x => x.GetReportPath()).Returns(() => null!); + Assert.That(() => sut.GetAssetFilePath("myAsset.png"), Is.Null); + } + + [Test, AutoMoqData] + public void GetAssetFilePathShouldReturnANameBasedOnTheReportPathAndPerformanceIdentifier([Frozen] IGetsReportPath reportPathProvider, + [Frozen] IPerformance performance, + AssetPathProvider sut) + { + Mock.Get(reportPathProvider).Setup(x => x.GetReportPath()).Returns(Path.Combine("foo", "bar")); + Mock.Get(performance).SetupGet(x => x.NamingHierarchy).Returns([new ("firstId")]); + Assert.That(() => sut.GetAssetFilePath("myAsset.png"), Is.EqualTo(Path.Combine("foo", "bar", "firstId_001_myAsset.png"))); + } + + [Test, AutoMoqData] + public void GetAssetFilePathShouldReturnANameBasedOnTheReportPathAndPerformanceIdIfNoPerformanceIdentifier([Frozen] IGetsReportPath reportPathProvider, + [Frozen] IPerformance performance, + AssetPathProvider sut, + Guid performanceId) + { + Mock.Get(reportPathProvider).Setup(x => x.GetReportPath()).Returns(Path.Combine("foo", "bar")); + Mock.Get(performance).SetupGet(x => x.NamingHierarchy).Returns([]); + Mock.Get(performance).SetupGet(x => x.PerformanceIdentity).Returns(performanceId); + Assert.That(() => sut.GetAssetFilePath("myAsset.png"), Is.EqualTo(Path.Combine("foo", "bar", $"{performanceId}_001_myAsset.png"))); + } + + [Test, AutoMoqData] + public void GetAssetFilePathShouldRemoveInvalidFilenameChars([Frozen] IGetsReportPath reportPathProvider, + [Frozen] IPerformance performance, + AssetPathProvider sut) + { + Mock.Get(reportPathProvider).Setup(x => x.GetReportPath()).Returns(Path.Combine("foo", "bar")); + Mock.Get(performance).SetupGet(x => x.NamingHierarchy).Returns([new ("first***Id")]); + Assert.That(() => sut.GetAssetFilePath("my***Asset.png"), Is.EqualTo(Path.Combine("foo", "bar", "firstId_001_myAsset.png"))); + } +} \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Tests/Reporting/ReportPathProviderTests.cs b/Tests/CSF.Screenplay.Tests/Reporting/ReportPathProviderTests.cs new file mode 100644 index 00000000..7a535e86 --- /dev/null +++ b/Tests/CSF.Screenplay.Tests/Reporting/ReportPathProviderTests.cs @@ -0,0 +1,62 @@ +using System.IO; +using Microsoft.Extensions.Options; +using NUnit.Framework.Internal; + +namespace CSF.Screenplay.Reporting; + +[TestFixture, Parallelizable] +public class ReportPathProviderTests +{ + [Test, AutoMoqData] + public void GetReportPathShouldReturnAbsolutePathIfSpecified([Frozen] ITestsPathForWritePermissions permissionsTester, + [Frozen, Options] IOptions opts, + ReportPathProvider sut) + { + var path = Path.Combine(Environment.CurrentDirectory, "myreport", "reporting_path"); + Mock.Get(permissionsTester).Setup(x => x.HasWritePermission(path)).Returns(true); + opts.Value.ReportPath = path; + Assert.That(() => sut.GetReportPath(), Is.EqualTo(path)); + } + + [Test, AutoMoqData] + public void GetReportPathShouldReturnAbsolutePathBasedOnCurrentDirIfRelativePathSpecified([Frozen] ITestsPathForWritePermissions permissionsTester, + [Frozen, Options] IOptions opts, + ReportPathProvider sut) + { + var path = Path.Combine("myreport", "reporting_path"); + Mock.Get(permissionsTester).Setup(x => x.HasWritePermission(Path.Combine(Environment.CurrentDirectory, "myreport", "reporting_path"))).Returns(true); + opts.Value.ReportPath = path; + Assert.That(() => sut.GetReportPath(), Is.EqualTo(Path.Combine(Environment.CurrentDirectory, "myreport", "reporting_path"))); + } + + [Test, AutoMoqData] + public void GetReportPathShouldReturnNullIfNoPathSpecified([Frozen] ITestsPathForWritePermissions permissionsTester, + [Frozen, Options] IOptions opts, + ReportPathProvider sut) + { + opts.Value.ReportPath = null; + Assert.That(() => sut.GetReportPath(), Is.Null); + } + + [Test, AutoMoqData] + public void GetReportPathShouldReturnNullIfNoPermissionsAvailable([Frozen] ITestsPathForWritePermissions permissionsTester, + [Frozen, Options] IOptions opts, + ReportPathProvider sut) + { + var path = Path.Combine("myreport", "reporting_path"); + Mock.Get(permissionsTester).Setup(x => x.HasWritePermission(Path.Combine(Environment.CurrentDirectory, "myreport", "reporting_path"))).Returns(false); + opts.Value.ReportPath = path; + Assert.That(() => sut.GetReportPath(), Is.Null); + } + + [Test, AutoMoqData] + public void GetReportFilePathShouldReturnFilePath([Frozen] ITestsPathForWritePermissions permissionsTester, + [Frozen, Options] IOptions opts, + ReportPathProvider sut) + { + var path = Path.Combine(Environment.CurrentDirectory, "myreport", "reporting_path"); + Mock.Get(permissionsTester).Setup(x => x.HasWritePermission(path)).Returns(true); + opts.Value.ReportPath = path; + Assert.That(() => sut.GetReportFilePath(), Is.EqualTo(Path.Combine(Environment.CurrentDirectory, "myreport", "reporting_path", "ScreenplayReport.json"))); + } +} \ No newline at end of file From 01935e47800784e587ba0a2f6d0aa71a9da7361e Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Thu, 26 Feb 2026 21:50:04 +0000 Subject: [PATCH 2/7] #208 change test Common illegal characters for operating systems. --- .../CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs b/Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs index 3e584189..4119a5b4 100644 --- a/Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs +++ b/Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs @@ -58,7 +58,7 @@ public void GetAssetFilePathShouldRemoveInvalidFilenameChars([Frozen] IGetsRepor AssetPathProvider sut) { Mock.Get(reportPathProvider).Setup(x => x.GetReportPath()).Returns(Path.Combine("foo", "bar")); - Mock.Get(performance).SetupGet(x => x.NamingHierarchy).Returns([new ("first***Id")]); - Assert.That(() => sut.GetAssetFilePath("my***Asset.png"), Is.EqualTo(Path.Combine("foo", "bar", "firstId_001_myAsset.png"))); + Mock.Get(performance).SetupGet(x => x.NamingHierarchy).Returns([new ("first///Id")]); + Assert.That(() => sut.GetAssetFilePath("my///Asset.png"), Is.EqualTo(Path.Combine("foo", "bar", "firstId_001_myAsset.png"))); } } \ No newline at end of file From 73610cb37b1f9dd1d8a72fdf2f7f1213db2b5025 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Thu, 26 Feb 2026 22:00:48 +0000 Subject: [PATCH 3/7] #208 - fix test --- Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs b/Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs index 4119a5b4..6b67bb5f 100644 --- a/Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs +++ b/Tests/CSF.Screenplay.Tests/Reporting/AssetPathProviderTests.cs @@ -59,6 +59,6 @@ public void GetAssetFilePathShouldRemoveInvalidFilenameChars([Frozen] IGetsRepor { Mock.Get(reportPathProvider).Setup(x => x.GetReportPath()).Returns(Path.Combine("foo", "bar")); Mock.Get(performance).SetupGet(x => x.NamingHierarchy).Returns([new ("first///Id")]); - Assert.That(() => sut.GetAssetFilePath("my///Asset.png"), Is.EqualTo(Path.Combine("foo", "bar", "firstId_001_myAsset.png"))); + Assert.That(() => sut.GetAssetFilePath("myAsset.png"), Is.EqualTo(Path.Combine("foo", "bar", "firstId_001_myAsset.png"))); } } \ No newline at end of file From 6f46d71d9d23089a0e4ab5c80c2e8c0917417dc5 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Fri, 27 Feb 2026 16:58:55 +0000 Subject: [PATCH 4/7] #280 - fix code quality issue --- CSF.Screenplay/Reporting/ReportPathProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CSF.Screenplay/Reporting/ReportPathProvider.cs b/CSF.Screenplay/Reporting/ReportPathProvider.cs index 7a065602..7db689c0 100644 --- a/CSF.Screenplay/Reporting/ReportPathProvider.cs +++ b/CSF.Screenplay/Reporting/ReportPathProvider.cs @@ -28,7 +28,7 @@ public class ReportPathProvider : IGetsReportPath bool hasCachedReportPath; string cachedReportPath; - object syncRoot = new object(); + readonly object syncRoot = new object(); /// public string GetReportPath() From b2d8f2bca8d1845c57d74101a24ee4e036d3eb34 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Fri, 27 Feb 2026 17:37:14 +0000 Subject: [PATCH 5/7] Trivial - improve config readability --- .../src/webpack.config.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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({ From 55e731deb96bc5c127a6874efdad00cc44450734 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Fri, 27 Feb 2026 21:11:53 +0000 Subject: [PATCH 6/7] WIP #280 - .NET embedding of assets into reports --- .../AssetEmbedder.cs | 98 +++++++++++++++++++ .../CSF.Screenplay.JsonToHtmlReport.csproj | 5 +- .../IEmbedsReportAssets.cs | 20 ++++ .../ReportConverter.cs | 36 +++++-- .../ReportConverterOptions.cs | 72 ++++++++++++++ .../ServiceRegistrations.cs | 4 + .../ReportModel/PerformableAsset.cs | 27 ++++- CSF.Screenplay/Reporting/ISerializesReport.cs | 19 ++++ .../Reporting/JsonScreenplayReportReader.cs | 21 +++- .../Reporting/PerformanceReportBuilder.cs | 3 +- .../JsonScreenplayReportReaderTests.cs | 2 +- 11 files changed, 289 insertions(+), 18 deletions(-) create mode 100644 CSF.Screenplay.JsonToHtmlReport/AssetEmbedder.cs create mode 100644 CSF.Screenplay.JsonToHtmlReport/IEmbedsReportAssets.cs create mode 100644 CSF.Screenplay/Reporting/ISerializesReport.cs 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.0 Exe Library NU1903,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..8bf36799 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 @@ -21,6 +22,9 @@ public static void RegisterServices(IServiceCollection services) { services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); } } } \ No newline at end of file diff --git a/CSF.Screenplay/ReportModel/PerformableAsset.cs b/CSF.Screenplay/ReportModel/PerformableAsset.cs index 75d8b2b9..13cbc243 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,10 +7,21 @@ 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 @@ -18,6 +31,16 @@ public class PerformableAsset /// public string FilePath { get; set; } + /// + /// Gets or sets base64-encoded data which contains the data for the asset file. + /// + 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/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..8c7e1b50 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; @@ -187,7 +188,7 @@ 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 }); + => CurrentPerformable.Assets.Add(new PerformableAsset { FilePath = assetPath, FileSummary = assetSummary, FileName = Path.GetFileName(assetPath) }); /// /// Enriches the current performable with information about its result. diff --git a/Tests/CSF.Screenplay.Tests/Reporting/JsonScreenplayReportReaderTests.cs b/Tests/CSF.Screenplay.Tests/Reporting/JsonScreenplayReportReaderTests.cs index 38af09e2..64d08f14 100644 --- a/Tests/CSF.Screenplay.Tests/Reporting/JsonScreenplayReportReaderTests.cs +++ b/Tests/CSF.Screenplay.Tests/Reporting/JsonScreenplayReportReaderTests.cs @@ -38,7 +38,7 @@ public class JsonScreenplayReportReaderTests ]}"; [Test, AutoMoqData] - public async Task DeserializeAsyncShouldReturnAScreenplayReport(JsonScreenplayReportReader sut) + public async Task DeserializeAsyncShouldReturnAScreenplayReport(ScreenplayReportSerializer sut) { using var stream = new MemoryStream(Encoding.UTF8.GetBytes(reportJson)); var result = await sut.DeserializeAsync(stream); From 100956b0251eab5bbe0ea94d51133959dc62b658 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sat, 28 Feb 2026 09:14:50 +0000 Subject: [PATCH 7/7] Add content type info: #280 This will help when handling the file later. --- .../ServiceRegistrations.cs | 11 +++++----- CSF.Screenplay/CSF.Screenplay.csproj | 4 ++++ .../ReportModel/PerformableAsset.cs | 7 ++++++- .../Reporting/ContentTypeProvider.cs | 11 ++++++++++ CSF.Screenplay/Reporting/IGetsContentType.cs | 15 +++++++++++++ .../Reporting/PerformanceReportBuilder.cs | 21 +++++++++++++++---- .../ScreenplayServiceCollectionExtensions.cs | 1 + 7 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 CSF.Screenplay/Reporting/ContentTypeProvider.cs create mode 100644 CSF.Screenplay/Reporting/IGetsContentType.cs diff --git a/CSF.Screenplay.JsonToHtmlReport/ServiceRegistrations.cs b/CSF.Screenplay.JsonToHtmlReport/ServiceRegistrations.cs index 8bf36799..ae2bee18 100644 --- a/CSF.Screenplay.JsonToHtmlReport/ServiceRegistrations.cs +++ b/CSF.Screenplay.JsonToHtmlReport/ServiceRegistrations.cs @@ -20,11 +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(); - 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 13cbc243..b1a321cd 100644 --- a/CSF.Screenplay/ReportModel/PerformableAsset.cs +++ b/CSF.Screenplay/ReportModel/PerformableAsset.cs @@ -26,13 +26,18 @@ namespace CSF.Screenplay.ReportModel /// 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 file. + /// Gets or sets base64-encoded data which contains the data for the asset. /// public string FileData { get; set; } 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/PerformanceReportBuilder.cs b/CSF.Screenplay/Reporting/PerformanceReportBuilder.cs index 8c7e1b50..44b9398b 100644 --- a/CSF.Screenplay/Reporting/PerformanceReportBuilder.cs +++ b/CSF.Screenplay/Reporting/PerformanceReportBuilder.cs @@ -23,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. @@ -188,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, FileName = Path.GetFileName(assetPath) }); + { + 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. @@ -246,7 +257,7 @@ public void RecordFailureForCurrentPerformable(Exception exception) performableStack.Pop(); } -#endregion + #endregion /// /// Initialises a new instance of . @@ -254,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/ScreenplayServiceCollectionExtensions.cs b/CSF.Screenplay/ScreenplayServiceCollectionExtensions.cs index c5b97a37..66b32186 100644 --- a/CSF.Screenplay/ScreenplayServiceCollectionExtensions.cs +++ b/CSF.Screenplay/ScreenplayServiceCollectionExtensions.cs @@ -67,6 +67,7 @@ public static IServiceCollection AddScreenplay(this IServiceCollection services) .AddTransient() .AddTransient() .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddTransient()