diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 23ab4e0..222a592 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: windows-latest steps: - uses: actions/checkout@v4 diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/IWebDriverContext.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/IWebDriverContext.cs new file mode 100644 index 0000000..df7d09b --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/IWebDriverContext.cs @@ -0,0 +1,15 @@ +using OpenQA.Selenium; +using OpenQA.Selenium.Support.UI; +using Xunit.Abstractions; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Drivers; + +public interface IWebDriverContext : IDisposable +{ + IWebDriver Driver { get; } + IWait Wait { get; } + void GoToUri(string path); + void GoToUri(string baseUri, string path); + void TakeScreenshot(ITestOutputHelper logger, string testName); +} + diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/IWebDriverFactory.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/IWebDriverFactory.cs new file mode 100644 index 0000000..805797c --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/IWebDriverFactory.cs @@ -0,0 +1,9 @@ +using OpenQA.Selenium; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Drivers; + +public interface IWebDriverFactory +{ + // TODO: reimplement Lazy + IWebDriver CreateDriver(); +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/WebDriverContext.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/WebDriverContext.cs new file mode 100644 index 0000000..a86cc0d --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/WebDriverContext.cs @@ -0,0 +1,139 @@ +using OpenQA.Selenium.Support.UI; +using OpenQA.Selenium; +using System.Drawing; +using Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Options; +using Microsoft.Extensions.Options; +using Xunit.Abstractions; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Drivers; + +public class WebDriverContext : IWebDriverContext +{ + private readonly WebDriverOptions _driverOptions; + // TODO: reimplement Lazy + private readonly IWebDriver _driver; + private readonly Lazy> _wait; + private readonly string _baseUri; + + public IWebDriver Driver => _driver; + public IWait Wait => _wait.Value; + private Type[] IgnoredExceptions { get; } = [typeof(StaleElementReferenceException)]; + + public WebDriverContext( + IWebDriverFactory factory, + IOptions options, + IOptions driverOptions + ) + { + _driver = factory?.CreateDriver() ?? throw new ArgumentNullException(nameof(factory)); + _baseUri = options.Value.GetWebUri() ?? throw new ArgumentNullException(nameof(options)); + _wait = new(() => InitializeWait(Driver, IgnoredExceptions)); + _driverOptions = driverOptions.Value; + } + + private IJavaScriptExecutor JsExecutor => + Driver as IJavaScriptExecutor ?? + throw new ArgumentNullException(nameof(IJavaScriptExecutor)); + + /// + /// Navigate to relative path + /// + /// + /// + public void GoToUri(string path) => GoToUri(_baseUri, path); + + /// + /// Navigate to a uri + /// + /// baseUri for site e.g https://google.co.uk + /// path from baseUri defaults to '/' e.g '/login' + /// + public void GoToUri(string baseUri, string path = "/") + { + _ = baseUri ?? throw new ArgumentNullException(nameof(baseUri)); + var absoluteUri = $"{baseUri.TrimEnd('/')}{path}"; + if (Uri.TryCreate(absoluteUri, UriKind.Absolute, out var uri)) + { + Driver.Navigate().GoToUrl(uri); + } + else + { + throw new ArgumentException(nameof(absoluteUri)); + } + } + + /// + /// Dispose of + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + using (Driver) + { + Driver.Quit(); + } + } + } + + public void TakeScreenshot(ITestOutputHelper logger, string testName) + { + + // Allows alternative path + var baseScreenshotDirectory = + Path.IsPathFullyQualified(_driverOptions.ScreenshotsDirectory) ? + _driverOptions.ScreenshotsDirectory : + Path.Combine(Directory.GetCurrentDirectory(), _driverOptions.ScreenshotsDirectory); + + Directory.CreateDirectory(baseScreenshotDirectory); + + var outputPath = Path.Combine( + baseScreenshotDirectory, + testName + ".png" + ); + + // Maximise viewport + Driver.Manage().Window.Size = new Size( + width: GetBrowserWidth(), + height: GetBrowserHeight() + ); + + // Screenshot + (Driver as ITakesScreenshot)? + .GetScreenshot() + .SaveAsFile(outputPath); + + logger.WriteLine($"SCREENSHOT SAVED IN LOCATION: {outputPath}"); + } + + private static IWait InitializeWait(IWebDriver driver, Type[] ignoredExceptions) + { + var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(20)); + wait.IgnoreExceptionTypes(ignoredExceptions); + return wait; + } + + private int GetBrowserWidth() => + // Math.max returns a 64 bit number requiring casting + (int)(long)JsExecutor.ExecuteScript( + @"return Math.max( + window.innerWidth, + document.body.scrollWidth, + document.documentElement.scrollWidth)" + ); + + private int GetBrowserHeight() => + // Math.max returns a 64 bit number requiring casting + (int)(long)JsExecutor.ExecuteScript( + @"return Math.max( + window.innerHeight, + document.body.scrollHeight, + document.documentElement.scrollHeight)" + ); +} \ No newline at end of file diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/WebDriverExtensions.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/WebDriverExtensions.cs new file mode 100644 index 0000000..a2f061f --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/WebDriverExtensions.cs @@ -0,0 +1,47 @@ +using OpenQA.Selenium; +using OpenQA.Selenium.Support.UI; +using SeleniumExtras.WaitHelpers; +using Xunit; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Drivers; + +public static class WebDriverExtensions +{ + public static void ElementDoesNotExist(this IWebDriverContext context, Func locate) + { + context.Wait.Timeout = TimeSpan.FromSeconds(4); + Assert.ThrowsAny(locate); + } + + public static IWebElement UntilElementContainsText(this IWebElement element, IWebDriverContext context, string text) + { + _ = text ?? throw new ArgumentNullException(nameof(text)); + context.Wait.Message = $"Element did not contain text {text}"; + context.Wait.Until(t => element.Text.Contains(text)); + context.Wait.Message = string.Empty; + return element; + } + + public static IWebElement UntilElementTextIs(IWebElement element, IWait wait, string text) + { + _ = text ?? throw new ArgumentNullException(nameof(text)); + wait.Message = $"Element did not equal text {text}"; + wait.Until(t => element.Text.Contains(text)); + wait.Message = string.Empty; + return element; + } + + public static IWebElement UntilAriaExpandedIs(this IWait wait, bool isExpanded, By locator) + { + var element = wait.UntilElementExists(locator); + wait.Until(_ => element.GetAttribute("aria-expanded") == (isExpanded ? "true" : "false")); + return element; + } + public static IWebElement UntilElementExists(this IWait wait, By by) => wait.Until(ExpectedConditions.ElementExists(by)); + + public static IWebElement UntilElementIsVisible(this IWait wait, By by) => wait.Until(ExpectedConditions.ElementIsVisible(by)); + + public static IWebElement UntilElementIsClickable(this IWait wait, By by) => wait.Until(ExpectedConditions.ElementToBeClickable(by)); + public static IReadOnlyList UntilMultipleElementsExist(this IWait wait, By by) => wait.Until(ExpectedConditions.PresenceOfAllElementsLocatedBy(by)); + public static IReadOnlyList UntilMultipleElementsVisible(this IWait wait, By by) => wait.Until(ExpectedConditions.VisibilityOfAllElementsLocatedBy(by)); +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/WebDriverFactory.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/WebDriverFactory.cs new file mode 100644 index 0000000..17eeb38 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Drivers/WebDriverFactory.cs @@ -0,0 +1,117 @@ +using OpenQA.Selenium.Chrome; +using OpenQA.Selenium.Firefox; +using OpenQA.Selenium; +using Microsoft.Extensions.Options; +using System.Drawing; +using Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Options; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Drivers; + +public sealed class WebDriverFactory : IWebDriverFactory +{ + private static readonly IEnumerable DEFAULT_OPTIONS = new[] + { + "--incognito", + "--safebrowsing-disable-download-protection", + "--no-sandbox", + "--start-maximized", + "--start-fullscreen" + }; + + private static readonly Dictionary MOBILE_VIEWPORTS = new() + { + { "desktop", (1920, 1080) }, + { "iphone14", (390, 844) }, + { "iphone11", (414, 896) } + }; + + private static TimeSpan DEFAULT_PAGE_LOAD_TIMEOUT = TimeSpan.FromSeconds(30); + + private readonly WebDriverOptions _webDriverOptions; + + private readonly WebDriverSessionOptions _sessionOptions; + + public WebDriverFactory( + IOptions webDriverOptions, + WebDriverSessionOptions sessionOptions + ) + { + _webDriverOptions = webDriverOptions?.Value ?? throw new ArgumentNullException(nameof(webDriverOptions)); + _sessionOptions = sessionOptions ?? throw new ArgumentNullException(nameof(_sessionOptions)); + } + + public IWebDriver CreateDriver() + { + // viewports are expressed as cartesian coordinates (x,y) + var viewportDoesNotExist = !MOBILE_VIEWPORTS.TryGetValue(_sessionOptions.Device, out var viewport); + + if (viewportDoesNotExist) + { + throw new ArgumentException($"device value {_sessionOptions.Device} has no mapped viewport"); + } + var (width, height) = viewport; + + _webDriverOptions.DriverBinaryDirectory ??= Directory.GetCurrentDirectory(); + + IWebDriver driver = _sessionOptions switch + { + { DisableJs: true } or { Browser: "firefox" } => CreateFirefoxDriver(_webDriverOptions, _sessionOptions), + _ => CreateChromeDriver(_webDriverOptions) + }; + + driver.Manage().Window.Size = new Size(width, height); + driver.Manage().Cookies.DeleteAllCookies(); + driver.Manage().Timeouts().PageLoad = DEFAULT_PAGE_LOAD_TIMEOUT; + return driver; + + } + + private static ChromeDriver CreateChromeDriver( + WebDriverOptions driverOptions + ) + { + ChromeOptions option = new(); + + option.AddArguments(DEFAULT_OPTIONS); + + // chromium based browsers using new headless switch https://www.selenium.dev/blog/2023/headless-is-going-away/ + + if (driverOptions.Headless) + { + option.AddArgument("--headless=new"); + } + + option.AddUserProfilePreference("safebrowsing.enabled", true); + option.AddUserProfilePreference("download.prompt_for_download", false); + option.AddUserProfilePreference("disable-popup-blocking", "true"); + option.AddArgument("--window-size=1920,1080"); + return new ChromeDriver(driverOptions.DriverBinaryDirectory, option); + } + + private static FirefoxDriver CreateFirefoxDriver( + WebDriverOptions driverOptions, + WebDriverSessionOptions sessionOptions + ) + { + var options = new FirefoxOptions + { + // TODO load TLS cert into firefox options + AcceptInsecureCertificates = true, + EnableDevToolsProtocol = true, + }; + + options.AddArguments(DEFAULT_OPTIONS); + + if (driverOptions.Headless) + { + options.AddArgument("--headless"); + } + + if (sessionOptions.DisableJs) + { + options.SetPreference("javascript.enabled", false); + } + + return new(driverOptions.DriverBinaryDirectory, options); + } +} \ No newline at end of file diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Extensions/StringExtensions.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Extensions/StringExtensions.cs new file mode 100644 index 0000000..8f5790c --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Extensions/StringExtensions.cs @@ -0,0 +1,19 @@ +using System.Text.RegularExpressions; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Extensions; + +public static class StringExtensions +{ + public static readonly Regex HtmlReplacer = new("<[^>]*>"); + public static string? ToLowerRemoveHyphens(this string? str) + => string.IsNullOrEmpty(str) ? str : str.Replace(' ', '-').ToLower(); + + public static string? ReplaceHTML(this string? str) + => string.IsNullOrEmpty(str) ? str : HtmlReplacer.Replace(str, string.Empty); + + public static string SanitiseToHTML(string input) + { + var ouput = input.Replace("\"", "\'"); + return ouput; + } +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Features/AccessibilityTests.feature b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Features/AccessibilityTests.feature new file mode 100644 index 0000000..209b0b6 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Features/AccessibilityTests.feature @@ -0,0 +1,10 @@ +Feature: AccessibilityTests + +Scenario: Home page accessibility + When the user views the home page + Then the home page is accessible + +@ignore +Scenario: Search results page accessibility + When the user views the search results page + Then the search results page is accessible \ No newline at end of file diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Features/AccessibilityTests.feature.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Features/AccessibilityTests.feature.cs new file mode 100644 index 0000000..5dc5198 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Features/AccessibilityTests.feature.cs @@ -0,0 +1,158 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by SpecFlow (https://www.specflow.org/). +// SpecFlow Version:3.9.0.0 +// SpecFlow Generator Version:3.9.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +namespace Dfe.Data.SearchPrototype.Web.Tests.AcceptanceTests.Features +{ + using TechTalk.SpecFlow; + using System; + using System.Linq; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "3.9.0.0")] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public partial class AccessibilityTestsFeature : object, Xunit.IClassFixture, System.IDisposable + { + + private static TechTalk.SpecFlow.ITestRunner testRunner; + + private static string[] featureTags = ((string[])(null)); + + private Xunit.Abstractions.ITestOutputHelper _testOutputHelper; + +#line 1 "AccessibilityTests.feature" +#line hidden + + public AccessibilityTestsFeature(AccessibilityTestsFeature.FixtureData fixtureData, Dfe_Data_SearchPrototype_Web_Tests_XUnitAssemblyFixture assemblyFixture, Xunit.Abstractions.ITestOutputHelper testOutputHelper) + { + this._testOutputHelper = testOutputHelper; + this.TestInitialize(); + } + + public static void FeatureSetup() + { + testRunner = TechTalk.SpecFlow.TestRunnerManager.GetTestRunner(); + TechTalk.SpecFlow.FeatureInfo featureInfo = new TechTalk.SpecFlow.FeatureInfo(new System.Globalization.CultureInfo("en-US"), "AcceptanceTests/Features", "AccessibilityTests", null, ProgrammingLanguage.CSharp, featureTags); + testRunner.OnFeatureStart(featureInfo); + } + + public static void FeatureTearDown() + { + testRunner.OnFeatureEnd(); + testRunner = null; + } + + public void TestInitialize() + { + } + + public void TestTearDown() + { + testRunner.OnScenarioEnd(); + } + + public void ScenarioInitialize(TechTalk.SpecFlow.ScenarioInfo scenarioInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testOutputHelper); + } + + public void ScenarioStart() + { + testRunner.OnScenarioStart(); + } + + public void ScenarioCleanup() + { + testRunner.CollectScenarioErrors(); + } + + void System.IDisposable.Dispose() + { + this.TestTearDown(); + } + + [Xunit.SkippableFactAttribute(DisplayName="Home page accessibility")] + [Xunit.TraitAttribute("FeatureTitle", "AccessibilityTests")] + [Xunit.TraitAttribute("Description", "Home page accessibility")] + public void HomePageAccessibility() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("Home page accessibility", null, tagsOfScenario, argumentsOfScenario, featureTags); +#line 3 +this.ScenarioInitialize(scenarioInfo); +#line hidden + if ((TagHelper.ContainsIgnoreTag(tagsOfScenario) || TagHelper.ContainsIgnoreTag(featureTags))) + { + testRunner.SkipScenario(); + } + else + { + this.ScenarioStart(); +#line 4 + testRunner.When("the user views the home page", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); +#line hidden +#line 5 + testRunner.Then("the home page is accessible", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); +#line hidden + } + this.ScenarioCleanup(); + } + + [Xunit.SkippableFactAttribute(DisplayName="Search results page accessibility", Skip="Ignored")] + [Xunit.TraitAttribute("FeatureTitle", "AccessibilityTests")] + [Xunit.TraitAttribute("Description", "Search results page accessibility")] + public void SearchResultsPageAccessibility() + { + string[] tagsOfScenario = new string[] { + "ignore"}; + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("Search results page accessibility", null, tagsOfScenario, argumentsOfScenario, featureTags); +#line 8 +this.ScenarioInitialize(scenarioInfo); +#line hidden + if ((TagHelper.ContainsIgnoreTag(tagsOfScenario) || TagHelper.ContainsIgnoreTag(featureTags))) + { + testRunner.SkipScenario(); + } + else + { + this.ScenarioStart(); +#line 9 + testRunner.When("the user views the search results page", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); +#line hidden +#line 10 + testRunner.Then("the search results page is accessible", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); +#line hidden + } + this.ScenarioCleanup(); + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "3.9.0.0")] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class FixtureData : System.IDisposable + { + + public FixtureData() + { + AccessibilityTestsFeature.FeatureSetup(); + } + + void System.IDisposable.Dispose() + { + AccessibilityTestsFeature.FeatureTearDown(); + } + } + } +} +#pragma warning restore +#endregion diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Hooks/SpecFlowHooks.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Hooks/SpecFlowHooks.cs new file mode 100644 index 0000000..2b68c4d --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Hooks/SpecFlowHooks.cs @@ -0,0 +1,36 @@ +using BoDi; +using TechTalk.SpecFlow; +using Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Drivers; +using Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Options; +using Microsoft.Extensions.Options; + +namespace UnitTestProject1 +{ + [Binding] + public class SpecFlowHooks + { + [BeforeTestRun] + public static void BeforeTest(ObjectContainer container) + { + + container.BaseContainer.RegisterInstanceAs(OptionsHelper.GetOptions(WebOptions.Key)); + + var driverOptions = OptionsHelper.GetOptions(WebDriverOptions.Key); + if (string.IsNullOrEmpty(driverOptions.Value.DriverBinaryDirectory)) + { + driverOptions.Value.DriverBinaryDirectory = Directory.GetCurrentDirectory(); + } + container.BaseContainer.RegisterInstanceAs>(driverOptions); + var accessibilityOptions = OptionsHelper.GetOptions(AccessibilityOptions.Key); + accessibilityOptions.Value.CreateArtifactOutputDirectory(); + container.BaseContainer.RegisterInstanceAs(accessibilityOptions); + } + + [BeforeScenario] + public void CreateWebDriver(IObjectContainer container) + { + container.RegisterTypeAs(); + container.RegisterTypeAs(); + } + } +} \ No newline at end of file diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/AccessibilityOptions.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/AccessibilityOptions.cs new file mode 100644 index 0000000..a26e9f6 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/AccessibilityOptions.cs @@ -0,0 +1,10 @@ +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Options; + +public sealed class AccessibilityOptions +{ + public const string Key = "accessibility"; + public string ArtifactsOutputPath { get; set; } = string.Empty; + public string ReportOutputDirectory { get; set; } = "axe-reports"; + public string[] WcagTags { get; set; } = new[] { "wcag2a", "wcag2aa", "wcag21a", "wcag21aa" }; +} + diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/AccessibilityOptionsExtensions.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/AccessibilityOptionsExtensions.cs new file mode 100644 index 0000000..ee24f74 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/AccessibilityOptionsExtensions.cs @@ -0,0 +1,20 @@ +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Options; + +internal static class AccessibilityOptionsExtensions +{ + internal static AccessibilityOptions CreateArtifactOutputDirectory( + this AccessibilityOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + var outputPath = Path.IsPathFullyQualified(options.ReportOutputDirectory) ? + options.ReportOutputDirectory : + Path.Combine(Directory.GetCurrentDirectory(), options.ReportOutputDirectory); + + Directory.CreateDirectory(outputPath); + options.ArtifactsOutputPath = outputPath; + return options; + } +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/OptionsHelper.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/OptionsHelper.cs new file mode 100644 index 0000000..314158a --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/OptionsHelper.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using static Microsoft.Extensions.Options.Options; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Options; + +public static class OptionsHelper +{ + private static readonly IConfiguration Configuration = + new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.test.json", false) + .Build(); + + public static IOptions GetOptions(string key) where T : class + => Create(Configuration.GetRequiredSection(key) + .Get() + ?? throw new ArgumentException("Configuration section returned null object")); +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/WebDriverOptions.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/WebDriverOptions.cs new file mode 100644 index 0000000..c60d01c --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/WebDriverOptions.cs @@ -0,0 +1,10 @@ +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Options; + +public sealed class WebDriverOptions +{ + public const string Key = "webDriver"; + public bool Headless { get; set; } + public string ScreenshotsDirectory { get; set; } = "screenshots"; + public string? DriverBinaryDirectory { get; set; } = null!; +} + diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/WebDriverSessionOptions.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/WebDriverSessionOptions.cs new file mode 100644 index 0000000..aea5965 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/WebDriverSessionOptions.cs @@ -0,0 +1,8 @@ +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Options; + +public sealed class WebDriverSessionOptions +{ + public string Browser { get; set; } = "chrome"; + public string Device { get; set; } = "desktop"; + public bool DisableJs { get; set; } = false; +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/WebOptions.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/WebOptions.cs new file mode 100644 index 0000000..4dfe4de --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Options/WebOptions.cs @@ -0,0 +1,15 @@ +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Options; + +public sealed class WebOptions +{ + private const ushort DEFAULT_HTTPS_PORT = 443; + public const string Key = "web"; + public string Scheme { get; set; } = null!; + public string Domain { get; set; } = null!; + public ushort Port { get; set; } = DEFAULT_HTTPS_PORT; + public string? WebUri { private get; set; } = null; + + public string GetWebUri() => + !string.IsNullOrEmpty(WebUri) ? WebUri : + Port == DEFAULT_HTTPS_PORT ? $"{Scheme}://{Domain}" : $"{Scheme}://{Domain}:{Port}"; +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/README.md b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/README.md new file mode 100644 index 0000000..be2463a --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/README.md @@ -0,0 +1,30 @@ +# Search Prototype Acceptance & Accessbility Tests + + +## Overview + +This test suite contains automated tests that check the functional and non-functional aspects of the search prototype application. + +## Prerequisites + +Currently only supports chrome browser, so ensure you have the chrome browser installed. + +## Test Levels + +- Functional + - Acceptance testing +- Non-functional + - Accessibility testing + +## Languages + +C# + +## Tools + +- [SpecFlow](https://specflow.org/) +- [Axe-core](https://github.com/dequelabs/axe-core/tree/develop) + +## Contribution + +Branches should be in the format `[contributor-initials]/test-[ticket-number]-[feature-description]`. diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Steps/AccessibilitySteps.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Steps/AccessibilitySteps.cs new file mode 100644 index 0000000..2c28934 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/Steps/AccessibilitySteps.cs @@ -0,0 +1,81 @@ +using Deque.AxeCore.Selenium; +using Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Drivers; +using Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Extensions; +using Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Options; +using Dfe.Data.SearchPrototype.Web.Tests.AcceptanceTests; +using Dfe.Data.SearchPrototype.Web.Tests.PageObjectModel; +using FluentAssertions; +using Microsoft.Extensions.Options; +using TechTalk.SpecFlow; +using Xunit; +using Xunit.Abstractions; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Steps +{ + [Binding] + public sealed class AccessibilitySteps : IClassFixture> + { + private readonly AccessibilityOptions _options; + private readonly ITestOutputHelper _logger; + private readonly IWebDriverContext _driverContext; + private readonly SearchPage _searchPage; + private readonly WebDriverSessionOptions _sessionOptions; + + private Dictionary _pageNameToUrlConverter = new Dictionary() + { + { "home", "/" }, + { "search results", "/?searchKeyWord=Academy" } + }; + + public AccessibilitySteps( + WebApplicationFactoryFixture factory, + IOptions options, + ITestOutputHelper logger, + IWebDriverContext driverContext, + SearchPage searchPage, + WebDriverSessionOptions sessionOptions + ) + { + factory.CreateDefaultClient(); + + _driverContext = driverContext; + _searchPage = searchPage; + _logger = logger; + _options = options.Value; + _sessionOptions = sessionOptions; + } + + [StepDefinition(@"the user views the (home|search results) page")] + public void OpenPage(string pageName) + { + _driverContext.GoToUri($"{_pageNameToUrlConverter[pageName]}"); + + _searchPage.HeadingElement.Text.Should().Be("Search prototype"); + } + + [StepDefinition(@"the (.*) is accessible")] + public void IsAccessible(string component) + { + // see https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#axe-core-tags + + var outputFile = Path.Combine( + _options.ArtifactsOutputPath, + $"{_sessionOptions.Device}-axe-result-{component.ToLowerRemoveHyphens()}.json" + ); + var axeResult = new AxeBuilder(_driverContext.Driver) + .WithTags(_options.WcagTags) + .WithOutputFile(outputFile) + .Exclude(_searchPage.SearchHiddenDiv.Criteria) + .Analyze(); + + _logger.WriteLine($"Scan completed output location {outputFile}"); + + // Check that axe ran successfuly https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#error-result + axeResult.Violations.Should().BeEmpty(); + + var passCount = axeResult.Passes.Length; + passCount.Should().NotBe(0); + _logger.WriteLine($"Passed accessibility test count {passCount} for {component} at {axeResult.Url}"); + } + } +} \ No newline at end of file diff --git a/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/WebApplicationFactoryFixture.cs b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/WebApplicationFactoryFixture.cs new file mode 100644 index 0000000..94d8630 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/AcceptanceTests/WebApplicationFactoryFixture.cs @@ -0,0 +1,62 @@ +using Dfe.Data.SearchPrototype.SearchForEstablishments; +using Dfe.Data.SearchPrototype.Web.Tests.Shared.SearchServiceAdapter; +using Dfe.Data.SearchPrototype.Web.Tests.Shared.SearchServiceAdapter.Options; +using Dfe.Data.SearchPrototype.Web.Tests.Shared.SearchServiceAdapter.Resources; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Dfe.Data.SearchPrototype.Web.Tests.AcceptanceTests +{ + public sealed class WebApplicationFactoryFixture : WebApplicationFactory where TEntryPoint : class + { + public string HostUrl { get; set; } = "http://localhost:5000"; // Default. + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseUrls(HostUrl); + builder.ConfigureServices(services => ConfigureServices(services)); + } + + protected override IHost CreateHost(IHostBuilder builder) + { + IHost testHost = builder.Build(); + + builder.ConfigureWebHost(webHostBuilder => webHostBuilder.UseKestrel()); + + var host = builder.Build(); + host.Start(); + + return testHost; + } + + private void ConfigureServices(IServiceCollection services) + { + // Remove registration of the default ISearchServiceAdapter (i.e. CognitiveSearchServiceAdapter). + var searchServiceAdapterDescriptor = + services.SingleOrDefault( + serviceDescriptor => serviceDescriptor.ServiceType == + typeof(ISearchServiceAdapter)); + + services.Remove(searchServiceAdapterDescriptor!); + + // Register our dummy search service adapter. + services.AddSingleton(); + services.AddScoped( + typeof(ISearchServiceAdapter), + typeof(DummySearchServiceAdapter)); + + string fileName = + new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.test.json", false) + .Build()["dummySearchServiceAdapter:fileName"]!; + + services.AddOptions() + .Configure((options) => + options.FileName = fileName); + } + } +} \ No newline at end of file diff --git a/Dfe.Data.SearchPrototype/Web/Tests/Dfe.Data.SearchPrototype.Web.Tests.csproj b/Dfe.Data.SearchPrototype/Web/Tests/Dfe.Data.SearchPrototype.Web.Tests.csproj index 17b8013..f3a6a34 100644 --- a/Dfe.Data.SearchPrototype/Web/Tests/Dfe.Data.SearchPrototype.Web.Tests.csproj +++ b/Dfe.Data.SearchPrototype/Web/Tests/Dfe.Data.SearchPrototype.Web.Tests.csproj @@ -1,27 +1,54 @@  - - net8.0 - enable - enable - + + net8.0 + enable + enable + - - - - - - - - - - - - + + + - - - - + + + + + + + Always + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + diff --git a/Dfe.Data.SearchPrototype/Web/Tests/PageIntegrationTests/SearchPageTests.cs b/Dfe.Data.SearchPrototype/Web/Tests/PageIntegrationTests/SearchPageTests.cs new file mode 100644 index 0000000..38fb0ac --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/PageIntegrationTests/SearchPageTests.cs @@ -0,0 +1,150 @@ +using AngleSharp.Dom; +using AngleSharp.Html.Dom; +using Dfe.Data.SearchPrototype.Web.Tests.PageObjectModel; +using Dfe.Data.SearchPrototype.Web.Tests.Shared.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; +using Xunit.Abstractions; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Integration +{ + public class SearchPageTests : IClassFixture + { + private const string uri = "http://localhost:5000"; + private readonly HttpClient _client; + private readonly ITestOutputHelper _logger; + private readonly WebApplicationFactory _factory; + + public SearchPageTests(PageWebApplicationFactory factory, ITestOutputHelper logger) + { + _factory = factory; + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = true + }); + _logger = logger; + } + + [Fact] + public async Task Search_Title_IsDisplayed() + { + var response = await _factory.CreateClient().GetAsync(uri); + + var document = await HtmlHelpers.GetDocumentAsync(response); + + document.GetElementText(SearchPage.Heading.Criteria).Should().Be("Search prototype"); + } + + [Fact] + public async Task Header_Link_IsDisplayed() + { + var response = await _factory.CreateClient().GetAsync(uri); + + var document = await HtmlHelpers.GetDocumentAsync(response); + + document.GetElementText(SearchPage.HomeLink.Criteria).Should().Be("Home"); + } + + [Fact] + public async Task Search_Establishment_IsDisplayed() + { + var response = await _factory.CreateClient().GetAsync(uri); + + var document = await HtmlHelpers.GetDocumentAsync(response); + + document.GetElementText(SearchPage.SearchHeading.Criteria).Should().Be("Search"); + + document.GetElementText(SearchPage.SearchSubHeading.Criteria).Should().Be("Search establishments and check their performance"); + + document.GetMultipleElements(SearchPage.SearchInput.Criteria).Count().Should().Be(1); + + document.GetMultipleElements(SearchPage.SearchButton.Criteria).Count().Should().Be(1); + } + + [Fact] + public async Task Search_ByName_ReturnsSingleResult() + { + var response = await _factory.CreateClient().GetAsync(uri); + var document = await HtmlHelpers.GetDocumentAsync(response); + + var formElement = document.QuerySelector(SearchPage.SearchForm.Criteria) ?? throw new Exception("Unable to find the sign in form"); + var formButton = document.QuerySelector(SearchPage.SearchButton.Criteria) ?? throw new Exception("Unable to find the submit button on search form"); + + var formResponse = await _client.SendAsync( + formElement, + formButton, + new Dictionary + { + ["searchKeyWord"] = "School" + }); + + _logger.WriteLine("SendAsync client base address: " + _client.BaseAddress); + _logger.WriteLine("SendAsync request message: " + formResponse.RequestMessage!.ToString()); + + var resultsPage = await HtmlHelpers.GetDocumentAsync(formResponse); + + _logger.WriteLine("Document: " + resultsPage.Body!.OuterHtml); + + resultsPage.GetElementText(SearchPage.SearchResultsNumber.Criteria).Should().Contain("Result"); + resultsPage.GetMultipleElements(SearchPage.SearchResultLinks.Criteria).Count().Should().Be(1); + } + + [Fact] + public async Task Search_ByName_ReturnsMultipleResults() + { + var response = await _factory.CreateClient().GetAsync(uri); + var document = await HtmlHelpers.GetDocumentAsync(response); + + var formElement = document.QuerySelector(SearchPage.SearchForm.Criteria) ?? throw new Exception("Unable to find the sign in form"); + var formButton = document.QuerySelector(SearchPage.SearchButton.Criteria) ?? throw new Exception("Unable to find the submit button on search form"); + + var formResponse = await _client.SendAsync( + formElement, + formButton, + new Dictionary + { + ["searchKeyWord"] = "Academy" + }); + + _logger.WriteLine("SendAsync client base address: " + _client.BaseAddress); + _logger.WriteLine("SendAsync request message: " + formResponse.RequestMessage!.ToString()); + + var resultsPage = await HtmlHelpers.GetDocumentAsync(formResponse); + + _logger.WriteLine("Document: " + resultsPage.Body!.OuterHtml); + + resultsPage.GetElementText(SearchPage.SearchResultsNumber.Criteria).Should().Contain("Results"); + resultsPage.GetMultipleElements(SearchPage.SearchResultLinks.Criteria).Count().Should().BeGreaterThan(1); + } + + [Theory] + [InlineData("ant")] + [InlineData("boo")] + public async Task Search_ByName_NoMatch_ReturnsNoResults(string searchTerm) + { + var response = await _factory.CreateClient().GetAsync(uri); + var document = await HtmlHelpers.GetDocumentAsync(response); + + var formElement = document.QuerySelector(SearchPage.SearchForm.Criteria) ?? throw new Exception("Unable to find the sign in form"); + var formButton = document.QuerySelector(SearchPage.SearchButton.Criteria) ?? throw new Exception("Unable to find the submit button on search form"); + + var formResponse = await _client.SendAsync( + formElement, + formButton, + new Dictionary + { + ["searchKeyWord"] = searchTerm + }); + + _logger.WriteLine("SendAsync client base address: " + _client.BaseAddress); + _logger.WriteLine("SendAsync request message: " + formResponse.RequestMessage!.ToString()); + + var resultsPage = await HtmlHelpers.GetDocumentAsync(formResponse); + + _logger.WriteLine("Document: " + resultsPage.Body!.OuterHtml); + + resultsPage.GetElementText(SearchPage.SearchNoResultText.Criteria).Should().Be("Sorry no results found please amend your search criteria"); + } + } +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/PageWebApplicationFactory.cs b/Dfe.Data.SearchPrototype/Web/Tests/PageWebApplicationFactory.cs new file mode 100644 index 0000000..10b909a --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/PageWebApplicationFactory.cs @@ -0,0 +1,55 @@ +using Dfe.Data.SearchPrototype.SearchForEstablishments; +using Dfe.Data.SearchPrototype.Web.Tests.Shared.SearchServiceAdapter; +using Dfe.Data.SearchPrototype.Web.Tests.Shared.SearchServiceAdapter.Options; +using Dfe.Data.SearchPrototype.Web.Tests.Shared.SearchServiceAdapter.Resources; +using DfE.Data.ComponentLibrary.Infrastructure.CognitiveSearch.Options; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Dfe.Data.SearchPrototype.Web.Tests; + +public sealed class PageWebApplicationFactory : WebApplicationFactory +{ + + public static readonly IConfiguration TestConfiguration = + new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.test.json", false) + .Build(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // TODO temp fix to read and set application settings from test appsettings.json, until devs generate appsettings with secrets in + if ( + string.IsNullOrEmpty(TestConfiguration["web:domain"]) || + string.IsNullOrEmpty(TestConfiguration["web:port"]) || + string.IsNullOrEmpty(TestConfiguration["web:scheme"])) + { + throw new ArgumentNullException("Missing test configuration: configure your user secrets file"); + }; + builder.ConfigureServices(services => + { + // remove any services that need overriding with test configuration + services.RemoveAll>(); + + services.RemoveAll(); + + // register dependencies with test configuration + + services.AddSingleton(); + + services.AddScoped(typeof(ISearchServiceAdapter), typeof(DummySearchServiceAdapter)); + + services.AddOptions().Configure( + (options) => options.FileName = TestConfiguration["dummySearchServiceAdapter:fileName"]); + + services.AddOptions().Configure( + (options) => options.Credentials = TestConfiguration["azureSearchClientOptions:credentials"]); + + }); + } +} \ No newline at end of file diff --git a/Dfe.Data.SearchPrototype/Web/Tests/Pages/BasePage.cs b/Dfe.Data.SearchPrototype/Web/Tests/Pages/BasePage.cs new file mode 100644 index 0000000..2678f30 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/Pages/BasePage.cs @@ -0,0 +1,13 @@ +using Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Drivers; + +namespace Dfe.Data.SearchPrototype.Web.Tests.PageObjectModel; + +public abstract class BasePage +{ + internal IWebDriverContext DriverContext { get; } + + public BasePage(IWebDriverContext driverContext) + { + DriverContext = driverContext ?? throw new ArgumentNullException(nameof(driverContext)); + } +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/Pages/SearchPage.cs b/Dfe.Data.SearchPrototype/Web/Tests/Pages/SearchPage.cs new file mode 100644 index 0000000..b8b1ab0 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/Pages/SearchPage.cs @@ -0,0 +1,24 @@ +using Dfe.Data.SearchPrototype.Web.Tests.Acceptance.Drivers; +using OpenQA.Selenium; + +namespace Dfe.Data.SearchPrototype.Web.Tests.PageObjectModel; + +public sealed class SearchPage : BasePage +{ + public SearchPage(IWebDriverContext driverContext) : base(driverContext) + { + } + + public IWebElement HeadingElement => DriverContext.Wait.UntilElementExists(By.CssSelector("header div div:nth-of-type(2) a")); + public static By Heading => By.CssSelector("header div div:nth-of-type(2) a"); + public static By HomeLink => By.CssSelector("nav a"); + public static By SearchHeading => By.CssSelector("h1 label"); + public static By SearchSubHeading => By.CssSelector("#searchKeyWord-hint"); + public By SearchHiddenDiv => By.CssSelector("#searchKeyWord + div"); + public static By SearchInput => By.CssSelector("#searchKeyWord"); + public static By SearchForm => By.CssSelector("#main-content form"); + public static By SearchButton => By.CssSelector("#main-content form button"); + public static By SearchResultsNumber => By.CssSelector(".govuk-heading-m"); + public static By SearchResultLinks => By.CssSelector("ul li h4 a"); + public static By SearchNoResultText => By.CssSelector("#main-content form + p"); +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/Shared/Helpers/HtmlHelpers.cs b/Dfe.Data.SearchPrototype/Web/Tests/Shared/Helpers/HtmlHelpers.cs new file mode 100644 index 0000000..2469948 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/Shared/Helpers/HtmlHelpers.cs @@ -0,0 +1,72 @@ +using AngleSharp; +using AngleSharp.Dom; +using AngleSharp.Html.Dom; +using AngleSharp.Io; +using System.Net.Http.Headers; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Shared.Helpers; + +public static class HtmlHelpers +{ + public static async Task GetDocumentAsync(this HttpResponseMessage response) + { + var content = await response.Content.ReadAsStringAsync(); + var config = Configuration.Default; + var document = await + BrowsingContext.New(config) + .OpenAsync(ResponseFactory, CancellationToken.None); + + return (IHtmlDocument)document; + void ResponseFactory(VirtualResponse htmlResponse) + { + htmlResponse + .Address(response.RequestMessage!.RequestUri) + .Status(response.StatusCode); + + MapHeaders(response.Headers); + MapHeaders(response.Content.Headers); + htmlResponse.Content(content); + void MapHeaders(HttpHeaders headers) + { + foreach (var header in headers) + { + foreach (var value in header.Value) + { + htmlResponse.Header(header.Key, value); + } + } + } + } + } + + public static IElement GetElement(this IParentNode document, string cssSelector) + { + if (string.IsNullOrEmpty(cssSelector)) + { + throw new ArgumentException("selector cannot be null or empty", nameof(cssSelector)); + } + return document.QuerySelector(cssSelector) ?? throw new ArgumentException($"Element not found with selector {cssSelector}"); + } + + public static IEnumerable GetMultipleElements(this IParentNode document, string cssSelector) + { + if (string.IsNullOrEmpty(cssSelector)) + { + throw new ArgumentException("selector cannot be null or empty", nameof(cssSelector)); + } + return document.QuerySelectorAll(cssSelector) ?? + throw new ArgumentNullException($"Multiple elements not found with selector {cssSelector}"); + } + + public static string GetElementText(this IParentNode document, string cssSelector) => document.GetElement(cssSelector).TextContent.Trim(); + + public static string? GetElementLinkValue(this IParentNode document, string cssSelector) => document.GetElement(cssSelector).GetAttribute("href"); + + public static IEnumerable GetMultipleElementText(this IParentNode document, string cssSelector) + => document.GetMultipleElements(cssSelector).Select(t => t.TextContent.Trim()); + + public static IEnumerable GetMultipleChildrenElementText(this IParentNode document, string cssSelector) + => document.GetMultipleElements(cssSelector) + .SelectMany(t => t.Children) + .Select(t => t.TextContent.Trim()); +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/Shared/Helpers/HttpClientExtensions.cs b/Dfe.Data.SearchPrototype/Web/Tests/Shared/Helpers/HttpClientExtensions.cs new file mode 100644 index 0000000..aff0060 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/Shared/Helpers/HttpClientExtensions.cs @@ -0,0 +1,75 @@ +using AngleSharp.Html.Dom; +using Dfe.Data.SearchPrototype.Web.Tests.Shared.Helpers; +using Xunit; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Shared.Helpers +{ + public static class HttpClientExtensions + { + public static Task SendAsync( + this HttpClient client, + IHtmlFormElement form, + IHtmlElement submitButton) + { + return client.SendAsync(form, submitButton, new Dictionary()); + } + + public static Task SendAsync( + this HttpClient client, + IHtmlFormElement form, + IEnumerable> formValues) + { + var submitElement = Assert.Single(form.QuerySelectorAll("[type=submit]")); + var submitButton = Assert.IsAssignableFrom(submitElement); + + return client.SendAsync(form, submitButton, formValues); + } + + public static Task SendAsync( + this HttpClient client, + IHtmlFormElement form, + IHtmlElement submitButton, + IEnumerable> formValues) + { + foreach (var kvp in formValues) + { + switch (form[kvp.Key]) + { + case IHtmlInputElement input: + input.Value = kvp.Value; + if (bool.TryParse(kvp.Value, out var isChecked)) + { + input.IsChecked = isChecked; + } + + break; + case IHtmlSelectElement select: + select.Value = kvp.Value; + break; + default: + throw new Exception($"Unknown form element: '{kvp.Key}'"); + } + } + + var submit = form.GetSubmission(submitButton); + var target = (Uri)submit!.Target; + if (submitButton.HasAttribute("formaction")) + { + var formaction = submitButton.GetAttribute("formaction"); + target = new Uri(formaction!, UriKind.Relative); + } + var submision = new HttpRequestMessage(new HttpMethod(submit.Method.ToString()), target) + { + Content = new StreamContent(submit.Body) + }; + + foreach (var header in submit.Headers) + { + submision.Headers.TryAddWithoutValidation(header.Key, header.Value); + submision.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + return client.SendAsync(submision); + } + } +} \ No newline at end of file diff --git a/Dfe.Data.SearchPrototype/Web/Tests/Shared/SearchServiceAdapter/DummySearchServiceAdapter.cs b/Dfe.Data.SearchPrototype/Web/Tests/Shared/SearchServiceAdapter/DummySearchServiceAdapter.cs new file mode 100644 index 0000000..75b177e --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/Shared/SearchServiceAdapter/DummySearchServiceAdapter.cs @@ -0,0 +1,39 @@ +using Dfe.Data.SearchPrototype.SearchForEstablishments; +using Dfe.Data.SearchPrototype.Web.Tests.Shared.SearchServiceAdapter.Resources; +using Newtonsoft.Json.Linq; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Shared.SearchServiceAdapter +{ + public sealed class DummySearchServiceAdapter : ISearchServiceAdapter where TSearchResult : class + { + private readonly IJsonFileLoader _jsonFileLoader; + + public DummySearchServiceAdapter(IJsonFileLoader jsonFileLoader) + { + _jsonFileLoader = jsonFileLoader; + } + + public async Task SearchAsync(SearchContext searchContext) + { + string json = await _jsonFileLoader.LoadJsonFile(); + + JObject establishmentsObject = JObject.Parse(json); + + IEnumerable establishments = + from establishmentToken in establishmentsObject["establishments"] + where establishmentToken["name"]!.ToString().Contains(searchContext.SearchKeyword) + select new Establishment( + (string)establishmentToken["urn"]!, + (string)establishmentToken["name"]!, + new Address( + (string)establishmentToken["address"]!["street"]!, + (string)establishmentToken["address"]!["locality"]!, + (string)establishmentToken["address"]!["address3"]!, + (string)establishmentToken["address"]!["town"]!, + (string)establishmentToken["address"]!["postcode"]!), + (string)establishmentToken["establishmentType"]!); + + return new EstablishmentResults(establishments); + } + } +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/Shared/SearchServiceAdapter/Options/DummySearchServiceAdapterOptions.cs b/Dfe.Data.SearchPrototype/Web/Tests/Shared/SearchServiceAdapter/Options/DummySearchServiceAdapterOptions.cs new file mode 100644 index 0000000..0f527a2 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/Shared/SearchServiceAdapter/Options/DummySearchServiceAdapterOptions.cs @@ -0,0 +1,7 @@ +namespace Dfe.Data.SearchPrototype.Web.Tests.Shared.SearchServiceAdapter.Options +{ + public sealed class DummySearchServiceAdapterOptions + { + public string? FileName { get; set; } + } +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/Shared/SearchServiceAdapter/Resources/Establishments.json b/Dfe.Data.SearchPrototype/Web/Tests/Shared/SearchServiceAdapter/Resources/Establishments.json new file mode 100644 index 0000000..bf4f62c --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/Shared/SearchServiceAdapter/Resources/Establishments.json @@ -0,0 +1,41 @@ +{ + "establishments": [ + + { + "urn": "123456", + "name": "Goose Academy", + "address": { + "street": "Goose Street", + "locality": "Goose Locality", + "address3": "Goose Address 3", + "town": "Goose Town", + "postcode": "GOO OSE" + }, + "establishmentType": "Academy" + }, + { + "urn": "234567", + "name": "Horse Academy", + "address": { + "street": "Horse Street", + "locality": "Horse Locality", + "address3": "Horse Address 3", + "town": "Horse Town", + "postcode": "HOR SEE" + }, + "establishmentType": "Academy" + }, + { + "urn": "345678", + "name": "Duck School", + "address": { + "street": "Duck Street", + "locality": "Duck Locality", + "address3": "Duck Address 3", + "town": "Duck Town", + "postcode": "DUU CKK" + }, + "establishmentType": "Community School" + } + ] +} \ No newline at end of file diff --git a/Dfe.Data.SearchPrototype/Web/Tests/Shared/SearchServiceAdapter/Resources/JsonFileLoader.cs b/Dfe.Data.SearchPrototype/Web/Tests/Shared/SearchServiceAdapter/Resources/JsonFileLoader.cs new file mode 100644 index 0000000..f9b2948 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/Shared/SearchServiceAdapter/Resources/JsonFileLoader.cs @@ -0,0 +1,37 @@ +using Dfe.Data.SearchPrototype.Web.Tests.Shared.SearchServiceAdapter.Options; +using Microsoft.Extensions.Options; + +namespace Dfe.Data.SearchPrototype.Web.Tests.Shared.SearchServiceAdapter.Resources +{ + public sealed class JsonFileLoader : IJsonFileLoader + { + private readonly DummySearchServiceAdapterOptions _options; + + public JsonFileLoader(IOptions options) + { + _options = options.Value; + } + + public Task LoadJsonFile() => LoadJsonFile(_options.FileName!); + + public async Task LoadJsonFile(string path) + { + string? rawJson = null; + + using (StreamReader sr = new StreamReader(path)) + { + rawJson = await sr.ReadToEndAsync(); + } + + return rawJson; + + } + } + + public interface IJsonFileLoader + { + Task LoadJsonFile(); + + Task LoadJsonFile(string path); + } +} diff --git a/Dfe.Data.SearchPrototype/Web/Tests/appsettings.test.json b/Dfe.Data.SearchPrototype/Web/Tests/appsettings.test.json new file mode 100644 index 0000000..e463148 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Web/Tests/appsettings.test.json @@ -0,0 +1,19 @@ +{ + "web": { + "domain": "localhost", + "port": 5000, + "scheme": "http" + }, + "webDriver": { + "headless": true + }, + "accessibility": { + "ArtifactsOutputPath": "" + }, + "azureSearchClientOptions": { + "credentials": "" + }, + "dummySearchServiceAdapter": { + "fileName": "Establishments.json" + } +} diff --git a/Dfe.Data.SearchPrototype/Web/Views/Home/Index.cshtml b/Dfe.Data.SearchPrototype/Web/Views/Home/Index.cshtml index f7fd0fa..10485f4 100644 --- a/Dfe.Data.SearchPrototype/Web/Views/Home/Index.cshtml +++ b/Dfe.Data.SearchPrototype/Web/Views/Home/Index.cshtml @@ -49,8 +49,8 @@
  • Type of establishment: @searchItem.EstablishmentType +
  • -
    }