From 4bac526d524ba3bf4d598df8e7b81796d6ca2fc7 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sat, 24 Jan 2026 16:47:57 +0000 Subject: [PATCH 01/23] WIP #250 Initial work towards testing with BrowserStack Right now this is just one browser, with a first stab at the CI logic and test logic to do this. I've not tried any of this out locally yet but conceptually everything's here. --- .github/workflows/crossBrowserTesting.yml | 136 ++++++++++++++++++ .github/workflows/dotnetCi.yml | 7 +- CSF.Screenplay/ScreenplayExtensions.cs | 13 ++ .../Actions/ClearCookiesTests.cs | 2 +- .../Actions/ClearLocalStorageTests.cs | 2 +- .../Actions/ClearTheContentsTests.cs | 2 +- .../Actions/ClickTests.cs | 2 +- .../Actions/DeleteTheCookieTests.cs | 2 +- .../Actions/DeselectAllTests.cs | 2 +- .../Actions/DeselectByIndexTests.cs | 2 +- .../Actions/DeselectByTextTests.cs | 2 +- .../Actions/DeselectByValueTests.cs | 2 +- .../Actions/ExecuteJavaScriptTests.cs | 2 +- .../Actions/OpenUrlTests.cs | 2 +- .../Actions/SelectByIndexTests.cs | 2 +- .../Actions/SelectByTextTests.cs | 2 +- .../Actions/SelectByValueTests.cs | 2 +- .../Actions/SendKeysTests.cs | 2 +- .../Actions/WaitTests.cs | 3 +- .../BrowserStack/BrowserStackDriverFactory.cs | 67 +++++++++ .../BrowserStack/BrowserStackEnvironment.cs | 36 +++++ .../BrowserStack/BrowserStackExtension.cs | 91 ++++++++++++ .../BrowserStackSessionIdProvider.cs | 21 +++ .../QueryPredicatePrototypeBuilderTests.cs | 2 +- .../Builders/UnnamedWaitBuilderTests.cs | 1 - .../CSF.Screenplay.Selenium.Tests.csproj | 2 + .../Elements/FilterElementsTests.cs | 3 +- .../Elements/LocatorTests.cs | 2 +- .../Queries/QueriesTests.cs | 2 +- .../ExecuteJavaScriptAndGetResultTests.cs | 2 +- .../Questions/FindElementsTests.cs | 2 +- .../Questions/GetWindowTitleTests.cs | 2 +- .../Questions/TakeScreenshotTests.cs | 2 +- .../ClickAndWaitForDocumentReadyTests.cs | 2 +- .../Tasks/TakeAndSaveAScreenshotTests.cs | 2 +- .../TestWebappSetupAndTeardown.cs | 8 ++ .../appsettings.json | 4 + 37 files changed, 407 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/crossBrowserTesting.yml create mode 100644 Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs create mode 100644 Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackEnvironment.cs create mode 100644 Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs create mode 100644 Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackSessionIdProvider.cs diff --git a/.github/workflows/crossBrowserTesting.yml b/.github/workflows/crossBrowserTesting.yml new file mode 100644 index 00000000..e174bf1b --- /dev/null +++ b/.github/workflows/crossBrowserTesting.yml @@ -0,0 +1,136 @@ +name: Cross-browser testing + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: "27 20 * * 0" + +jobs: + + # Summary: + # + # * Installs and configures the environment + # * Builds the solution in Debug configuration + # * Runs just the Selenium tests + # * In Debug configuration (.NET tests) + # * Using the BrowserStack browser configuration + + browser_tests: + + strategy: + matrix: + include: + - browserName: Chrome + browserVersion: latest-50 + os: Windows + osVersion: 11 + # - browserName: Chrome + # browserVersion: latest + # os: Windows + # osVersion: 11 + # - browserName: Edge + # browserVersion: latest + # os: Windows + # osVersion: 11 + # - browserName: Firefox + # browserVersion: latest + # os: Windows + # osVersion: 11 + # - browserName: Firefox + # browserVersion: latest-40 + # os: Windows + # osVersion: 11 + # - browserName: Safari + # browserVersion: 17.3 + # os: OS X + # osVersion: Sonoma + # - browserName: Safari + # browserVersion: 26.2 + # os: OS X + # osVersion: Tahoe + + name: Build, test & package + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + env: + RunNumber: ${{ github.run_number }}.${{ github.run_attempt }} + VersionSuffix: crossbrowser.${{ github.run_number }} + Configuration: Release + Tfm: net8.0 + DotnetVersion: 8.0.x + "WebDriverFactory::SelectedConfiguration": BrowserStack + BSbrowserName: ${{ matrix.browserName }} + BSbrowserVersion: ${{ matrix.browserVersion }} + BSos: ${{ matrix.os }} + BSosVersion: ${{ matrix.osVersion }} + BSuserName: ${{ secrets.BROWSERSTACK_USERNAME }} + BSaccessKey: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + BSprojectName: CSF.Screenplay + BSbuildName: ghActionsRun.${{ github.run_number }}.${{ github.run_attempt }}_${{ matrix.browserName }}:${{ matrix.browserVersion }}_${{ matrix.os }}:${{ matrix.osVersion }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Install build dependencies + + - name: Install .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DotnetVersion }} + - name: Install Node.js for building JSON-to-HTML report converter + uses: actions/setup-node@v6.2.0 + + # Environment setup pre-build + + - name: Restore .NET packages + run: dotnet restore + - name: Restore Node modules + run: | + cd CSF.Screenplay.JsonToHtmlReport.Template/src + npm ci + cd ../.. + + # Build and test the solution + + - name: Build the solution + run: dotnet build -c ${{ env.Configuration }} + - name: Run .NET tests with coverage + id: dotnet_tests + run: dotnet test -c ${{ env.Configuration }} --no-build Tests/CSF.Screenplay.Selenium.Tests -- NumberOfTestWorkers=5 + continue-on-error: true + + # Post-test tasks (artifacts, overall status) + - name: Upload .NET test results artifacts + uses: actions/upload-artifact@v4 + with: + name: NUnit test results + path: Tests/CSF.Screenplay.Selenium.Tests/**/TestResults.xml + - name: Upload Screenplay JSON report artifact + uses: actions/upload-artifact@v4 + with: + name: Screenplay JSON reports + 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") + do + reportDir=$(dirname "$report") + outputFile="$reportDir/ScreenplayReport.html" + dotnet run --no-build --framework $Tfm -c {{ env.Configuration }} --project CSF.Screenplay.JsonToHtmlReport --ReportPath "$report" --OutputPath "$outputFile" + done + - name: Upload Screenplay HTML report artifact + uses: actions/upload-artifact@v4 + with: + name: Screenplay HTML reports + path: Tests/**/ScreenplayReport.html + - name: Fail the build if any test failures + if: steps.dotnet_tests.outputs.failures == 'true' + run: | + echo "Failing the build due to test failures" + exit 1 diff --git a/.github/workflows/dotnetCi.yml b/.github/workflows/dotnetCi.yml index 0bfb8788..a3cff3a2 100644 --- a/.github/workflows/dotnetCi.yml +++ b/.github/workflows/dotnetCi.yml @@ -40,6 +40,8 @@ jobs: BranchParam: ${{ github.event_name == 'pull_request' && 'sonar.pullrequest.branch' || 'sonar.branch.name' }} PullRequestParam: ${{ github.event_name == 'pull_request' && format('/d:sonar.pullrequest.key={0}', github.event.number) || '' }} DISPLAY: :99 + # Change selected factory to VerboseChrome to debug Chrome-related issues + "WebDriverFactory::SelectedConfiguration": DefaultChrome steps: - name: Checkout @@ -171,7 +173,7 @@ jobs: do reportDir=$(dirname "$report") outputFile="$reportDir/ScreenplayReport.html" - dotnet run --no-build --framework $Tfm --project CSF.Screenplay.JsonToHtmlReport --ReportPath "$report" --OutputPath "$outputFile" + dotnet run --no-build --framework $Tfm -c ${{ env.Configuration }} --project CSF.Screenplay.JsonToHtmlReport --ReportPath "$report" --OutputPath "$outputFile" done - name: Upload Screenplay HTML report artifact uses: actions/upload-artifact@v4 @@ -202,6 +204,3 @@ jobs: with: name: Docs website path: docs/**/* - - # runBrowserTests: - # TODO: Use build-results artifacts and run tests on matrix of browsers diff --git a/CSF.Screenplay/ScreenplayExtensions.cs b/CSF.Screenplay/ScreenplayExtensions.cs index 8aed7762..41097a2f 100644 --- a/CSF.Screenplay/ScreenplayExtensions.cs +++ b/CSF.Screenplay/ScreenplayExtensions.cs @@ -208,6 +208,19 @@ public static ScopeAndPerformance CreateScopedPerformance(this Screenplay screen return new ScopeAndPerformance(performance, scope); } + /// + /// Gets the event bus from the screenplay's service provider. + /// + /// The screenplay from which to retrieve the event bus. + /// The event bus instance. + /// If is . + public static IHasPerformanceEvents GetEventBus(this Screenplay screenplay) + { + if (screenplay is null) + throw new ArgumentNullException(nameof(screenplay)); + return screenplay.ServiceProvider.GetRequiredService(); + } + static AsyncPerformanceLogic GetAsyncPerformanceLogic(SyncPerformanceLogic syncPerformanceLogic) { return (services, token) => diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClearCookiesTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClearCookiesTests.cs index 282240e4..2d08deef 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClearCookiesTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClearCookiesTests.cs @@ -5,7 +5,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class ClearCookiesTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClearLocalStorageTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClearLocalStorageTests.cs index 0ea3f400..f883dd5e 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClearLocalStorageTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClearLocalStorageTests.cs @@ -11,7 +11,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class ClearLocalStorageTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClearTheContentsTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClearTheContentsTests.cs index c7ae7092..2c6ebb9b 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClearTheContentsTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClearTheContentsTests.cs @@ -5,7 +5,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class ClearTheContentsTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClickTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClickTests.cs index 4c3f2aab..973e32a1 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClickTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/ClickTests.cs @@ -5,7 +5,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class ClickTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeleteTheCookieTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeleteTheCookieTests.cs index e98bc464..9d345472 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeleteTheCookieTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeleteTheCookieTests.cs @@ -5,7 +5,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class DeleteTheCookieTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectAllTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectAllTests.cs index 347b0954..520abeb7 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectAllTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectAllTests.cs @@ -5,7 +5,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class DeselectAllTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectByIndexTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectByIndexTests.cs index c395de28..ef76073b 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectByIndexTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectByIndexTests.cs @@ -5,7 +5,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class DeselectByIndexTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectByTextTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectByTextTests.cs index 9e136906..fcf50825 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectByTextTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectByTextTests.cs @@ -5,7 +5,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class DeselectByTextTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectByValueTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectByValueTests.cs index 01392935..a1fb7570 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectByValueTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/DeselectByValueTests.cs @@ -5,7 +5,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class DeselectByValueTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/ExecuteJavaScriptTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/ExecuteJavaScriptTests.cs index d3e3103e..c7bd2617 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/ExecuteJavaScriptTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/ExecuteJavaScriptTests.cs @@ -4,7 +4,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class ExecuteJavaScriptTests { const string scriptBody = """ diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/OpenUrlTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/OpenUrlTests.cs index 332d5e71..0191cddc 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/OpenUrlTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/OpenUrlTests.cs @@ -5,7 +5,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class OpenUrlTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/SelectByIndexTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/SelectByIndexTests.cs index 20261bff..8f4e005f 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/SelectByIndexTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/SelectByIndexTests.cs @@ -6,7 +6,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class SelectByIndexTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/SelectByTextTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/SelectByTextTests.cs index 459bd6a5..3ed84074 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/SelectByTextTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/SelectByTextTests.cs @@ -6,7 +6,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class SelectByTextTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/SelectByValueTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/SelectByValueTests.cs index fe7dbd96..098a262c 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/SelectByValueTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/SelectByValueTests.cs @@ -6,7 +6,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class SelectByValueTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/SendKeysTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/SendKeysTests.cs index 13ce74c0..7e86e419 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/SendKeysTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/SendKeysTests.cs @@ -4,7 +4,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class SendKeysTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Actions/WaitTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Actions/WaitTests.cs index 2c407228..db9b43b6 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Actions/WaitTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Actions/WaitTests.cs @@ -2,13 +2,12 @@ using System; using CSF.Screenplay.Performables; using CSF.Screenplay.Selenium.Elements; -using OpenQA.Selenium; using static CSF.Screenplay.PerformanceStarter; using static CSF.Screenplay.Selenium.PerformableBuilder; namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class WaitTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs new file mode 100644 index 00000000..81a93ba3 --- /dev/null +++ b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using CSF.Extensions.WebDriver.Factories; +using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; +using OpenQA.Selenium.Edge; +using OpenQA.Selenium.Firefox; +using OpenQA.Selenium.Remote; +using OpenQA.Selenium.Safari; +using static CSF.Screenplay.Selenium.BrowserStack.BrowserStackEnvironment; + +namespace CSF.Screenplay.Selenium.BrowserStack; + +/// +/// Implementation of which creates drivers for BrowserStack. +/// +/// +/// +/// I'm using this instead of the BrowserStack SDK because I'm already modifying the way that tests run via Screenplay, so I want to avoid +/// messing with them twice. Later, I can give a try with the official SDK to see if its compatible with Screenplay. +/// +/// +public class BrowserStackDriverFactory : ICreatesWebDriverFromOptions +{ + const string AdditionalOptionsCapabilityName = "bstack:options"; + + const string GridUrl = "http://localhost:4444/wd/hub/"; + + public WebDriverAndOptions GetWebDriver(WebDriverCreationOptions options, Action? supplementaryConfiguration = null) + { + var driverOptions = GetDriverOptions(); + driverOptions.AddAdditionalOption(AdditionalOptionsCapabilityName, GetBrowserStackOptions()); + var driver = new RemoteWebDriver(new Uri(GridUrl), driverOptions); + return new (driver, driverOptions); + } + + DriverOptions GetDriverOptions() + { + var browserName = GetBrowserName(); + return browserName switch + { + "Chrome" => new ChromeOptions(), + "Edge" => new EdgeOptions(), + "Firefox" => new FirefoxOptions(), + "Safari" => new SafariOptions(), + _ => throw new InvalidOperationException($"The {BrowserName} environment variable: '{GetBrowserName()}' must indicate a supported browser"), + }; + } + + static Dictionary GetBrowserStackOptions() + { + return new () + { + { "os", GetOperatingSystem() }, + { "osVersion", GetOperatingSystemVersion() }, + { "browserVersion", GetBrowserVersion() }, + { "userName", GetBrowserStackUserName() }, + { "accessKey", GetBrowserStackAccessKey() }, + { "local", bool.TrueString.ToLowerInvariant() }, + { "projectName", GetProjectName() }, + { "buildName", GetBuildName() }, + { "sessionName", GetTestName() } + }; + } + + static string GetTestName() => TestContext.CurrentContext.Test.ID; +} diff --git a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackEnvironment.cs b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackEnvironment.cs new file mode 100644 index 00000000..a9996fc9 --- /dev/null +++ b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackEnvironment.cs @@ -0,0 +1,36 @@ +using System; +using static System.Environment; + +namespace CSF.Screenplay.Selenium.BrowserStack; + +/// +/// Static helper provides access to environment variables which control . +/// +public static class BrowserStackEnvironment +{ + internal const string + BrowserName = "BSbrowserName", + BrowserVersion = "BSbrowserVersion", + OperatingSystem = "BSos", + OperatingSystemVersion = "BSosVersion", + BrowserStackUserName = "BSuserName", + BrowserStackAccessKey = "BSaccessKey", + ProjectName = "BSprojectName", + BuildName = "BSbuildName", + SelectedWebDriverConfig = "WebDriverFactory::SelectedConfiguration", + BrowserStackConfigName = "BrowserStack"; + + internal static string? GetBrowserName() => GetEnvironmentVariable(BrowserName); + internal static string? GetBrowserVersion() => GetEnvironmentVariable(BrowserVersion); + internal static string? GetOperatingSystem() => GetEnvironmentVariable(OperatingSystem); + internal static string? GetOperatingSystemVersion() => GetEnvironmentVariable(OperatingSystemVersion); + internal static string? GetBrowserStackUserName() => GetEnvironmentVariable(BrowserStackUserName); + internal static string? GetBrowserStackAccessKey() => GetEnvironmentVariable(BrowserStackAccessKey); + internal static string? GetProjectName() => GetEnvironmentVariable(ProjectName); + internal static string? GetBuildName() => GetEnvironmentVariable(BuildName); + + /// + /// Gets a value indicating whether the current test run is running on BrowserStack + /// + internal static bool IsBrowserStackTest() => GetEnvironmentVariable(SelectedWebDriverConfig) == BrowserStackConfigName; +} \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs new file mode 100644 index 00000000..fb2e887d --- /dev/null +++ b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Configuration.Assemblies; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Reflection; +using System.Text; +using BrowserStack; +using CSF.Screenplay.Performances; +using Microsoft.Extensions.DependencyInjection; + +namespace CSF.Screenplay.Selenium.BrowserStack; + +public sealed class BrowserStackExtension : IDisposable +{ + const string urlPattern = "https://www.browserstack.com/automate/sessions/{0}.json"; + + Local? browserStackLocal; + IHasPerformanceEvents? eventBus; + HttpClient? httpClient; + + public void OnTestRunStarting() + { + if(!BrowserStackEnvironment.IsBrowserStackTest()) return; + + browserStackLocal = new Local(); + browserStackLocal.start(GetBrowserStackLocalArgs().ToList()); + + httpClient = GetHttpClient(); + + eventBus = ScreenplayLocator.GetScreenplay(Assembly.GetExecutingAssembly()).GetEventBus(); + eventBus.PerformanceFinished += UpdateBrowserStackSession; + + } + + void UpdateBrowserStackSession(object? sender, PerformanceFinishedEventArgs e) + { + if(!e.Success.HasValue) return; + + var sessionId = BrowserStackSessionIdProvider.GetBrowserStackSessionId(e.Performance); + if(sessionId is null) return; + + var uri = GetApiUri(sessionId); + var requestMessage = GetRequestMessage(uri, e.Success.Value); + httpClient!.Send(requestMessage); + } + + static HttpRequestMessage GetRequestMessage(Uri requestUri, bool success) + { + return new (HttpMethod.Put, requestUri) + { + Content = JsonContent.Create(@$"{{""status"":""{ (success ? "passed" : "failed") }"", ""reason"":""""}}"), + }; + } + + static Uri GetApiUri(string sessionId) => new(string.Format(urlPattern, sessionId)); + + static HttpClient GetHttpClient() + { + var client = new HttpClient(); + client.DefaultRequestHeaders.Authorization = GetAuthenticationHeaderValue(); + return client; + } + + static AuthenticationHeaderValue GetAuthenticationHeaderValue() + { + var headerBytes = Encoding.ASCII.GetBytes($"{BrowserStackEnvironment.GetBrowserStackUserName()}:{BrowserStackEnvironment.GetBrowserStackAccessKey()}"); + var headerValue = Convert.ToBase64String(headerBytes); + return new ("Basic", headerValue); + } + + /// + public void Dispose() + { + browserStackLocal?.stop(); + httpClient?.Dispose(); + + if(eventBus is null) return; + eventBus.PerformanceFinished -= UpdateBrowserStackSession; + } + + static Dictionary GetBrowserStackLocalArgs() + { + return new () + { + { "key", BrowserStackEnvironment.GetBrowserStackAccessKey()! }, + }; + } +} \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackSessionIdProvider.cs b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackSessionIdProvider.cs new file mode 100644 index 00000000..23a134c3 --- /dev/null +++ b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackSessionIdProvider.cs @@ -0,0 +1,21 @@ +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json.Nodes; + +namespace CSF.Screenplay.Selenium.BrowserStack; + +public static class BrowserStackSessionIdProvider +{ + public static string? GetBrowserStackSessionId(IPerformance performance) + { + var performanceCast = performance.ServiceProvider.GetRequiredService(); + var actors = performanceCast.GetCastList().Select(name => performanceCast.GetActor(name)); + var browseTheWeb = actors.FirstOrDefault((ICanPerform actor) => actor.HasAbility())?.GetAbility(); + if(browseTheWeb is null) return null; + + var javascriptExecutor = browseTheWeb.GetJavaScriptExecutor(); + var sessionDetailsJson = (string) javascriptExecutor.ExecuteScript("browserstack_executor: {\"action\": \"getSessionDetails\"}"); + var sessionDetails = JsonNode.Parse(sessionDetailsJson)!; + return (string?) sessionDetails["hashed_id"]; + } +} \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Builders/QueryPredicatePrototypeBuilderTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Builders/QueryPredicatePrototypeBuilderTests.cs index 85d491ab..637515a4 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Builders/QueryPredicatePrototypeBuilderTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Builders/QueryPredicatePrototypeBuilderTests.cs @@ -7,7 +7,7 @@ namespace CSF.Screenplay.Selenium.Builders; -[TestFixture] +[TestFixture, Parallelizable] public class QueryPredicatePrototypeBuilderTests { [Test, AutoMoqData] diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Builders/UnnamedWaitBuilderTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Builders/UnnamedWaitBuilderTests.cs index 32857132..2c48b1b0 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Builders/UnnamedWaitBuilderTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Builders/UnnamedWaitBuilderTests.cs @@ -1,5 +1,4 @@ using System; -using System.Reflection; using CSF.Screenplay.Performables; using CSF.Screenplay.Selenium.Actions; diff --git a/Tests/CSF.Screenplay.Selenium.Tests/CSF.Screenplay.Selenium.Tests.csproj b/Tests/CSF.Screenplay.Selenium.Tests/CSF.Screenplay.Selenium.Tests.csproj index 00908a6b..2ab33c4a 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/CSF.Screenplay.Selenium.Tests.csproj +++ b/Tests/CSF.Screenplay.Selenium.Tests/CSF.Screenplay.Selenium.Tests.csproj @@ -22,6 +22,8 @@ + + diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Elements/FilterElementsTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Elements/FilterElementsTests.cs index 823cc11f..1bd0d1ab 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Elements/FilterElementsTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Elements/FilterElementsTests.cs @@ -1,10 +1,9 @@ namespace CSF.Screenplay.Selenium.Elements; -using CSF.Specifications; using static CSF.Screenplay.PerformanceStarter; using static CSF.Screenplay.Selenium.PerformableBuilder; -[TestFixture] +[TestFixture, Parallelizable] public class FilterElementsTests { static readonly Locator diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Elements/LocatorTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Elements/LocatorTests.cs index 51cfc2f9..d6497e5f 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Elements/LocatorTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Elements/LocatorTests.cs @@ -2,7 +2,7 @@ namespace CSF.Screenplay.Selenium.Elements; using static CSF.Screenplay.PerformanceStarter; using static CSF.Screenplay.Selenium.PerformableBuilder; -[TestFixture, Description("Tests for various subclasses of Locator")] +[TestFixture, Description("Tests for various subclasses of Locator"), Parallelizable] public class LocatorTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Queries/QueriesTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Queries/QueriesTests.cs index 6f300c5c..c492a792 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Queries/QueriesTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Queries/QueriesTests.cs @@ -6,7 +6,7 @@ namespace CSF.Screenplay.Selenium.Queries; using static CSF.Screenplay.PerformanceStarter; using static CSF.Screenplay.Selenium.PerformableBuilder; -[TestFixture, Description("Tests for many classes in the Queries namespace")] +[TestFixture, Description("Tests for many classes in the Queries namespace"), Parallelizable] public class QueriesTests { static readonly ITarget diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Questions/ExecuteJavaScriptAndGetResultTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Questions/ExecuteJavaScriptAndGetResultTests.cs index 914e230a..af9e36b1 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Questions/ExecuteJavaScriptAndGetResultTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Questions/ExecuteJavaScriptAndGetResultTests.cs @@ -4,7 +4,7 @@ namespace CSF.Screenplay.Selenium.Actions; -[TestFixture] +[TestFixture, Parallelizable] public class ExecuteJavaScriptAndGetResultTests { const string scriptBody1 = """ diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Questions/FindElementsTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Questions/FindElementsTests.cs index 028cef8a..d0314f6c 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Questions/FindElementsTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Questions/FindElementsTests.cs @@ -5,7 +5,7 @@ namespace CSF.Screenplay.Selenium.Questions; -[TestFixture] +[TestFixture, Parallelizable] public class FindElementsTests { static readonly Locator diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Questions/GetWindowTitleTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Questions/GetWindowTitleTests.cs index 374f08b9..675d5116 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Questions/GetWindowTitleTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Questions/GetWindowTitleTests.cs @@ -3,7 +3,7 @@ namespace CSF.Screenplay.Selenium.Questions; -[TestFixture] +[TestFixture, Parallelizable] public class GetWindowTitleTests { static readonly NamedUri testPage = new NamedUri("OpenUrlTests.html", "the test page"); diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Questions/TakeScreenshotTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Questions/TakeScreenshotTests.cs index 9a9113ff..45ea41f5 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Questions/TakeScreenshotTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Questions/TakeScreenshotTests.cs @@ -3,7 +3,7 @@ namespace CSF.Screenplay.Selenium.Questions; -[TestFixture] +[TestFixture, Parallelizable] public class TakeScreenshotTests { static readonly NamedUri testPage = new NamedUri("OpenUrlTests.html", "the test page"); diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Tasks/ClickAndWaitForDocumentReadyTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Tasks/ClickAndWaitForDocumentReadyTests.cs index 7f55d7fe..907afe03 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Tasks/ClickAndWaitForDocumentReadyTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Tasks/ClickAndWaitForDocumentReadyTests.cs @@ -7,7 +7,7 @@ namespace CSF.Screenplay.Selenium.Tasks; -[TestFixture] +[TestFixture, Parallelizable] public class ClickAndWaitForDocumentReadyTests { static readonly NamedUri startPage = new NamedUri("DelayedNavigation.html", "the test page"); diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Tasks/TakeAndSaveAScreenshotTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Tasks/TakeAndSaveAScreenshotTests.cs index 05a93930..d73ab8b6 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/Tasks/TakeAndSaveAScreenshotTests.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/Tasks/TakeAndSaveAScreenshotTests.cs @@ -4,7 +4,7 @@ namespace CSF.Screenplay.Selenium.Questions; -[TestFixture] +[TestFixture, Parallelizable] public class TakeAndSaveAScreenshotTests { static readonly NamedUri testPage = new NamedUri("OpenUrlTests.html", "the test page"); diff --git a/Tests/CSF.Screenplay.Selenium.Tests/TestWebappSetupAndTeardown.cs b/Tests/CSF.Screenplay.Selenium.Tests/TestWebappSetupAndTeardown.cs index 7598be40..b534c716 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/TestWebappSetupAndTeardown.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/TestWebappSetupAndTeardown.cs @@ -2,6 +2,8 @@ using System.Diagnostics; using System.IO; using System.Net.Http; +using CSF.Screenplay.Performances; +using CSF.Screenplay.Selenium.BrowserStack; namespace CSF.Screenplay.Selenium; @@ -12,12 +14,16 @@ public class TestWebappSetupAndTeardown const int secondsDelay = 2; static Process? webAppProcess; + static BrowserStackExtension browserStack; [OneTimeSetUp] public async Task StartWebAppAsync() { webAppProcess = Process.Start("dotnet", $"run --project {GetPathToWebappProject()}"); await WaitForWebAppToBeAvailableAsync(); + + browserStack = new BrowserStackExtension(); + browserStack.OnTestRunStarting(); } [OneTimeTearDown] @@ -25,6 +31,8 @@ public void StopWebApp() { webAppProcess?.Kill(true); webAppProcess?.Dispose(); + + browserStack?.Dispose(); } /// diff --git a/Tests/CSF.Screenplay.Selenium.Tests/appsettings.json b/Tests/CSF.Screenplay.Selenium.Tests/appsettings.json index 2e931337..05665cd4 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/appsettings.json +++ b/Tests/CSF.Screenplay.Selenium.Tests/appsettings.json @@ -7,6 +7,10 @@ "VerboseChrome": { "DriverType": "ChromeDriver", "DriverFactoryType": "CSF.Screenplay.Selenium.VerboseChromeDriverFactory, CSF.Screenplay.Selenium.Tests" + }, + "BrowserStack": { + "DriverType": "RemoteWebDriver", + "DriverFactoryType": "CSF.Screenplay.Selenium.BrowserStack.BrowserStackDriverFactory, CSF.Screenplay.Selenium.Tests" } }, "SelectedConfiguration": "DefaultChrome" From e6f4ab97ff597dfedb28265bdce84af388602540 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sat, 24 Jan 2026 16:58:20 +0000 Subject: [PATCH 02/23] WIP #250 - Attempt to fix syntax --- .github/workflows/crossBrowserTesting.yml | 4 ++-- .github/workflows/dotnetCi.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/crossBrowserTesting.yml b/.github/workflows/crossBrowserTesting.yml index e174bf1b..208198aa 100644 --- a/.github/workflows/crossBrowserTesting.yml +++ b/.github/workflows/crossBrowserTesting.yml @@ -52,7 +52,7 @@ jobs: # os: OS X # osVersion: Tahoe - name: Build, test & package + name: Run tests runs-on: ubuntu-24.04 timeout-minutes: 30 @@ -62,7 +62,7 @@ jobs: Configuration: Release Tfm: net8.0 DotnetVersion: 8.0.x - "WebDriverFactory::SelectedConfiguration": BrowserStack + WebDriverFactory__SelectedConfiguration: BrowserStack BSbrowserName: ${{ matrix.browserName }} BSbrowserVersion: ${{ matrix.browserVersion }} BSos: ${{ matrix.os }} diff --git a/.github/workflows/dotnetCi.yml b/.github/workflows/dotnetCi.yml index a3cff3a2..1695f40f 100644 --- a/.github/workflows/dotnetCi.yml +++ b/.github/workflows/dotnetCi.yml @@ -41,7 +41,7 @@ jobs: PullRequestParam: ${{ github.event_name == 'pull_request' && format('/d:sonar.pullrequest.key={0}', github.event.number) || '' }} DISPLAY: :99 # Change selected factory to VerboseChrome to debug Chrome-related issues - "WebDriverFactory::SelectedConfiguration": DefaultChrome + WebDriverFactory__SelectedConfiguration: DefaultChrome steps: - name: Checkout From 907efdd4a2d8e286ab3b5000bb9f289e3ff55912 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sat, 24 Jan 2026 17:03:53 +0000 Subject: [PATCH 03/23] WIP #250 - Fix fail-condition --- .github/workflows/crossBrowserTesting.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/crossBrowserTesting.yml b/.github/workflows/crossBrowserTesting.yml index 208198aa..b681ec08 100644 --- a/.github/workflows/crossBrowserTesting.yml +++ b/.github/workflows/crossBrowserTesting.yml @@ -130,7 +130,7 @@ jobs: name: Screenplay HTML reports path: Tests/**/ScreenplayReport.html - name: Fail the build if any test failures - if: steps.dotnet_tests.outputs.failures == 'true' + if: steps.dotnet_tests.outcome == 'failure' run: | echo "Failing the build due to test failures" exit 1 From 4b9b458e13549c83d7b8ed7df54f651488d71f75 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 10:54:25 +0000 Subject: [PATCH 04/23] WIP #250 - Update YML Small improvements --- .github/workflows/crossBrowserTesting.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/crossBrowserTesting.yml b/.github/workflows/crossBrowserTesting.yml index b681ec08..cb153667 100644 --- a/.github/workflows/crossBrowserTesting.yml +++ b/.github/workflows/crossBrowserTesting.yml @@ -71,6 +71,7 @@ jobs: BSaccessKey: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} BSprojectName: CSF.Screenplay BSbuildName: ghActionsRun.${{ github.run_number }}.${{ github.run_attempt }}_${{ matrix.browserName }}:${{ matrix.browserVersion }}_${{ matrix.os }}:${{ matrix.osVersion }} + BSparallelism: 5 steps: - name: Checkout @@ -99,9 +100,9 @@ jobs: - name: Build the solution run: dotnet build -c ${{ env.Configuration }} - - name: Run .NET tests with coverage + - name: Run .NET Selenium tests only id: dotnet_tests - run: dotnet test -c ${{ env.Configuration }} --no-build Tests/CSF.Screenplay.Selenium.Tests -- NumberOfTestWorkers=5 + run: dotnet test -c ${{ env.Configuration }} --no-build Tests/CSF.Screenplay.Selenium.Tests -- NumberOfTestWorkers=$BSparallelism continue-on-error: true # Post-test tasks (artifacts, overall status) From 717bdc3b1f4aa275a5205c3e14d722320103ffea Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 11:20:50 +0000 Subject: [PATCH 05/23] WIP #250 - Add console logging This is to see what's happening in WebDriverExtensions. I suspect that it's rejecting my config, but it really doesn't need to. --- .../CSF.Screenplay.Selenium.Tests.csproj | 1 + Tests/CSF.Screenplay.Selenium.Tests/ScreenplayFactory.cs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Tests/CSF.Screenplay.Selenium.Tests/CSF.Screenplay.Selenium.Tests.csproj b/Tests/CSF.Screenplay.Selenium.Tests/CSF.Screenplay.Selenium.Tests.csproj index 2ab33c4a..abe377c4 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/CSF.Screenplay.Selenium.Tests.csproj +++ b/Tests/CSF.Screenplay.Selenium.Tests/CSF.Screenplay.Selenium.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/Tests/CSF.Screenplay.Selenium.Tests/ScreenplayFactory.cs b/Tests/CSF.Screenplay.Selenium.Tests/ScreenplayFactory.cs index 113e6b73..110d7afc 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/ScreenplayFactory.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/ScreenplayFactory.cs @@ -1,6 +1,7 @@ using CSF.Extensions.WebDriver; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace CSF.Screenplay.Selenium; @@ -12,6 +13,7 @@ public Screenplay GetScreenplay() { services.AddSingleton(GetConfiguration()); services.AddWebDriverFactory(); + services.AddLogging(l => l.AddConsole()); services.AddTransient(); services.AddTransient(); From a7f8c1eff6db32eaa6c03abe304ee04d3dba549c Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 11:24:38 +0000 Subject: [PATCH 06/23] WIP #250 - Trying to force log output --- Tests/CSF.Screenplay.Selenium.Tests/ScreenplayFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CSF.Screenplay.Selenium.Tests/ScreenplayFactory.cs b/Tests/CSF.Screenplay.Selenium.Tests/ScreenplayFactory.cs index 110d7afc..5fde4d84 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/ScreenplayFactory.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/ScreenplayFactory.cs @@ -13,7 +13,7 @@ public Screenplay GetScreenplay() { services.AddSingleton(GetConfiguration()); services.AddWebDriverFactory(); - services.AddLogging(l => l.AddConsole()); + services.AddLogging(l => l.AddConsole(c => c.LogToStandardErrorThreshold = LogLevel.Warning)); services.AddTransient(); services.AddTransient(); From ace6d18b1c4c902085dc35cb74ed2767533aa21c Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 15:45:48 +0000 Subject: [PATCH 07/23] WIP #250 - Consume new version of WebDriver extensions This should fix the error I'm getting about the config not existing. --- CSF.Screenplay.Selenium/CSF.Screenplay.Selenium.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CSF.Screenplay.Selenium/CSF.Screenplay.Selenium.csproj b/CSF.Screenplay.Selenium/CSF.Screenplay.Selenium.csproj index c649c3c8..743e2d55 100644 --- a/CSF.Screenplay.Selenium/CSF.Screenplay.Selenium.csproj +++ b/CSF.Screenplay.Selenium/CSF.Screenplay.Selenium.csproj @@ -15,7 +15,7 @@ - + From d645793677c47720eb85cea10e46e940eaec5f79 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 15:50:34 +0000 Subject: [PATCH 08/23] Fix BrowserStack Grid URL --- .../BrowserStack/BrowserStackDriverFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs index 81a93ba3..d3c29702 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs @@ -24,7 +24,7 @@ public class BrowserStackDriverFactory : ICreatesWebDriverFromOptions { const string AdditionalOptionsCapabilityName = "bstack:options"; - const string GridUrl = "http://localhost:4444/wd/hub/"; + const string GridUrl = "https://hub-cloud.browserstack.com/wd/hub/"; public WebDriverAndOptions GetWebDriver(WebDriverCreationOptions options, Action? supplementaryConfiguration = null) { From ade75c06eb3c4eaf44529076e0db5d8dfa510619 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 15:56:43 +0000 Subject: [PATCH 09/23] Fix report syntax --- .github/workflows/crossBrowserTesting.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/crossBrowserTesting.yml b/.github/workflows/crossBrowserTesting.yml index cb153667..206e1316 100644 --- a/.github/workflows/crossBrowserTesting.yml +++ b/.github/workflows/crossBrowserTesting.yml @@ -123,7 +123,7 @@ jobs: do reportDir=$(dirname "$report") outputFile="$reportDir/ScreenplayReport.html" - dotnet run --no-build --framework $Tfm -c {{ env.Configuration }} --project CSF.Screenplay.JsonToHtmlReport --ReportPath "$report" --OutputPath "$outputFile" + dotnet run --no-build --framework $Tfm -c ${{ env.Configuration }} --project CSF.Screenplay.JsonToHtmlReport --ReportPath "$report" --OutputPath "$outputFile" done - name: Upload Screenplay HTML report artifact uses: actions/upload-artifact@v4 From 496484b2b17362f59dbc4f78b9eb4201fbda9395 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 16:12:55 +0000 Subject: [PATCH 10/23] Fix crash issue in report JS --- .../src/js/ReportWriter/ReportableElementCreator.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportableElementCreator.js b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportableElementCreator.js index efc18215..b330a690 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportableElementCreator.js +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/js/ReportWriter/ReportableElementCreator.js @@ -87,8 +87,11 @@ export class ReportableElementCreator { } const reportElement = reportableElement.querySelector('.report'); - reportElement.setAttribute('title', 'Performable report; click to expand/collapse'); - reportElement.addEventListener('click', ev => ev.currentTarget.parentElement.classList.toggle('collapsed')); + if(reportElement) { + reportElement.setAttribute('title', 'Performable report; click to expand/collapse'); + reportElement.addEventListener('click', ev => ev.currentTarget.parentElement.classList.toggle('collapsed')); + } + for (const containedReportable of reportable.Reportables) { const reportableElement = this.createReportableElement(containedReportable); From 2f609bec0ea166682482a2f5cd6890ef1941f1af Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 16:52:00 +0000 Subject: [PATCH 11/23] Wait for BrowserStack local to start --- .../BrowserStack/BrowserStackExtension.cs | 10 +++++++--- .../TestWebappSetupAndTeardown.cs | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs index fb2e887d..126ba1d6 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Configuration.Assemblies; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; @@ -9,7 +8,6 @@ using System.Text; using BrowserStack; using CSF.Screenplay.Performances; -using Microsoft.Extensions.DependencyInjection; namespace CSF.Screenplay.Selenium.BrowserStack; @@ -21,12 +19,18 @@ public sealed class BrowserStackExtension : IDisposable IHasPerformanceEvents? eventBus; HttpClient? httpClient; - public void OnTestRunStarting() + public async Task OnTestRunStarting() { if(!BrowserStackEnvironment.IsBrowserStackTest()) return; browserStackLocal = new Local(); browserStackLocal.start(GetBrowserStackLocalArgs().ToList()); + for(var i = 0; i < 10; i++) + { + await Task.Delay(250); + if(browserStackLocal.isRunning()) break; + } + if(!browserStackLocal.isRunning()) throw new TimeoutException("BrowserStack Local is still not running after 2.5 seconds"); httpClient = GetHttpClient(); diff --git a/Tests/CSF.Screenplay.Selenium.Tests/TestWebappSetupAndTeardown.cs b/Tests/CSF.Screenplay.Selenium.Tests/TestWebappSetupAndTeardown.cs index b534c716..791f4249 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/TestWebappSetupAndTeardown.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/TestWebappSetupAndTeardown.cs @@ -23,7 +23,7 @@ public async Task StartWebAppAsync() await WaitForWebAppToBeAvailableAsync(); browserStack = new BrowserStackExtension(); - browserStack.OnTestRunStarting(); + await browserStack.OnTestRunStarting(); } [OneTimeTearDown] From b2e8412eed39695f0e74799df6364e0e97949f69 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 16:59:02 +0000 Subject: [PATCH 12/23] Extend timeout --- .../BrowserStack/BrowserStackExtension.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs index 126ba1d6..5492fef1 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs @@ -25,12 +25,12 @@ public async Task OnTestRunStarting() browserStackLocal = new Local(); browserStackLocal.start(GetBrowserStackLocalArgs().ToList()); - for(var i = 0; i < 10; i++) + for(var i = 0; i < 80; i++) { await Task.Delay(250); if(browserStackLocal.isRunning()) break; } - if(!browserStackLocal.isRunning()) throw new TimeoutException("BrowserStack Local is still not running after 2.5 seconds"); + if(!browserStackLocal.isRunning()) throw new TimeoutException("BrowserStack Local is still not running after 20 seconds"); httpClient = GetHttpClient(); From 02bc357e6d7690e640175aea9c24171b18ab31dd Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 17:02:20 +0000 Subject: [PATCH 13/23] Print wait attempts --- .../BrowserStack/BrowserStackExtension.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs index 5492fef1..1eae5c10 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackExtension.cs @@ -27,6 +27,7 @@ public async Task OnTestRunStarting() browserStackLocal.start(GetBrowserStackLocalArgs().ToList()); for(var i = 0; i < 80; i++) { + Console.WriteLine("Waiting for BrowserStackLocal to start up, attempt {0} of {1} ...", i + 1, 80); await Task.Delay(250); if(browserStackLocal.isRunning()) break; } From a0be644141e89b3d1239654b761cc0c52ff4cf71 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 18:11:13 +0000 Subject: [PATCH 14/23] Possibly fix BrowserStack integration I made a mistake with an env variable name, so my logic wasn't being executed. --- .../BrowserStack/BrowserStackEnvironment.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackEnvironment.cs b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackEnvironment.cs index a9996fc9..e74637ba 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackEnvironment.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackEnvironment.cs @@ -17,7 +17,7 @@ internal const string BrowserStackAccessKey = "BSaccessKey", ProjectName = "BSprojectName", BuildName = "BSbuildName", - SelectedWebDriverConfig = "WebDriverFactory::SelectedConfiguration", + SelectedWebDriverConfig = "WebDriverFactory__SelectedConfiguration", BrowserStackConfigName = "BrowserStack"; internal static string? GetBrowserName() => GetEnvironmentVariable(BrowserName); From 5cdc7d9aeec4527ec28537338b074f03b6c1edcf Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 18:25:39 +0000 Subject: [PATCH 15/23] Enable cross-browser testing This enables the tests in all browsers, it also attempts to improve the name of the test in BrowserStack. --- .github/workflows/crossBrowserTesting.yml | 49 ++++++++++--------- .../BrowserStack/BrowserStackDriverFactory.cs | 2 +- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/.github/workflows/crossBrowserTesting.yml b/.github/workflows/crossBrowserTesting.yml index 206e1316..1b567a30 100644 --- a/.github/workflows/crossBrowserTesting.yml +++ b/.github/workflows/crossBrowserTesting.yml @@ -21,36 +21,37 @@ jobs: browser_tests: strategy: + max-parallel: 1 matrix: include: - browserName: Chrome browserVersion: latest-50 os: Windows osVersion: 11 - # - browserName: Chrome - # browserVersion: latest - # os: Windows - # osVersion: 11 - # - browserName: Edge - # browserVersion: latest - # os: Windows - # osVersion: 11 - # - browserName: Firefox - # browserVersion: latest - # os: Windows - # osVersion: 11 - # - browserName: Firefox - # browserVersion: latest-40 - # os: Windows - # osVersion: 11 - # - browserName: Safari - # browserVersion: 17.3 - # os: OS X - # osVersion: Sonoma - # - browserName: Safari - # browserVersion: 26.2 - # os: OS X - # osVersion: Tahoe + - browserName: Chrome + browserVersion: latest + os: Windows + osVersion: 11 + - browserName: Edge + browserVersion: latest + os: Windows + osVersion: 11 + - browserName: Firefox + browserVersion: latest + os: Windows + osVersion: 11 + - browserName: Firefox + browserVersion: latest-40 + os: Windows + osVersion: 11 + - browserName: Safari + browserVersion: 17.3 + os: OS X + osVersion: Sonoma + - browserName: Safari + browserVersion: 26.2 + os: OS X + osVersion: Tahoe name: Run tests runs-on: ubuntu-24.04 diff --git a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs index d3c29702..59363bf1 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs @@ -63,5 +63,5 @@ DriverOptions GetDriverOptions() }; } - static string GetTestName() => TestContext.CurrentContext.Test.ID; + static string GetTestName() => TestContext.CurrentContext.Test.FullName; } From e17f33bcf3301bac80a9d8a96bc4335ce9bd5502 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 18:57:59 +0000 Subject: [PATCH 16/23] Update failure strategy I want to see the results from all the browsers, not just the first failure. --- .github/workflows/crossBrowserTesting.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/crossBrowserTesting.yml b/.github/workflows/crossBrowserTesting.yml index 1b567a30..79124df4 100644 --- a/.github/workflows/crossBrowserTesting.yml +++ b/.github/workflows/crossBrowserTesting.yml @@ -22,6 +22,7 @@ jobs: strategy: max-parallel: 1 + fail-fast: false matrix: include: - browserName: Chrome From da0347fdbaa7f89ff80c487bc741945b8d6d3a99 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 22:11:12 +0000 Subject: [PATCH 17/23] Update artifact names Also remove a redundant step --- .github/workflows/crossBrowserTesting.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/crossBrowserTesting.yml b/.github/workflows/crossBrowserTesting.yml index 79124df4..2c64273b 100644 --- a/.github/workflows/crossBrowserTesting.yml +++ b/.github/workflows/crossBrowserTesting.yml @@ -108,15 +108,10 @@ jobs: continue-on-error: true # Post-test tasks (artifacts, overall status) - - name: Upload .NET test results artifacts - uses: actions/upload-artifact@v4 - with: - name: NUnit test results - path: Tests/CSF.Screenplay.Selenium.Tests/**/TestResults.xml - name: Upload Screenplay JSON report artifact uses: actions/upload-artifact@v4 with: - name: Screenplay JSON reports + name: Screenplay JSON reports ${{ matrix.browserName }}:${{ matrix.browserVersion }}_${{ matrix.os }}:${{ matrix.osVersion }} path: Tests/CSF.Screenplay.Selenium.Tests/**/ScreenplayReport_*.json - name: Convert Screenplay reports to HTML continue-on-error: true @@ -130,7 +125,7 @@ jobs: - name: Upload Screenplay HTML report artifact uses: actions/upload-artifact@v4 with: - name: Screenplay HTML reports + name: Screenplay HTML reports ${{ matrix.browserName }}:${{ matrix.browserVersion }}_${{ matrix.os }}:${{ matrix.osVersion }} path: Tests/**/ScreenplayReport.html - name: Fail the build if any test failures if: steps.dotnet_tests.outcome == 'failure' From 05862e07233fa9e5325beba057d341113daf5428 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sun, 25 Jan 2026 22:28:06 +0000 Subject: [PATCH 18/23] Fix artifact names --- .github/workflows/crossBrowserTesting.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/crossBrowserTesting.yml b/.github/workflows/crossBrowserTesting.yml index 2c64273b..4fe2994a 100644 --- a/.github/workflows/crossBrowserTesting.yml +++ b/.github/workflows/crossBrowserTesting.yml @@ -111,7 +111,7 @@ jobs: - name: Upload Screenplay JSON report artifact uses: actions/upload-artifact@v4 with: - name: Screenplay JSON reports ${{ matrix.browserName }}:${{ matrix.browserVersion }}_${{ matrix.os }}:${{ matrix.osVersion }} + name: Screenplay JSON reports ${{ matrix.browserName }}_${{ matrix.browserVersion }}_${{ matrix.os }}_${{ matrix.osVersion }} path: Tests/CSF.Screenplay.Selenium.Tests/**/ScreenplayReport_*.json - name: Convert Screenplay reports to HTML continue-on-error: true @@ -125,7 +125,7 @@ jobs: - name: Upload Screenplay HTML report artifact uses: actions/upload-artifact@v4 with: - name: Screenplay HTML reports ${{ matrix.browserName }}:${{ matrix.browserVersion }}_${{ matrix.os }}:${{ matrix.osVersion }} + name: Screenplay HTML reports ${{ matrix.browserName }}_${{ matrix.browserVersion }}_${{ matrix.os }}_${{ matrix.osVersion }} path: Tests/**/ScreenplayReport.html - name: Fail the build if any test failures if: steps.dotnet_tests.outcome == 'failure' From 89e642aa7ba3a43b915676899a7c786fea33f102 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Tue, 27 Jan 2026 22:04:45 +0000 Subject: [PATCH 19/23] Resolve #250 - Add tests for entering dates --- .../Builders/EnterTheDateBuilder.cs | 80 +++++++++++++++++++ .../PerformableBuilder.elementPerformables.cs | 13 +++ CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs | 76 ++++++++++++++++++ .../wwwroot/InputDateTests.html | 21 +++++ .../Tasks/EnterTheDateTests.cs | 60 ++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 CSF.Screenplay.Selenium/Builders/EnterTheDateBuilder.cs create mode 100644 CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs create mode 100644 Tests/CSF.Screenplay.Selenium.TestWebapp/wwwroot/InputDateTests.html create mode 100644 Tests/CSF.Screenplay.Selenium.Tests/Tasks/EnterTheDateTests.cs diff --git a/CSF.Screenplay.Selenium/Builders/EnterTheDateBuilder.cs b/CSF.Screenplay.Selenium/Builders/EnterTheDateBuilder.cs new file mode 100644 index 00000000..95c63371 --- /dev/null +++ b/CSF.Screenplay.Selenium/Builders/EnterTheDateBuilder.cs @@ -0,0 +1,80 @@ +using System; +using System.Globalization; +using CSF.Screenplay.Performables; +using CSF.Screenplay.Selenium.Actions; +using CSF.Screenplay.Selenium.Elements; + +namespace CSF.Screenplay.Selenium.Builders +{ + /// + /// A builder type which creates an instance of . + /// + public class EnterTheDateBuilder : IGetsPerformable + { + readonly DateTime? date; + ITarget target; + CultureInfo culture; + + /// + /// Specifies the target element into which to enter the date. This must be an <input type="date"> element. + /// + /// The target element + /// This same builder, so calls may be chained + /// If is null + /// If this method is used more than once + public EnterTheDateBuilder Into(ITarget target) + { + if (target is null) + throw new ArgumentNullException(nameof(target)); + if(this.target != null) + throw new InvalidOperationException("The target has already been set; it may not be set again."); + + this.target = target; + return this; + } + + /// + /// Specifies the culture for which to enter the date. This must be the culture in which the web browser is operating. + /// + /// + /// + /// Web browser are culture-aware applications and they will render the input/display value of a date field using the culture + /// in which their operating system is configured. This impacts the manner in which users input dates. + /// If this method is not used, the task returned by this builder will use the culture of the operating system/environment + /// that is executing the Screenplay Performance. This is usually OK when running the web browser locally, but it might not match + /// the browser's culture when using remote web browsers. + /// + /// + /// + /// + /// For example, a British English browser en-GB expects dates to be entered in the format ddMMyyyy. + /// However, a US English browser en-US expects dates to be entered in the format MMddyyyy. + /// + /// + /// The parameter of this method must be the culture identifier of the culture which the + /// browser is operating under, such as en-GB. + /// + /// + /// A culture identifier string + /// This same builder, so calls may be chained + /// If is null + /// If indicates a culture which is not found + public EnterTheDateBuilder ForTheCultureNamed(string cultureIdentifier) + { + culture = CultureInfo.GetCultureInfo(cultureIdentifier); + return this; + } + + /// + public IPerformable GetPerformable() => new EnterTheDate(date, target, culture); + + /// + /// Initializes a new instance of the class with the specified date. + /// + /// The date to enter, or null + public EnterTheDateBuilder(DateTime? date) + { + this.date = date; + } + } +} \ No newline at end of file diff --git a/CSF.Screenplay.Selenium/PerformableBuilder.elementPerformables.cs b/CSF.Screenplay.Selenium/PerformableBuilder.elementPerformables.cs index b3c253ff..add96cb2 100644 --- a/CSF.Screenplay.Selenium/PerformableBuilder.elementPerformables.cs +++ b/CSF.Screenplay.Selenium/PerformableBuilder.elementPerformables.cs @@ -1,3 +1,4 @@ +using System; using CSF.Screenplay.Selenium.Actions; using CSF.Screenplay.Selenium.Builders; using CSF.Screenplay.Selenium.Elements; @@ -32,6 +33,18 @@ public static partial class PerformableBuilder /// A builder with which the user may select a target element. public static SendKeysBuilder EnterTheText(params string[] text) => new SendKeysBuilder(string.Join(string.Empty, text)); + /// + /// Gets a builder for creating a performable action which represents an actor entering a date into an <input type="date"> element. + /// + /// + /// + /// If the specified is then the input element will be cleared. + /// + /// + /// The date to enter into the input control. + /// A builder with which the user may select a target element and optionally a culture. + public static EnterTheDateBuilder EnterTheDate(DateTime? date) => new EnterTheDateBuilder(date); + /// /// Gets a performable which represents an actor deselecting everything from a <select> element. /// diff --git a/CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs b/CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs new file mode 100644 index 00000000..fac9bb8f --- /dev/null +++ b/CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs @@ -0,0 +1,76 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using CSF.Screenplay.Selenium.Elements; +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay.Selenium.Actions +{ + /// + /// A which represents an actor entering a date value into an <input type="date"> element. + /// + /// + /// + /// Note that this task is culture-sensitive. Ensure that the date value is entered into the browser using the culture in which the browser is + /// running. + /// If no culture information is specified then this task defaults to the current culture: . + /// However, this is not certain to be correct, particularly in remote/cloud configurations where the web browser is operating on different + /// infrastructure to the computer which is executing the Screenplay performance. These two computers might be operating in different cultures. + /// + /// + /// If the date specified to this task is then this task will clear the date from the target. + /// + /// + /// + /// + /// For example, a British English browser en-GB expects dates to be entered in the format ddMMyyyy. + /// However, a US English browser en-US expects dates to be entered in the format MMddyyyy. + /// + /// + public class EnterTheDate : IPerformable, ICanReport + { + const string nonNumericPattern = @"\D"; + static readonly Regex nonNumeric = new Regex(nonNumericPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + readonly DateTime? date; + readonly ITarget target; + readonly CultureInfo culture; + + string GetShortDatePattern() => culture.DateTimeFormat.ShortDatePattern; + + string FormatDate() => date.HasValue ? date.Value.ToString(GetShortDatePattern()) : null; + + /// + public ValueTask PerformAsAsync(ICanPerform actor, CancellationToken cancellationToken = default) + { + if(!date.HasValue) + return actor.PerformAsync(ClearTheContentsOf(target), cancellationToken); + + var dateText = nonNumeric.Replace(FormatDate(), string.Empty); + return actor.PerformAsync(EnterTheText(dateText).Into(target), cancellationToken); + } + + /// + public ReportFragment GetReportFragment(Actor actor, IFormatsReportFragment formatter) + { + return date.HasValue + ? formatter.Format("{Actor} enters the date {Date} into {Target}", actor.Name, FormatDate(), target) + : formatter.Format("{Actor} clears the date from {Target}", actor.Name, date, target); + } + + /// + /// Initializes a new instance of the class with the specified date. + /// + /// The date to enter into the element. + /// The element into which to enter the data + /// The culture for which to enter the date + public EnterTheDate(DateTime? date, ITarget target, CultureInfo culture = null) + { + this.date = date; + this.target = target ?? throw new ArgumentNullException(nameof(target)); + this.culture = culture ?? CultureInfo.CurrentCulture; + } + } +} \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Selenium.TestWebapp/wwwroot/InputDateTests.html b/Tests/CSF.Screenplay.Selenium.TestWebapp/wwwroot/InputDateTests.html new file mode 100644 index 00000000..af2f5c0a --- /dev/null +++ b/Tests/CSF.Screenplay.Selenium.TestWebapp/wwwroot/InputDateTests.html @@ -0,0 +1,21 @@ + + + + +

