From da10f585a11ef76daca2e3860e0c94b28fae438e Mon Sep 17 00:00:00 2001 From: aristotelos Date: Wed, 8 May 2024 20:13:14 +0200 Subject: [PATCH 01/13] Add failing test Add failing test based on reproduce in #35. --- .../FindElementsTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 }; From f2c484088f7207868508e99e4982bbc58c9b25c6 Mon Sep 17 00:00:00 2001 From: aristotelos Date: Mon, 13 May 2024 11:33:28 +0200 Subject: [PATCH 02/13] Evict unavailable elements and windows Evict unavailable elements and windows to prevent COM exceptions and to free memory. See #35. The COM exceptions from a growing set of unavailable elements may be the cause of degrading performance as well. --- src/FlaUI.WebDriver/ISessionRepository.cs | 1 + src/FlaUI.WebDriver/Session.cs | 20 ++++++++++ src/FlaUI.WebDriver/SessionCleanupService.cs | 40 ++++++++++++++------ src/FlaUI.WebDriver/SessionRepository.cs | 5 +++ 4 files changed, 54 insertions(+), 12 deletions(-) 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/Session.cs b/src/FlaUI.WebDriver/Session.cs index 9991fd9..630def1 100644 --- a/src/FlaUI.WebDriver/Session.cs +++ b/src/FlaUI.WebDriver/Session.cs @@ -124,6 +124,26 @@ public void RemoveKnownWindow(Window window) } } + public void EvictUnavailableElements() + { + // Evict unavailable elements to prevent slowing down + var unavailableElements = KnownElementsByElementReference.Where(item => !item.Value.Element.IsAvailable).Select(item => item.Key).ToArray(); + foreach (var unavailableElementKey in unavailableElements) + { + KnownElementsByElementReference.Remove(unavailableElementKey); + } + } + + public void EvictUnavailableWindows() + { + // Evict unavailable windows to prevent slowing down + var unavailableWindows = KnownWindowsByWindowHandle.Where(item => !item.Value.Window.IsAvailable).Select(item => item.Key).ToArray(); + foreach (var unavailableWindowKey in unavailableWindows) + { + KnownWindowsByWindowHandle.Remove(unavailableWindowKey); + } + } + public void Dispose() { if (IsAppOwnedBySession && App != null && !App.HasExited) 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); + } } } From 249137d3caa35cb23a0b2c6ad87aed21966b0bb3 Mon Sep 17 00:00:00 2001 From: aristotelos Date: Mon, 13 May 2024 11:34:34 +0200 Subject: [PATCH 03/13] Use RuntimeId for performance Use the RuntimeId property to speed up equality comparison for known elements and windows. Along with this, catch possible COM exceptions during equality comparison if the element is no longer available. Closes #35. --- src/FlaUI.WebDriver/KnownElement.cs | 13 +++++++++-- src/FlaUI.WebDriver/KnownWindow.cs | 11 ++++++++- src/FlaUI.WebDriver/Session.cs | 35 +++++++++++++++++++++++++---- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/FlaUI.WebDriver/KnownElement.cs b/src/FlaUI.WebDriver/KnownElement.cs index 0058ae7..0ea834e 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) { Element = element; + ElementRuntimeId = elementRuntimeId; ElementReference = Guid.NewGuid().ToString(); } - 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..b9bc5fa 100644 --- a/src/FlaUI.WebDriver/KnownWindow.cs +++ b/src/FlaUI.WebDriver/KnownWindow.cs @@ -4,12 +4,21 @@ namespace FlaUI.WebDriver { public class KnownWindow { - public KnownWindow(Window window) + public KnownWindow(Window window, string? windowRuntimeId) { Window = window; + WindowRuntimeId = windowRuntimeId; WindowHandle = Guid.NewGuid().ToString(); } public string WindowHandle { get; set; } + + /// + /// 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; set; } + public Window Window { get; set; } } } diff --git a/src/FlaUI.WebDriver/Session.cs b/src/FlaUI.WebDriver/Session.cs index 630def1..9cf9db2 100644 --- a/src/FlaUI.WebDriver/Session.cs +++ b/src/FlaUI.WebDriver/Session.cs @@ -1,6 +1,7 @@ using FlaUI.Core; using FlaUI.Core.AutomationElements; using FlaUI.UIA3; +using System.Runtime.InteropServices; namespace FlaUI.WebDriver { @@ -77,10 +78,11 @@ 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); + result = new KnownElement(element, elementRuntimeId); KnownElementsByElementReference.Add(result.ElementReference, result); } return result; @@ -97,10 +99,11 @@ 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); + result = new KnownWindow(window, windowRuntimeId); KnownWindowsByWindowHandle.Add(result.WindowHandle, result); } return result; @@ -153,5 +156,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; + } + } + } } From f1ec5a651f594d6e51bf05a7b9cb03ca0727c8e2 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 14 May 2024 12:14:54 +0200 Subject: [PATCH 04/13] Add input sources in GetOrCreateInputSource The spec is unclear on when input sources should be added to the input source map. Previously it was being done in `ExtractActionSequence` but the problem with doing it there is that there's no way to know if `GetOrCreateInputSource` is returning an existing input source or creating a new one, causing #43. Move the logic to add an input source to `GetOrCreateInputSource` to fix this. (It would kinda make sense to do it instead in `CreateInputSource` but the spec defines a caller of this method which adds the input source manually :/ ) Fixes #43 --- .../Controllers/ActionsController.cs | 7 +------ src/FlaUI.WebDriver/InputState.cs | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) 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/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; } /// From b58018fb4a90943128faf5796b14fd1b1186ffad Mon Sep 17 00:00:00 2001 From: aristotelos Date: Tue, 14 May 2024 21:17:53 +0200 Subject: [PATCH 05/13] Thread safe adding and evicting of elements Prevent stale enumerators and concurrency issues while adding or victing unavailable elements or windows. Closes #42. --- src/FlaUI.WebDriver/KnownElement.cs | 4 ++-- src/FlaUI.WebDriver/KnownWindow.cs | 11 +++++----- src/FlaUI.WebDriver/Session.cs | 31 +++++++++++++++++++---------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/FlaUI.WebDriver/KnownElement.cs b/src/FlaUI.WebDriver/KnownElement.cs index 0ea834e..fe46ba1 100644 --- a/src/FlaUI.WebDriver/KnownElement.cs +++ b/src/FlaUI.WebDriver/KnownElement.cs @@ -4,11 +4,11 @@ namespace FlaUI.WebDriver { public class KnownElement { - public KnownElement(AutomationElement element, string? elementRuntimeId) + public KnownElement(AutomationElement element, string? elementRuntimeId, string elementReference) { Element = element; ElementRuntimeId = elementRuntimeId; - ElementReference = Guid.NewGuid().ToString(); + ElementReference = elementReference; } public string ElementReference { get; } diff --git a/src/FlaUI.WebDriver/KnownWindow.cs b/src/FlaUI.WebDriver/KnownWindow.cs index b9bc5fa..8d88285 100644 --- a/src/FlaUI.WebDriver/KnownWindow.cs +++ b/src/FlaUI.WebDriver/KnownWindow.cs @@ -4,21 +4,22 @@ namespace FlaUI.WebDriver { public class KnownWindow { - public KnownWindow(Window window, string? windowRuntimeId) + public KnownWindow(Window window, string? windowRuntimeId, string windowHandle) { Window = window; WindowRuntimeId = windowRuntimeId; - WindowHandle = Guid.NewGuid().ToString(); + WindowHandle = windowHandle; } - public string WindowHandle { 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; set; } + public string? WindowRuntimeId { get; } - public Window Window { get; set; } + public Window Window { get; } } } diff --git a/src/FlaUI.WebDriver/Session.cs b/src/FlaUI.WebDriver/Session.cs index 9cf9db2..52bc1b7 100644 --- a/src/FlaUI.WebDriver/Session.cs +++ b/src/FlaUI.WebDriver/Session.cs @@ -1,6 +1,7 @@ using FlaUI.Core; using FlaUI.Core.AutomationElements; using FlaUI.UIA3; +using System.Collections.Concurrent; using System.Runtime.InteropServices; namespace FlaUI.WebDriver @@ -27,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; @@ -82,8 +83,11 @@ public KnownElement GetOrAddKnownElement(AutomationElement element) var result = KnownElementsByElementReference.Values.FirstOrDefault(knownElement => knownElement.ElementRuntimeId == elementRuntimeId && SafeElementEquals(knownElement.Element, element)); if (result == null) { - result = new KnownElement(element, elementRuntimeId); - KnownElementsByElementReference.Add(result.ElementReference, result); + do + { + result = new KnownElement(element, elementRuntimeId, Guid.NewGuid().ToString()); + } + while (!KnownElementsByElementReference.TryAdd(result.ElementReference, result)); } return result; } @@ -103,8 +107,11 @@ public KnownWindow GetOrAddKnownWindow(Window window) var result = KnownWindowsByWindowHandle.Values.FirstOrDefault(knownWindow => knownWindow.WindowRuntimeId == windowRuntimeId && SafeElementEquals(knownWindow.Window, window)); if (result == null) { - result = new KnownWindow(window, windowRuntimeId); - KnownWindowsByWindowHandle.Add(result.WindowHandle, result); + do + { + result = new KnownWindow(window, windowRuntimeId, Guid.NewGuid().ToString()); + } + while (!KnownWindowsByWindowHandle.TryAdd(result.WindowHandle, result)); } return result; } @@ -123,27 +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 - var unavailableElements = KnownElementsByElementReference.Where(item => !item.Value.Element.IsAvailable).Select(item => item.Key).ToArray(); + // (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.Remove(unavailableElementKey); + KnownElementsByElementReference.TryRemove(unavailableElementKey, out _); } } public void EvictUnavailableWindows() { // Evict unavailable windows to prevent slowing down - var unavailableWindows = KnownWindowsByWindowHandle.Where(item => !item.Value.Window.IsAvailable).Select(item => item.Key).ToArray(); + // (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.Remove(unavailableWindowKey); + KnownWindowsByWindowHandle.TryRemove(unavailableWindowKey, out _); } } From d0d426d574a45b466b8ffd378bb9bc426c3fbc97 Mon Sep 17 00:00:00 2001 From: aristotelos Date: Wed, 15 May 2024 11:55:35 +0200 Subject: [PATCH 06/13] Better capability matching errors Use very specific error messages to indicate capability mismatches. --- src/FlaUI.WebDriver.UITests/SessionTests.cs | 16 +- .../Controllers/SessionController.cs | 141 +++++++----------- src/FlaUI.WebDriver/MergedCapabilities.cs | 94 ++++++++++++ 3 files changed, 157 insertions(+), 94 deletions(-) create mode 100644 src/FlaUI.WebDriver/MergedCapabilities.cs diff --git a/src/FlaUI.WebDriver.UITests/SessionTests.cs b/src/FlaUI.WebDriver.UITests/SessionTests.cs index 2d5940d..0115396 100644 --- a/src/FlaUI.WebDriver.UITests/SessionTests.cs +++ b/src/FlaUI.WebDriver.UITests/SessionTests.cs @@ -17,30 +17,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] @@ -115,7 +115,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 +158,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 +176,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/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/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; + } + } +} From 0b71dbbc6ccaf6b8d03ed1b623bb880ebad74e64 Mon Sep 17 00:00:00 2001 From: aristotelos Date: Wed, 15 May 2024 16:04:59 +0200 Subject: [PATCH 07/13] Update Selenium.WebDriver to 4.20.0 Along with this, use the new timeout capability properties. Fixed an issue that the timeouts could be floating point numbers. --- .../FlaUI.WebDriver.UITests.csproj | 2 +- src/FlaUI.WebDriver.UITests/SessionTests.cs | 10 +++------- src/FlaUI.WebDriver/TimeoutsConfiguration.cs | 6 +++--- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj b/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj index 5723505..d9bd732 100644 --- a/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj +++ b/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj @@ -11,7 +11,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/FlaUI.WebDriver.UITests/SessionTests.cs b/src/FlaUI.WebDriver.UITests/SessionTests.cs index 0115396..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 { @@ -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))); 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; } } From d9617e0de568582c0ba8c476124984efbef77adc Mon Sep 17 00:00:00 2001 From: aristotelos Date: Wed, 15 May 2024 16:06:16 +0200 Subject: [PATCH 08/13] Update Swashbuckle from 6.5.0 to 6.6.1 --- src/FlaUI.WebDriver/FlaUI.WebDriver.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 - + From 4733bcec82c31aaeafe6b40820c75723da17ec2d Mon Sep 17 00:00:00 2001 From: aristotelos Date: Wed, 15 May 2024 16:08:32 +0200 Subject: [PATCH 09/13] Update coverlet.collector from 3.0.2 to 6.0.2 --- src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj b/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj index d9bd732..f5e1049 100644 --- a/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj +++ b/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj @@ -12,7 +12,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 1c5d4bee6120dbe9db90f36ed0fcd00ae203a92c Mon Sep 17 00:00:00 2001 From: aristotelos Date: Wed, 15 May 2024 16:09:29 +0200 Subject: [PATCH 10/13] Update NUnit from 3.14.0 to 4.1.0 --- src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj b/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj index f5e1049..4975f6f 100644 --- a/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj +++ b/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj @@ -9,7 +9,7 @@ - + From 63b25858a4a1ad46e2976b3ba3a6a5c4b69d18a7 Mon Sep 17 00:00:00 2001 From: aristotelos Date: Wed, 15 May 2024 15:47:37 +0200 Subject: [PATCH 11/13] Add basic Windows-specific extensions Add Windows-specific extensions similar to appium-windows-driver to allow easier replacement. --- src/FlaUI.WebDriver.UITests/ExecuteTests.cs | 51 +++++++ src/FlaUI.WebDriver.UITests/WindowTests.cs | 2 +- .../Controllers/ExecuteController.cs | 96 ++++++++++++- .../Models/ExecuteScriptRequest.cs | 6 +- .../Models/WindowsClickScript.cs | 10 ++ .../Models/WindowsHoverScript.cs | 13 ++ .../Models/WindowsKeyScript.cs | 10 ++ src/FlaUI.WebDriver/Program.cs | 1 + .../Services/IWindowsExtensionService.cs | 11 ++ .../Services/WindowsExtensionService.cs | 129 ++++++++++++++++++ .../WpfApplication/MainWindow.xaml | 3 +- .../WpfApplication/MainWindow.xaml.cs | 10 ++ 12 files changed, 335 insertions(+), 7 deletions(-) create mode 100644 src/FlaUI.WebDriver/Models/WindowsClickScript.cs create mode 100644 src/FlaUI.WebDriver/Models/WindowsHoverScript.cs create mode 100644 src/FlaUI.WebDriver/Models/WindowsKeyScript.cs create mode 100644 src/FlaUI.WebDriver/Services/IWindowsExtensionService.cs create mode 100644 src/FlaUI.WebDriver/Services/WindowsExtensionService.cs 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/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/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/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/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" />