diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..ddc68a7f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.png filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text 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..f44f8d96 100644 --- a/.github/workflows/dotnetCi.yml +++ b/.github/workflows/dotnetCi.yml @@ -48,6 +48,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + lfs: true # Install build dependencies @@ -165,11 +166,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/.gitignore b/.gitignore index e84dffd3..a523e9a5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,8 @@ node_modules/ *.orig /CSF.Screenplay.JsonToHtmlReport/template/ /CSF.Screenplay.JsonToHtmlReport.Template/src/output/ -**/ScreenplayReport* +**/ScreenplayReport_*/**/* +**/ScreenplayReport.html **/TestResult.xml .obsidian/ packages/ 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/css/litebox.css b/CSF.Screenplay.JsonToHtmlReport.Template/src/css/litebox.css new file mode 100644 index 00000000..f22c0a40 --- /dev/null +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/css/litebox.css @@ -0,0 +1,71 @@ +@layer layout { + #liteboxMask { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #00000044; + backdrop-filter: blur(3px); + display: none; + z-index: 100; + } + #liteboxMask.open { + display: block; + } + #litebox { + position: fixed; + top: 2em; + left: 2em; + right: 2em; + bottom: 2em; + background: #F0F0F0; + border-radius: 2em; + border: 1px solid #999; + box-shadow: 3px 3px 5px 2px #00000099; + padding: 1em; + display: flex; + flex-direction: column; + } + #litebox .controls { + display: flex; + height: 24px; + flex-direction: row; + align-items: start; + justify-content: end; + flex: 0 0 auto; + } + #litebox .controls button { + border: 0 none; + display: inline-block; + padding: 12px; + background: transparent center / contain no-repeat; + cursor: pointer; + margin-left: 12px; + } + #litebox .controls button:focus { + outline: 1px dotted #999; + } + #litebox .controls button span { + display: none; + } + #litebox .controls .download { + background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzcz0iYmkgYmktY2xvdWQtYXJyb3ctZG93bi1maWxsIiB2aWV3Qm94PSIwIDAgMTYgMTYiPgogIDxwYXRoIGQ9Ik04IDJhNS41MyA1LjUzIDAgMCAwLTMuNTk0IDEuMzQyYy0uNzY2LjY2LTEuMzIxIDEuNTItMS40NjQgMi4zODNDMS4yNjYgNi4wOTUgMCA3LjU1NSAwIDkuMzE4IDAgMTEuMzY2IDEuNzA4IDEzIDMuNzgxIDEzaDguOTA2QzE0LjUwMiAxMyAxNiAxMS41NyAxNiA5Ljc3M2MwLTEuNjM2LTEuMjQyLTIuOTY5LTIuODM0LTMuMTk0QzEyLjkyMyAzLjk5OSAxMC42OSAyIDggMm0yLjM1NCA2Ljg1NC0yIDJhLjUuNSAwIDAgMS0uNzA4IDBsLTItMmEuNS41IDAgMSAxIC43MDgtLjcwOEw3LjUgOS4yOTNWNS41YS41LjUgMCAwIDEgMSAwdjMuNzkzbDEuMTQ2LTEuMTQ3YS41LjUgMCAwIDEgLjcwOC43MDgiLz4KPC9zdmc+); + } + #litebox .controls .close { + background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzcz0iYmkgYmkteC1jaXJjbGUiIHZpZXdCb3g9IjAgMCAxNiAxNiI+CiAgPHBhdGggZD0iTTggMTVBNyA3IDAgMSAxIDggMWE3IDcgMCAwIDEgMCAxNG0wIDFBOCA4IDAgMSAwIDggMGE4IDggMCAwIDAgMCAxNiIvPgogIDxwYXRoIGQ9Ik00LjY0NiA0LjY0NmEuNS41IDAgMCAxIC43MDggMEw4IDcuMjkzbDIuNjQ2LTIuNjQ3YS41LjUgMCAwIDEgLjcwOC43MDhMOC43MDcgOGwyLjY0NyAyLjY0NmEuNS41IDAgMCAxLS43MDguNzA4TDggOC43MDdsLTIuNjQ2IDIuNjQ3YS41LjUgMCAwIDEtLjcwOC0uNzA4TDcuMjkzIDggNC42NDYgNS4zNTRhLjUuNSAwIDAgMSAwLS43MDgiLz4KPC9zdmc+); + } + #litebox .content { + margin: 12px 0; + flex: 1 1 0; + object-fit: contain; + min-width: 0; + min-height: 0; + max-width: 100%; + max-height: 100%; + } + #litebox .summary { + flex: 0 0 auto; + text-align: center; + } +} \ No newline at end of file diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/css/spinner.css b/CSF.Screenplay.JsonToHtmlReport.Template/src/css/spinner.css index 5559ed43..e126f03d 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/css/spinner.css +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/css/spinner.css @@ -1,5 +1,5 @@ @layer spinner { - #pageMask { + #loadingMask { position: fixed; top: 0; left: 0; diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/index.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/index.js index a46e047d..f3755291 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/index.js +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/index.js @@ -5,16 +5,19 @@ import "./css/layout.css"; import "./css/content.css"; import "./css/summaryTable.css"; import "./css/scenarioList.css"; +import "./css/litebox.css"; import { getReportLoader } from "./js/ReportLoader"; import { activatePage } from "./js/activatePage"; import { updateReportTime } from "./js/updateReportTime"; import { getScenarioAggregator } from "./js/ScenarioAggregator"; import { getSummaryGenerator } from "./js/SummaryGenerator"; import { getReportWriter } from "./js/ReportWriter"; +import { Litebox } from "./js/Litebox"; document.onreadystatechange = () => { if (document.readyState !== "complete") return; + const litebox = new Litebox(); const loader = getReportLoader(); const report = loader.loadJson(); @@ -26,7 +29,7 @@ document.onreadystatechange = () => { const summaryGenerator = getSummaryGenerator(scenariosByFeature); const summary = summaryGenerator.generateSummary(); const reportWriter = getReportWriter(); - const featureReport = reportWriter.getReport(scenariosByFeature); + const featureReport = reportWriter.getReport(scenariosByFeature, litebox); activatePage(summary, featureReport); } diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/Litebox.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/Litebox.js new file mode 100644 index 00000000..8c50dccd --- /dev/null +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/Litebox.js @@ -0,0 +1,57 @@ +import { getElementById } from './getElementById'; + +const + maskId = 'liteboxMask', + downloadSelector = '#litebox .download', + closeSelector = '#litebox .close', + contentSelector = '#litebox .content', + summarySelector = '#litebox .summary'; + +export class Litebox { + #main; + #download; + #close; + #content; + #summary; + #imageUrl; + #isBlobUrl; + #fileName; + #downloadHandler; + + open(imageUrl, filename, summary, isBlobUrl) { + this.#imageUrl = imageUrl; + this.#isBlobUrl = isBlobUrl; + this.#main.classList.add('open'); + + this.#fileName = filename; + this.#summary.textContent = summary; + this.#content.src = this.#imageUrl; + this.#content.title = filename; + this.#downloadHandler = () => this.#onDownloadClick(); + this.#download.addEventListener('click', this.#downloadHandler); + } + + close() { + this.#main.classList.remove('open'); + if(this.#isBlobUrl) { + URL.revokeObjectURL(this.#imageUrl); + } + this.#download.removeEventListener('click', this.#downloadHandler); + } + + #onDownloadClick() { + const hyperLink = document.createElement('a'); + hyperLink.href = this.#imageUrl; + hyperLink.download = this.#fileName; + hyperLink.click(); + } + + constructor() { + this.#main = getElementById(maskId); + this.#download = document.querySelector(downloadSelector); + this.#close = document.querySelector(closeSelector); + this.#close.addEventListener('click', () => this.close()); + this.#content = document.querySelector(contentSelector); + this.#summary = document.querySelector(summarySelector); + } +} \ No newline at end of file diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/Litebox.spec.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/Litebox.spec.js new file mode 100644 index 00000000..1697ddea --- /dev/null +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/Litebox.spec.js @@ -0,0 +1,61 @@ +/** + * @jest-environment jsdom +*/ + +import { Litebox } from "./Litebox"; + +test('open should show the mask', () => { + document.body.innerHTML = htmlContent; + const sut = new Litebox(); + + sut.open('foo/bar.png', 'bar.png', 'test image', false); + + const hasOpenClass = document.querySelector('#liteboxMask').classList.contains('open'); + expect(hasOpenClass).toBe(true); +}); + +test('open should set the summary using the image summary', () => { + document.body.innerHTML = htmlContent; + const sut = new Litebox(); + + sut.open('foo/bar.png', 'bar.png', 'test image', false); + + const summaryText = document.querySelector('#liteboxMask .summary').textContent; + expect(summaryText).toBe('test image'); +}); + +test('open should set the image src and title', () => { + document.body.innerHTML = htmlContent; + const sut = new Litebox(); + + sut.open('foo/bar.png', 'bar.png', 'test image', false); + + const src = document.querySelector('#liteboxMask .content').src; + expect(src).toMatch(/foo\/bar.png$/); + + const title = document.querySelector('#liteboxMask .content').title; + expect(title).toBe('bar.png'); +}); + +test('close should remove the open class', () => { + document.body.innerHTML = htmlContent; + document.querySelector('#liteboxMask').classList.add('open'); + const sut = new Litebox(); + + sut.open('foo/bar.png', 'bar.png', 'test image', false); + sut.close(); + + const hasOpenClass = document.querySelector('#liteboxMask').classList.contains('open'); + expect(hasOpenClass).toBe(false); +}); + +const htmlContent = `
+
+
+ + +
+ +

Asset summary