Date input Tests

+

+ Typing into the input box will immediately update the value of the display area, below. +

+ +
+ + + \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Tasks/EnterTheDateTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Tasks/EnterTheDateTests.cs new file mode 100644 index 00000000..08cbb1fb --- /dev/null +++ b/Tests/CSF.Screenplay.Selenium.Tests/Tasks/EnterTheDateTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Globalization; +using CSF.Screenplay.Selenium.Elements; +using static CSF.Screenplay.PerformanceStarter; +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay.Selenium.Tasks; + +[TestFixture, Parallelizable] +public class EnterTheDateTests +{ + static readonly ITarget + inputArea = new ElementId("inputArea", "the input area"), + displayText = new ElementId("display", "the displayable text"); + + static readonly NamedUri testPage = new NamedUri("InputDateTests.html", "the test page"); + + [Test, Screenplay] + public async Task EnteringADateShouldYieldTheCorrectValue(IStage stage) + { + var webster = stage.Spotlight(); + + await Given(webster).WasAbleTo(OpenTheUrl(testPage)); + await When(webster).AttemptsTo(EnterTheDate(new DateTime(2025, 11, 12)).Into(inputArea)); + var result = await Then(webster).Should(ReadFromTheElement(displayText).TheText()); + + Assert.That(result, Is.EqualTo("2025-11-12")); + } + + [Test, Screenplay] + public async Task EnteringANullDateShouldClearTheValue(IStage stage) + { + var webster = stage.Spotlight(); + + await Given(webster).WasAbleTo(OpenTheUrl(testPage)); + await When(webster).AttemptsTo(EnterTheDate(null).Into(inputArea)); + var result = await Then(webster).Should(ReadFromTheElement(displayText).TheText()); + + Assert.That(result, Is.EqualTo(string.Empty)); + } + + [Test, Screenplay] + public async Task EnteringADateInAnUnusualCultureShouldYieldIncorrectResults(IStage stage) + { + var webster = stage.Spotlight(); + + if(CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern.StartsWith("y", StringComparison.InvariantCultureIgnoreCase)) + Assert.Inconclusive("This test can't be meaningfully run when the current culture uses Y/M/D date formatting"); + + await Given(webster).WasAbleTo(OpenTheUrl(testPage)); + await When(webster).AttemptsTo(EnterTheDate(new DateTime(2025, 11, 12)).Into(inputArea).ForTheCultureNamed("ja-JP")); + var result = await Then(webster).Should(ReadFromTheElement(displayText).TheText()); + + Assert.Multiple(() => + { + Assert.That(result, Is.Not.EqualTo(string.Empty), "The date shouldn't be empty"); + Assert.That(result, Is.Not.EqualTo("2025-11-12"), "The date shouldn't be the value which was entered either, because of the culture/format difference"); + }); + } +} \ No newline at end of file From 08a674d00a903df20f595d4693c3aeffb5b94dc4 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Tue, 27 Jan 2026 22:15:38 +0000 Subject: [PATCH 20/23] Add more tests for #250 --- .../ScreenplayExtensionsTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Tests/CSF.Screenplay.Tests/ScreenplayExtensionsTests.cs b/Tests/CSF.Screenplay.Tests/ScreenplayExtensionsTests.cs index 3e2883ea..c661674b 100644 --- a/Tests/CSF.Screenplay.Tests/ScreenplayExtensionsTests.cs +++ b/Tests/CSF.Screenplay.Tests/ScreenplayExtensionsTests.cs @@ -1,5 +1,6 @@ using CSF.Screenplay.Performances; using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework.Internal; namespace CSF.Screenplay; @@ -71,6 +72,18 @@ public void ExecuteAsPerformanceGenericShouldExecuteThePerformanceHostLogic() Assert.That(sut.ServiceProvider.GetRequiredService().HasExecuted, Is.True); } + [Test, AutoMoqData] + public void GetEventBusShouldReturnAnEventBus([DefaultScreenplay] Screenplay sut) + { + Assert.That(() => sut.GetEventBus(), Is.InstanceOf()); + } + + [Test, AutoMoqData] + public void GetEventBusShouldThrowIfScreenplayIsNull() + { + Assert.That(() => ((Screenplay?) null).GetEventBus(), Throws.ArgumentNullException); + } + public class SamplePerformanceHost : IHostsPerformance { public bool HasExecuted { get; set; } From cfa44780b87160b7650d27f4e3973bfa2825e831 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Tue, 27 Jan 2026 22:18:25 +0000 Subject: [PATCH 21/23] Related to #250 - Timeout for regex This prevents DoS via runaway regex evaluation. 100ms is more than generous for stripping non-alpahnumeric characters from a date. --- CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs b/CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs index fac9bb8f..db80a07f 100644 --- a/CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs +++ b/CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs @@ -32,7 +32,9 @@ namespace CSF.Screenplay.Selenium.Actions public class EnterTheDate : IPerformable, ICanReport { const string nonNumericPattern = @"\D"; - static readonly Regex nonNumeric = new Regex(nonNumericPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + static readonly Regex nonNumeric = new Regex(nonNumericPattern, + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); readonly DateTime? date; readonly ITarget target; From c9578cc62a5ea56f62d0f7f3430ecb843c26cb1e Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Tue, 27 Jan 2026 22:22:15 +0000 Subject: [PATCH 22/23] Minor tech debt fixes --- CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs | 1 + .../BrowserStack/BrowserStackDriverFactory.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs b/CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs index db80a07f..f16af502 100644 --- a/CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs +++ b/CSF.Screenplay.Selenium/Tasks/EnterTheDate.cs @@ -32,6 +32,7 @@ namespace CSF.Screenplay.Selenium.Actions public class EnterTheDate : IPerformable, ICanReport { const string nonNumericPattern = @"\D"; + static readonly Regex nonNumeric = new Regex(nonNumericPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); diff --git a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs index 59363bf1..a03a7ad7 100644 --- a/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs +++ b/Tests/CSF.Screenplay.Selenium.Tests/BrowserStack/BrowserStackDriverFactory.cs @@ -34,7 +34,7 @@ public WebDriverAndOptions GetWebDriver(WebDriverCreationOptions options, Action return new (driver, driverOptions); } - DriverOptions GetDriverOptions() + static DriverOptions GetDriverOptions() { var browserName = GetBrowserName(); return browserName switch From 655fa6bf1e49b9f46a6753de567ba3c27892bfd7 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Tue, 27 Jan 2026 22:30:53 +0000 Subject: [PATCH 23/23] Add quit logic to end the session On BrowserStack it seems my sessions are staying open until they time out. That's making the tests take way too long. This is an attempt to force them closed more quickly. --- CSF.Screenplay.Selenium/BrowseTheWeb.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/CSF.Screenplay.Selenium/BrowseTheWeb.cs b/CSF.Screenplay.Selenium/BrowseTheWeb.cs index 4f88e6ad..ca229ade 100644 --- a/CSF.Screenplay.Selenium/BrowseTheWeb.cs +++ b/CSF.Screenplay.Selenium/BrowseTheWeb.cs @@ -83,6 +83,7 @@ protected virtual void Dispose(bool disposing) { if (disposing) { + webDriverAndOptions?.WebDriver.Quit(); webDriverAndOptions?.Dispose(); } disposedValue = true;