diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bc18f00 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "daily" diff --git a/src/FlaUI.WebDriver.UITests/ActionsTest.cs b/src/FlaUI.WebDriver.UITests/ActionsTest.cs index de85390..2fb2a77 100644 --- a/src/FlaUI.WebDriver.UITests/ActionsTest.cs +++ b/src/FlaUI.WebDriver.UITests/ActionsTest.cs @@ -50,7 +50,7 @@ public void ReleaseActions_Default_ReleasesKeys() } [Test] - public void PerformActions_MoveToElement_Click() + public void PerformActions_MoveToElementAndClick_SelectsElement() { var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); @@ -60,7 +60,17 @@ public void PerformActions_MoveToElement_Click() } [Test] - public void PerformActions_MoveToElement_MoveByOffset_Click() + public void PerformActions_MoveToElement_IsSupported() + { + var element = _driver.FindElement(ExtendedBy.AccessibilityId("LabelWithHover")); + + new Actions(_driver).MoveToElement(element).Perform(); + + Assert.That(element.Text, Is.EqualTo("Hovered!")); + } + + [Test] + public void PerformActions_MoveToElementMoveByOffsetAndClick_SelectsElement() { var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); diff --git a/src/FlaUI.WebDriver.UITests/ExecuteTests.cs b/src/FlaUI.WebDriver.UITests/ExecuteTests.cs index 43c9a93..d21f333 100644 --- a/src/FlaUI.WebDriver.UITests/ExecuteTests.cs +++ b/src/FlaUI.WebDriver.UITests/ExecuteTests.cs @@ -1,5 +1,7 @@ using FlaUI.WebDriver.UITests.TestUtil; using NUnit.Framework; +using OpenQA.Selenium; +using OpenQA.Selenium.Interactions; using OpenQA.Selenium.Remote; using System.Collections.Generic; @@ -18,5 +20,54 @@ public void ExecuteScript_PowerShellCommand_ReturnsResult() Assert.That(executeScriptResult, Is.EqualTo("2\r\n")); } + + [Test] + public void ExecuteScript_WindowsClickXY_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + driver.ExecuteScript("windows: click", new Dictionary { ["x"] = element.Location.X + element.Size.Width / 2, ["y"] = element.Location.Y + element.Size.Height / 2}); + + string activeElementText = driver.SwitchTo().ActiveElement().Text; + Assert.That(activeElementText, Is.EqualTo("Test TextBox")); + } + + [Test] + public void ExecuteScript_WindowsHoverXY_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("LabelWithHover")); + + driver.ExecuteScript("windows: hover", new Dictionary { + ["startX"] = element.Location.X + element.Size.Width / 2, + ["startY"] = element.Location.Y + element.Size.Height / 2, + ["endX"] = element.Location.X + element.Size.Width / 2, + ["endY"] = element.Location.Y + element.Size.Height / 2 + }); + + Assert.That(element.Text, Is.EqualTo("Hovered!")); + } + + [Test] + public void ExecuteScript_WindowsKeys_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + element.Click(); + + driver.ExecuteScript("windows: keys", new Dictionary { ["actions"] = new[] { + new Dictionary { ["virtualKeyCode"] = 0x11, ["down"]=true }, // CTRL + new Dictionary { ["virtualKeyCode"] = 0x08, ["down"]=true }, // BACKSPACE + new Dictionary { ["virtualKeyCode"] = 0x08, ["down"]=false }, + new Dictionary { ["virtualKeyCode"] = 0x11, ["down"]=false } + } }); + + string activeElementText = driver.SwitchTo().ActiveElement().Text; + Assert.That(activeElementText, Is.EqualTo("Test ")); + } } } diff --git a/src/FlaUI.WebDriver.UITests/FindElementsTests.cs b/src/FlaUI.WebDriver.UITests/FindElementsTests.cs index 851ed37..e2fe3b2 100644 --- a/src/FlaUI.WebDriver.UITests/FindElementsTests.cs +++ b/src/FlaUI.WebDriver.UITests/FindElementsTests.cs @@ -227,6 +227,23 @@ public void FindElements_InOtherWindow_ReturnsEmptyList() Assert.That(elementsInNewWindow, Has.Count.EqualTo(1)); } + [Test] + public void FindElements_AfterPreviousKnownElementUnavailable_DoesNotThrow() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var initialWindowHandle = driver.CurrentWindowHandle; + OpenAndSwitchToAnotherWindow(driver); + var elementInNewWindow = driver.FindElement(ExtendedBy.AccessibilityId("Window1TextBox")); + // close to make the elementInNewWindow unavailable + driver.Close(); + driver.SwitchTo().Window(initialWindowHandle); + + var foundElements = driver.FindElements(ExtendedBy.AccessibilityId("TextBox")); + + Assert.That(foundElements, Has.Count.EqualTo(1)); + } + private static void OpenAndSwitchToAnotherWindow(RemoteWebDriver driver) { var initialWindowHandles = new[] { driver.CurrentWindowHandle }; diff --git a/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj b/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj index 5723505..4975f6f 100644 --- a/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj +++ b/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj @@ -9,10 +9,10 @@ - + - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/FlaUI.WebDriver.UITests/SessionTests.cs b/src/FlaUI.WebDriver.UITests/SessionTests.cs index 2d5940d..919e411 100644 --- a/src/FlaUI.WebDriver.UITests/SessionTests.cs +++ b/src/FlaUI.WebDriver.UITests/SessionTests.cs @@ -3,7 +3,6 @@ using FlaUI.WebDriver.UITests.TestUtil; using OpenQA.Selenium; using System; -using System.Collections.Generic; namespace FlaUI.WebDriver.UITests { @@ -17,30 +16,30 @@ public void NewSession_PlatformNameMissing_ReturnsError() var newSession = () => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, emptyOptions); - Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Required capabilities did not match. Capability `platformName` with value `windows` is required, capability 'appium:automationName' with value `FlaUI` is required, and one of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability (SessionNotCreated)")); + Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Missing capability 'platformName' with value 'windows' (SessionNotCreated)")); } [Test] public void NewSession_AutomationNameMissing_ReturnsError() { var emptyOptions = FlaUIDriverOptions.Empty(); - emptyOptions.AddAdditionalOption("appium:platformName", "windows"); + emptyOptions.PlatformName = "Windows"; var newSession = () => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, emptyOptions); - Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Required capabilities did not match. Capability `platformName` with value `windows` is required, capability 'appium:automationName' with value `FlaUI` is required, and one of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability (SessionNotCreated)")); + Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Missing capability 'appium:automationName' with value 'flaui' (SessionNotCreated)")); } [Test] public void NewSession_AllAppCapabilitiesMissing_ReturnsError() { var emptyOptions = FlaUIDriverOptions.Empty(); - emptyOptions.AddAdditionalOption("appium:platformName", "windows"); + emptyOptions.PlatformName = "Windows"; emptyOptions.AddAdditionalOption("appium:automationName", "windows"); var newSession = () => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, emptyOptions); - Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Required capabilities did not match. Capability `platformName` with value `windows` is required, capability 'appium:automationName' with value `FlaUI` is required, and one of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability (SessionNotCreated)")); + Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Missing capability 'appium:automationName' with value 'flaui' (SessionNotCreated)")); } [Test] @@ -95,12 +94,9 @@ public void NewSession_AppWorkingDir_IsSupported() public void NewSession_Timeouts_IsSupported() { var driverOptions = FlaUIDriverOptions.TestApp(); - driverOptions.AddAdditionalOption("timeouts", new Dictionary() - { - ["script"] = 10000, - ["pageLoad"] = 50000, - ["implicit"] = 3000 - }); + driverOptions.ScriptTimeout = TimeSpan.FromSeconds(10); + driverOptions.PageLoadTimeout = TimeSpan.FromSeconds(50); + driverOptions.ImplicitWaitTimeout = TimeSpan.FromSeconds(3); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); Assert.That(driver.Manage().Timeouts().AsynchronousJavaScript, Is.EqualTo(TimeSpan.FromSeconds(10))); @@ -115,7 +111,7 @@ public void NewSession_NotSupportedCapability_Throws() driverOptions.AddAdditionalOption("unknown:unknown", "value"); Assert.That(() => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions), - Throws.TypeOf().With.Message.EqualTo("Required capabilities did not match. Capability `platformName` with value `windows` is required, capability 'appium:automationName' with value `FlaUI` is required, and one of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability (SessionNotCreated)")); + Throws.TypeOf().With.Message.EqualTo("The following capabilities could not be matched: 'unknown:unknown' (SessionNotCreated)")); } [Test] @@ -158,7 +154,7 @@ public void NewSession_AppTopLevelWindowZero_ReturnsError() Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Capability appium:appTopLevelWindow '0x0' should not be zero")); } - [Ignore("Sometimes multiple processes are left open")] + [Explicit("Sometimes multiple processes are left open")] [TestCase("FlaUI WPF Test App")] [TestCase("FlaUI WPF .*")] public void NewSession_AppTopLevelWindowTitleMatch_IsSupported(string match) @@ -176,7 +172,7 @@ public void NewSession_AppTopLevelWindowTitleMatch_IsSupported(string match) Assert.That(testAppProcess.Process.HasExited, Is.False); } - [Test, Ignore("Sometimes multiple processes are left open")] + [Test, Explicit("Sometimes multiple processes are left open")] public void NewSession_AppTopLevelWindowTitleMatchMultipleMatching_ReturnsError() { using var testAppProcess = new TestAppProcess(); diff --git a/src/FlaUI.WebDriver.UITests/WindowTests.cs b/src/FlaUI.WebDriver.UITests/WindowTests.cs index 63df71d..25c1f6a 100644 --- a/src/FlaUI.WebDriver.UITests/WindowTests.cs +++ b/src/FlaUI.WebDriver.UITests/WindowTests.cs @@ -22,7 +22,7 @@ public void GetWindowRect_Default_IsSupported() Assert.That(position.X, Is.GreaterThanOrEqualTo(0)); Assert.That(position.Y, Is.GreaterThanOrEqualTo(0)); Assert.That(size.Width, Is.InRange(629 * scaling, 630 * scaling)); - Assert.That(size.Height, Is.InRange(515 * scaling, 516 * scaling)); + Assert.That(size.Height, Is.InRange(550 * scaling, 551 * scaling)); } [Test] diff --git a/src/FlaUI.WebDriver/Controllers/ActionsController.cs b/src/FlaUI.WebDriver/Controllers/ActionsController.cs index d73cd9a..c53e9ca 100644 --- a/src/FlaUI.WebDriver/Controllers/ActionsController.cs +++ b/src/FlaUI.WebDriver/Controllers/ActionsController.cs @@ -71,12 +71,7 @@ private static List> ExtractActionSequence(Session session, Actions // TODO: Implement other input source types. if (actionSequence.Type == "key") { - var source = session.InputState.GetOrCreateInputSource(actionSequence.Type, actionSequence.Id); - - // The spec says that input sources must be created for actions and they are later expected to be - // found in the input source map, but doesn't specify what should add them. Guessing that it should - // be done here. https://github.com/w3c/webdriver/issues/1810 - session.InputState.AddInputSource(actionSequence.Id, source); + session.InputState.GetOrCreateInputSource(actionSequence.Type, actionSequence.Id); } for (var tickIndex = 0; tickIndex < actionSequence.Actions.Count; tickIndex++) diff --git a/src/FlaUI.WebDriver/Controllers/ExecuteController.cs b/src/FlaUI.WebDriver/Controllers/ExecuteController.cs index 85931c2..77eae66 100644 --- a/src/FlaUI.WebDriver/Controllers/ExecuteController.cs +++ b/src/FlaUI.WebDriver/Controllers/ExecuteController.cs @@ -1,6 +1,12 @@ -using FlaUI.WebDriver.Models; +using FlaUI.Core.Input; +using FlaUI.Core.WindowsAPI; +using FlaUI.WebDriver.Models; +using FlaUI.WebDriver.Services; +using Microsoft.AspNetCore.Authentication.OAuth.Claims; using Microsoft.AspNetCore.Mvc; using System.Diagnostics; +using System.Drawing; +using System.Text.Json; namespace FlaUI.WebDriver.Controllers { @@ -10,10 +16,12 @@ public class ExecuteController : ControllerBase { private readonly ILogger _logger; private readonly ISessionRepository _sessionRepository; + private readonly IWindowsExtensionService _windowsExtensionService; - public ExecuteController(ISessionRepository sessionRepository, ILogger logger) + public ExecuteController(ISessionRepository sessionRepository, IWindowsExtensionService windowsExtensionService, ILogger logger) { _sessionRepository = sessionRepository; + _windowsExtensionService = windowsExtensionService; _logger = logger; } @@ -25,10 +33,17 @@ public async Task ExecuteScript([FromRoute] string sessionId, [Fro { case "powerShell": return await ExecutePowerShellScript(session, executeScriptRequest); + case "windows: keys": + return await ExecuteWindowsKeysScript(session, executeScriptRequest); + case "windows: click": + return await ExecuteWindowsClickScript(session, executeScriptRequest); + case "windows: hover": + return await ExecuteWindowsHoverScript(session, executeScriptRequest); default: throw WebDriverResponseException.UnsupportedOperation("Only 'powerShell' scripts are supported"); } } + private async Task ExecutePowerShellScript(Session session, ExecuteScriptRequest executeScriptRequest) { if (executeScriptRequest.Args.Count != 1) @@ -36,10 +51,19 @@ private async Task ExecutePowerShellScript(Session session, Execut throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the PowerShell script, but got {executeScriptRequest.Args.Count} arguments"); } var powerShellArgs = executeScriptRequest.Args[0]; - if (!powerShellArgs.TryGetValue("command", out var powerShellCommand)) + if (!powerShellArgs.TryGetProperty("command", out var powerShellCommandJson)) { throw WebDriverResponseException.InvalidArgument("Expected a \"command\" property of the first argument for the PowerShell script"); } + if (powerShellCommandJson.ValueKind != JsonValueKind.String) + { + throw WebDriverResponseException.InvalidArgument($"Powershell \"command\" property must be a string"); + } + string? powerShellCommand = powerShellCommandJson.GetString(); + if (string.IsNullOrEmpty(powerShellCommand)) + { + throw WebDriverResponseException.InvalidArgument($"Powershell \"command\" property must be non-empty"); + } _logger.LogInformation("Executing PowerShell command {Command} (session {SessionId})", powerShellCommand, session.SessionId); @@ -68,6 +92,72 @@ private async Task ExecutePowerShellScript(Session session, Execut return WebDriverResult.Success(result); } + private async Task ExecuteWindowsClickScript(Session session, ExecuteScriptRequest executeScriptRequest) + { + if (executeScriptRequest.Args.Count != 1) + { + throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: click script, but got {executeScriptRequest.Args.Count} arguments"); + } + var action = JsonSerializer.Deserialize(executeScriptRequest.Args[0], new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (action == null) + { + throw WebDriverResponseException.InvalidArgument("Action cannot be null"); + } + await _windowsExtensionService.ExecuteClickScript(session, action); + return WebDriverResult.Success(); + } + + private async Task ExecuteWindowsHoverScript(Session session, ExecuteScriptRequest executeScriptRequest) + { + if (executeScriptRequest.Args.Count != 1) + { + throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: hover script, but got {executeScriptRequest.Args.Count} arguments"); + } + var action = JsonSerializer.Deserialize(executeScriptRequest.Args[0], new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (action == null) + { + throw WebDriverResponseException.InvalidArgument("Action cannot be null"); + } + await _windowsExtensionService.ExecuteHoverScript(session, action); + return WebDriverResult.Success(); + } + + private async Task ExecuteWindowsKeysScript(Session session, ExecuteScriptRequest executeScriptRequest) + { + if (executeScriptRequest.Args.Count != 1) + { + throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: keys script, but got {executeScriptRequest.Args.Count} arguments"); + } + var windowsKeysArgs = executeScriptRequest.Args[0]; + if (!windowsKeysArgs.TryGetProperty("actions", out var actionsJson)) + { + throw WebDriverResponseException.InvalidArgument("Expected a \"actions\" property of the first argument for the windows: keys script"); + } + session.CurrentWindow.FocusNative(); + if (actionsJson.ValueKind == JsonValueKind.Array) + { + var actions = JsonSerializer.Deserialize>(actionsJson, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (actions == null) + { + throw WebDriverResponseException.InvalidArgument("Argument \"actions\" cannot be null"); + } + foreach (var action in actions) + { + await _windowsExtensionService.ExecuteKeyScript(session, action); + } + } + else + { + var action = JsonSerializer.Deserialize(actionsJson, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (action == null) + { + throw WebDriverResponseException.InvalidArgument("Action cannot be null"); + } + await _windowsExtensionService.ExecuteKeyScript(session, action); + } + return WebDriverResult.Success(); + } + private Session GetSession(string sessionId) { var session = _sessionRepository.FindById(sessionId); diff --git a/src/FlaUI.WebDriver/Controllers/SessionController.cs b/src/FlaUI.WebDriver/Controllers/SessionController.cs index 4759dda..b612817 100644 --- a/src/FlaUI.WebDriver/Controllers/SessionController.cs +++ b/src/FlaUI.WebDriver/Controllers/SessionController.cs @@ -23,10 +23,10 @@ public SessionController(ILogger logger, ISessionRepository s [HttpPost] public async Task CreateNewSession([FromBody] CreateSessionRequest request) { - var possibleCapabilities = GetPossibleCapabilities(request); - IDictionary? matchedCapabilities = null; - IEnumerable> matchingCapabilities = possibleCapabilities - .Where(capabilities => IsMatchingCapabilitySet(capabilities, out matchedCapabilities)) + var mergedCapabilities = GetMergedCapabilities(request); + MergedCapabilities? matchedCapabilities = null; + IEnumerable matchingCapabilities = mergedCapabilities + .Where(capabilities => TryMatchCapabilities(capabilities, out matchedCapabilities, out _)) .Select(capabillities => matchedCapabilities!); Core.Application? app; @@ -34,13 +34,16 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest var capabilities = matchingCapabilities.FirstOrDefault(); if (capabilities == null) { + var mismatchIndications = mergedCapabilities + .Select(capabilities => GetMismatchIndication(capabilities)); + return WebDriverResult.Error(new ErrorResponse { ErrorCode = "session not created", - Message = "Required capabilities did not match. Capability `platformName` with value `windows` is required, capability 'appium:automationName' with value `FlaUI` is required, and one of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability" + Message = string.Join("; ", mismatchIndications) }); } - if (TryGetStringCapability(capabilities, "appium:app", out var appPath)) + if (capabilities.TryGetStringCapability("appium:app", out var appPath)) { if (appPath == "Root") { @@ -48,7 +51,7 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest } else { - TryGetStringCapability(capabilities, "appium:appArguments", out var appArguments); + capabilities.TryGetStringCapability("appium:appArguments", out var appArguments); try { if (appPath.EndsWith("!App")) @@ -58,7 +61,7 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest else { var processStartInfo = new ProcessStartInfo(appPath, appArguments ?? ""); - if(TryGetStringCapability(capabilities, "appium:appWorkingDir", out var appWorkingDir)) + if(capabilities.TryGetStringCapability("appium:appWorkingDir", out var appWorkingDir)) { processStartInfo.WorkingDirectory = appWorkingDir; } @@ -73,12 +76,12 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest isAppOwnedBySession = true; } - else if (TryGetStringCapability(capabilities, "appium:appTopLevelWindow", out var appTopLevelWindowString)) + else if (capabilities.TryGetStringCapability("appium:appTopLevelWindow", out var appTopLevelWindowString)) { Process process = GetProcessByMainWindowHandle(appTopLevelWindowString); app = Core.Application.Attach(process); } - else if (TryGetStringCapability(capabilities, "appium:appTopLevelWindowTitleMatch", out var appTopLevelWindowTitleMatch)) + else if (capabilities.TryGetStringCapability("appium:appTopLevelWindowTitleMatch", out var appTopLevelWindowTitleMatch)) { Process? process = GetProcessByMainWindowTitle(appTopLevelWindowTitleMatch); app = Core.Application.Attach(process); @@ -88,128 +91,106 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest throw WebDriverResponseException.InvalidArgument("One of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability"); } var session = new Session(app, isAppOwnedBySession); - if(TryGetNumberCapability(capabilities, "appium:newCommandTimeout", out var newCommandTimeout)) + if(capabilities.TryGetNumberCapability("appium:newCommandTimeout", out var newCommandTimeout)) { session.NewCommandTimeout = TimeSpan.FromSeconds(newCommandTimeout); } - if (capabilities.TryGetValue("timeouts", out var valueJson)) + if (capabilities.TryGetCapability("timeouts", out var timeoutsConfiguration)) { - var timeoutsConfiguration = JsonSerializer.Deserialize(valueJson); - if (timeoutsConfiguration == null) - { - throw WebDriverResponseException.InvalidArgument("Could not deserialize timeouts capability"); - } - session.TimeoutsConfiguration = timeoutsConfiguration; + session.TimeoutsConfiguration = timeoutsConfiguration!; } _sessionRepository.Add(session); _logger.LogInformation("Created session with ID {SessionId} and capabilities {Capabilities}", session.SessionId, capabilities); return await Task.FromResult(WebDriverResult.Success(new CreateSessionResponse() { SessionId = session.SessionId, - Capabilities = capabilities + Capabilities = capabilities.Capabilities })); } - private bool IsMatchingCapabilitySet(IDictionary capabilities, out IDictionary matchedCapabilities) + private string? GetMismatchIndication(MergedCapabilities capabilities) + { + TryMatchCapabilities(capabilities, out _, out var mismatchIndication); + return mismatchIndication; + } + + private bool TryMatchCapabilities(MergedCapabilities capabilities, [MaybeNullWhen(false)] out MergedCapabilities matchedCapabilities, [MaybeNullWhen(true)] out string? mismatchIndication) { - matchedCapabilities = new Dictionary(); - if (TryGetStringCapability(capabilities, "platformName", out var platformName) + matchedCapabilities = new MergedCapabilities(new Dictionary()); + if (capabilities.TryGetStringCapability("platformName", out var platformName) && platformName.ToLowerInvariant() == "windows") { - matchedCapabilities.Add("platformName", capabilities["platformName"]); + matchedCapabilities.Copy("platformName", capabilities); } else { + mismatchIndication = "Missing capability 'platformName' with value 'windows'"; return false; } - if (TryGetStringCapability(capabilities, "appium:automationName", out var automationName) + if (capabilities.TryGetStringCapability("appium:automationName", out var automationName) && automationName.ToLowerInvariant() == "flaui") { - matchedCapabilities.Add("appium:automationName", capabilities["appium:automationName"]); + matchedCapabilities.Copy("appium:automationName", capabilities); } else { + mismatchIndication = "Missing capability 'appium:automationName' with value 'flaui'"; return false; } - if (TryGetStringCapability(capabilities, "appium:app", out var appPath)) + if (capabilities.TryGetStringCapability("appium:app", out var appPath)) { - matchedCapabilities.Add("appium:app", capabilities["appium:app"]); + matchedCapabilities.Copy("appium:app", capabilities); if (appPath != "Root") { - if(capabilities.ContainsKey("appium:appArguments")) + if (capabilities.Contains("appium:appArguments")) { - matchedCapabilities.Add("appium:appArguments", capabilities["appium:appArguments"]); + matchedCapabilities.Copy("appium:appArguments", capabilities); } if (!appPath.EndsWith("!App")) { - if (capabilities.ContainsKey("appium:appWorkingDir")) + if (capabilities.Contains("appium:appWorkingDir")) { - matchedCapabilities.Add("appium:appWorkingDir", capabilities["appium:appWorkingDir"]); + matchedCapabilities.Copy("appium:appWorkingDir", capabilities); } } } } - else if (capabilities.ContainsKey("appium:appTopLevelWindow")) + else if (capabilities.Contains("appium:appTopLevelWindow")) { - matchedCapabilities.Add("appium:appTopLevelWindow", capabilities["appium:appTopLevelWindow"]); + matchedCapabilities.Copy("appium:appTopLevelWindow", capabilities); } - else if (capabilities.ContainsKey("appium:appTopLevelWindowTitleMatch")) - { - matchedCapabilities.Add("appium:appTopLevelWindowTitleMatch", capabilities["appium:appTopLevelWindowTitleMatch"]); + else if (capabilities.Contains("appium:appTopLevelWindowTitleMatch")) + { + matchedCapabilities.Copy("appium:appTopLevelWindowTitleMatch", capabilities); } else { + mismatchIndication = "One of 'appium:app', 'appium:appTopLevelWindow' or 'appium:appTopLevelWindowTitleMatch' should be specified"; return false; } - if (capabilities.ContainsKey("appium:newCommandTimeout")) + if (capabilities.Contains("appium:newCommandTimeout")) { - matchedCapabilities.Add("appium:newCommandTimeout", capabilities["appium:newCommandTimeout"]); ; + matchedCapabilities.Copy("appium:newCommandTimeout", capabilities); ; } - if (capabilities.ContainsKey("timeouts")) + if (capabilities.Contains("timeouts")) { - matchedCapabilities.Add("timeouts", capabilities["timeouts"]); + matchedCapabilities.Copy("timeouts", capabilities); } - return matchedCapabilities.Count == capabilities.Count; - } - - private static bool TryGetStringCapability(IDictionary capabilities, string key, [MaybeNullWhen(false)] out string value) - { - if(capabilities.TryGetValue(key, out var valueJson)) + var notMatchedCapabilities = capabilities.Capabilities.Keys.Except(matchedCapabilities.Capabilities.Keys); + if (notMatchedCapabilities.Any()) { - if(valueJson.ValueKind != JsonValueKind.String) - { - throw WebDriverResponseException.InvalidArgument($"Capability {key} must be a string"); - } - - value = valueJson.GetString(); - return value != null; - } - - value = null; - return false; - } - - private static bool TryGetNumberCapability(IDictionary capabilities, string key, out double value) - { - if (capabilities.TryGetValue(key, out var valueJson)) - { - if (valueJson.ValueKind != JsonValueKind.Number) - { - throw WebDriverResponseException.InvalidArgument($"Capability {key} must be a number"); - } - - value = valueJson.GetDouble(); - return true; + mismatchIndication = $"The following capabilities could not be matched: '{string.Join("', '", notMatchedCapabilities)}'"; + return false; } - value = default; - return false; + mismatchIndication = null; + return true; } private static Process GetProcessByMainWindowTitle(string appTopLevelWindowTitleMatch) @@ -258,23 +239,11 @@ private static Process GetProcessByMainWindowHandle(string appTopLevelWindowStri return process; } - private static IEnumerable> GetPossibleCapabilities(CreateSessionRequest request) + private static IEnumerable GetMergedCapabilities(CreateSessionRequest request) { var requiredCapabilities = request.Capabilities.AlwaysMatch ?? new Dictionary(); var allFirstMatchCapabilities = request.Capabilities.FirstMatch ?? new List>(new[] { new Dictionary() }); - return allFirstMatchCapabilities.Select(firstMatchCapabilities => MergeCapabilities(firstMatchCapabilities, requiredCapabilities)); - } - - private static IDictionary MergeCapabilities(IDictionary firstMatchCapabilities, IDictionary requiredCapabilities) - { - var duplicateKeys = firstMatchCapabilities.Keys.Intersect(requiredCapabilities.Keys); - if (duplicateKeys.Any()) - { - throw WebDriverResponseException.InvalidArgument($"Capabilities cannot be merged because there are duplicate capabilities: {string.Join(", ", duplicateKeys)}"); - } - - return firstMatchCapabilities.Concat(requiredCapabilities) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + return allFirstMatchCapabilities.Select(firstMatchCapabilities => new MergedCapabilities(firstMatchCapabilities, requiredCapabilities)); } [HttpDelete("{sessionId}")] diff --git a/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj b/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj index ae81853..558cbed 100644 --- a/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj +++ b/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj @@ -18,7 +18,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/FlaUI.WebDriver/ISessionRepository.cs b/src/FlaUI.WebDriver/ISessionRepository.cs index e90b0ab..d2c3f88 100644 --- a/src/FlaUI.WebDriver/ISessionRepository.cs +++ b/src/FlaUI.WebDriver/ISessionRepository.cs @@ -4,6 +4,7 @@ public interface ISessionRepository { void Add(Session session); void Delete(Session session); + List FindAll(); Session? FindById(string sessionId); List FindTimedOut(); } diff --git a/src/FlaUI.WebDriver/InputState.cs b/src/FlaUI.WebDriver/InputState.cs index 1d2739f..fdbcce5 100644 --- a/src/FlaUI.WebDriver/InputState.cs +++ b/src/FlaUI.WebDriver/InputState.cs @@ -74,7 +74,8 @@ public InputSource CreateInputSource(string type) /// /// /// Implements "get or create an input source" from https://www.w3.org/TR/webdriver2/#input-state - /// Note: The spec does not specify that a created input source should be added to the input state map. + /// Note: The spec does not specify that a created input source should be added to the input state map + /// but this implementation does. /// public InputSource GetOrCreateInputSource(string type, string id) { @@ -86,7 +87,16 @@ public InputSource GetOrCreateInputSource(string type, string id) $"Input source with id '{id}' already exists and has a different type: {source.Type}"); } - return CreateInputSource(type); + // Note: The spec does not specify that a created input source should be added to the input state map, + // however it needs to be added somewhere. The caller can't do it because it doesn't know if the source + // was created or already existed. See https://github.com/w3c/webdriver/issues/1810 + if (source == null) + { + source = CreateInputSource(type); + AddInputSource(id, source); + } + + return source; } /// diff --git a/src/FlaUI.WebDriver/KnownElement.cs b/src/FlaUI.WebDriver/KnownElement.cs index 0058ae7..fe46ba1 100644 --- a/src/FlaUI.WebDriver/KnownElement.cs +++ b/src/FlaUI.WebDriver/KnownElement.cs @@ -4,13 +4,22 @@ namespace FlaUI.WebDriver { public class KnownElement { - public KnownElement(AutomationElement element) + public KnownElement(AutomationElement element, string? elementRuntimeId, string elementReference) { Element = element; - ElementReference = Guid.NewGuid().ToString(); + ElementRuntimeId = elementRuntimeId; + ElementReference = elementReference; } - public string ElementReference { get; set; } + public string ElementReference { get; } + + /// + /// A temporarily unique ID, so cannot be used for identity over time, but can be used for improving performance of equality tests. + /// "The identifier is only guaranteed to be unique to the UI of the desktop on which it was generated. Identifiers can be reused over time." + /// + /// + public string? ElementRuntimeId { get; } + public AutomationElement Element { get; } } } diff --git a/src/FlaUI.WebDriver/KnownWindow.cs b/src/FlaUI.WebDriver/KnownWindow.cs index 0c91e77..8d88285 100644 --- a/src/FlaUI.WebDriver/KnownWindow.cs +++ b/src/FlaUI.WebDriver/KnownWindow.cs @@ -4,12 +4,22 @@ namespace FlaUI.WebDriver { public class KnownWindow { - public KnownWindow(Window window) + public KnownWindow(Window window, string? windowRuntimeId, string windowHandle) { Window = window; - WindowHandle = Guid.NewGuid().ToString(); + WindowRuntimeId = windowRuntimeId; + WindowHandle = windowHandle; } - public string WindowHandle { get; set; } - public Window Window { get; set; } + + public string WindowHandle { get; } + + /// + /// A temporarily unique ID, so cannot be used for identity over time, but can be used for improving performance of equality tests. + /// "The identifier is only guaranteed to be unique to the UI of the desktop on which it was generated. Identifiers can be reused over time." + /// + /// + public string? WindowRuntimeId { get; } + + public Window Window { get; } } } diff --git a/src/FlaUI.WebDriver/MergedCapabilities.cs b/src/FlaUI.WebDriver/MergedCapabilities.cs new file mode 100644 index 0000000..321e1d4 --- /dev/null +++ b/src/FlaUI.WebDriver/MergedCapabilities.cs @@ -0,0 +1,94 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace FlaUI.WebDriver +{ + public class MergedCapabilities + { + public Dictionary Capabilities { get; } + + public MergedCapabilities(Dictionary firstMatchCapabilities, Dictionary requiredCapabilities) + : this(MergeCapabilities(firstMatchCapabilities, requiredCapabilities)) + { + + } + + public MergedCapabilities(Dictionary capabilities) + { + Capabilities = capabilities; + } + + private static Dictionary MergeCapabilities(Dictionary firstMatchCapabilities, Dictionary requiredCapabilities) + { + var duplicateKeys = firstMatchCapabilities.Keys.Intersect(requiredCapabilities.Keys); + if (duplicateKeys.Any()) + { + throw WebDriverResponseException.InvalidArgument($"Capabilities cannot be merged because there are duplicate capabilities: {string.Join(", ", duplicateKeys)}"); + } + + return firstMatchCapabilities.Concat(requiredCapabilities) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + public bool TryGetStringCapability(string key, [MaybeNullWhen(false)] out string value) + { + if (Capabilities.TryGetValue(key, out var valueJson)) + { + if (valueJson.ValueKind != JsonValueKind.String) + { + throw WebDriverResponseException.InvalidArgument($"Capability {key} must be a string"); + } + + value = valueJson.GetString(); + return value != null; + } + + value = null; + return false; + } + + public bool TryGetNumberCapability(string key, out double value) + { + if (Capabilities.TryGetValue(key, out var valueJson)) + { + if (valueJson.ValueKind != JsonValueKind.Number) + { + throw WebDriverResponseException.InvalidArgument($"Capability {key} must be a number"); + } + + value = valueJson.GetDouble(); + return true; + } + + value = default; + return false; + } + + public void Copy(string key, MergedCapabilities fromCapabilities) + { + Capabilities.Add(key, fromCapabilities.Capabilities[key]); + } + + public bool Contains(string key) + { + return Capabilities.ContainsKey(key); + } + + public bool TryGetCapability(string key, [MaybeNullWhen(false)] out T? value) + { + if (!Capabilities.TryGetValue(key, out var valueJson)) + { + value = default; + return false; + } + + var deserializedValue = JsonSerializer.Deserialize(valueJson); + if (deserializedValue == null) + { + throw WebDriverResponseException.InvalidArgument($"Could not deserialize {key} capability"); + } + value = deserializedValue; + return true; + } + } +} diff --git a/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs b/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs index 94d62d6..8a09ca6 100644 --- a/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs +++ b/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs @@ -1,8 +1,10 @@ -namespace FlaUI.WebDriver.Models +using System.Text.Json; + +namespace FlaUI.WebDriver.Models { public class ExecuteScriptRequest { public string Script { get; set; } = null!; - public List> Args { get; set; } = new List>(); + public List Args { get; set; } = new List(); } } diff --git a/src/FlaUI.WebDriver/Models/WindowsClickScript.cs b/src/FlaUI.WebDriver/Models/WindowsClickScript.cs new file mode 100644 index 0000000..25df7c9 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/WindowsClickScript.cs @@ -0,0 +1,10 @@ +namespace FlaUI.WebDriver.Models +{ + public class WindowsClickScript + { + public string? ElementId { get; set; } + public int? X { get; set; } + public int? Y { get; set; } + public string? Button { get; set; } + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Models/WindowsHoverScript.cs b/src/FlaUI.WebDriver/Models/WindowsHoverScript.cs new file mode 100644 index 0000000..faa4b76 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/WindowsHoverScript.cs @@ -0,0 +1,13 @@ +namespace FlaUI.WebDriver.Models +{ + public class WindowsHoverScript + { + public string? StartElementId { get; set; } + public int? StartX { get; set; } + public int? StartY { get; set; } + public int? EndX { get; set; } + public int? EndY { get; set; } + public string? EndElementId { get; set; } + public int? DurationMs { get; set; } + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Models/WindowsKeyScript.cs b/src/FlaUI.WebDriver/Models/WindowsKeyScript.cs new file mode 100644 index 0000000..ae675ad --- /dev/null +++ b/src/FlaUI.WebDriver/Models/WindowsKeyScript.cs @@ -0,0 +1,10 @@ +namespace FlaUI.WebDriver.Models +{ + public class WindowsKeyScript + { + public int? Pause { get; set; } + public string? Text { get; set; } + public ushort? VirtualKeyCode { get; set; } + public bool? Down { get; set; } + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Program.cs b/src/FlaUI.WebDriver/Program.cs index 152365f..bbc78f6 100644 --- a/src/FlaUI.WebDriver/Program.cs +++ b/src/FlaUI.WebDriver/Program.cs @@ -6,6 +6,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure(options => options.LowercaseUrls = true); builder.Services.AddControllers(options => diff --git a/src/FlaUI.WebDriver/Services/IWindowsExtensionService.cs b/src/FlaUI.WebDriver/Services/IWindowsExtensionService.cs new file mode 100644 index 0000000..9b6e2dd --- /dev/null +++ b/src/FlaUI.WebDriver/Services/IWindowsExtensionService.cs @@ -0,0 +1,11 @@ +using FlaUI.WebDriver.Models; + +namespace FlaUI.WebDriver.Services +{ + public interface IWindowsExtensionService + { + Task ExecuteClickScript(Session session, WindowsClickScript action); + Task ExecuteHoverScript(Session session, WindowsHoverScript action); + Task ExecuteKeyScript(Session session, WindowsKeyScript action); + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Services/WindowsExtensionService.cs b/src/FlaUI.WebDriver/Services/WindowsExtensionService.cs new file mode 100644 index 0000000..d774ac1 --- /dev/null +++ b/src/FlaUI.WebDriver/Services/WindowsExtensionService.cs @@ -0,0 +1,129 @@ +using FlaUI.Core.Input; +using FlaUI.Core.WindowsAPI; +using FlaUI.WebDriver.Models; +using System.Drawing; +using System.Runtime.CompilerServices; + +namespace FlaUI.WebDriver.Services +{ + public class WindowsExtensionService : IWindowsExtensionService + { + private readonly ILogger _logger; + + public WindowsExtensionService(ILogger logger) + { + _logger = logger; + } + + public async Task ExecuteClickScript(Session session, WindowsClickScript action) + { + var mouseButton = action.Button != null ? Enum.Parse(action.Button, true) : MouseButton.Left; + if (action.ElementId != null) + { + var element = session.FindKnownElementById(action.ElementId); + if (element == null) + { + throw WebDriverResponseException.ElementNotFound(action.ElementId); + } + Mouse.Click(element.BoundingRectangle.Location, mouseButton); + } + else if (action.X.HasValue && action.Y.HasValue) + { + Mouse.Click(new Point { X = action.X.Value, Y = action.Y.Value }, mouseButton); + } + else + { + throw WebDriverResponseException.InvalidArgument("Either \"elementId\" or \"x\" and \"y\" must be provided"); + } + await Task.Yield(); + } + + public async Task ExecuteHoverScript(Session session, WindowsHoverScript action) + { + if (action.StartX.HasValue && action.StartY.HasValue) + { + Mouse.MoveTo(action.StartX.Value, action.StartY.Value); + } + else if (action.StartElementId != null) + { + var element = session.FindKnownElementById(action.StartElementId); + if (element == null) + { + throw WebDriverResponseException.ElementNotFound(action.StartElementId); + } + Mouse.MoveTo(element.BoundingRectangle.Location); + } + else + { + throw WebDriverResponseException.InvalidArgument("Either \"startElementId\" or \"startX\" and \"startY\" must be provided"); + } + + if (action.DurationMs.HasValue) + { + await Task.Delay(action.DurationMs.Value); + } + + if (action.EndX.HasValue && action.EndY.HasValue) + { + Mouse.MoveTo(action.EndX.Value, action.EndY.Value); + } + else if (action.EndElementId != null) + { + var element = session.FindKnownElementById(action.EndElementId); + if (element == null) + { + throw WebDriverResponseException.ElementNotFound(action.EndElementId); + } + Mouse.MoveTo(element.BoundingRectangle.Location); + } + else + { + throw WebDriverResponseException.InvalidArgument("Either \"endElementId\" or \"endX\" and \"endY\" must be provided"); + } + } + + public async Task ExecuteKeyScript(Session session, WindowsKeyScript action) + { + if (action.VirtualKeyCode.HasValue) + { + if (action.Down.HasValue) + { + if (action.Down.Value == true) + { + _logger.LogDebug("Pressing key {VirtualKeyCode}", action.VirtualKeyCode.Value); + Keyboard.Press((VirtualKeyShort)action.VirtualKeyCode.Value); + await Task.Delay(10); + } + else + { + _logger.LogDebug("Releasing key {VirtualKeyCode}", action.VirtualKeyCode.Value); + Keyboard.Release((VirtualKeyShort)action.VirtualKeyCode.Value); + await Task.Delay(10); + } + } + else + { + _logger.LogDebug("Pressing and releasing key {VirtualKeyCode}", action.VirtualKeyCode.Value); + Keyboard.Press((VirtualKeyShort)action.VirtualKeyCode.Value); + await Task.Delay(10); + Keyboard.Release((VirtualKeyShort)action.VirtualKeyCode.Value); + await Task.Delay(10); + } + } + else if (action.Text != null) + { + _logger.LogDebug("Typing {Text}", action.Text); + Keyboard.Type(action.Text); + } + else if (action.Pause.HasValue) + { + _logger.LogDebug("Pausing for {Pause} milliseconds", action.Pause.Value); + await Task.Delay(action.Pause.Value); + } + else + { + throw WebDriverResponseException.InvalidArgument("Action must have either \"text\", \"virtualKeyCode\" or \"pause\""); + } + } + } +} diff --git a/src/FlaUI.WebDriver/Session.cs b/src/FlaUI.WebDriver/Session.cs index 9991fd9..52bc1b7 100644 --- a/src/FlaUI.WebDriver/Session.cs +++ b/src/FlaUI.WebDriver/Session.cs @@ -1,6 +1,8 @@ using FlaUI.Core; using FlaUI.Core.AutomationElements; using FlaUI.UIA3; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; namespace FlaUI.WebDriver { @@ -26,8 +28,8 @@ public Session(Application? app, bool isAppOwnedBySession) public UIA3Automation Automation { get; } public Application? App { get; } public InputState InputState { get; } - private Dictionary KnownElementsByElementReference { get; } = new Dictionary(); - private Dictionary KnownWindowsByWindowHandle { get; } = new Dictionary(); + private ConcurrentDictionary KnownElementsByElementReference { get; } = new ConcurrentDictionary(); + private ConcurrentDictionary KnownWindowsByWindowHandle { get; } = new ConcurrentDictionary(); public TimeSpan ImplicitWaitTimeout => TimeSpan.FromMilliseconds(TimeoutsConfiguration.ImplicitWaitTimeoutMs); public TimeSpan PageLoadTimeout => TimeSpan.FromMilliseconds(TimeoutsConfiguration.PageLoadTimeoutMs); public TimeSpan? ScriptTimeout => TimeoutsConfiguration.ScriptTimeoutMs.HasValue ? TimeSpan.FromMilliseconds(TimeoutsConfiguration.ScriptTimeoutMs.Value) : null; @@ -77,11 +79,15 @@ public void SetLastCommandTimeToNow() public KnownElement GetOrAddKnownElement(AutomationElement element) { - var result = KnownElementsByElementReference.Values.FirstOrDefault(knownElement => knownElement.Element.Equals(element)); + var elementRuntimeId = GetRuntimeId(element); + var result = KnownElementsByElementReference.Values.FirstOrDefault(knownElement => knownElement.ElementRuntimeId == elementRuntimeId && SafeElementEquals(knownElement.Element, element)); if (result == null) { - result = new KnownElement(element); - KnownElementsByElementReference.Add(result.ElementReference, result); + do + { + result = new KnownElement(element, elementRuntimeId, Guid.NewGuid().ToString()); + } + while (!KnownElementsByElementReference.TryAdd(result.ElementReference, result)); } return result; } @@ -97,11 +103,15 @@ public KnownElement GetOrAddKnownElement(AutomationElement element) public KnownWindow GetOrAddKnownWindow(Window window) { - var result = KnownWindowsByWindowHandle.Values.FirstOrDefault(knownElement => knownElement.Window.Equals(window)); + var windowRuntimeId = GetRuntimeId(window); + var result = KnownWindowsByWindowHandle.Values.FirstOrDefault(knownWindow => knownWindow.WindowRuntimeId == windowRuntimeId && SafeElementEquals(knownWindow.Window, window)); if (result == null) { - result = new KnownWindow(window); - KnownWindowsByWindowHandle.Add(result.WindowHandle, result); + do + { + result = new KnownWindow(window, windowRuntimeId, Guid.NewGuid().ToString()); + } + while (!KnownWindowsByWindowHandle.TryAdd(result.WindowHandle, result)); } return result; } @@ -120,7 +130,29 @@ public void RemoveKnownWindow(Window window) var item = KnownWindowsByWindowHandle.Values.FirstOrDefault(knownElement => knownElement.Window.Equals(window)); if (item != null) { - KnownWindowsByWindowHandle.Remove(item.WindowHandle); + KnownWindowsByWindowHandle.TryRemove(item.WindowHandle, out _); + } + } + + public void EvictUnavailableElements() + { + // Evict unavailable elements to prevent slowing down + // (use ToArray to prevent concurrency issues while enumerating) + var unavailableElements = KnownElementsByElementReference.ToArray().Where(item => !item.Value.Element.IsAvailable).Select(item => item.Key); + foreach (var unavailableElementKey in unavailableElements) + { + KnownElementsByElementReference.TryRemove(unavailableElementKey, out _); + } + } + + public void EvictUnavailableWindows() + { + // Evict unavailable windows to prevent slowing down + // (use ToArray to prevent concurrency issues while enumerating) + var unavailableWindows = KnownWindowsByWindowHandle.ToArray().Where(item => !item.Value.Window.IsAvailable).Select(item => item.Key).ToArray(); + foreach (var unavailableWindowKey in unavailableWindows) + { + KnownWindowsByWindowHandle.TryRemove(unavailableWindowKey, out _); } } @@ -133,5 +165,29 @@ public void Dispose() Automation.Dispose(); App?.Dispose(); } + + private string? GetRuntimeId(AutomationElement element) + { + if (!element.Properties.RuntimeId.IsSupported) + { + return null; + } + + return string.Join(",", element.Properties.RuntimeId.Value.Select(item => Convert.ToBase64String(BitConverter.GetBytes(item)))); + } + + private bool SafeElementEquals(AutomationElement element1, AutomationElement element2) + { + try + { + return element1.Equals(element2); + } + catch (COMException) + { + // May occur if the element is suddenly no longer available + return false; + } + } + } } diff --git a/src/FlaUI.WebDriver/SessionCleanupService.cs b/src/FlaUI.WebDriver/SessionCleanupService.cs index 47bdb9b..ef5518e 100644 --- a/src/FlaUI.WebDriver/SessionCleanupService.cs +++ b/src/FlaUI.WebDriver/SessionCleanupService.cs @@ -34,22 +34,38 @@ private void DoWork(object? state) scope.ServiceProvider .GetRequiredService(); - var timedOutSessions = sessionRepository.FindTimedOut(); - if(timedOutSessions.Count > 0) - { - _logger.LogInformation("Session cleanup service cleaning up {Count} sessions that did not receive commands in their specified new command timeout interval", timedOutSessions.Count); + RemoveTimedOutSessions(sessionRepository); - foreach (Session session in timedOutSessions) - { - sessionRepository.Delete(session); - session.Dispose(); - } - } - else + EvictUnavailableElements(sessionRepository); + } + } + + private void EvictUnavailableElements(ISessionRepository sessionRepository) + { + foreach(var session in sessionRepository.FindAll()) + { + session.EvictUnavailableElements(); + session.EvictUnavailableWindows(); + } + } + + private void RemoveTimedOutSessions(ISessionRepository sessionRepository) + { + var timedOutSessions = sessionRepository.FindTimedOut(); + if (timedOutSessions.Count > 0) + { + _logger.LogInformation("Session cleanup service cleaning up {Count} sessions that did not receive commands in their specified new command timeout interval", timedOutSessions.Count); + + foreach (Session session in timedOutSessions) { - _logger.LogInformation("Session cleanup service did not find sessions to cleanup"); + sessionRepository.Delete(session); + session.Dispose(); } } + else + { + _logger.LogInformation("Session cleanup service did not find sessions to cleanup"); + } } public Task StopAsync(CancellationToken stoppingToken) diff --git a/src/FlaUI.WebDriver/SessionRepository.cs b/src/FlaUI.WebDriver/SessionRepository.cs index 18d91f7..f7a8320 100644 --- a/src/FlaUI.WebDriver/SessionRepository.cs +++ b/src/FlaUI.WebDriver/SessionRepository.cs @@ -23,5 +23,10 @@ public List FindTimedOut() { return Sessions.Where(session => session.IsTimedOut).ToList(); } + + public List FindAll() + { + return new List(Sessions); + } } } diff --git a/src/FlaUI.WebDriver/TimeoutsConfiguration.cs b/src/FlaUI.WebDriver/TimeoutsConfiguration.cs index 47ff012..27feb4d 100644 --- a/src/FlaUI.WebDriver/TimeoutsConfiguration.cs +++ b/src/FlaUI.WebDriver/TimeoutsConfiguration.cs @@ -5,10 +5,10 @@ namespace FlaUI.WebDriver public class TimeoutsConfiguration { [JsonPropertyName("script")] - public int? ScriptTimeoutMs { get; set; } = 30000; + public double? ScriptTimeoutMs { get; set; } = 30000; [JsonPropertyName("pageLoad")] - public int PageLoadTimeoutMs { get; set; } = 300000; + public double PageLoadTimeoutMs { get; set; } = 300000; [JsonPropertyName("implicit")] - public int ImplicitWaitTimeoutMs { get; set; } = 0; + public double ImplicitWaitTimeoutMs { get; set; } = 0; } } diff --git a/src/TestApplications/WpfApplication/MainWindow.xaml b/src/TestApplications/WpfApplication/MainWindow.xaml index 0e2cc41..93d6ad1 100644 --- a/src/TestApplications/WpfApplication/MainWindow.xaml +++ b/src/TestApplications/WpfApplication/MainWindow.xaml @@ -6,7 +6,7 @@ xmlns:local="clr-namespace:WpfApplication" mc:Ignorable="d" Title="FlaUI WPF Test App" - Height="515.931" Width="629.303" + Height="550" Width="629.303" ResizeMode="CanResize"> @@ -98,6 +98,7 @@ PlacementTarget="{Binding ElementName=PopupToggleButton2}" StaysOpen="False" />