+
+
`; \ No newline at end of file diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportBehaviour/AssetBehaviour.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportBehaviour/AssetBehaviour.js new file mode 100644 index 00000000..862a0f14 --- /dev/null +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportBehaviour/AssetBehaviour.js @@ -0,0 +1,54 @@ +import { imageTypes } from "./ImageTypes"; + +export class AssetBehaviour { + #litebox; + #linkElement; + #assetModel; + + initialise() { + this.#linkElement.textContent = this.#assetModel.FileSummary; + + if(imageTypes.includes(this.#assetModel.ContentType)) + this.#initialiseImage(); + else + this.#initialiseDownload(); + } + + #initialiseImage() { + this.#linkElement.addEventListener('click', ev => { + const usesBlobUrl = !!this.#assetModel.FileData + const url = usesBlobUrl ? this.#getBlobUrl() : this.#assetModel.FilePath; + this.#litebox.open(url, this.#assetModel.FileName, this.#assetModel.FileSummary, usesBlobUrl); + ev.preventDefault(); + }); + this.#linkElement.href = null; + } + + #initialiseDownload() { + const usesBlobUrl = !!this.#assetModel.FileData + const url = usesBlobUrl ? this.#getBlobUrl() : this.#assetModel.FilePath; + this.#linkElement.href = url; + this.#linkElement.download = this.#assetModel.FileName; + } + + #getBlobUrl() { + const dataBytes = this.#base64ToBytes(this.#assetModel.FileData); + const blob = new Blob([dataBytes], { type: this.#assetModel.ContentType }); + return URL.createObjectURL(blob); + } + + #base64ToBytes(base64Data) { + const binaryData = atob(base64Data); + const buffer = new Uint8Array(binaryData.length); + for (let i = 0; i < binaryData.length; i++) { + buffer[i] = binaryData.codePointAt(i); + } + return buffer; + } + + constructor(litebox, element, assetModel) { + this.#litebox = litebox; + this.#linkElement = element.querySelector('a') + this.#assetModel = assetModel; + } +} \ No newline at end of file diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportBehaviour/ImageTypes.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportBehaviour/ImageTypes.js new file mode 100644 index 00000000..6662dc8f --- /dev/null +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportBehaviour/ImageTypes.js @@ -0,0 +1,13 @@ +/** + * A list of image MIME types which browsers can display natively. + */ +export const imageTypes = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', + 'image/bmp', + 'image/x-icon', + 'image/vnd.microsoft.icon' +]; \ No newline at end of file diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportBehaviour/index.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportBehaviour/index.js new file mode 100644 index 00000000..0c527a07 --- /dev/null +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportBehaviour/index.js @@ -0,0 +1,2 @@ +export { AssetBehaviour } from "./AssetBehaviour"; +export { imageTypes } from "./ImageTypes"; \ No newline at end of file diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportLoader.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportLoader.js index 8b33e496..07987aec 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportLoader.js +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportLoader.js @@ -19,9 +19,20 @@ export class ReportLoader { const jsonData = JSON.parse(scriptElement.textContent); return jsonData; } catch (error) { - throw new Error('Failed to parse JSON content whilst loading a Screenplay report', { cause: error }); + console.error('Failed to parse JSON content whilst loading a Screenplay report', error); + return this.#getEmptyReport(); } } + + #getEmptyReport() { + return { + Metadata: { + Timestamp: new Date(), + ReportFormatVersion: "0.0.0", + }, + Performances: [], + }; + } } export function getReportLoader() { diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportLoader.spec.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportLoader.spec.js index 4b298d57..f4ad3b15 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportLoader.spec.js +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportLoader.spec.js @@ -20,10 +20,18 @@ test('ReportLoader should throw an error if the specified script element is not expect(() => reportLoader.loadJson()).toThrowError('Element with id scriptId not found'); }); -test('ReportLoader should throw an error if the specified script element does not contain valid JSON', () => { +test('ReportLoader should return an empty report if the specified script element does not contain valid JSON', () => { const scriptElement = { textContent: 'invalid json' }; getElementById.mockReturnValue(scriptElement); const reportLoader = new ReportLoader('scriptId'); - expect(() => reportLoader.loadJson()).toThrowError('Failed to parse JSON content'); + const jsonData = reportLoader.loadJson(); + + expect(jsonData).toEqual({ + Metadata: { + Timestamp: new Date(), + ReportFormatVersion: "0.0.0", + }, + Performances: [], + }); }); \ No newline at end of file diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/AssetsWriter.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/AssetsWriter.js new file mode 100644 index 00000000..2aea6afa --- /dev/null +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/AssetsWriter.js @@ -0,0 +1,36 @@ +import { AssetBehaviour } from "../ReportBehaviour"; +import { getElementById } from "../getElementById"; + +export class AssetsWriter { + #litebox; + #reportableElement; + #assetTemplateElement; + + writeAssets(reportable) { + const assetsRootElement = this.#reportableElement.querySelector('.assets'); + + if (!reportable.Assets?.length) { + assetsRootElement.remove(); + return; + } + + const assetsElement = assetsRootElement.querySelector('ul'); + + for (const asset of reportable.Assets) { + const assetElement = this.#assetTemplateElement.content.cloneNode(true); + const behaviour = new AssetBehaviour(this.#litebox, assetElement, asset); + assetsElement.appendChild(assetElement); + behaviour.initialise(); + } + } + + constructor(litebox, reportableElement, assetTemplateElement) { + this.#litebox = litebox; + this.#reportableElement = reportableElement; + this.#assetTemplateElement = assetTemplateElement; + } +} + +export function getAssetsWriter(reportableElement, litebox) { + return new AssetsWriter(litebox, reportableElement, getElementById('assetTemplate')); +} \ No newline at end of file diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/FeatureElementCreator.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/FeatureElementCreator.js index 18b8e8d7..de958cb8 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/FeatureElementCreator.js +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/FeatureElementCreator.js @@ -7,7 +7,7 @@ export class FeatureElementCreator { this.scenarioElementCreator = scenarioElementCreator; } - createFeatureElement(feature) { + createFeatureElement(feature, litebox) { const featureElement = this.featureTemplate.content.cloneNode(true); const hasFailures = feature.scenarios.some(x => x.performance.Outcome == 'Failed'); @@ -26,7 +26,7 @@ export class FeatureElementCreator { const scenariosElement = featureElement.querySelector('.scenarioList'); for (const scenario of feature.scenarios) { - const scenarioElement = this.scenarioElementCreator.createScenarioElement(scenario); + const scenarioElement = this.scenarioElementCreator.createScenarioElement(scenario, litebox); scenariosElement.appendChild(scenarioElement); } return featureElement; diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportWriter.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportWriter.js index 4b0c0c2f..4377596d 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportWriter.js +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportWriter.js @@ -5,14 +5,14 @@ export class ReportWriter { this.featureElementCreator = featureElementCreator; } - getReport(scenariosByFeature) { + getReport(scenariosByFeature, litebox) { const report = document.createDocumentFragment(); for (const feature in scenariosByFeature.features) { - const featureElement = this.featureElementCreator.createFeatureElement(scenariosByFeature.features[feature]); + const featureElement = this.featureElementCreator.createFeatureElement(scenariosByFeature.features[feature], litebox); report.appendChild(featureElement); } if (scenariosByFeature.noFeatureScenarios.scenarios.length) { - const featureElement = this.featureElementCreator.createFeatureElement(scenariosByFeature.noFeatureScenarios); + const featureElement = this.featureElementCreator.createFeatureElement(scenariosByFeature.noFeatureScenarios, litebox); report.appendChild(featureElement); } return report; diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportWriter.spec.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportWriter.spec.js index 35451b20..dc09ef2f 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportWriter.spec.js +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportWriter.spec.js @@ -68,11 +68,13 @@ const testData = { "Assets": [ { "FilePath": "abc.txt", - "FileSummary": "A text file" + "FileSummary": "A text file", + "ContentType": "text/plain" }, { "FilePath": "one.png", - "FileSummary": "A picture" + "FileSummary": "A picture", + "ContentType": "image/png" } ], "Reportables": [], diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportableElementCreator.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportableElementCreator.js index b330a690..b8ff4d24 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportableElementCreator.js +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportableElementCreator.js @@ -1,13 +1,13 @@ import { setContentOrRemove } from './setContentOrRemove'; import { getElementById } from '../getElementById'; +import { getAssetsWriter } from './AssetsWriter'; export class ReportableElementCreator { - constructor(templateElement, assetTemplateElement) { + constructor(templateElement) { this.templateElement = templateElement; - this.assetTemplate = assetTemplateElement; } - createReportableElement(reportable) { + createReportableElement(reportable, litebox) { const reportableElement = this.templateElement.content.cloneNode(true); this.#setupReportableType(reportableElement, reportable); @@ -16,8 +16,8 @@ export class ReportableElementCreator { setContentOrRemove(reportableElement, reportable, '.exception', r => r.Exception && !r.ExceptionIsFromConsumedPerformable, r => r.Exception); this.#setupResult(reportableElement, reportable); this.#setupPerformableType(reportableElement, reportable); - this.#setupAssets(reportableElement, reportable); - this.#setupContainedReportables(reportableElement, reportable); + this.#setupAssets(reportableElement, reportable, litebox); + this.#setupContainedReportables(reportableElement, reportable, litebox); return reportableElement; } @@ -60,25 +60,12 @@ export class ReportableElementCreator { else performableTypeElement.remove() } - #setupAssets(reportableElement, reportable) { - const assetsRootElement = reportableElement.querySelector('.assets'); - if (!reportable.Assets?.length) { - assetsRootElement.remove(); - } - else { - const assetsElement = assetsRootElement.querySelector('ul'); - - for (const asset of reportable.Assets) { - const assetElement = this.assetTemplate.content.cloneNode(true); - const assetLinkElement = assetElement.querySelector('a'); - assetLinkElement.textContent = asset.FileSummary; - assetLinkElement.href = asset.FilePath; - assetsElement.appendChild(assetElement); - } - } + #setupAssets(reportableElement, reportable, litebox) { + const assetsWriter = getAssetsWriter(reportableElement, litebox); + assetsWriter.writeAssets(reportable); } - #setupContainedReportables(reportableElement, reportable) { + #setupContainedReportables(reportableElement, reportable, litebox) { const containedReportablesElement = reportableElement.querySelector('.reportableList'); if (!reportable.Reportables?.length) { reportableElement.firstElementChild.classList.remove('collapsed'); @@ -94,13 +81,12 @@ export class ReportableElementCreator { for (const containedReportable of reportable.Reportables) { - const reportableElement = this.createReportableElement(containedReportable); + const reportableElement = this.createReportableElement(containedReportable, litebox); containedReportablesElement.appendChild(reportableElement); } } } export function getReportableElementCreator() { - return new ReportableElementCreator(getElementById('reportableTemplate'), - getElementById('assetTemplate')); + return new ReportableElementCreator(getElementById('reportableTemplate')); } \ No newline at end of file diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ScenarioElementCreator.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ScenarioElementCreator.js index 5564409b..4ac75003 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ScenarioElementCreator.js +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ScenarioElementCreator.js @@ -8,7 +8,7 @@ export class ScenarioElementCreator { this.reportableElementCreator = reportableElementCreator; } - createScenarioElement(scenario) { + createScenarioElement(scenario, litebox) { const scenarioElement = this.scenarioTemplate.content.cloneNode(true); if(scenario.performance.Outcome == 'Failed') @@ -21,7 +21,7 @@ export class ScenarioElementCreator { const reportablesElement = scenarioElement.querySelector('.reportableList'); for (const reportable of scenario.performance.Reportables) { - const reportableElement = this.reportableElementCreator.createReportableElement(reportable); + const reportableElement = this.reportableElementCreator.createReportableElement(reportable, litebox); reportablesElement.appendChild(reportableElement); } diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/activatePage.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/activatePage.js index 6f112d51..2218e259 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/activatePage.js +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/activatePage.js @@ -13,5 +13,5 @@ export const activatePage = (summaryTable, featureList) => { showFeatures(); }; -const hideSpinner = () => getElementById("pageMask").classList.add("hidden"); +const hideSpinner = () => getElementById("loadingMask").classList.add("hidden"); const showFeatures = () => getElementById("features").classList.remove("hidden"); diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/activatePage.spec.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/activatePage.spec.js index 5a7f7ca8..31e71f32 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/activatePage.spec.js +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/activatePage.spec.js @@ -21,7 +21,7 @@ test('activatePage should append the summary table and feature list to the speci return summaryElement; case "featureList": return featuresElement; - case "pageMask": + case "loadingMask": return pageMask; case "features": return features; diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/template.html b/CSF.Screenplay.JsonToHtmlReport.Template/src/template.html index a764df9c..d09262b5 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/template.html +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/template.html @@ -6,9 +6,19 @@ Screenplay report template -
+
+
+
+
+ + +
+ +

Asset summary

+
+

Screenplay report

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..6f4059e4 --- /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).ToList(); + 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 List GetAllPerformables(PerformanceReport performance) + { + var open = new List(); + var closed = new List(); + open.AddRange(performance.Reportables.OfType()); + + while(open.Count > 0) + { + var current = open[0]; + 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..902267b1 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,23 +12,32 @@ 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) @@ -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/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 deleted file mode 100644 index 27784d23..00000000 --- a/CSF.Screenplay/Reporting/JsonScreenplayReportReader.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.IO; -using System.Text.Json; -using System.Threading.Tasks; -using CSF.Screenplay.ReportModel; - -namespace CSF.Screenplay.Reporting -{ - - /// - /// Implementation of that deserializes a Screenplay report from a JSON stream. - /// - public class JsonScreenplayReportReader : IDeserializesReport - { - /// - public async Task DeserializeAsync(Stream stream) - { - if (stream == null) - throw new ArgumentNullException(nameof(stream)); - - return await JsonSerializer.DeserializeAsync(stream); - } - } -} \ No newline at end of file diff --git a/CSF.Screenplay/Reporting/MimeTypes.cs b/CSF.Screenplay/Reporting/MimeTypes.cs new file mode 100644 index 00000000..4aaafea1 --- /dev/null +++ b/CSF.Screenplay/Reporting/MimeTypes.cs @@ -0,0 +1,1291 @@ +// + +#pragma warning disable + +namespace CSF.Screenplay.Reporting +{ + using global::System; + using global::System.Linq; + using global::System.Collections.Generic; + using global::System.Diagnostics; + using global::System.Runtime.CompilerServices; + + /// + /// Provides utilities for mapping file names and extensions to MIME-types. + /// + /// + /// + /// Note that this file was copied directly from the + /// source code for the MimeTypes package. I had tried to include it via NuGet in the past but the mechanism seems to be incompatible + /// with SonarScanner. As such I have abandoned including it via NuGet and I'm switching to including the file manually. + /// Obviously, to upgrade this package, I will need to re-download and include this file again. + /// + /// + [CompilerGenerated] + [DebuggerNonUserCode] + public static class MimeTypes + { + private const string DefaultFallbackMimeType = "application/octet-stream"; + private static string s_fallbackMimeType; + + /// + /// The fallback MIME-type. Defaults to application/octet-stream. + /// + public static string FallbackMimeType + { + get => s_fallbackMimeType; + set => s_fallbackMimeType = value ?? DefaultFallbackMimeType; + } + + private static readonly Dictionary s_typeMap; + + static MimeTypes() + { + s_fallbackMimeType = DefaultFallbackMimeType; + + s_typeMap = new Dictionary(1180, StringComparer.OrdinalIgnoreCase) + { + { "123", "application/vnd.lotus-1-2-3" }, + { "1km", "application/vnd.1000minds.decision-model+xml" }, + { "3dml", "text/vnd.in3d.3dml" }, + { "3ds", "image/x-3ds" }, + { "3g2", "video/3gpp2" }, + { "3gp", "video/3gpp" }, + { "3gpp", "audio/3gpp" }, + { "3mf", "model/3mf" }, + { "7z", "application/x-7z-compressed" }, + { "aab", "application/x-authorware-bin" }, + { "aac", "audio/aac" }, + { "aam", "application/x-authorware-map" }, + { "aas", "application/x-authorware-seg" }, + { "abw", "application/x-abiword" }, + { "ac", "application/pkix-attr-cert" }, + { "acc", "application/vnd.americandynamics.acc" }, + { "ace", "application/x-ace-compressed" }, + { "acu", "application/vnd.acucobol" }, + { "acutc", "application/vnd.acucorp" }, + { "adp", "audio/adpcm" }, + { "adts", "audio/aac" }, + { "aep", "application/vnd.audiograph" }, + { "afm", "application/x-font-type1" }, + { "afp", "application/vnd.ibm.modcap" }, + { "age", "application/vnd.age" }, + { "ahead", "application/vnd.ahead.space" }, + { "ai", "application/postscript" }, + { "aif", "audio/x-aiff" }, + { "aifc", "audio/x-aiff" }, + { "aiff", "audio/x-aiff" }, + { "air", "application/vnd.adobe.air-application-installer-package+zip" }, + { "ait", "application/vnd.dvb.ait" }, + { "ami", "application/vnd.amiga.ami" }, + { "aml", "application/automationml-aml+xml" }, + { "amlx", "application/automationml-amlx+zip" }, + { "amr", "audio/amr" }, + { "apk", "application/vnd.android.package-archive" }, + { "apng", "image/apng" }, + { "appcache", "text/cache-manifest" }, + { "appx", "application/appx" }, + { "apr", "application/vnd.lotus-approach" }, + { "arc", "application/x-freearc" }, + { "arj", "application/x-arj" }, + { "asc", "application/pgp-keys" }, + { "asf", "video/x-ms-asf" }, + { "asm", "text/x-asm" }, + { "aso", "application/vnd.accpac.simply.aso" }, + { "asx", "video/x-ms-asf" }, + { "atc", "application/vnd.acucorp" }, + { "atom", "application/atom+xml" }, + { "atomcat", "application/atomcat+xml" }, + { "atomsvc", "application/atomsvc+xml" }, + { "atx", "application/vnd.antix.game-component" }, + { "au", "audio/basic" }, + { "avci", "image/avci" }, + { "avcs", "image/avcs" }, + { "avi", "video/x-msvideo" }, + { "avif", "image/avif" }, + { "aw", "application/applixware" }, + { "azf", "application/vnd.airzip.filesecure.azf" }, + { "azs", "application/vnd.airzip.filesecure.azs" }, + { "azv", "image/vnd.airzip.accelerator.azv" }, + { "azw", "application/vnd.amazon.ebook" }, + { "b16", "image/vnd.pco.b16" }, + { "bat", "application/x-msdownload" }, + { "bcpio", "application/x-bcpio" }, + { "bdf", "application/x-font-bdf" }, + { "bdm", "application/vnd.syncml.dm+wbxml" }, + { "bdoc", "application/bdoc" }, + { "bed", "application/vnd.realvnc.bed" }, + { "bh2", "application/vnd.fujitsu.oasysprs" }, + { "bin", "application/octet-stream" }, + { "blb", "application/x-blorb" }, + { "blorb", "application/x-blorb" }, + { "bmi", "application/vnd.bmi" }, + { "bmml", "application/vnd.balsamiq.bmml+xml" }, + { "bmp", "image/bmp" }, + { "book", "application/vnd.framemaker" }, + { "box", "application/vnd.previewsystems.box" }, + { "boz", "application/x-bzip2" }, + { "bpk", "application/octet-stream" }, + { "bsp", "model/vnd.valve.source.compiled-map" }, + { "btf", "image/prs.btif" }, + { "btif", "image/prs.btif" }, + { "buffer", "application/octet-stream" }, + { "bz", "application/x-bzip" }, + { "bz2", "application/x-bzip2" }, + { "c", "text/x-c" }, + { "c11amc", "application/vnd.cluetrust.cartomobile-config" }, + { "c11amz", "application/vnd.cluetrust.cartomobile-config-pkg" }, + { "c4d", "application/vnd.clonk.c4group" }, + { "c4f", "application/vnd.clonk.c4group" }, + { "c4g", "application/vnd.clonk.c4group" }, + { "c4p", "application/vnd.clonk.c4group" }, + { "c4u", "application/vnd.clonk.c4group" }, + { "cab", "application/vnd.ms-cab-compressed" }, + { "caf", "audio/x-caf" }, + { "cap", "application/vnd.tcpdump.pcap" }, + { "car", "application/vnd.curl.car" }, + { "cat", "application/vnd.ms-pki.seccat" }, + { "cb7", "application/x-cbr" }, + { "cba", "application/x-cbr" }, + { "cbr", "application/x-cbr" }, + { "cbt", "application/x-cbr" }, + { "cbz", "application/x-cbr" }, + { "cc", "text/x-c" }, + { "cco", "application/x-cocoa" }, + { "cct", "application/x-director" }, + { "ccxml", "application/ccxml+xml" }, + { "cdbcmsg", "application/vnd.contact.cmsg" }, + { "cdf", "application/x-netcdf" }, + { "cdfx", "application/cdfx+xml" }, + { "cdkey", "application/vnd.mediastation.cdkey" }, + { "cdmia", "application/cdmi-capability" }, + { "cdmic", "application/cdmi-container" }, + { "cdmid", "application/cdmi-domain" }, + { "cdmio", "application/cdmi-object" }, + { "cdmiq", "application/cdmi-queue" }, + { "cdx", "chemical/x-cdx" }, + { "cdxml", "application/vnd.chemdraw+xml" }, + { "cdy", "application/vnd.cinderella" }, + { "cer", "application/pkix-cert" }, + { "cfs", "application/x-cfs-compressed" }, + { "cgm", "image/cgm" }, + { "chat", "application/x-chat" }, + { "chm", "application/vnd.ms-htmlhelp" }, + { "chrt", "application/vnd.kde.kchart" }, + { "cif", "chemical/x-cif" }, + { "cii", "application/vnd.anser-web-certificate-issue-initiation" }, + { "cil", "application/vnd.ms-artgalry" }, + { "cjs", "application/node" }, + { "cla", "application/vnd.claymore" }, + { "class", "application/java-vm" }, + { "cld", "model/vnd.cld" }, + { "clkk", "application/vnd.crick.clicker.keyboard" }, + { "clkp", "application/vnd.crick.clicker.palette" }, + { "clkt", "application/vnd.crick.clicker.template" }, + { "clkw", "application/vnd.crick.clicker.wordbank" }, + { "clkx", "application/vnd.crick.clicker" }, + { "clp", "application/x-msclip" }, + { "cmc", "application/vnd.cosmocaller" }, + { "cmdf", "chemical/x-cmdf" }, + { "cml", "chemical/x-cml" }, + { "cmp", "application/vnd.yellowriver-custom-menu" }, + { "cmx", "image/x-cmx" }, + { "cod", "application/vnd.rim.cod" }, + { "coffee", "text/coffeescript" }, + { "com", "application/x-msdownload" }, + { "conf", "text/plain" }, + { "cpio", "application/x-cpio" }, + { "cpl", "application/cpl+xml" }, + { "cpp", "text/x-c" }, + { "cpt", "application/mac-compactpro" }, + { "crd", "application/x-mscardfile" }, + { "crl", "application/pkix-crl" }, + { "crt", "application/x-x509-ca-cert" }, + { "crx", "application/x-chrome-extension" }, + { "csh", "application/x-csh" }, + { "csl", "application/vnd.citationstyles.style+xml" }, + { "csml", "chemical/x-csml" }, + { "csp", "application/vnd.commonspace" }, + { "css", "text/css" }, + { "cst", "application/x-director" }, + { "csv", "text/csv" }, + { "cu", "application/cu-seeme" }, + { "curl", "text/vnd.curl" }, + { "cwl", "application/cwl" }, + { "cww", "application/prs.cww" }, + { "cxt", "application/x-director" }, + { "cxx", "text/x-c" }, + { "dae", "model/vnd.collada+xml" }, + { "daf", "application/vnd.mobius.daf" }, + { "dart", "application/vnd.dart" }, + { "dataless", "application/vnd.fdsn.seed" }, + { "davmount", "application/davmount+xml" }, + { "dbf", "application/vnd.dbf" }, + { "dbk", "application/docbook+xml" }, + { "dcr", "application/x-director" }, + { "dcurl", "text/vnd.curl.dcurl" }, + { "dd2", "application/vnd.oma.dd2+xml" }, + { "ddd", "application/vnd.fujixerox.ddd" }, + { "ddf", "application/vnd.syncml.dmddf+xml" }, + { "dds", "image/vnd.ms-dds" }, + { "deb", "application/octet-stream" }, + { "def", "text/plain" }, + { "deploy", "application/octet-stream" }, + { "der", "application/x-x509-ca-cert" }, + { "dfac", "application/vnd.dreamfactory" }, + { "dgc", "application/x-dgc-compressed" }, + { "dib", "image/bmp" }, + { "dic", "text/x-c" }, + { "dir", "application/x-director" }, + { "dis", "application/vnd.mobius.dis" }, + { "dist", "application/octet-stream" }, + { "distz", "application/octet-stream" }, + { "djv", "image/vnd.djvu" }, + { "djvu", "image/vnd.djvu" }, + { "dll", "application/octet-stream" }, + { "dmg", "application/octet-stream" }, + { "dmp", "application/vnd.tcpdump.pcap" }, + { "dms", "application/octet-stream" }, + { "dna", "application/vnd.dna" }, + { "doc", "application/msword" }, + { "docm", "application/vnd.ms-word.document.macroenabled.12" }, + { "docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, + { "dot", "application/msword" }, + { "dotm", "application/vnd.ms-word.template.macroenabled.12" }, + { "dotx", "application/vnd.openxmlformats-officedocument.wordprocessingml.template" }, + { "dp", "application/vnd.osgi.dp" }, + { "dpg", "application/vnd.dpgraph" }, + { "dpx", "image/dpx" }, + { "dra", "audio/vnd.dra" }, + { "drle", "image/dicom-rle" }, + { "dsc", "text/prs.lines.tag" }, + { "dssc", "application/dssc+der" }, + { "dtb", "application/x-dtbook+xml" }, + { "dtd", "application/xml-dtd" }, + { "dts", "audio/vnd.dts" }, + { "dtshd", "audio/vnd.dts.hd" }, + { "dump", "application/octet-stream" }, + { "dvb", "video/vnd.dvb.file" }, + { "dvi", "application/x-dvi" }, + { "dwd", "application/atsc-dwd+xml" }, + { "dwf", "model/vnd.dwf" }, + { "dwg", "image/vnd.dwg" }, + { "dxf", "image/vnd.dxf" }, + { "dxp", "application/vnd.spotfire.dxp" }, + { "dxr", "application/x-director" }, + { "ear", "application/java-archive" }, + { "ecma", "application/ecmascript" }, + { "edm", "application/vnd.novadigm.edm" }, + { "edx", "application/vnd.novadigm.edx" }, + { "efif", "application/vnd.picsel" }, + { "ei6", "application/vnd.pg.osasli" }, + { "elc", "application/octet-stream" }, + { "emf", "image/emf" }, + { "eml", "message/rfc822" }, + { "emma", "application/emma+xml" }, + { "emz", "application/x-msmetafile" }, + { "eol", "audio/vnd.digital-winds" }, + { "eot", "application/vnd.ms-fontobject" }, + { "eps", "application/postscript" }, + { "epub", "application/epub+zip" }, + { "es3", "application/vnd.eszigno3+xml" }, + { "esa", "application/vnd.osgi.subsystem" }, + { "esf", "application/vnd.epson.esf" }, + { "et3", "application/vnd.eszigno3+xml" }, + { "etx", "text/x-setext" }, + { "eva", "application/x-eva" }, + { "evy", "application/x-envoy" }, + { "exe", "application/x-msdos-program" }, + { "exi", "application/exi" }, + { "exp", "application/express" }, + { "exr", "image/aces" }, + { "ext", "application/vnd.novadigm.ext" }, + { "ez", "application/andrew-inset" }, + { "ez2", "application/vnd.ezpix-album" }, + { "ez3", "application/vnd.ezpix-package" }, + { "f", "text/x-fortran" }, + { "f4v", "video/x-f4v" }, + { "f77", "text/x-fortran" }, + { "f90", "text/x-fortran" }, + { "fbs", "image/vnd.fastbidsheet" }, + { "fcdt", "application/vnd.adobe.formscentral.fcdt" }, + { "fcs", "application/vnd.isac.fcs" }, + { "fdf", "application/fdf" }, + { "fdt", "application/fdt+xml" }, + { "fg5", "application/vnd.fujitsu.oasysgp" }, + { "fgd", "application/x-director" }, + { "fh", "image/x-freehand" }, + { "fh4", "image/x-freehand" }, + { "fh5", "image/x-freehand" }, + { "fh7", "image/x-freehand" }, + { "fhc", "image/x-freehand" }, + { "fig", "application/x-xfig" }, + { "fits", "image/fits" }, + { "flac", "audio/x-flac" }, + { "fli", "video/x-fli" }, + { "flo", "application/vnd.micrografx.flo" }, + { "flv", "video/x-flv" }, + { "flw", "application/vnd.kde.kivio" }, + { "flx", "text/vnd.fmi.flexstor" }, + { "fly", "text/vnd.fly" }, + { "fm", "application/vnd.framemaker" }, + { "fnc", "application/vnd.frogans.fnc" }, + { "fo", "application/vnd.software602.filler.form+xml" }, + { "for", "text/x-fortran" }, + { "fpx", "image/vnd.fpx" }, + { "frame", "application/vnd.framemaker" }, + { "fsc", "application/vnd.fsc.weblaunch" }, + { "fst", "image/vnd.fst" }, + { "ftc", "application/vnd.fluxtime.clip" }, + { "fti", "application/vnd.anser-web-funds-transfer-initiation" }, + { "fvt", "video/vnd.fvt" }, + { "fxp", "application/vnd.adobe.fxp" }, + { "fxpl", "application/vnd.adobe.fxp" }, + { "fzs", "application/vnd.fuzzysheet" }, + { "g2w", "application/vnd.geoplan" }, + { "g3", "image/g3fax" }, + { "g3w", "application/vnd.geospace" }, + { "gac", "application/vnd.groove-account" }, + { "gam", "application/x-tads" }, + { "gbr", "application/rpki-ghostbusters" }, + { "gca", "application/x-gca-compressed" }, + { "gdl", "model/vnd.gdl" }, + { "gdoc", "application/vnd.google-apps.document" }, + { "ged", "text/vnd.familysearch.gedcom" }, + { "geo", "application/vnd.dynageo" }, + { "geojson", "application/geo+json" }, + { "gex", "application/vnd.geometry-explorer" }, + { "ggb", "application/vnd.geogebra.file" }, + { "ggt", "application/vnd.geogebra.tool" }, + { "ghf", "application/vnd.groove-help" }, + { "gif", "image/gif" }, + { "gim", "application/vnd.groove-identity-message" }, + { "glb", "model/gltf-binary" }, + { "gltf", "model/gltf+json" }, + { "gml", "application/gml+xml" }, + { "gmx", "application/vnd.gmx" }, + { "gnumeric", "application/x-gnumeric" }, + { "gph", "application/vnd.flographit" }, + { "gpx", "application/gpx+xml" }, + { "gqf", "application/vnd.grafeq" }, + { "gqs", "application/vnd.grafeq" }, + { "gram", "application/srgs" }, + { "gramps", "application/x-gramps-xml" }, + { "gre", "application/vnd.geometry-explorer" }, + { "grv", "application/vnd.groove-injector" }, + { "grxml", "application/srgs+xml" }, + { "gsf", "application/x-font-ghostscript" }, + { "gsheet", "application/vnd.google-apps.spreadsheet" }, + { "gslides", "application/vnd.google-apps.presentation" }, + { "gtar", "application/x-gtar" }, + { "gtm", "application/vnd.groove-tool-message" }, + { "gtw", "model/vnd.gtw" }, + { "gv", "text/vnd.graphviz" }, + { "gxf", "application/gxf" }, + { "gxt", "application/vnd.geonext" }, + { "gz", "application/gzip" }, + { "h", "text/x-c" }, + { "h261", "video/h261" }, + { "h263", "video/h263" }, + { "h264", "video/h264" }, + { "hal", "application/vnd.hal+xml" }, + { "hbci", "application/vnd.hbci" }, + { "hbs", "text/x-handlebars-template" }, + { "hdd", "application/x-virtualbox-hdd" }, + { "hdf", "application/x-hdf" }, + { "heic", "image/heic" }, + { "heics", "image/heic-sequence" }, + { "heif", "image/heif" }, + { "heifs", "image/heif-sequence" }, + { "hej2", "image/hej2k" }, + { "held", "application/atsc-held+xml" }, + { "hh", "text/x-c" }, + { "hjson", "application/hjson" }, + { "hlp", "application/winhlp" }, + { "hpgl", "application/vnd.hp-hpgl" }, + { "hpid", "application/vnd.hp-hpid" }, + { "hps", "application/vnd.hp-hps" }, + { "hqx", "application/mac-binhex40" }, + { "hsj2", "image/hsj2" }, + { "htc", "text/x-component" }, + { "htke", "application/vnd.kenameaapp" }, + { "htm", "text/html" }, + { "html", "text/html" }, + { "hvd", "application/vnd.yamaha.hv-dic" }, + { "hvp", "application/vnd.yamaha.hv-voice" }, + { "hvs", "application/vnd.yamaha.hv-script" }, + { "i2g", "application/vnd.intergeo" }, + { "icc", "application/vnd.iccprofile" }, + { "ice", "x-conference/x-cooltalk" }, + { "icm", "application/vnd.iccprofile" }, + { "ico", "image/vnd.microsoft.icon" }, + { "ics", "text/calendar" }, + { "ief", "image/ief" }, + { "ifb", "text/calendar" }, + { "ifm", "application/vnd.shana.informed.formdata" }, + { "iges", "model/iges" }, + { "igl", "application/vnd.igloader" }, + { "igm", "application/vnd.insors.igm" }, + { "igs", "model/iges" }, + { "igx", "application/vnd.micrografx.igx" }, + { "iif", "application/vnd.shana.informed.interchange" }, + { "img", "application/octet-stream" }, + { "imp", "application/vnd.accpac.simply.imp" }, + { "ims", "application/vnd.ms-ims" }, + { "in", "text/plain" }, + { "ini", "text/plain" }, + { "ink", "application/inkml+xml" }, + { "inkml", "application/inkml+xml" }, + { "install", "application/x-install-instructions" }, + { "iota", "application/vnd.astraea-software.iota" }, + { "ipfix", "application/ipfix" }, + { "ipk", "application/vnd.shana.informed.package" }, + { "irm", "application/vnd.ibm.rights-management" }, + { "irp", "application/vnd.irepository.package+xml" }, + { "iso", "application/octet-stream" }, + { "itp", "application/vnd.shana.informed.formtemplate" }, + { "its", "application/its+xml" }, + { "ivp", "application/vnd.immervision-ivp" }, + { "ivu", "application/vnd.immervision-ivu" }, + { "jad", "text/vnd.sun.j2me.app-descriptor" }, + { "jade", "text/jade" }, + { "jam", "application/vnd.jam" }, + { "jar", "application/java-archive" }, + { "jardiff", "application/x-java-archive-diff" }, + { "java", "text/x-java-source" }, + { "jhc", "image/jphc" }, + { "jisp", "application/vnd.jisp" }, + { "jls", "image/jls" }, + { "jlt", "application/vnd.hp-jlyt" }, + { "jng", "image/x-jng" }, + { "jnlp", "application/x-java-jnlp-file" }, + { "joda", "application/vnd.joost.joda-archive" }, + { "jp2", "image/jp2" }, + { "jpe", "image/jpeg" }, + { "jpeg", "image/jpeg" }, + { "jpf", "image/jpx" }, + { "jpg", "image/jpeg" }, + { "jpg2", "image/jp2" }, + { "jpgm", "image/jpm" }, + { "jpgv", "video/jpeg" }, + { "jph", "image/jph" }, + { "jpm", "image/jpm" }, + { "jpx", "image/jpx" }, + { "js", "application/javascript" }, + { "json", "application/json" }, + { "json5", "application/json5" }, + { "jsonld", "application/ld+json" }, + { "jsonml", "application/jsonml+json" }, + { "jsx", "text/jsx" }, + { "jt", "model/jt" }, + { "jxr", "image/jxr" }, + { "jxra", "image/jxra" }, + { "jxrs", "image/jxrs" }, + { "jxs", "image/jxs" }, + { "jxsc", "image/jxsc" }, + { "jxsi", "image/jxsi" }, + { "jxss", "image/jxss" }, + { "kar", "audio/midi" }, + { "karbon", "application/vnd.kde.karbon" }, + { "kdbx", "application/x-keepass2" }, + { "key", "application/vnd.apple.keynote" }, + { "kfo", "application/vnd.kde.kformula" }, + { "kia", "application/vnd.kidspiration" }, + { "kml", "application/vnd.google-earth.kml+xml" }, + { "kmz", "application/vnd.google-earth.kmz" }, + { "kne", "application/vnd.kinar" }, + { "knp", "application/vnd.kinar" }, + { "kon", "application/vnd.kde.kontour" }, + { "kpr", "application/vnd.kde.kpresenter" }, + { "kpt", "application/vnd.kde.kpresenter" }, + { "kpxx", "application/vnd.ds-keypoint" }, + { "ksp", "application/vnd.kde.kspread" }, + { "ktr", "application/vnd.kahootz" }, + { "ktx", "image/ktx" }, + { "ktx2", "image/ktx2" }, + { "ktz", "application/vnd.kahootz" }, + { "kwd", "application/vnd.kde.kword" }, + { "kwt", "application/vnd.kde.kword" }, + { "lasxml", "application/vnd.las.las+xml" }, + { "latex", "application/x-latex" }, + { "lbd", "application/vnd.llamagraphics.life-balance.desktop" }, + { "lbe", "application/vnd.llamagraphics.life-balance.exchange+xml" }, + { "les", "application/vnd.hhe.lesson-player" }, + { "less", "text/less" }, + { "lgr", "application/lgr+xml" }, + { "lha", "application/x-lzh-compressed" }, + { "link66", "application/vnd.route66.link66+xml" }, + { "list", "text/plain" }, + { "list3820", "application/vnd.ibm.modcap" }, + { "listafp", "application/vnd.ibm.modcap" }, + { "lnk", "application/x-ms-shortcut" }, + { "log", "text/plain" }, + { "lostxml", "application/lost+xml" }, + { "lrf", "application/octet-stream" }, + { "lrm", "application/vnd.ms-lrm" }, + { "ltf", "application/vnd.frogans.ltf" }, + { "lua", "text/x-lua" }, + { "luac", "application/x-lua-bytecode" }, + { "lvp", "audio/vnd.lucent.voice" }, + { "lwp", "application/vnd.lotus-wordpro" }, + { "lzh", "application/x-lzh-compressed" }, + { "m13", "application/x-msmediaview" }, + { "m14", "application/x-msmediaview" }, + { "m1v", "video/mpeg" }, + { "m21", "application/mp21" }, + { "m2a", "audio/mpeg" }, + { "m2v", "video/mpeg" }, + { "m3a", "audio/mpeg" }, + { "m3u", "audio/x-mpegurl" }, + { "m3u8", "application/vnd.apple.mpegurl" }, + { "m4a", "audio/mp4" }, + { "m4p", "application/mp4" }, + { "m4s", "video/iso.segment" }, + { "m4u", "video/vnd.mpegurl" }, + { "m4v", "video/x-m4v" }, + { "ma", "application/mathematica" }, + { "mads", "application/mads+xml" }, + { "maei", "application/mmt-aei+xml" }, + { "mag", "application/vnd.ecowin.chart" }, + { "maker", "application/vnd.framemaker" }, + { "man", "text/troff" }, + { "manifest", "text/cache-manifest" }, + { "map", "application/json" }, + { "mar", "application/octet-stream" }, + { "markdown", "text/markdown" }, + { "mathml", "application/mathml+xml" }, + { "mb", "application/mathematica" }, + { "mbk", "application/vnd.mobius.mbk" }, + { "mbox", "application/mbox" }, + { "mc1", "application/vnd.medcalcdata" }, + { "mcd", "application/vnd.mcd" }, + { "mcurl", "text/vnd.curl.mcurl" }, + { "md", "text/markdown" }, + { "mdb", "application/x-msaccess" }, + { "mdi", "image/vnd.ms-modi" }, + { "mdx", "text/mdx" }, + { "me", "text/troff" }, + { "mesh", "model/mesh" }, + { "meta4", "application/metalink4+xml" }, + { "metalink", "application/metalink+xml" }, + { "mets", "application/mets+xml" }, + { "mfm", "application/vnd.mfmp" }, + { "mft", "application/rpki-manifest" }, + { "mgp", "application/vnd.osgeo.mapguide.package" }, + { "mgz", "application/vnd.proteus.magazine" }, + { "mid", "audio/midi" }, + { "midi", "audio/midi" }, + { "mie", "application/x-mie" }, + { "mif", "application/vnd.mif" }, + { "mime", "message/rfc822" }, + { "mj2", "video/mj2" }, + { "mjp2", "video/mj2" }, + { "mjs", "text/javascript" }, + { "mk3d", "video/x-matroska" }, + { "mka", "audio/x-matroska" }, + { "mkd", "text/x-markdown" }, + { "mks", "video/x-matroska" }, + { "mkv", "video/x-matroska" }, + { "mlp", "application/vnd.dolby.mlp" }, + { "mmd", "application/vnd.chipnuts.karaoke-mmd" }, + { "mmf", "application/vnd.smaf" }, + { "mml", "text/mathml" }, + { "mmr", "image/vnd.fujixerox.edmics-mmr" }, + { "mng", "video/x-mng" }, + { "mny", "application/x-msmoney" }, + { "mobi", "application/x-mobipocket-ebook" }, + { "mods", "application/mods+xml" }, + { "mov", "video/quicktime" }, + { "movie", "video/x-sgi-movie" }, + { "mp2", "audio/mpeg" }, + { "mp21", "application/mp21" }, + { "mp2a", "audio/mpeg" }, + { "mp3", "audio/mp3" }, + { "mp4", "video/mp4" }, + { "mp4a", "audio/mp4" }, + { "mp4s", "application/mp4" }, + { "mp4v", "video/mp4" }, + { "mpc", "application/vnd.mophun.certificate" }, + { "mpd", "application/dash+xml" }, + { "mpe", "video/mpeg" }, + { "mpeg", "video/mpeg" }, + { "mpf", "application/media-policy-dataset+xml" }, + { "mpg", "video/mpeg" }, + { "mpg4", "video/mp4" }, + { "mpga", "audio/mpeg" }, + { "mpkg", "application/vnd.apple.installer+xml" }, + { "mpm", "application/vnd.blueice.multipass" }, + { "mpn", "application/vnd.mophun.application" }, + { "mpp", "application/dash-patch+xml" }, + { "mpt", "application/vnd.ms-project" }, + { "mpy", "application/vnd.ibm.minipay" }, + { "mqy", "application/vnd.mobius.mqy" }, + { "mrc", "application/marc" }, + { "mrcx", "application/marcxml+xml" }, + { "ms", "text/troff" }, + { "mscml", "application/mediaservercontrol+xml" }, + { "mseed", "application/vnd.fdsn.mseed" }, + { "mseq", "application/vnd.mseq" }, + { "msf", "application/vnd.epson.msf" }, + { "msg", "application/vnd.ms-outlook" }, + { "msh", "model/mesh" }, + { "msi", "application/octet-stream" }, + { "msix", "application/msix" }, + { "msl", "application/vnd.mobius.msl" }, + { "msm", "application/octet-stream" }, + { "msp", "application/octet-stream" }, + { "msty", "application/vnd.muvee.style" }, + { "mtl", "model/mtl" }, + { "mts", "model/vnd.mts" }, + { "mus", "application/vnd.musician" }, + { "musd", "application/mmt-usd+xml" }, + { "musicxml", "application/vnd.recordare.musicxml+xml" }, + { "mvb", "application/x-msmediaview" }, + { "mvt", "application/vnd.mapbox-vector-tile" }, + { "mwf", "application/vnd.mfer" }, + { "mxf", "application/mxf" }, + { "mxl", "application/vnd.recordare.musicxml" }, + { "mxmf", "audio/mobile-xmf" }, + { "mxml", "application/xv+xml" }, + { "mxs", "application/vnd.triscape.mxs" }, + { "mxu", "video/vnd.mpegurl" }, + { "n3", "text/n3" }, + { "nb", "application/mathematica" }, + { "nbp", "application/vnd.wolfram.player" }, + { "nc", "application/x-netcdf" }, + { "ncx", "application/x-dtbncx+xml" }, + { "nfo", "text/x-nfo" }, + { "ngdat", "application/vnd.nokia.n-gage.data" }, + { "nitf", "application/vnd.nitf" }, + { "nlu", "application/vnd.neurolanguage.nlu" }, + { "nml", "application/vnd.enliven" }, + { "nnd", "application/vnd.noblenet-directory" }, + { "nns", "application/vnd.noblenet-sealer" }, + { "nnw", "application/vnd.noblenet-web" }, + { "npx", "image/vnd.net-fpx" }, + { "nq", "application/n-quads" }, + { "nsc", "application/x-conference" }, + { "nsf", "application/vnd.lotus-notes" }, + { "nt", "application/n-triples" }, + { "ntf", "application/vnd.nitf" }, + { "numbers", "application/vnd.apple.numbers" }, + { "nzb", "application/x-nzb" }, + { "oa2", "application/vnd.fujitsu.oasys2" }, + { "oa3", "application/vnd.fujitsu.oasys3" }, + { "oas", "application/vnd.fujitsu.oasys" }, + { "obd", "application/x-msbinder" }, + { "obgx", "application/vnd.openblox.game+xml" }, + { "obj", "model/obj" }, + { "oda", "application/oda" }, + { "odb", "application/vnd.oasis.opendocument.database" }, + { "odc", "application/vnd.oasis.opendocument.chart" }, + { "odf", "application/vnd.oasis.opendocument.formula" }, + { "odft", "application/vnd.oasis.opendocument.formula-template" }, + { "odg", "application/vnd.oasis.opendocument.graphics" }, + { "odi", "application/vnd.oasis.opendocument.image" }, + { "odm", "application/vnd.oasis.opendocument.text-master" }, + { "odp", "application/vnd.oasis.opendocument.presentation" }, + { "ods", "application/vnd.oasis.opendocument.spreadsheet" }, + { "odt", "application/vnd.oasis.opendocument.text" }, + { "oga", "audio/ogg" }, + { "ogex", "model/vnd.opengex" }, + { "ogg", "audio/ogg" }, + { "ogv", "video/ogg" }, + { "ogx", "application/ogg" }, + { "omdoc", "application/omdoc+xml" }, + { "onepkg", "application/onenote" }, + { "onetmp", "application/onenote" }, + { "onetoc", "application/onenote" }, + { "onetoc2", "application/onenote" }, + { "opf", "application/oebps-package+xml" }, + { "opml", "text/x-opml" }, + { "oprc", "application/vnd.palm" }, + { "opus", "audio/ogg" }, + { "org", "application/vnd.lotus-organizer" }, + { "osf", "application/vnd.yamaha.openscoreformat" }, + { "osfpvg", "application/vnd.yamaha.openscoreformat.osfpvg+xml" }, + { "osm", "application/vnd.openstreetmap.data+xml" }, + { "otc", "application/vnd.oasis.opendocument.chart-template" }, + { "otf", "font/otf" }, + { "otg", "application/vnd.oasis.opendocument.graphics-template" }, + { "oth", "application/vnd.oasis.opendocument.text-web" }, + { "oti", "application/vnd.oasis.opendocument.image-template" }, + { "otp", "application/vnd.oasis.opendocument.presentation-template" }, + { "ots", "application/vnd.oasis.opendocument.spreadsheet-template" }, + { "ott", "application/vnd.oasis.opendocument.text-template" }, + { "ova", "application/x-virtualbox-ova" }, + { "ovf", "application/x-virtualbox-ovf" }, + { "owl", "application/rdf+xml" }, + { "oxps", "application/oxps" }, + { "oxt", "application/vnd.openofficeorg.extension" }, + { "p", "text/x-pascal" }, + { "p10", "application/pkcs10" }, + { "p12", "application/x-pkcs12" }, + { "p7b", "application/x-pkcs7-certificates" }, + { "p7c", "application/pkcs7-mime" }, + { "p7m", "application/pkcs7-mime" }, + { "p7r", "application/x-pkcs7-certreqresp" }, + { "p7s", "application/pkcs7-signature" }, + { "p8", "application/pkcs8" }, + { "pac", "application/x-ns-proxy-autoconfig" }, + { "pages", "application/vnd.apple.pages" }, + { "pas", "text/x-pascal" }, + { "paw", "application/vnd.pawaafile" }, + { "pbd", "application/vnd.powerbuilder6" }, + { "pbm", "image/x-portable-bitmap" }, + { "pcap", "application/vnd.tcpdump.pcap" }, + { "pcf", "application/x-font-pcf" }, + { "pcl", "application/vnd.hp-pcl" }, + { "pclxl", "application/vnd.hp-pclxl" }, + { "pct", "image/x-pict" }, + { "pcurl", "application/vnd.curl.pcurl" }, + { "pcx", "image/vnd.zbrush.pcx" }, + { "pdb", "application/vnd.palm" }, + { "pde", "text/x-processing" }, + { "pdf", "application/pdf" }, + { "pem", "application/x-x509-ca-cert" }, + { "pfa", "application/x-font-type1" }, + { "pfb", "application/x-font-type1" }, + { "pfm", "application/x-font-type1" }, + { "pfr", "application/font-tdpfr" }, + { "pfx", "application/x-pkcs12" }, + { "pgm", "image/x-portable-graymap" }, + { "pgn", "application/x-chess-pgn" }, + { "pgp", "application/pgp-encrypted" }, + { "php", "application/x-httpd-php" }, + { "pic", "image/x-pict" }, + { "pkg", "application/octet-stream" }, + { "pki", "application/pkixcmp" }, + { "pkipath", "application/pkix-pkipath" }, + { "pkpass", "application/vnd.apple.pkpass" }, + { "pl", "application/x-perl" }, + { "plb", "application/vnd.3gpp.pic-bw-large" }, + { "plc", "application/vnd.mobius.plc" }, + { "plf", "application/vnd.pocketlearn" }, + { "pls", "application/pls+xml" }, + { "pm", "application/x-perl" }, + { "pml", "application/vnd.ctc-posml" }, + { "png", "image/png" }, + { "pnm", "image/x-portable-anymap" }, + { "portpkg", "application/vnd.macports.portpkg" }, + { "pot", "application/vnd.ms-powerpoint" }, + { "potm", "application/vnd.ms-powerpoint.template.macroenabled.12" }, + { "potx", "application/vnd.openxmlformats-officedocument.presentationml.template" }, + { "ppam", "application/vnd.ms-powerpoint.addin.macroenabled.12" }, + { "ppd", "application/vnd.cups-ppd" }, + { "ppm", "image/x-portable-pixmap" }, + { "pps", "application/vnd.ms-powerpoint" }, + { "ppsm", "application/vnd.ms-powerpoint.slideshow.macroenabled.12" }, + { "ppsx", "application/vnd.openxmlformats-officedocument.presentationml.slideshow" }, + { "ppt", "application/vnd.ms-powerpoint" }, + { "pptm", "application/vnd.ms-powerpoint.presentation.macroenabled.12" }, + { "pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" }, + { "pqa", "application/vnd.palm" }, + { "prc", "model/prc" }, + { "pre", "application/vnd.lotus-freelance" }, + { "prf", "application/pics-rules" }, + { "provx", "application/provenance+xml" }, + { "ps", "application/postscript" }, + { "psb", "application/vnd.3gpp.pic-bw-small" }, + { "psd", "image/vnd.adobe.photoshop" }, + { "psf", "application/x-font-linux-psf" }, + { "pskcxml", "application/pskc+xml" }, + { "pti", "image/prs.pti" }, + { "ptid", "application/vnd.pvi.ptid1" }, + { "pub", "application/x-mspublisher" }, + { "pvb", "application/vnd.3gpp.pic-bw-var" }, + { "pwn", "application/vnd.3m.post-it-notes" }, + { "pya", "audio/vnd.ms-playready.media.pya" }, + { "pyo", "model/vnd.pytha.pyox" }, + { "pyox", "model/vnd.pytha.pyox" }, + { "pyv", "video/vnd.ms-playready.media.pyv" }, + { "qam", "application/vnd.epson.quickanime" }, + { "qbo", "application/vnd.intu.qbo" }, + { "qfx", "application/vnd.intu.qfx" }, + { "qps", "application/vnd.publishare-delta-tree" }, + { "qt", "video/quicktime" }, + { "qwd", "application/vnd.quark.quarkxpress" }, + { "qwt", "application/vnd.quark.quarkxpress" }, + { "qxb", "application/vnd.quark.quarkxpress" }, + { "qxd", "application/vnd.quark.quarkxpress" }, + { "qxl", "application/vnd.quark.quarkxpress" }, + { "qxt", "application/vnd.quark.quarkxpress" }, + { "ra", "audio/x-realaudio" }, + { "ram", "audio/x-pn-realaudio" }, + { "raml", "application/raml+yaml" }, + { "rapd", "application/route-apd+xml" }, + { "rar", "application/vnd.rar" }, + { "ras", "image/x-cmu-raster" }, + { "rdf", "application/rdf+xml" }, + { "rdz", "application/vnd.data-vision.rdz" }, + { "relo", "application/p2p-overlay+xml" }, + { "rep", "application/vnd.businessobjects" }, + { "res", "application/x-dtbresource+xml" }, + { "rgb", "image/x-rgb" }, + { "rif", "application/reginfo+xml" }, + { "rip", "audio/vnd.rip" }, + { "ris", "application/x-research-info-systems" }, + { "rl", "application/resource-lists+xml" }, + { "rlc", "image/vnd.fujixerox.edmics-rlc" }, + { "rld", "application/resource-lists-diff+xml" }, + { "rm", "application/vnd.rn-realmedia" }, + { "rmi", "audio/midi" }, + { "rmp", "audio/x-pn-realaudio-plugin" }, + { "rms", "application/vnd.jcp.javame.midlet-rms" }, + { "rmvb", "application/vnd.rn-realmedia-vbr" }, + { "rnc", "application/relax-ng-compact-syntax" }, + { "rng", "application/xml" }, + { "roa", "application/rpki-roa" }, + { "roff", "text/troff" }, + { "rp9", "application/vnd.cloanto.rp9" }, + { "rpm", "application/x-redhat-package-manager" }, + { "rpss", "application/vnd.nokia.radio-presets" }, + { "rpst", "application/vnd.nokia.radio-preset" }, + { "rq", "application/sparql-query" }, + { "rs", "application/rls-services+xml" }, + { "rsat", "application/atsc-rsat+xml" }, + { "rsd", "application/rsd+xml" }, + { "rsheet", "application/urc-ressheet+xml" }, + { "rss", "application/rss+xml" }, + { "rtf", "text/rtf" }, + { "rtx", "text/richtext" }, + { "run", "application/x-makeself" }, + { "rusd", "application/route-usd+xml" }, + { "s", "text/x-asm" }, + { "s3m", "audio/s3m" }, + { "saf", "application/vnd.yamaha.smaf-audio" }, + { "sass", "text/x-sass" }, + { "sbml", "application/sbml+xml" }, + { "sc", "application/vnd.ibm.secure-container" }, + { "scd", "application/x-msschedule" }, + { "scm", "application/vnd.lotus-screencam" }, + { "scq", "application/scvp-cv-request" }, + { "scs", "application/scvp-cv-response" }, + { "scss", "text/x-scss" }, + { "scurl", "text/vnd.curl.scurl" }, + { "sda", "application/vnd.stardivision.draw" }, + { "sdc", "application/vnd.stardivision.calc" }, + { "sdd", "application/vnd.stardivision.impress" }, + { "sdkd", "application/vnd.solent.sdkm+xml" }, + { "sdkm", "application/vnd.solent.sdkm+xml" }, + { "sdp", "application/sdp" }, + { "sdw", "application/vnd.stardivision.writer" }, + { "sea", "application/x-sea" }, + { "see", "application/vnd.seemail" }, + { "seed", "application/vnd.fdsn.seed" }, + { "sema", "application/vnd.sema" }, + { "semd", "application/vnd.semd" }, + { "semf", "application/vnd.semf" }, + { "senmlx", "application/senml+xml" }, + { "sensmlx", "application/sensml+xml" }, + { "ser", "application/java-serialized-object" }, + { "setpay", "application/set-payment-initiation" }, + { "setreg", "application/set-registration-initiation" }, + { "sfs", "application/vnd.spotfire.sfs" }, + { "sfv", "text/x-sfv" }, + { "sgi", "image/sgi" }, + { "sgl", "application/vnd.stardivision.writer-global" }, + { "sgm", "text/sgml" }, + { "sgml", "text/sgml" }, + { "sh", "application/x-sh" }, + { "shar", "application/x-shar" }, + { "shex", "text/shex" }, + { "shf", "application/shf+xml" }, + { "shtml", "text/html" }, + { "sid", "image/x-mrsid-image" }, + { "sieve", "application/sieve" }, + { "sig", "application/pgp-signature" }, + { "sil", "audio/silk" }, + { "silo", "model/mesh" }, + { "sis", "application/vnd.symbian.install" }, + { "sisx", "application/vnd.symbian.install" }, + { "sit", "application/x-stuffit" }, + { "sitx", "application/x-stuffitx" }, + { "siv", "application/sieve" }, + { "skd", "application/vnd.koan" }, + { "skm", "application/vnd.koan" }, + { "skp", "application/vnd.koan" }, + { "skt", "application/vnd.koan" }, + { "sldm", "application/vnd.ms-powerpoint.slide.macroenabled.12" }, + { "sldx", "application/vnd.openxmlformats-officedocument.presentationml.slide" }, + { "slim", "text/slim" }, + { "slm", "text/slim" }, + { "sls", "application/route-s-tsid+xml" }, + { "slt", "application/vnd.epson.salt" }, + { "sm", "application/vnd.stepmania.stepchart" }, + { "smf", "application/vnd.stardivision.math" }, + { "smi", "application/smil+xml" }, + { "smil", "application/smil+xml" }, + { "smv", "video/x-smv" }, + { "smzip", "application/vnd.stepmania.package" }, + { "snd", "audio/basic" }, + { "snf", "application/x-font-snf" }, + { "so", "application/octet-stream" }, + { "spc", "application/x-pkcs7-certificates" }, + { "spdx", "text/spdx" }, + { "spf", "application/vnd.yamaha.smaf-phrase" }, + { "spl", "application/x-futuresplash" }, + { "spot", "text/vnd.in3d.spot" }, + { "spp", "application/scvp-vp-response" }, + { "spq", "application/scvp-vp-request" }, + { "spx", "audio/ogg" }, + { "sql", "application/sql" }, + { "src", "application/x-wais-source" }, + { "srt", "application/x-subrip" }, + { "sru", "application/sru+xml" }, + { "srx", "application/sparql-results+xml" }, + { "ssdl", "application/ssdl+xml" }, + { "sse", "application/vnd.kodak-descriptor" }, + { "ssf", "application/vnd.epson.ssf" }, + { "ssml", "application/ssml+xml" }, + { "st", "application/vnd.sailingtracker.track" }, + { "stc", "application/vnd.sun.xml.calc.template" }, + { "std", "application/vnd.sun.xml.draw.template" }, + { "stf", "application/vnd.wt.stf" }, + { "sti", "application/vnd.sun.xml.impress.template" }, + { "stk", "application/hyperstudio" }, + { "stl", "model/stl" }, + { "stpx", "model/step+xml" }, + { "stpxz", "model/step-xml+zip" }, + { "stpz", "model/step+zip" }, + { "str", "application/vnd.pg.format" }, + { "stw", "application/vnd.sun.xml.writer.template" }, + { "styl", "text/stylus" }, + { "stylus", "text/stylus" }, + { "sub", "text/vnd.dvb.subtitle" }, + { "sus", "application/vnd.sus-calendar" }, + { "susp", "application/vnd.sus-calendar" }, + { "sv4cpio", "application/x-sv4cpio" }, + { "sv4crc", "application/x-sv4crc" }, + { "svc", "application/vnd.dvb.service" }, + { "svd", "application/vnd.svd" }, + { "svg", "image/svg+xml" }, + { "svgz", "image/svg+xml" }, + { "swa", "application/x-director" }, + { "swf", "application/x-shockwave-flash" }, + { "swi", "application/vnd.aristanetworks.swi" }, + { "swidtag", "application/swid+xml" }, + { "sxc", "application/vnd.sun.xml.calc" }, + { "sxd", "application/vnd.sun.xml.draw" }, + { "sxg", "application/vnd.sun.xml.writer.global" }, + { "sxi", "application/vnd.sun.xml.impress" }, + { "sxm", "application/vnd.sun.xml.math" }, + { "sxw", "application/vnd.sun.xml.writer" }, + { "t", "text/troff" }, + { "t3", "application/x-t3vm-image" }, + { "t38", "image/t38" }, + { "taglet", "application/vnd.mynfc" }, + { "tao", "application/vnd.tao.intent-module-archive" }, + { "tap", "image/vnd.tencent.tap" }, + { "tar", "application/x-tar" }, + { "tcap", "application/vnd.3gpp2.tcap" }, + { "tcl", "application/x-tcl" }, + { "td", "application/urc-targetdesc+xml" }, + { "teacher", "application/vnd.smart.teacher" }, + { "tei", "application/tei+xml" }, + { "tex", "application/x-tex" }, + { "texi", "application/x-texinfo" }, + { "texinfo", "application/x-texinfo" }, + { "text", "text/plain" }, + { "tfi", "application/thraud+xml" }, + { "tfm", "application/x-tex-tfm" }, + { "tfx", "image/tiff-fx" }, + { "tga", "image/x-tga" }, + { "thmx", "application/vnd.ms-officetheme" }, + { "tif", "image/tiff" }, + { "tiff", "image/tiff" }, + { "tk", "application/x-tcl" }, + { "tmo", "application/vnd.tmobile-livetv" }, + { "toml", "application/toml" }, + { "torrent", "application/x-bittorrent" }, + { "tpl", "application/vnd.groove-tool-template" }, + { "tpt", "application/vnd.trid.tpt" }, + { "tr", "text/troff" }, + { "tra", "application/vnd.trueapp" }, + { "trig", "application/trig" }, + { "trm", "application/x-msterminal" }, + { "ts", "video/mp2t" }, + { "tsd", "application/timestamped-data" }, + { "tsv", "text/tab-separated-values" }, + { "ttc", "font/collection" }, + { "ttf", "font/ttf" }, + { "ttl", "text/turtle" }, + { "ttml", "application/ttml+xml" }, + { "twd", "application/vnd.simtech-mindmapper" }, + { "twds", "application/vnd.simtech-mindmapper" }, + { "txd", "application/vnd.genomatix.tuxedo" }, + { "txf", "application/vnd.mobius.txf" }, + { "txt", "text/plain" }, + { "u32", "application/x-authorware-bin" }, + { "u3d", "model/u3d" }, + { "u8dsn", "message/global-delivery-status" }, + { "u8hdr", "message/global-headers" }, + { "u8mdn", "message/global-disposition-notification" }, + { "u8msg", "message/global" }, + { "ubj", "application/ubjson" }, + { "udeb", "application/x-debian-package" }, + { "ufd", "application/vnd.ufdl" }, + { "ufdl", "application/vnd.ufdl" }, + { "ulx", "application/x-glulx" }, + { "umj", "application/vnd.umajin" }, + { "unityweb", "application/vnd.unity" }, + { "uo", "application/vnd.uoml+xml" }, + { "uoml", "application/vnd.uoml+xml" }, + { "uri", "text/uri-list" }, + { "uris", "text/uri-list" }, + { "urls", "text/uri-list" }, + { "usda", "model/vnd.usda" }, + { "usdz", "model/vnd.usdz+zip" }, + { "ustar", "application/x-ustar" }, + { "utz", "application/vnd.uiq.theme" }, + { "uu", "text/x-uuencode" }, + { "uva", "audio/vnd.dece.audio" }, + { "uvd", "application/vnd.dece.data" }, + { "uvf", "application/vnd.dece.data" }, + { "uvg", "image/vnd.dece.graphic" }, + { "uvh", "video/vnd.dece.hd" }, + { "uvi", "image/vnd.dece.graphic" }, + { "uvm", "video/vnd.dece.mobile" }, + { "uvp", "video/vnd.dece.pd" }, + { "uvs", "video/vnd.dece.sd" }, + { "uvt", "application/vnd.dece.ttml+xml" }, + { "uvu", "video/vnd.uvvu.mp4" }, + { "uvv", "video/vnd.dece.video" }, + { "uvva", "audio/vnd.dece.audio" }, + { "uvvd", "application/vnd.dece.data" }, + { "uvvf", "application/vnd.dece.data" }, + { "uvvg", "image/vnd.dece.graphic" }, + { "uvvh", "video/vnd.dece.hd" }, + { "uvvi", "image/vnd.dece.graphic" }, + { "uvvm", "video/vnd.dece.mobile" }, + { "uvvp", "video/vnd.dece.pd" }, + { "uvvs", "video/vnd.dece.sd" }, + { "uvvt", "application/vnd.dece.ttml+xml" }, + { "uvvu", "video/vnd.uvvu.mp4" }, + { "uvvv", "video/vnd.dece.video" }, + { "uvvx", "application/vnd.dece.unspecified" }, + { "uvvz", "application/vnd.dece.zip" }, + { "uvx", "application/vnd.dece.unspecified" }, + { "uvz", "application/vnd.dece.zip" }, + { "vbox", "application/x-virtualbox-vbox" }, + { "vcard", "text/vcard" }, + { "vcd", "application/x-cdlink" }, + { "vcf", "text/x-vcard" }, + { "vcg", "application/vnd.groove-vcard" }, + { "vcs", "text/x-vcalendar" }, + { "vcx", "application/vnd.vcx" }, + { "vdi", "application/x-virtualbox-vdi" }, + { "vds", "model/vnd.sap.vds" }, + { "vhd", "application/x-virtualbox-vhd" }, + { "vis", "application/vnd.visionary" }, + { "viv", "video/vnd.vivo" }, + { "vmdk", "application/x-virtualbox-vmdk" }, + { "vob", "video/x-ms-vob" }, + { "vor", "application/vnd.stardivision.writer" }, + { "vox", "application/x-authorware-bin" }, + { "vrml", "model/vrml" }, + { "vsd", "application/vnd.visio" }, + { "vsf", "application/vnd.vsf" }, + { "vss", "application/vnd.visio" }, + { "vst", "application/vnd.visio" }, + { "vsw", "application/vnd.visio" }, + { "vtf", "image/vnd.valve.source.texture" }, + { "vtt", "text/vtt" }, + { "vtu", "model/vnd.vtu" }, + { "vxml", "application/voicexml+xml" }, + { "w3d", "application/x-director" }, + { "wad", "application/x-doom" }, + { "wadl", "application/vnd.sun.wadl+xml" }, + { "war", "application/java-archive" }, + { "wasm", "application/wasm" }, + { "wav", "audio/wav" }, + { "wax", "audio/x-ms-wax" }, + { "wbmp", "image/vnd.wap.wbmp" }, + { "wbs", "application/vnd.criticaltools.wbs+xml" }, + { "wbxml", "application/vnd.wap.wbxml" }, + { "wcm", "application/vnd.ms-works" }, + { "wdb", "application/vnd.ms-works" }, + { "wdp", "image/vnd.ms-photo" }, + { "weba", "audio/webm" }, + { "webapp", "application/x-web-app-manifest+json" }, + { "webm", "video/webm" }, + { "webp", "image/webp" }, + { "wg", "application/vnd.pmi.widget" }, + { "wgsl", "text/wgsl" }, + { "wgt", "application/widget" }, + { "wif", "application/watcherinfo+xml" }, + { "wks", "application/vnd.ms-works" }, + { "wm", "video/x-ms-wm" }, + { "wma", "audio/x-ms-wma" }, + { "wmd", "application/x-ms-wmd" }, + { "wmf", "image/wmf" }, + { "wml", "text/vnd.wap.wml" }, + { "wmlc", "application/vnd.wap.wmlc" }, + { "wmls", "text/vnd.wap.wmlscript" }, + { "wmlsc", "application/vnd.wap.wmlscriptc" }, + { "wmv", "video/x-ms-wmv" }, + { "wmx", "video/x-ms-wmx" }, + { "wmz", "application/x-ms-wmz" }, + { "woff", "font/woff" }, + { "woff2", "font/woff2" }, + { "wpd", "application/vnd.wordperfect" }, + { "wpl", "application/vnd.ms-wpl" }, + { "wps", "application/vnd.ms-works" }, + { "wqd", "application/vnd.wqd" }, + { "wri", "application/x-mswrite" }, + { "wrl", "model/vrml" }, + { "wsc", "message/vnd.wfa.wsc" }, + { "wsdl", "application/wsdl+xml" }, + { "wspolicy", "application/wspolicy+xml" }, + { "wtb", "application/vnd.webturbo" }, + { "wvx", "video/x-ms-wvx" }, + { "x32", "application/x-authorware-bin" }, + { "x3d", "model/x3d+xml" }, + { "x3db", "model/x3d+binary" }, + { "x3dbz", "model/x3d+binary" }, + { "x3dv", "model/x3d+vrml" }, + { "x3dvz", "model/x3d+vrml" }, + { "x3dz", "model/x3d+xml" }, + { "xaml", "application/xaml+xml" }, + { "xap", "application/x-silverlight-app" }, + { "xar", "application/vnd.xara" }, + { "xav", "application/xcap-att+xml" }, + { "xbap", "application/x-ms-xbap" }, + { "xbd", "application/vnd.fujixerox.docuworks.binder" }, + { "xbm", "image/x-xbitmap" }, + { "xca", "application/xcap-caps+xml" }, + { "xcs", "application/calendar+xml" }, + { "xdf", "application/xcap-diff+xml" }, + { "xdm", "application/vnd.syncml.dm+xml" }, + { "xdp", "application/vnd.adobe.xdp+xml" }, + { "xdssc", "application/dssc+xml" }, + { "xdw", "application/vnd.fujixerox.docuworks" }, + { "xel", "application/xcap-el+xml" }, + { "xenc", "application/xenc+xml" }, + { "xer", "application/patch-ops-error+xml" }, + { "xfdf", "application/xfdf" }, + { "xfdl", "application/vnd.xfdl" }, + { "xht", "application/xhtml+xml" }, + { "xhtm", "application/vnd.pwg-xhtml-print+xml" }, + { "xhtml", "application/xhtml+xml" }, + { "xhvml", "application/xv+xml" }, + { "xif", "image/vnd.xiff" }, + { "xla", "application/vnd.ms-excel" }, + { "xlam", "application/vnd.ms-excel.addin.macroenabled.12" }, + { "xlc", "application/vnd.ms-excel" }, + { "xlf", "application/xliff+xml" }, + { "xlm", "application/vnd.ms-excel" }, + { "xls", "application/vnd.ms-excel" }, + { "xlsb", "application/vnd.ms-excel.sheet.binary.macroenabled.12" }, + { "xlsm", "application/vnd.ms-excel.sheet.macroenabled.12" }, + { "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, + { "xlt", "application/vnd.ms-excel" }, + { "xltm", "application/vnd.ms-excel.template.macroenabled.12" }, + { "xltx", "application/vnd.openxmlformats-officedocument.spreadsheetml.template" }, + { "xlw", "application/vnd.ms-excel" }, + { "xm", "audio/xm" }, + { "xml", "application/xml" }, + { "xns", "application/xcap-ns+xml" }, + { "xo", "application/vnd.olpc-sugar" }, + { "xop", "application/xop+xml" }, + { "xpi", "application/x-xpinstall" }, + { "xpl", "application/xproc+xml" }, + { "xpm", "image/x-xpixmap" }, + { "xpr", "application/vnd.is-xpr" }, + { "xps", "application/vnd.ms-xpsdocument" }, + { "xpw", "application/vnd.intercon.formnet" }, + { "xpx", "application/vnd.intercon.formnet" }, + { "xsd", "application/xml" }, + { "xsf", "application/prs.xsf+xml" }, + { "xsl", "application/xml" }, + { "xslt", "application/xslt+xml" }, + { "xsm", "application/vnd.syncml+xml" }, + { "xspf", "application/xspf+xml" }, + { "xul", "application/vnd.mozilla.xul+xml" }, + { "xvm", "application/xv+xml" }, + { "xvml", "application/xv+xml" }, + { "xwd", "image/x-xwindowdump" }, + { "xyz", "chemical/x-xyz" }, + { "xz", "application/x-xz" }, + { "yaml", "text/yaml" }, + { "yang", "application/yang" }, + { "yin", "application/yin+xml" }, + { "yml", "text/yaml" }, + { "ymp", "text/x-suse-ymp" }, + { "z1", "application/x-zmachine" }, + { "z2", "application/x-zmachine" }, + { "z3", "application/x-zmachine" }, + { "z4", "application/x-zmachine" }, + { "z5", "application/x-zmachine" }, + { "z6", "application/x-zmachine" }, + { "z7", "application/x-zmachine" }, + { "z8", "application/x-zmachine" }, + { "zaz", "application/vnd.zzazz.deck+xml" }, + { "zip", "application/zip" }, + { "zir", "application/vnd.zul" }, + { "zirz", "application/vnd.zul" }, + { "zmm", "application/vnd.handheld-entertainment+xml" }, + }; + } + + /// + /// Attempts to fetch all available file extensions for a MIME-type. + /// + /// The name of the MIME-type + /// All available extensions for the given MIME-type + public static IEnumerable GetMimeTypeExtensions(string mimeType) + { + if (mimeType is null) + { + throw new ArgumentNullException(nameof(mimeType)); + } + + return s_typeMap + .Where(keyPair => string.Equals(keyPair.Value, mimeType, StringComparison.OrdinalIgnoreCase)) + .Select(keyPair => keyPair.Key); + } + + /// + /// Tries to get the MIME-type for the given file name. + /// + /// The name of the file. + /// The MIME-type for the given file name. + /// true if a MIME-type was found, false otherwise. + public static bool TryGetMimeType(string fileName, out string mimeType) + { + if (fileName is null) + { + mimeType = null; + return false; + } + + var dotIndex = fileName.LastIndexOf('.'); + + if (dotIndex != -1 && fileName.Length > dotIndex + 1) + { + return s_typeMap.TryGetValue(fileName.Substring(dotIndex + 1), out mimeType); + } + + mimeType = null; + return false; + } + + /// + /// Gets the MIME-type for the given file name, + /// or if a mapping doesn't exist. + /// + /// The name of the file. + /// The MIME-type for the given file name. + public static string GetMimeType(string fileName) + { + if (fileName is null) + { + throw new ArgumentNullException(nameof(fileName)); + } + + return TryGetMimeType(fileName, out var result) ? result : FallbackMimeType; + } + } +} + +#pragma warning enable 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/ScreenplayReportSerializer.cs b/CSF.Screenplay/Reporting/ScreenplayReportSerializer.cs new file mode 100644 index 00000000..0f208591 --- /dev/null +++ b/CSF.Screenplay/Reporting/ScreenplayReportSerializer.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using CSF.Screenplay.ReportModel; + +namespace CSF.Screenplay.Reporting +{ + + /// + /// Implementation of and + /// which serializes and/or deserializes a Screenplay report to/from a JSON stream. + /// + public class ScreenplayReportSerializer : IDeserializesReport, ISerializesReport + { + /// + public async Task DeserializeAsync(Stream stream) + { + if (stream is null) + throw new ArgumentNullException(nameof(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/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/CSF.Screenplay.Tests.csproj b/Tests/CSF.Screenplay.Tests/CSF.Screenplay.Tests.csproj index 89c5a653..d58814bf 100644 --- a/Tests/CSF.Screenplay.Tests/CSF.Screenplay.Tests.csproj +++ b/Tests/CSF.Screenplay.Tests/CSF.Screenplay.Tests.csproj @@ -28,4 +28,13 @@ + + + Always + + + Always + + + diff --git a/Tests/CSF.Screenplay.Tests/JsonToHtmlReport/AssetEmbedderTests.cs b/Tests/CSF.Screenplay.Tests/JsonToHtmlReport/AssetEmbedderTests.cs new file mode 100644 index 00000000..4e9d2e74 --- /dev/null +++ b/Tests/CSF.Screenplay.Tests/JsonToHtmlReport/AssetEmbedderTests.cs @@ -0,0 +1,80 @@ +using System.Linq; +using CSF.Screenplay.ReportModel; +using NUnit.Framework.Internal; + +namespace CSF.Screenplay.JsonToHtmlReport; + +[TestFixture, Parallelizable] +public class AssetEmbedderTests +{ + const string + asset1Base64 = "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Wi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAJzFQ/kAAAIzSURBVFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDVErAAAFotAQsAoNUSsAAAWi0BCwCg1RKwAABaLQELAKDV+n+jC1muNUrXEgAAAABJRU5ErkJggg==", + asset2Base64 = "/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAMgAyADASIAAhEBAxEB/8QAHQABAAIDAQEBAQAAAAAAAAAAAAcIBQYJBAMBAv/EAEUQAQABAwMBBQYCBgcGBgMAAAABAgMEBQYRBxIhMUFRCBNhcYGRFKEiMkJSYsEVM1NygpKxFiOistLhJGPD0fDxQ3Sz/8QAHAEBAAIDAQEBAAAAAAAAAAAAAAMEBQYHAgEI/8QAOBEBAAECBAMFBgUDBQEBAAAAAAECAwQFESEGMUESUWGBkRMicaHB0RQyUrHhFpLwByMkQvEVwv/aAAwDAQACEQMRAD8AsGA/NrMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//Z"; + + [Test, AutoMoqData] + public async Task EmbedReportAssetsAsyncShouldEmbedTwoEligibleAssets(AssetEmbedder sut, [SampleAssets] ScreenplayReport report) + { + await sut.EmbedReportAssetsAsync(report, new () { EmbeddedFileExtensions = "png,jpeg", EmbeddedFileSizeThresholdKb = 50 }); + var performable = report.Performances.First().Reportables.OfType().First(); + Assert.That(performable.Assets, Has.All.Matches(a => a is { FilePath: null, FileData: not null }), "Assets have been embedded"); + } + + [Test, AutoMoqData] + public async Task EmbedReportAssetsAsyncShouldNotEmbedAnAssetOfAnUnsupportedType(AssetEmbedder sut, [SampleAssets] ScreenplayReport report) + { + await sut.EmbedReportAssetsAsync(report, new () { EmbeddedFileExtensions = "jpeg", EmbeddedFileSizeThresholdKb = 50 }); + var performable = report.Performances.First().Reportables.OfType().First(); + + Assert.Multiple(() => + { + Assert.That(performable.Assets, + Has.One.Matches(a => a is { FileName: SampleAssetsCustomization.Asset2Filename, FileData: not null }), + "JPEG asset has been embedded"); + Assert.That(performable.Assets, + Has.One.Matches(a => a is { FileName: SampleAssetsCustomization.Asset1Filename, FileData: null }), + "PNG asset has not been embedded"); + }); + } + + [Test, AutoMoqData] + public async Task EmbedReportAssetsAsyncShouldEmbedAllAssetsIfFileExtensionIsAsterisk(AssetEmbedder sut, [SampleAssets] ScreenplayReport report) + { + await sut.EmbedReportAssetsAsync(report, new () { EmbeddedFileExtensions = "*", EmbeddedFileSizeThresholdKb = 50 }); + var performable = report.Performances.First().Reportables.OfType().First(); + Assert.That(performable.Assets, Has.All.Matches(a => a is { FilePath: null, FileData: not null }), "Assets have been embedded"); + } + + [Test, AutoMoqData] + public async Task EmbedReportAssetsAsyncShouldNotEmbedAnAssetWhichIsTooLarge(AssetEmbedder sut, [SampleAssets] ScreenplayReport report) + { + await sut.EmbedReportAssetsAsync(report, new () { EmbeddedFileExtensions = "png,jpeg", EmbeddedFileSizeThresholdKb = 10 }); + var performable = report.Performances.First().Reportables.OfType().First(); + + Assert.Multiple(() => + { + Assert.That(performable.Assets, + Has.One.Matches(a => a is { FileName: SampleAssetsCustomization.Asset2Filename, FileData: null }), + "JPEG asset has not been embedded"); + Assert.That(performable.Assets, + Has.One.Matches(a => a is { FileName: SampleAssetsCustomization.Asset1Filename, FileData: not null }), + "PNG asset has been embedded"); + }); + } + + [Test, AutoMoqData] + public async Task EmbedReportAssetsAsyncShouldUseCorrectBase64(AssetEmbedder sut, [SampleAssets] ScreenplayReport report) + { + await sut.EmbedReportAssetsAsync(report, new () { EmbeddedFileExtensions = "png,jpeg", EmbeddedFileSizeThresholdKb = 50 }); + var performable = report.Performances.First().Reportables.OfType().First(); + + Assert.Multiple(() => + { + var asset1Data = performable.Assets.FirstOrDefault(x => x.FileName == SampleAssetsCustomization.Asset1Filename)?.FileData; + Assert.That(asset1Data, Is.EqualTo(asset1Base64), "PNG asset encoded correctly"); + + var asset2Data = performable.Assets.FirstOrDefault(x => x.FileName == SampleAssetsCustomization.Asset2Filename)?.FileData; + Assert.That(asset2Data, Is.EqualTo(asset2Base64), "JPEG asset encoded correctly"); + }); + } + +} \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Tests/JsonToHtmlReport/ReportConverterTests.cs b/Tests/CSF.Screenplay.Tests/JsonToHtmlReport/ReportConverterTests.cs new file mode 100644 index 00000000..26c3a5d0 --- /dev/null +++ b/Tests/CSF.Screenplay.Tests/JsonToHtmlReport/ReportConverterTests.cs @@ -0,0 +1,44 @@ +using System.IO; +using System.Text; +using CSF.Screenplay.Reporting; +using CSF.Screenplay.ReportModel; + +namespace CSF.Screenplay.JsonToHtmlReport; + +[TestFixture, Parallelizable] +public class ReportConverterTests +{ + [Test, AutoMoqData] + public async Task ConvertAsyncShouldWriteTheReportUsingATemplate([NoAutoProperties] ScreenplayReport report, + [Frozen] IDeserializesReport deserializer, + [Frozen] ISerializesReport serializer, + [Frozen] IGetsHtmlTemplate templateReader, + [Frozen] IEmbedsReportAssets assetsEmbedder, + ReportConverter sut) + { + var options = new ReportConverterOptions() + { + ReportPath = Path.Combine(Environment.CurrentDirectory, "SampleReport.json"), + OutputPath = Path.Combine(Environment.CurrentDirectory, "ReportConverterTestsOutput.html") + }; + if(File.Exists(options.OutputPath)) + File.Delete(options.OutputPath); + + Mock.Get(deserializer).Setup(x => x.DeserializeAsync(It.IsAny())).Returns(Task.FromResult(report)); + Mock.Get(assetsEmbedder).Setup(x => x.EmbedReportAssetsAsync(report, options)).Returns(Task.CompletedTask); + var bytes = Encoding.UTF8.GetBytes("SAMPLE REPORT"); + using var stream = new MemoryStream(bytes); + Mock.Get(serializer).Setup(x => x.SerializeAsync(report)).Returns(Task.FromResult(stream)); + Mock.Get(templateReader).Setup(x => x.ReadTemplate()).Returns(Task.FromResult("")); + + await sut.ConvertAsync(options); + + Assert.Multiple(async () => + { + FileAssert.Exists(options.ReportPath, "Report exists"); + using var reader = new StreamReader(File.Open(options.OutputPath, FileMode.Open)); + var reportContent = await reader.ReadToEndAsync(); + Assert.That(reportContent, Is.EqualTo(""), "Report has correct content"); + }); + } +} \ No newline at end of file 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..6b67bb5f --- /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("myAsset.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/JsonScreenplayReportReaderTests.cs b/Tests/CSF.Screenplay.Tests/Reporting/JsonScreenplayReportReaderTests.cs deleted file mode 100644 index 38af09e2..00000000 --- a/Tests/CSF.Screenplay.Tests/Reporting/JsonScreenplayReportReaderTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.IO; -using System.Linq; -using System.Text; -using CSF.Screenplay.ReportModel; - -namespace CSF.Screenplay.Reporting -{ - [TestFixture,Parallelizable] - public class JsonScreenplayReportReaderTests - { - const string reportJson = @"{""Metadata"": {""Timestamp"": ""2021-01-01T00:00:00Z"",""ReportFormatVersion"": ""2.0.0""}, -""Performances"": [ - { - ""NamingHierarchy"": [ - {""Identifier"": ""1"",""Name"": ""First""}, - {""Identifier"": ""2"",""Name"": ""Second""} - ], - ""Reportables"": [ - { - ""Type"": ""PerformableReport"", - ""Report"": ""This is a report string"", - ""ActorName"": ""Joe"", - ""PerformableType"": ""APerformableType"", - ""PerformancePhase"": ""Given"", - ""Result"": ""A result"", - ""HasResult"": true, - ""Assets"": [{""FilePath"": ""../a/file/path.txt"",""FileSummary"": ""This is a test asset""}], - ""Reportables"": [ - { - ""Type"": ""ActorCreatedReport"", - ""Report"": ""This is a nested report string"", - ""ActorName"": ""Joe"" - } - ] - } - ] - } -]}"; - - [Test, AutoMoqData] - public async Task DeserializeAsyncShouldReturnAScreenplayReport(JsonScreenplayReportReader sut) - { - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(reportJson)); - var result = await sut.DeserializeAsync(stream); - - Assert.Multiple(() => - { - Assert.That(result, Is.Not.Null, "The deserialized report should not be null."); - Assert.That(result.Metadata.Timestamp, Is.EqualTo(new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc)), "The timestamp should be correct."); - Assert.That(result.Metadata.ReportFormatVersion, Is.EqualTo("2.0.0"), "The report format version should be correct."); - Assert.That(result.Performances, Has.Count.EqualTo(1), "There should be one performance."); - - var firstPerformance = result.Performances.Single(); - Assert.That(firstPerformance.NamingHierarchy, Has.Count.EqualTo(2), "There should be two naming hierarchy items."); - Assert.That(firstPerformance.NamingHierarchy.Select(x => x.Name), Is.EquivalentTo(new[] { "First", "Second" }), "The naming hierarchy names should be correct."); - Assert.That(firstPerformance.Reportables, Has.Count.EqualTo(1), "There should be one reportable."); - - var firstReportable = firstPerformance.Reportables.Single(); - Assert.That(firstReportable.Report, Is.EqualTo("This is a report string"), "The report string should be correct."); - Assert.That(firstReportable.ActorName, Is.EqualTo("Joe"), "The actor name should be correct."); - Assert.That(firstReportable, Is.InstanceOf(), "The reportable should be a PerformableReport."); - - var performableReport = (PerformableReport) firstReportable; - Assert.That(performableReport.PerformableType, Is.EqualTo("APerformableType"), "The performable type should be correct."); - Assert.That(performableReport.PerformancePhase, Is.EqualTo("Given"), "The performance phase should be correct."); - Assert.That(performableReport.Result, Is.EqualTo("A result"), "The result should be correct."); - Assert.That(performableReport.HasResult, Is.True, "The HasResult flag should be correct."); - Assert.That(performableReport.Assets, Has.Count.EqualTo(1), "There should be one asset."); - Assert.That(performableReport.Assets.Single().FilePath, Is.EqualTo("../a/file/path.txt"), "The asset file path should be correct."); - Assert.That(performableReport.Assets.Single().FileSummary, Is.EqualTo("This is a test asset"), "The asset file summary should be correct."); - Assert.That(performableReport.Reportables, Has.Count.EqualTo(1), "There should be one nested reportable."); - }); - } - } -} \ 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 diff --git a/Tests/CSF.Screenplay.Tests/Reporting/ScreenplayReportSerializerTests.cs b/Tests/CSF.Screenplay.Tests/Reporting/ScreenplayReportSerializerTests.cs new file mode 100644 index 00000000..b852d64a --- /dev/null +++ b/Tests/CSF.Screenplay.Tests/Reporting/ScreenplayReportSerializerTests.cs @@ -0,0 +1,105 @@ +using System.IO; +using System.Linq; +using System.Text; +using CSF.Screenplay.ReportModel; + +namespace CSF.Screenplay.Reporting; + +[TestFixture, Parallelizable] +public class ScreenplayReportSerializerTests +{ + const string reportJson = @"{""Metadata"": {""Timestamp"": ""2021-01-01T00:00:00Z"",""ReportFormatVersion"": ""2.0.0""}, +""Performances"": [ + { + ""NamingHierarchy"": [ + {""Identifier"": ""1"",""Name"": ""First""}, + {""Identifier"": ""2"",""Name"": ""Second""} + ], + ""Reportables"": [ + { + ""Type"": ""PerformableReport"", + ""Report"": ""This is a report string"", + ""ActorName"": ""Joe"", + ""PerformableType"": ""APerformableType"", + ""PerformancePhase"": ""Given"", + ""Result"": ""A result"", + ""HasResult"": true, + ""Assets"": [{""FilePath"": ""../a/file/path.txt"",""FileSummary"": ""This is a test asset""}], + ""Reportables"": [ + { + ""Type"": ""ActorCreatedReport"", + ""Report"": ""This is a nested report string"", + ""ActorName"": ""Joe"" + } + ] + } + ] + } +]}"; + + [Test, AutoMoqData] + public void DeserializeAsyncShouldThrowIfStreamIsNull(ScreenplayReportSerializer sut) + { + Assert.That(() => sut.DeserializeAsync(null), Throws.ArgumentNullException); + } + + [Test, AutoMoqData] + public async Task DeserializeAsyncShouldDeserializeAStream(ScreenplayReportSerializer sut) + { + var bytes = Encoding.UTF8.GetBytes(@"{""Metadata"": { ""ReportFormatVersion"": ""foo bar"" }}"); + var stream = new MemoryStream(bytes); + var report = await sut.DeserializeAsync(stream); + Assert.That(report.Metadata.ReportFormatVersion, Is.EqualTo("foo bar")); + } + + [Test, AutoMoqData] + public async Task DeserializeAsyncShouldReturnAScreenplayReport(ScreenplayReportSerializer sut) + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(reportJson)); + var result = await sut.DeserializeAsync(stream); + + Assert.Multiple(() => + { + Assert.That(result, Is.Not.Null, "The deserialized report should not be null."); + Assert.That(result.Metadata.Timestamp, Is.EqualTo(new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc)), "The timestamp should be correct."); + Assert.That(result.Metadata.ReportFormatVersion, Is.EqualTo("2.0.0"), "The report format version should be correct."); + Assert.That(result.Performances, Has.Count.EqualTo(1), "There should be one performance."); + + var firstPerformance = result.Performances.Single(); + Assert.That(firstPerformance.NamingHierarchy, Has.Count.EqualTo(2), "There should be two naming hierarchy items."); + Assert.That(firstPerformance.NamingHierarchy.Select(x => x.Name), Is.EquivalentTo(new[] { "First", "Second" }), "The naming hierarchy names should be correct."); + Assert.That(firstPerformance.Reportables, Has.Count.EqualTo(1), "There should be one reportable."); + + var firstReportable = firstPerformance.Reportables.Single(); + Assert.That(firstReportable.Report, Is.EqualTo("This is a report string"), "The report string should be correct."); + Assert.That(firstReportable.ActorName, Is.EqualTo("Joe"), "The actor name should be correct."); + Assert.That(firstReportable, Is.InstanceOf(), "The reportable should be a PerformableReport."); + + var performableReport = (PerformableReport) firstReportable; + Assert.That(performableReport.PerformableType, Is.EqualTo("APerformableType"), "The performable type should be correct."); + Assert.That(performableReport.PerformancePhase, Is.EqualTo("Given"), "The performance phase should be correct."); + Assert.That(performableReport.Result, Is.EqualTo("A result"), "The result should be correct."); + Assert.That(performableReport.HasResult, Is.True, "The HasResult flag should be correct."); + Assert.That(performableReport.Assets, Has.Count.EqualTo(1), "There should be one asset."); + Assert.That(performableReport.Assets.Single().FilePath, Is.EqualTo("../a/file/path.txt"), "The asset file path should be correct."); + Assert.That(performableReport.Assets.Single().FileSummary, Is.EqualTo("This is a test asset"), "The asset file summary should be correct."); + Assert.That(performableReport.Reportables, Has.Count.EqualTo(1), "There should be one nested reportable."); + }); + } + + [Test, AutoMoqData] + public void SerializeAsyncShouldThrowIfReportIsNull(ScreenplayReportSerializer sut) + { + Assert.That(() => sut.SerializeAsync(null), Throws.ArgumentNullException); + } + + [Test, AutoMoqData] + public async Task SerializeAsyncShouldSerializeToAStream(ScreenplayReportSerializer sut) + { + var report = new ScreenplayReport() { Metadata = new () { ReportFormatVersion = "foo bar" } }; + var stream = await sut.SerializeAsync(report); + using var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync(); + Assert.That(content, Does.Match(@"^\{""Metadata"":\{""Timestamp"":""[\d-]{10}T[\d:.]{16}Z"",""ReportFormatVersion"":""foo bar""\},""Performances"":\[\]\}$")); + } +} \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Tests/SampleAsset1.png b/Tests/CSF.Screenplay.Tests/SampleAsset1.png new file mode 100644 index 00000000..90ed0fcb --- /dev/null +++ b/Tests/CSF.Screenplay.Tests/SampleAsset1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9fbd23de764423a16f8f13238500459a16305cc59476098faeb8d77e256da0e1 +size 8845 diff --git a/Tests/CSF.Screenplay.Tests/SampleAsset2.jpeg b/Tests/CSF.Screenplay.Tests/SampleAsset2.jpeg new file mode 100644 index 00000000..28bca9b1 --- /dev/null +++ b/Tests/CSF.Screenplay.Tests/SampleAsset2.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6dff8adf61abfe2831235f87627c6ca0f5b1896cea5116fbc9c966ee7db2e99 +size 17310 diff --git a/Tests/CSF.Screenplay.Tests/SampleAssetsAttribute.cs b/Tests/CSF.Screenplay.Tests/SampleAssetsAttribute.cs new file mode 100644 index 00000000..f49e2b2a --- /dev/null +++ b/Tests/CSF.Screenplay.Tests/SampleAssetsAttribute.cs @@ -0,0 +1,51 @@ +using System.IO; +using System.Reflection; +using AutoFixture; +using CSF.Screenplay.Reporting; +using CSF.Screenplay.ReportModel; + +namespace CSF.Screenplay; + +/// +/// Creates a which contains two sample assets, a PNG and a JPEG image file. +/// +public class SampleAssetsAttribute : CustomizeAttribute +{ + public override ICustomization GetCustomization(ParameterInfo parameter) + => new SampleAssetsCustomization(); +} + +public class SampleAssetsCustomization : ICustomization +{ + internal const string Asset1Filename = "SampleAsset1.png", Asset2Filename = "SampleAsset2.jpeg"; + + public void Customize(IFixture fixture) + { + fixture.Customize(c => c.FromFactory(() => + { + return new ScreenplayReport() + { + Performances = [ + new () { + Reportables = [ + new PerformableReport() { + Assets = [ + new () { + ContentType = MimeTypes.GetMimeType(Asset1Filename), + FileName = Asset1Filename, + FilePath = Path.Combine(Environment.CurrentDirectory, Asset1Filename) + }, + new () { + ContentType = MimeTypes.GetMimeType(Asset2Filename), + FileName = Asset2Filename, + FilePath = Path.Combine(Environment.CurrentDirectory, Asset2Filename) + }, + ] + } + ] + } + ] + }; + }).OmitAutoProperties()); + } +} \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Tests/SampleReport.json b/Tests/CSF.Screenplay.Tests/SampleReport.json new file mode 100644 index 00000000..a9a5975a --- /dev/null +++ b/Tests/CSF.Screenplay.Tests/SampleReport.json @@ -0,0 +1 @@ +{"Report": "This is a fake report file"} \ No newline at end of file