diff --git a/README.md b/README.md index 35cd349..6caaaab 100644 --- a/README.md +++ b/README.md @@ -131,16 +131,13 @@ The driver supports switching windows. The behavior of windows is as following ( - By default, the window is the window that the application was started with. - The window does not change if the app/user opens another window, also not if that window happens to be on the foreground. -- ~~All open window handles from the same app process (same process ID in Windows) can be retrieved.~~ Currently only the main window and modal windows are returned when getting window handles. See issue below. +- All open window handles from the same app process (same process ID in Windows) can be retrieved. - Other processes spawned by the app that open windows are not visible as window handles. Those can be automated by starting a new driver session with e.g. the `appium:appTopLevelWindow` capability. - Closing a window does not automatically switch the window handle. That means that after closing a window, most commands will return an error "no such window" until the window is switched. - Switching to a window will set that window in the foreground. -> [!IMPORTANT] -> Currently only the main window and modal windows are returned when getting window handles. See - ## Running scripts The driver supports PowerShell commands. @@ -159,86 +156,113 @@ const result = driver.executeScript("powerShell", [{ command: `1+1` }]); ## Supported WebDriver Commands -| Method | URI Template | Command | Implemented | -| ------ | -------------------------------------------------------------- | ------------------------------ | ------------------ | -| POST | /session | New Session | :white_check_mark: | -| DELETE | /session/{session id} | Delete Session | :white_check_mark: | -| GET | /status | Status | :white_check_mark: | -| GET | /session/{session id}/timeouts | Get Timeouts | :white_check_mark: | -| POST | /session/{session id}/timeouts | Set Timeouts | :white_check_mark: | -| POST | /session/{session id}/url | Navigate To | N/A | -| GET | /session/{session id}/url | Get Current URL | N/A | -| POST | /session/{session id}/back | Back | N/A | -| POST | /session/{session id}/forward | Forward | N/A | -| POST | /session/{session id}/refresh | Refresh | N/A | -| GET | /session/{session id}/title | Get Title | :white_check_mark: | -| GET | /session/{session id}/window | Get Window Handle | :white_check_mark: | -| DELETE | /session/{session id}/window | Close Window | :white_check_mark: | -| POST | /session/{session id}/window | Switch To Window | :white_check_mark: | -| GET | /session/{session id}/window/handles | Get Window Handles | :white_check_mark: | -| POST | /session/{session id}/window/new | New Window | | -| POST | /session/{session id}/frame | Switch To Frame | N/A | -| POST | /session/{session id}/frame/parent | Switch To Parent Frame | N/A | -| GET | /session/{session id}/window/rect | Get Window Rect | :white_check_mark: | -| POST | /session/{session id}/window/rect | Set Window Rect | :white_check_mark: | -| POST | /session/{session id}/window/maximize | Maximize Window | | -| POST | /session/{session id}/window/minimize | Minimize Window | | -| POST | /session/{session id}/window/fullscreen | Fullscreen Window | | -| GET | /session/{session id}/element/active | Get Active Element | :white_check_mark: | -| GET | /session/{session id}/element/{element id}/shadow | Get Element Shadow Root | N/A | -| POST | /session/{session id}/element | Find Element | :white_check_mark: | -| POST | /session/{session id}/elements | Find Elements | :white_check_mark: | -| POST | /session/{session id}/element/{element id}/element | Find Element From Element | :white_check_mark: | -| POST | /session/{session id}/element/{element id}/elements | Find Elements From Element | :white_check_mark: | -| POST | /session/{session id}/shadow/{shadow id}/element | Find Element From Shadow Root | N/A | -| POST | /session/{session id}/shadow/{shadow id}/elements | Find Elements From Shadow Root | N/A | -| GET | /session/{session id}/element/{element id}/selected | Is Element Selected | :white_check_mark: | -| GET | /session/{session id}/element/{element id}/attribute/{name} | Get Element Attribute | | -| GET | /session/{session id}/element/{element id}/property/{name} | Get Element Property | | -| GET | /session/{session id}/element/{element id}/css/{property name} | Get Element CSS Value | N/A | -| GET | /session/{session id}/element/{element id}/text | Get Element Text | :white_check_mark: | -| GET | /session/{session id}/element/{element id}/name | Get Element Tag Name | :white_check_mark: | -| GET | /session/{session id}/element/{element id}/rect | Get Element Rect | :white_check_mark: | -| GET | /session/{session id}/element/{element id}/enabled | Is Element Enabled | :white_check_mark: | -| GET | /session/{session id}/element/{element id}/computedrole | Get Computed Role | | -| GET | /session/{session id}/element/{element id}/computedlabel | Get Computed Label | | -| POST | /session/{session id}/element/{element id}/click | Element Click | :white_check_mark: | -| POST | /session/{session id}/element/{element id}/clear | Element Clear | :white_check_mark: | -| POST | /session/{session id}/element/{element id}/value | Element Send Keys | :white_check_mark: | -| GET | /session/{session id}/source | Get Page Source | N/A | -| POST | /session/{session id}/execute/sync | Execute Script | :white_check_mark: | -| POST | /session/{session id}/execute/async | Execute Async Script | | -| GET | /session/{session id}/cookie | Get All Cookies | N/A | -| GET | /session/{session id}/cookie/{name} | Get Named Cookie | N/A | -| POST | /session/{session id}/cookie | Add Cookie | N/A | -| DELETE | /session/{session id}/cookie/{name} | Delete Cookie | N/A | -| DELETE | /session/{session id}/cookie | Delete All Cookies | N/A | -| POST | /session/{session id}/actions | Perform Actions | :white_check_mark: | -| DELETE | /session/{session id}/actions | Release Actions | :white_check_mark: | -| POST | /session/{session id}/alert/dismiss | Dismiss Alert | | -| POST | /session/{session id}/alert/accept | Accept Alert | | -| GET | /session/{session id}/alert/text | Get Alert Text | | -| POST | /session/{session id}/alert/text | Send Alert Text | | -| GET | /session/{session id}/screenshot | Take Screenshot | :white_check_mark: | -| GET | /session/{session id}/element/{element id}/screenshot | Take Element Screenshot | :white_check_mark: | -| POST | /session/{session id}/print | Print Page | | +| Method | URI Template | Command | Implemented | +| ------ | -------------------------------------------------------------- | ------------------------------ | ---------------------------------- | +| POST | /session | New Session | :white_check_mark: | +| DELETE | /session/{session id} | Delete Session | :white_check_mark: | +| GET | /status | Status | :white_check_mark: | +| GET | /session/{session id}/timeouts | Get Timeouts | :white_check_mark: | +| POST | /session/{session id}/timeouts | Set Timeouts | :white_check_mark: | +| POST | /session/{session id}/url | Navigate To | N/A | +| GET | /session/{session id}/url | Get Current URL | N/A | +| POST | /session/{session id}/back | Back | N/A | +| POST | /session/{session id}/forward | Forward | N/A | +| POST | /session/{session id}/refresh | Refresh | N/A | +| GET | /session/{session id}/title | Get Title | :white_check_mark: | +| GET | /session/{session id}/window | Get Window Handle | :white_check_mark: | +| DELETE | /session/{session id}/window | Close Window | :white_check_mark: | +| POST | /session/{session id}/window | Switch To Window | :white_check_mark: | +| GET | /session/{session id}/window/handles | Get Window Handles | :white_check_mark: | +| POST | /session/{session id}/window/new | New Window | | +| POST | /session/{session id}/frame | Switch To Frame | N/A | +| POST | /session/{session id}/frame/parent | Switch To Parent Frame | N/A | +| GET | /session/{session id}/window/rect | Get Window Rect | :white_check_mark: | +| POST | /session/{session id}/window/rect | Set Window Rect | :white_check_mark: | +| POST | /session/{session id}/window/maximize | Maximize Window | | +| POST | /session/{session id}/window/minimize | Minimize Window | | +| POST | /session/{session id}/window/fullscreen | Fullscreen Window | | +| GET | /session/{session id}/element/active | Get Active Element | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/shadow | Get Element Shadow Root | N/A | +| POST | /session/{session id}/element | Find Element | :white_check_mark: | +| POST | /session/{session id}/elements | Find Elements | :white_check_mark: | +| POST | /session/{session id}/element/{element id}/element | Find Element From Element | :white_check_mark: | +| POST | /session/{session id}/element/{element id}/elements | Find Elements From Element | :white_check_mark: | +| POST | /session/{session id}/shadow/{shadow id}/element | Find Element From Shadow Root | N/A | +| POST | /session/{session id}/shadow/{shadow id}/elements | Find Elements From Shadow Root | N/A | +| GET | /session/{session id}/element/{element id}/selected | Is Element Selected | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/attribute/{name} | Get Element Attribute | :white_check_mark: [^getattribute] | +| GET | /session/{session id}/element/{element id}/property/{name} | Get Element Property | | +| GET | /session/{session id}/element/{element id}/css/{property name} | Get Element CSS Value | N/A | +| GET | /session/{session id}/element/{element id}/text | Get Element Text | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/name | Get Element Tag Name | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/rect | Get Element Rect | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/enabled | Is Element Enabled | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/computedrole | Get Computed Role | | +| GET | /session/{session id}/element/{element id}/computedlabel | Get Computed Label | | +| POST | /session/{session id}/element/{element id}/click | Element Click | :white_check_mark: | +| POST | /session/{session id}/element/{element id}/clear | Element Clear | :white_check_mark: | +| POST | /session/{session id}/element/{element id}/value | Element Send Keys | :white_check_mark: | +| GET | /session/{session id}/source | Get Page Source | N/A | +| POST | /session/{session id}/execute/sync | Execute Script | :white_check_mark: | +| POST | /session/{session id}/execute/async | Execute Async Script | | +| GET | /session/{session id}/cookie | Get All Cookies | N/A | +| GET | /session/{session id}/cookie/{name} | Get Named Cookie | N/A | +| POST | /session/{session id}/cookie | Add Cookie | N/A | +| DELETE | /session/{session id}/cookie/{name} | Delete Cookie | N/A | +| DELETE | /session/{session id}/cookie | Delete All Cookies | N/A | +| POST | /session/{session id}/actions | Perform Actions | :white_check_mark: | +| DELETE | /session/{session id}/actions | Release Actions | :white_check_mark: | +| POST | /session/{session id}/alert/dismiss | Dismiss Alert | | +| POST | /session/{session id}/alert/accept | Accept Alert | | +| GET | /session/{session id}/alert/text | Get Alert Text | | +| POST | /session/{session id}/alert/text | Send Alert Text | | +| GET | /session/{session id}/screenshot | Take Screenshot | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/screenshot | Take Element Screenshot | :white_check_mark: | +| POST | /session/{session id}/print | Print Page | | + +[^getattribute]: In Selenium WebDriver, use `GetDomAttribute` because `GetAttribute` converts to javascript. ### WebDriver Interpretation There is an interpretation to use the WebDriver specification to drive native automation. Appium does not seem to describe that interpretation and leaves it up to the implementer as well. Therefore we describe it here: -| WebDriver term | Interpretation | -| ---------------------------------- | ------------------------------------------------------------------------------------------------- | -| browser | The Windows OS on which the FlaUI.WebDriver instance is running | -| top-level browsing contexts | Any window of the app under test (modal windows too) | -| current top-level browsing context | The current selected window of the app under test | -| browsing contexts | Any window of the app under test (equal to "top-level browsing contexts") | -| current browsing context | The current selected window of the app under test (equal to "current top-level browsing context") | -| window | Any window of the app under test (modal windows too) | -| frame | Not implemented - frames are only relevant for web browsers | -| shadow root | Not implemented - shadow DOM is only relevant for web browsers | -| cookie | Not implemented - cookies are only relevant for web browsers | -| tag name | Control type in Windows | +| WebDriver term | Interpretation | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| browser | The Windows OS on which the FlaUI.WebDriver instance is running | +| top-level browsing contexts | Any window of the app under test (modal windows too) | +| current top-level browsing context | The current selected window of the app under test | +| browsing contexts | Any window of the app under test (equal to "top-level browsing contexts") | +| current browsing context | The current selected window of the app under test (equal to "current top-level browsing context") | +| window | Any window of the app under test (modal windows too) | +| frame | Not implemented - frames are only relevant for web browsers | +| shadow root | Not implemented - shadow DOM is only relevant for web browsers | +| cookie | Not implemented - cookies are only relevant for web browsers | +| tag name | Control type in Windows | +| attribute | [UI automation element property](https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-automation-element-propids) in Windows | + +### Deviations from W3C WebDriver Spec + +https://www.w3.org/TR/webdriver2/#element-send-keys says: + +> Set the text insertion caret using set selection range using current text length for both the start and end parameters. + +This is impossible using UIA, as there is no API to set the caret position: text instead gets inserted at the beginning of a text box. This is also WinAppDriver's behavior. + +### Element Attributes + +Attributes are mapped to UI automation element properties. Attributes without a period (`.`) are mapped to [Automation Element Properties](https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-automation-element-propids). For example to read the `UIA_ClassNamePropertyId` using Selenium WebDriver: + +```C# +var element = driver.FindElement(By.Id("TextBox")); +var value = element.GetDomAttribute("ClassName"); +``` + +Attributes with a period are treated as [Control Pattern Properties](https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-control-pattern-propids) with the form `Pattern.Property`. For example to read the `UIA_ToggleToggleStatePropertyId` using Selenium WebDriver: + +```C# +var element = driver.FindElement(By.Id("ToggleButton")); +var value = element.GetDomAttribute("Toggle.ToggleState"); +``` ## Next Steps diff --git a/src/FlaUI.WebDriver.UITests/ActionsTest.cs b/src/FlaUI.WebDriver.UITests/ActionsTest.cs index ddcd386..de85390 100644 --- a/src/FlaUI.WebDriver.UITests/ActionsTest.cs +++ b/src/FlaUI.WebDriver.UITests/ActionsTest.cs @@ -48,5 +48,25 @@ public void ReleaseActions_Default_ReleasesKeys() string activeElmentText = _driver.SwitchTo().ActiveElement().Text; Assert.That(activeElmentText, Is.EqualTo("Test TextBo")); } + + [Test] + public void PerformActions_MoveToElement_Click() + { + var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + new Actions(_driver).MoveToElement(element).Click().Perform(); + string activeElementText = _driver.SwitchTo().ActiveElement().Text; + Assert.That(activeElementText, Is.EqualTo("Test TextBox")); + } + + [Test] + public void PerformActions_MoveToElement_MoveByOffset_Click() + { + var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + new Actions(_driver).MoveToElement(element).MoveByOffset(5, 0).Click().Perform(); + string activeElementText = _driver.SwitchTo().ActiveElement().Text; + Assert.That(activeElementText, Is.EqualTo("Test TextBox")); + } } } diff --git a/src/FlaUI.WebDriver.UITests/ElementTests.cs b/src/FlaUI.WebDriver.UITests/ElementTests.cs index 7088640..a658ee4 100644 --- a/src/FlaUI.WebDriver.UITests/ElementTests.cs +++ b/src/FlaUI.WebDriver.UITests/ElementTests.cs @@ -1,48 +1,78 @@ -using FlaUI.WebDriver.UITests.TestUtil; +using System.Threading; +using FlaUI.WebDriver.UITests.TestUtil; using NUnit.Framework; using OpenQA.Selenium; +using OpenQA.Selenium.Interactions; using OpenQA.Selenium.Remote; -using System; namespace FlaUI.WebDriver.UITests { [TestFixture] public class ElementTests { - [Test] - public void GetText_Label_ReturnsRenderedText() + [TestCase("TextBox", "Test TextBox")] + [TestCase("PasswordBox", "●●●●●●●●●●")] + [TestCase("EditableCombo", "Item 1")] + [TestCase("NonEditableCombo", "Item 1")] + [TestCase("ListBox", "ListBox Item #1")] + [TestCase("SimpleCheckBox", "Test Checkbox")] + [TestCase("ThreeStateCheckBox", "3-Way Test Checkbox")] + [TestCase("RadioButton1", "RadioButton1")] + [TestCase("RadioButton2", "RadioButton2")] + [TestCase("Slider", "5")] + [TestCase("InvokableButton", "Invoke me!")] + [TestCase("PopupToggleButton1", "Popup Toggle 1")] + [TestCase("Label", "Menu Item Checked")] + public void GetText_Returns_Correct_Text(string elementAccessibilityId, string expectedValue) { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); - var element = driver.FindElement(ExtendedBy.AccessibilityId("Label")); - + var element = driver.FindElement(ExtendedBy.AccessibilityId(elementAccessibilityId)); var text = element.Text; - Assert.That(text, Is.EqualTo("Menu Item Checked")); + Assert.That(text, Is.EqualTo(expectedValue)); } [Test] - public void GetText_TextBox_ReturnsTextBoxText() + public void GetText_Returns_Text_For_Multiple_Selection() { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); - var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + var element = driver.FindElement(ExtendedBy.AccessibilityId("ListBox")); + + new Actions(driver) + .MoveToElement(element) + .Click() + .KeyDown(Keys.Control) + .KeyDown("a") + .KeyUp("a") + .KeyUp(Keys.Control) + .Perform(); var text = element.Text; - - Assert.That(text, Is.EqualTo("Test TextBox")); + + // Seems that the order in which the selected items are returned is not guaranteed. + Assert.That(text, Is.EqualTo("ListBox Item #1, ListBox Item #2").Or.EqualTo("ListBox Item #2, ListBox Item #1")); } [Test] - public void GetText_Button_ReturnsButtonText() + public void GetText_Returns_Empty_String_For_No_Selection() { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); - var element = driver.FindElement(ExtendedBy.AccessibilityId("InvokableButton")); + var element = driver.FindElement(ExtendedBy.AccessibilityId("ListBox")); + var item = driver.FindElement(ExtendedBy.Name("ListBox Item #1")); + + new Actions(driver) + .MoveToElement(item) + .KeyDown(Keys.Control) + .Click() + .KeyUp(Keys.Control) + .Perform(); var text = element.Text; - Assert.That(text, Is.EqualTo("Invoke me!")); + Assert.That(text, Is.Empty); } [Test] @@ -104,7 +134,51 @@ public void SendKeys_Default_IsSupported() element.SendKeys("Hello World!"); - Assert.That(element.Text, Is.EqualTo("Hello World!")); + Assert.That(element.Text, Is.EqualTo("Hello World!Test TextBox")); + } + + [Test] + public void SendKeys_ShiftedCharacter_ShiftIsReleased() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + element.SendKeys("!"); + element.SendKeys("1"); + + Assert.That(element.Text, Is.EqualTo("!1Test TextBox")); + } + + [Test] + public void SendKeys_DownArrow_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("NonEditableCombo")); + + element.SendKeys(Keys.Down); + + Assert.That(element.Text, Is.EqualTo("Item 2")); + } + + [Test, Ignore("Alt key combinations currently fail due to https://github.com/FlaUI/FlaUI/issues/320")] + public void SendKeys_AltDownArrowEscape_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("NonEditableCombo")); + var expandCollapseState = element.GetDomAttribute("ExpandCollapse.ExpandCollapseState"); + + Assert.That(expandCollapseState, Is.EqualTo("Collapsed")); + + element.SendKeys(Keys.Alt + Keys.Down); + + Assert.That(expandCollapseState, Is.EqualTo("Expanded")); + + element.SendKeys(Keys.Escape); + + Assert.That(expandCollapseState, Is.EqualTo("Collapsed")); } [Test] @@ -138,14 +212,15 @@ public void GetElementRect_Default_IsSupported() using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); var element = driver.FindElement(ExtendedBy.AccessibilityId("EditableCombo")); + var scaling = TestApplication.GetScaling(driver); var location = element.Location; var size = element.Size; var windowLocation = driver.Manage().Window.Position; - Assert.That(location.X, Is.InRange(windowLocation.X + 253, windowLocation.X + 257)); - Assert.That(location.Y, Is.InRange(windowLocation.Y + 132, windowLocation.Y + 136)); - Assert.That(size.Width, Is.EqualTo(120)); - Assert.That(size.Height, Is.EqualTo(22)); + Assert.That(location.X, Is.InRange(windowLocation.X + (253 * scaling), windowLocation.X + (257 * scaling))); + Assert.That(location.Y, Is.InRange(windowLocation.Y + (132 * scaling), windowLocation.Y + (136 * scaling))); + Assert.That(size.Width, Is.EqualTo(120 * scaling)); + Assert.That(size.Height, Is.EqualTo(22 * scaling)); } [TestCase("TextBox")] @@ -210,5 +285,39 @@ public void ActiveElement_Default_IsSupported() Assert.That(activeElement.Text, Is.EqualTo("Invoked!")); } + + [TestCase(["ClassName", "TextBox"])] + [TestCase(["FrameworkId", "WPF"])] + [TestCase(["NonExistent", null])] + public void GetAttribute_TextBox_ReturnsValue(string attributeName, string expectedValue) + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + var value = element.GetDomAttribute(attributeName); + + Assert.That(value, Is.EqualTo(expectedValue)); + } + + [Test] + public void GetAttribute_PatternProperty_ReturnsValue() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("SimpleCheckBox")); + + var value = element.GetDomAttribute("Toggle.ToggleState"); + + Assert.That(value, Is.EqualTo("Off")); + + element.Click(); + + value = element.GetDomAttribute("Toggle.ToggleState"); + + Assert.That(value, Is.EqualTo("On")); + + element.Click(); + } } } diff --git a/src/FlaUI.WebDriver.UITests/FindElementsTests.cs b/src/FlaUI.WebDriver.UITests/FindElementsTests.cs index b090ef2..851ed37 100644 --- a/src/FlaUI.WebDriver.UITests/FindElementsTests.cs +++ b/src/FlaUI.WebDriver.UITests/FindElementsTests.cs @@ -119,7 +119,7 @@ public void FindElement_ByTagName_ReturnsElement() } [Test] - public void FindElement_NotExisting_TimesOut() + public void FindElement_NotExisting_ThrowsNoSuchElementException() { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); @@ -142,7 +142,7 @@ public void FindElementFromElement_InsideElement_ReturnsElement() } [Test] - public void FindElementFromElement_OutsideElement_TimesOut() + public void FindElementFromElement_OutsideElement_ThrowsNoSuchElementException() { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); @@ -166,15 +166,15 @@ public void FindElementsFromElement_InsideElement_ReturnsElement() } [Test] - public void FindElementsFromElement_OutsideElement_TimesOut() + public void FindElementsFromElement_OutsideElement_ReturnsEmptyList() { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); var fromElement = driver.FindElement(ExtendedBy.AccessibilityId("ListBox")); - var findElements = () => fromElement.FindElements(ExtendedBy.AccessibilityId("TextBox")); + var foundElements = fromElement.FindElements(ExtendedBy.AccessibilityId("TextBox")); - Assert.That(findElements, Throws.TypeOf()); + Assert.That(foundElements, Is.Empty); } [Test] @@ -189,18 +189,18 @@ public void FindElements_Default_ReturnsElements() } [Test] - public void FindElements_NotExisting_TimesOut() + public void FindElements_NotExisting_ReturnsEmptyList() { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); - var findElements = () => driver.FindElements(ExtendedBy.AccessibilityId("NotExisting")); + var foundElements = driver.FindElements(ExtendedBy.AccessibilityId("NotExisting")); - Assert.That(findElements, Throws.TypeOf()); + Assert.That(foundElements, Is.Empty); } [Test] - public void FindElement_InOtherWindow_TimesOut() + public void FindElement_InOtherWindow_ThrowsNoSuchElementException() { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); @@ -214,15 +214,15 @@ public void FindElement_InOtherWindow_TimesOut() } [Test] - public void FindElements_InOtherWindow_TimesOut() + public void FindElements_InOtherWindow_ReturnsEmptyList() { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); OpenAndSwitchToAnotherWindow(driver); - var findElements = () => driver.FindElements(ExtendedBy.AccessibilityId("TextBox")); + var foundElements = driver.FindElements(ExtendedBy.AccessibilityId("TextBox")); - Assert.That(findElements, Throws.TypeOf()); + Assert.That(foundElements, Is.Empty); var elementsInNewWindow = driver.FindElements(ExtendedBy.AccessibilityId("Window1TextBox")); Assert.That(elementsInNewWindow, Has.Count.EqualTo(1)); } diff --git a/src/FlaUI.WebDriver.UITests/SessionTests.cs b/src/FlaUI.WebDriver.UITests/SessionTests.cs index f7f8cdd..2d5940d 100644 --- a/src/FlaUI.WebDriver.UITests/SessionTests.cs +++ b/src/FlaUI.WebDriver.UITests/SessionTests.cs @@ -129,6 +129,10 @@ public void NewSession_AppTopLevelWindow_IsSupported() var title = driver.Title; Assert.That(title, Is.EqualTo("FlaUI WPF Test App")); + + driver.Quit(); + + Assert.That(testAppProcess.Process.HasExited, Is.False); } [Test] @@ -166,6 +170,10 @@ public void NewSession_AppTopLevelWindowTitleMatch_IsSupported(string match) var title = driver.Title; Assert.That(title, Is.EqualTo("FlaUI WPF Test App")); + + driver.Quit(); + + Assert.That(testAppProcess.Process.HasExited, Is.False); } [Test, Ignore("Sometimes multiple processes are left open")] @@ -315,6 +323,35 @@ public void NewCommandTimeout_NotExpired_DoesNotEndSession() Assert.That(() => driver.Title, Throws.Nothing); } + [Test, Explicit(("Sometimes multiple processes are left open"))] + public void NewCommandTimeout_SessionWithAppTopLevelWindowTitleMatch_ClosesSessionButDoesNotCloseApp() + { + using var testAppProcess = new TestAppProcess(); + var driverOptions = FlaUIDriverOptions.AppTopLevelWindowTitleMatch("FlaUI WPF Test App"); + driverOptions.AddAdditionalOption("appium:newCommandTimeout", 1); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + System.Threading.Thread.Sleep(TimeSpan.FromSeconds(1) + WebDriverFixture.SessionCleanupInterval * 2); + + Assert.That(testAppProcess.Process.HasExited, Is.False); + Assert.That(() => driver.Title, Throws.TypeOf().With.Message.Matches("No active session with ID '.*'")); + } + + [Test] + public void NewCommandTimeout_SessionWithAppTopLevelWindow_ClosesSessionButDoesNotCloseApp() + { + using var testAppProcess = new TestAppProcess(); + var windowHandle = string.Format("0x{0:x}", testAppProcess.Process.MainWindowHandle); + var driverOptions = FlaUIDriverOptions.AppTopLevelWindow(windowHandle); + driverOptions.AddAdditionalOption("appium:newCommandTimeout", 1); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + System.Threading.Thread.Sleep(TimeSpan.FromSeconds(1) + WebDriverFixture.SessionCleanupInterval * 2); + + Assert.That(testAppProcess.Process.HasExited, Is.False); + Assert.That(() => driver.Title, Throws.TypeOf().With.Message.Matches("No active session with ID '.*'")); + } + [TestCase("123")] [TestCase(false)] [TestCase("not a number")] diff --git a/src/FlaUI.WebDriver.UITests/TestUtil/FlaUIDriverOptions.cs b/src/FlaUI.WebDriver.UITests/TestUtil/FlaUIDriverOptions.cs index ac1fb82..a8bd833 100644 --- a/src/FlaUI.WebDriver.UITests/TestUtil/FlaUIDriverOptions.cs +++ b/src/FlaUI.WebDriver.UITests/TestUtil/FlaUIDriverOptions.cs @@ -1,5 +1,4 @@ using OpenQA.Selenium; -using System; namespace FlaUI.WebDriver.UITests.TestUtil { diff --git a/src/FlaUI.WebDriver.UITests/TestUtil/TestApplication.cs b/src/FlaUI.WebDriver.UITests/TestUtil/TestApplication.cs index 081f5b9..9c4cbfd 100644 --- a/src/FlaUI.WebDriver.UITests/TestUtil/TestApplication.cs +++ b/src/FlaUI.WebDriver.UITests/TestUtil/TestApplication.cs @@ -1,5 +1,6 @@ using System.IO; using System.Linq; +using OpenQA.Selenium.Remote; namespace FlaUI.WebDriver.UITests.TestUtil { @@ -9,6 +10,11 @@ public static class TestApplication private static readonly string s_currentDirectory = Directory.GetCurrentDirectory(); private static readonly string s_solutionDirectory = FindSolutionDirectory(s_currentDirectory); + public static double GetScaling(RemoteWebDriver driver) + { + return double.Parse(driver.FindElement(ExtendedBy.AccessibilityId("DpiScaling")).Text.ToString()); + } + private static string FindSolutionDirectory(string currentDirectory) { while (!Directory.GetFiles(currentDirectory, "*.sln").Any()) diff --git a/src/FlaUI.WebDriver.UITests/WindowTests.cs b/src/FlaUI.WebDriver.UITests/WindowTests.cs index ee803fb..63df71d 100644 --- a/src/FlaUI.WebDriver.UITests/WindowTests.cs +++ b/src/FlaUI.WebDriver.UITests/WindowTests.cs @@ -15,13 +15,14 @@ public void GetWindowRect_Default_IsSupported() var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var scaling = TestApplication.GetScaling(driver); var position = driver.Manage().Window.Position; var size = driver.Manage().Window.Size; Assert.That(position.X, Is.GreaterThanOrEqualTo(0)); Assert.That(position.Y, Is.GreaterThanOrEqualTo(0)); - Assert.That(size.Width, Is.InRange(629, 630)); - Assert.That(size.Height, Is.InRange(515, 516)); + Assert.That(size.Width, Is.InRange(629 * scaling, 630 * scaling)); + Assert.That(size.Height, Is.InRange(515 * scaling, 516 * scaling)); } [Test] @@ -63,20 +64,20 @@ public void GetWindowHandle_AppOpensNewWindow_DoesNotSwitchToNewWindow() Assert.That(windowHandleAfterOpenCloseOtherWindow, Is.EqualTo(initialWindowHandle)); } - [Test, Ignore("https://github.com/FlaUI/FlaUI/issues/596")] + [Test] public void GetWindowHandle_WindowClosed_ReturnsNoSuchWindow() { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); - OpenAndSwitchToNewWindow(driver); + var newWindowHandle = OpenAndSwitchToNewWindow(driver); driver.Close(); var getWindowHandle = () => driver.CurrentWindowHandle; - Assert.That(getWindowHandle, Throws.TypeOf().With.Message.EqualTo("Test")); + Assert.That(getWindowHandle, Throws.TypeOf().With.Message.EqualTo($"No window found with handle '{newWindowHandle}'")); } - [Test, Ignore("https://github.com/FlaUI/FlaUI/issues/596")] + [Test] public void GetWindowHandles_Default_ReturnsUniqueHandlePerWindow() { var driverOptions = FlaUIDriverOptions.TestApp(); @@ -146,12 +147,13 @@ public void SwitchWindow_Default_MovesWindowToForeground() Assert.That(element.Text, Is.EqualTo("Invoked!")); } - private static void OpenAndSwitchToNewWindow(RemoteWebDriver driver) + private static string OpenAndSwitchToNewWindow(RemoteWebDriver driver) { var initialWindowHandle = driver.CurrentWindowHandle; OpenAnotherWindow(driver); var newWindowHandle = driver.WindowHandles.Except(new[] { initialWindowHandle }).Single(); driver.SwitchTo().Window(newWindowHandle); + return newWindowHandle; } private static void OpenAnotherWindow(RemoteWebDriver driver) diff --git a/src/FlaUI.WebDriver/Action.cs b/src/FlaUI.WebDriver/Action.cs index 871253e..769d974 100644 --- a/src/FlaUI.WebDriver/Action.cs +++ b/src/FlaUI.WebDriver/Action.cs @@ -1,5 +1,4 @@ using FlaUI.WebDriver.Models; -using System; namespace FlaUI.WebDriver { @@ -7,6 +6,7 @@ public class Action { public Action(ActionSequence actionSequence, ActionItem actionItem) { + Id = actionSequence.Id; Type = actionSequence.Type; SubType = actionItem.Type; Button = actionItem.Button; @@ -30,6 +30,7 @@ public Action(ActionSequence actionSequence, ActionItem actionItem) public Action(Action action) { + Id = action.Id; Type = action.Type; SubType = action.SubType; Button = action.Button; @@ -51,11 +52,12 @@ public Action(Action action) Value = action.Value; } + public string Id { get; set; } public string Type { get; set; } public string SubType { get; set; } public int? Button { get; set; } public int? Duration { get; set; } - public string? Origin { get; set; } + public object? Origin { get; set; } public int? X { get; set; } public int? Y { get; set; } public int? DeltaX { get; set; } diff --git a/src/FlaUI.WebDriver/AutomationElementExtensions.cs b/src/FlaUI.WebDriver/AutomationElementExtensions.cs new file mode 100644 index 0000000..646ea94 --- /dev/null +++ b/src/FlaUI.WebDriver/AutomationElementExtensions.cs @@ -0,0 +1,58 @@ +using System.Diagnostics.CodeAnalysis; +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Identifiers; +using FlaUI.Core.Patterns.Infrastructure; +using static FlaUI.Core.FrameworkAutomationElementBase; + +namespace FlaUI.WebDriver; + +/// +/// Gets properties and patterns from an . +/// +/// +/// Not so crazy about using reflection here, but it seems it's the only way to query these objects by string? +/// +public static class AutomationElementExtensions +{ + public static bool TryGetPattern(this AutomationElement element, string patternName, [NotNullWhen(true)] out IPattern? pattern) + { + if (typeof(IFrameworkPatterns).GetProperty(patternName) is { } propertyInfo && + propertyInfo.GetValue(element.Patterns) is { } patterns && + patterns.GetType().GetProperty("PatternOrDefault") is { } patternPropertyInfo && + patternPropertyInfo.GetValue(patterns) is IPattern patternValue) + { + pattern = patternValue; + return true; + } + + pattern = null; + return false; + } + + public static bool TryGetProperty(this AutomationElement element, string propertyName, out object? value) + { + var library = element.FrameworkAutomationElement.PropertyIdLibrary; + + if (library.GetType().GetProperty(propertyName) is { } propertyInfo && + propertyInfo.GetValue(library) is PropertyId propertyId) + { + element.FrameworkAutomationElement.TryGetPropertyValue(propertyId, out value); + return true; + } + + value = null; + return false; + } + + public static bool TryGetProperty(this IPattern pattern, string propertyName, out object? value) + { + if (pattern.GetType().GetProperty(propertyName) is { } propertyInfo) + { + value = propertyInfo.GetValue(pattern); + return true; + } + + value = null; + return false; + } +} diff --git a/src/FlaUI.WebDriver/Controllers/ActionsController.cs b/src/FlaUI.WebDriver/Controllers/ActionsController.cs index a0c7c35..d73cd9a 100644 --- a/src/FlaUI.WebDriver/Controllers/ActionsController.cs +++ b/src/FlaUI.WebDriver/Controllers/ActionsController.cs @@ -1,12 +1,6 @@ -using FlaUI.Core.Input; -using FlaUI.Core.WindowsAPI; -using FlaUI.WebDriver.Models; +using FlaUI.WebDriver.Models; +using FlaUI.WebDriver.Services; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; -using System.Threading.Tasks; namespace FlaUI.WebDriver.Controllers { @@ -16,22 +10,26 @@ public class ActionsController : ControllerBase { private readonly ILogger _logger; private readonly ISessionRepository _sessionRepository; + private readonly IActionsDispatcher _actionsDispatcher; - public ActionsController(ILogger logger, ISessionRepository sessionRepository) + public ActionsController(ILogger logger, ISessionRepository sessionRepository, IActionsDispatcher actionsDispatcher) { _logger = logger; _sessionRepository = sessionRepository; + _actionsDispatcher = actionsDispatcher; } [HttpPost] public async Task PerformActions([FromRoute] string sessionId, [FromBody] ActionsRequest actionsRequest) { + _logger.LogDebug("Performing actions for session {SessionId}", sessionId); + var session = GetSession(sessionId); - var actionsByTick = ExtractActionSequence(actionsRequest); + var actionsByTick = ExtractActionSequence(session, actionsRequest); foreach (var tickActions in actionsByTick) { var tickDuration = tickActions.Max(tickAction => tickAction.Duration) ?? 0; - var dispatchTickActionTasks = tickActions.Select(tickAction => DispatchAction(session, tickAction)); + var dispatchTickActionTasks = tickActions.Select(tickAction => _actionsDispatcher.DispatchAction(session, tickAction)); if (tickDuration > 0) { dispatchTickActionTasks = dispatchTickActionTasks.Concat(new[] { Task.Delay(tickDuration) }); @@ -45,11 +43,13 @@ public async Task PerformActions([FromRoute] string sessionId, [Fr [HttpDelete] public async Task ReleaseActions([FromRoute] string sessionId) { + _logger.LogDebug("Releasing actions for session {SessionId}", sessionId); + var session = GetSession(sessionId); foreach (var cancelAction in session.InputState.InputCancelList) { - await DispatchAction(session, cancelAction); + await _actionsDispatcher.DispatchAction(session, cancelAction); } session.InputState.Reset(); @@ -60,13 +60,25 @@ public async Task ReleaseActions([FromRoute] string sessionId) /// See https://www.w3.org/TR/webdriver2/#dfn-extract-an-action-sequence. /// Returns all sequence actions synchronized by index. /// - /// + /// The session + /// The request /// - private static List> ExtractActionSequence(ActionsRequest actionsRequest) + private static List> ExtractActionSequence(Session session, ActionsRequest actionsRequest) { var actionsByTick = new List>(); foreach (var actionSequence in actionsRequest.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); + } + for (var tickIndex = 0; tickIndex < actionSequence.Actions.Count; tickIndex++) { var actionItem = actionSequence.Actions[tickIndex]; @@ -81,251 +93,6 @@ private static List> ExtractActionSequence(ActionsRequest actionsRe return actionsByTick; } - private static async Task DispatchAction(Session session, Action action) - { - switch (action.Type) - { - case "pointer": - await DispatchPointerAction(session, action); - return; - case "key": - await DispatchKeyAction(session, action); - return; - case "wheel": - await DispatchWheelAction(session, action); - return; - case "none": - await DispatchNullAction(session, action); - return; - default: - throw WebDriverResponseException.UnsupportedOperation($"Action type {action.Type} not supported"); - } - } - - private static async Task DispatchNullAction(Session session, Action action) - { - switch (action.SubType) - { - case "pause": - await Task.Yield(); - return; - default: - throw WebDriverResponseException.InvalidArgument($"Null action subtype {action.SubType} unknown"); - } - } - - private static async Task DispatchKeyAction(Session session, Action action) - { - switch (action.SubType) - { - case "keyDown": - var keyToPress = GetKey(action.Value); - Keyboard.Press(keyToPress); - var cancelAction = action.Clone(); - cancelAction.SubType = "keyUp"; - session.InputState.InputCancelList.Add(cancelAction); - await Task.Yield(); - return; - case "keyUp": - var keyToRelease = GetKey(action.Value); - Keyboard.Release(keyToRelease); - await Task.Yield(); - return; - case "pause": - await Task.Yield(); - return; - default: - throw WebDriverResponseException.InvalidArgument($"Pointer action subtype {action.SubType} unknown"); - } - } - - private static async Task DispatchWheelAction(Session session, Action action) - { - switch (action.SubType) - { - case "scroll": - if (action.X == null || action.Y == null) - { - throw WebDriverResponseException.InvalidArgument("For wheel scroll, X and Y are required"); - } - Mouse.MoveTo(action.X.Value, action.Y.Value); - if (action.DeltaX == null || action.DeltaY == null) - { - throw WebDriverResponseException.InvalidArgument("For wheel scroll, delta X and delta Y are required"); - } - if (action.DeltaY != 0) - { - Mouse.Scroll(action.DeltaY.Value); - } - if (action.DeltaX != 0) - { - Mouse.HorizontalScroll(action.DeltaX.Value); - } - return; - case "pause": - await Task.Yield(); - return; - default: - throw WebDriverResponseException.InvalidArgument($"Wheel action subtype {action.SubType} unknown"); - } - } - - private static VirtualKeyShort GetKey(string? value) - { - if (value == null || value.Length != 1) - { - throw WebDriverResponseException.InvalidArgument($"Key action value argument should be exactly one character"); - } - switch (value[0]) - { - case '\uE001': return VirtualKeyShort.CANCEL; - case '\uE002': return VirtualKeyShort.HELP; - case '\uE003': return VirtualKeyShort.BACK; - case '\uE004': return VirtualKeyShort.TAB; - case '\uE005': return VirtualKeyShort.CLEAR; - case '\uE006': return VirtualKeyShort.RETURN; - case '\uE007': return VirtualKeyShort.ENTER; - case '\uE008': return VirtualKeyShort.LSHIFT; - case '\uE009': return VirtualKeyShort.LCONTROL; - case '\uE00A': return VirtualKeyShort.ALT; - case '\uE00B': return VirtualKeyShort.PAUSE; - case '\uE00C': return VirtualKeyShort.ESCAPE; - case '\uE00D': return VirtualKeyShort.SPACE; - case '\uE00E': return VirtualKeyShort.PRIOR; - case '\uE00F': return VirtualKeyShort.NEXT; - case '\uE010': return VirtualKeyShort.END; - case '\uE011': return VirtualKeyShort.HOME; - case '\uE012': return VirtualKeyShort.LEFT; - case '\uE013': return VirtualKeyShort.UP; - case '\uE014': return VirtualKeyShort.RIGHT; - case '\uE015': return VirtualKeyShort.DOWN; - case '\uE016': return VirtualKeyShort.INSERT; - case '\uE017': return VirtualKeyShort.DELETE; - // case '\uE018': ";" - // case '\uE019': "=" - case '\uE01A': return VirtualKeyShort.NUMPAD0; - case '\uE01B': return VirtualKeyShort.NUMPAD1; - case '\uE01C': return VirtualKeyShort.NUMPAD2; - case '\uE01D': return VirtualKeyShort.NUMPAD3; - case '\uE01E': return VirtualKeyShort.NUMPAD4; - case '\uE01F': return VirtualKeyShort.NUMPAD5; - case '\uE020': return VirtualKeyShort.NUMPAD6; - case '\uE021': return VirtualKeyShort.NUMPAD7; - case '\uE022': return VirtualKeyShort.NUMPAD8; - case '\uE023': return VirtualKeyShort.NUMPAD9; - case '\uE024': return VirtualKeyShort.ADD; - case '\uE025': return VirtualKeyShort.MULTIPLY; - case '\uE026': return VirtualKeyShort.SEPARATOR; - case '\uE027': return VirtualKeyShort.SUBTRACT; - case '\uE028': return VirtualKeyShort.DECIMAL; - case '\uE029': return VirtualKeyShort.DIVIDE; - case '\uE031': return VirtualKeyShort.F1; - case '\uE032': return VirtualKeyShort.F2; - case '\uE033': return VirtualKeyShort.F3; - case '\uE034': return VirtualKeyShort.F4; - case '\uE035': return VirtualKeyShort.F5; - case '\uE036': return VirtualKeyShort.F6; - case '\uE037': return VirtualKeyShort.F7; - case '\uE038': return VirtualKeyShort.F8; - case '\uE039': return VirtualKeyShort.F9; - case '\uE03A': return VirtualKeyShort.F10; - case '\uE03B': return VirtualKeyShort.F11; - case '\uE03C': return VirtualKeyShort.F12; - // case '\uE03D': "Meta" - // case '\uE040': "ZenkakuHankaku" - case '\uE050': return VirtualKeyShort.RSHIFT; - case '\uE051': return VirtualKeyShort.RCONTROL; - case '\uE052': return VirtualKeyShort.ALT; - // case '\uE053': "Meta" - case '\uE054': return VirtualKeyShort.PRIOR; - case '\uE055': return VirtualKeyShort.NEXT; - case '\uE056': return VirtualKeyShort.END; - case '\uE057': return VirtualKeyShort.HOME; - case '\uE058': return VirtualKeyShort.LEFT; - case '\uE059': return VirtualKeyShort.UP; - case '\uE05A': return VirtualKeyShort.RIGHT; - case '\uE05B': return VirtualKeyShort.DOWN; - case '\uE05C': return VirtualKeyShort.INSERT; - case '\uE05D': return VirtualKeyShort.DELETE; - case 'a': return VirtualKeyShort.KEY_A; - case 'b': return VirtualKeyShort.KEY_B; - case 'c': return VirtualKeyShort.KEY_C; - case 'd': return VirtualKeyShort.KEY_D; - case 'e': return VirtualKeyShort.KEY_E; - case 'f': return VirtualKeyShort.KEY_F; - case 'g': return VirtualKeyShort.KEY_G; - case 'h': return VirtualKeyShort.KEY_H; - case 'i': return VirtualKeyShort.KEY_I; - case 'j': return VirtualKeyShort.KEY_J; - case 'k': return VirtualKeyShort.KEY_K; - case 'l': return VirtualKeyShort.KEY_L; - case 'm': return VirtualKeyShort.KEY_M; - case 'n': return VirtualKeyShort.KEY_N; - case 'o': return VirtualKeyShort.KEY_O; - case 'p': return VirtualKeyShort.KEY_P; - case 'q': return VirtualKeyShort.KEY_Q; - case 'r': return VirtualKeyShort.KEY_R; - case 's': return VirtualKeyShort.KEY_S; - case 't': return VirtualKeyShort.KEY_T; - case 'u': return VirtualKeyShort.KEY_U; - case 'v': return VirtualKeyShort.KEY_V; - case 'w': return VirtualKeyShort.KEY_W; - case 'x': return VirtualKeyShort.KEY_X; - case 'y': return VirtualKeyShort.KEY_Y; - case 'z': return VirtualKeyShort.KEY_Z; - default: throw WebDriverResponseException.UnsupportedOperation($"Key {value} is not supported"); - } - } - - private static async Task DispatchPointerAction(Session session, Action action) - { - switch (action.SubType) - { - case "pointerMove": - if (action.X == null || action.Y == null) - { - throw WebDriverResponseException.InvalidArgument("For pointer move, X and Y are required"); - } - Mouse.MoveTo(action.X.Value, action.Y.Value); - await Task.Yield(); - return; - case "pointerDown": - Mouse.Down(GetMouseButton(action.Button)); - var cancelAction = action.Clone(); - cancelAction.SubType = "pointerUp"; - session.InputState.InputCancelList.Add(cancelAction); - await Task.Yield(); - return; - case "pointerUp": - Mouse.Up(GetMouseButton(action.Button)); - await Task.Yield(); - return; - case "pause": - await Task.Yield(); - return; - default: - throw WebDriverResponseException.UnsupportedOperation($"Pointer action subtype {action.Type} not supported"); - } - } - - private static MouseButton GetMouseButton(int? button) - { - if(button == null) - { - throw WebDriverResponseException.InvalidArgument($"Pointer action button argument missing"); - } - switch(button) - { - case 0: return MouseButton.Left; - case 1: return MouseButton.Middle; - case 2: return MouseButton.Right; - case 3: return MouseButton.XButton1; - case 4: return MouseButton.XButton2; - default: - throw WebDriverResponseException.UnsupportedOperation($"Pointer button {button} not supported"); - } - } - private Session GetSession(string sessionId) { var session = _sessionRepository.FindById(sessionId); diff --git a/src/FlaUI.WebDriver/Controllers/ElementController.cs b/src/FlaUI.WebDriver/Controllers/ElementController.cs index 022bef7..aa9ec29 100644 --- a/src/FlaUI.WebDriver/Controllers/ElementController.cs +++ b/src/FlaUI.WebDriver/Controllers/ElementController.cs @@ -1,9 +1,8 @@ -using FlaUI.Core.AutomationElements; +using System.Text; +using FlaUI.Core.AutomationElements; using FlaUI.WebDriver.Models; +using FlaUI.WebDriver.Services; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System.Text; -using System.Threading.Tasks; namespace FlaUI.WebDriver.Controllers { @@ -13,11 +12,13 @@ public class ElementController : ControllerBase { private readonly ILogger _logger; private readonly ISessionRepository _sessionRepository; + private readonly IActionsDispatcher _actionsDispatcher; - public ElementController(ILogger logger, ISessionRepository sessionRepository) + public ElementController(ILogger logger, ISessionRepository sessionRepository, IActionsDispatcher actionsDispatcher) { _logger = logger; _sessionRepository = sessionRepository; + _actionsDispatcher = actionsDispatcher; } [HttpGet("active")] @@ -87,18 +88,41 @@ public async Task GetElementText([FromRoute] string sessionId, [Fr { var session = GetActiveSession(sessionId); var element = GetElement(session, elementId); + var text = GetElementText(element); - string text; + return await Task.FromResult(WebDriverResult.Success(text)); + } + + private static string GetElementText(AutomationElement element) + { + // https://www.w3.org/TR/webdriver2/#get-element-text says about this: + // + // > Let rendered text be the result of performing implementation-specific steps whose result is exactly + // > the same as the result of a Function.[[Call]](null, element) with bot.dom.getVisibleText as the this value. + // + // Because it's implementation-defined, this method tries to follow WinAppDriver's implementation as closely as + // possible. if (element.Patterns.Text.IsSupported) { - text = element.Patterns.Text.Pattern.DocumentRange.GetText(int.MaxValue); + return element.Patterns.Text.Pattern.DocumentRange.GetText(int.MaxValue); + } + else if (element.Patterns.Value.IsSupported) + { + return element.Patterns.Value.Pattern.Value.ToString(); + } + else if (element.Patterns.RangeValue.IsSupported) + { + return element.Patterns.RangeValue.Pattern.Value.ToString(); + } + else if (element.Patterns.Selection.IsSupported) + { + var selected = element.Patterns.Selection.Pattern.Selection.Value; + return string.Join(", ", selected.Select(GetElementText)); } else { - text = GetRenderedText(element); + return GetRenderedText(element); } - - return await Task.FromResult(WebDriverResult.Success(text)); } private static string GetRenderedText(AutomationElement element) @@ -149,6 +173,8 @@ public async Task IsElementSelected([FromRoute] string sessionId, [HttpPost("{elementId}/value")] public async Task ElementSendKeys([FromRoute] string sessionId, [FromRoute] string elementId, [FromBody] ElementSendKeysRequest elementSendKeysRequest) { + _logger.LogDebug("Element send keys for session {SessionId} and element {ElementId}", sessionId, elementId); + var session = GetActiveSession(sessionId); var element = GetElement(session, elementId); @@ -157,11 +183,61 @@ public async Task ElementSendKeys([FromRoute] string sessionId, [F { return ElementNotInteractable(elementId); } - element.AsTextBox().Text = elementSendKeysRequest.Text; + + element.Focus(); + + // Warning: Deviation from the spec. https://www.w3.org/TR/webdriver2/#element-send-keys says: + // + // > Set the text insertion caret using set selection range using current text length for both the start and end parameters. + // + // In English: "the caret should be placed at the end of the text before sending keys". That doesn't seem to be possible + // with UIA, meaning that the text gets inserted at the beginning, which is also WinAppDriver's behavior. + + var inputState = session.InputState; + var inputId = Guid.NewGuid().ToString(); + var source = (KeyInputSource)inputState.CreateInputSource("key"); + + inputState.AddInputSource(inputId, source); + + try + { + await _actionsDispatcher.DispatchActionsForString(session, inputId, source, elementSendKeysRequest.Text); + } + finally + { + inputState.RemoveInputSource(inputId); + } return WebDriverResult.Success(); } + [HttpGet("{elementId}/attribute/{attributeId}")] + public async Task GetAttribute([FromRoute] string sessionId, [FromRoute] string elementId, [FromRoute] string attributeId) + { + var session = GetSession(sessionId); + var element = GetElement(session, elementId); + var library = element.FrameworkAutomationElement.PropertyIdLibrary; + var periodIndex = attributeId.IndexOf('.'); + object? value = null; + + if (periodIndex >= 0) + { + var patternName = attributeId.Substring(0, periodIndex); + var propertyName = attributeId.Substring(periodIndex + 1); + + if (element.TryGetPattern(patternName, out var pattern)) + { + pattern.TryGetProperty(propertyName, out value); + } + } + else + { + element.TryGetProperty(attributeId, out value); + } + + return await Task.FromResult(WebDriverResult.Success(value?.ToString())); + } + [HttpGet("{elementId}/rect")] public async Task GetElementRect([FromRoute] string sessionId, [FromRoute] string elementId) { diff --git a/src/FlaUI.WebDriver/Controllers/ExecuteController.cs b/src/FlaUI.WebDriver/Controllers/ExecuteController.cs index 610d89b..85931c2 100644 --- a/src/FlaUI.WebDriver/Controllers/ExecuteController.cs +++ b/src/FlaUI.WebDriver/Controllers/ExecuteController.cs @@ -1,9 +1,6 @@ using FlaUI.WebDriver.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; namespace FlaUI.WebDriver.Controllers { diff --git a/src/FlaUI.WebDriver/Controllers/FindElementsController.cs b/src/FlaUI.WebDriver/Controllers/FindElementsController.cs index 91ccc5b..9247a00 100644 --- a/src/FlaUI.WebDriver/Controllers/FindElementsController.cs +++ b/src/FlaUI.WebDriver/Controllers/FindElementsController.cs @@ -1,11 +1,7 @@ using FlaUI.WebDriver.Models; using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; -using System; -using Microsoft.Extensions.Logging; using FlaUI.Core.Conditions; using FlaUI.Core.Definitions; -using System.Linq; using FlaUI.Core.AutomationElements; using System.Text.RegularExpressions; @@ -92,11 +88,6 @@ private static async Task FindElementsFrom(Func elements = await Wait.Until(() => startNode().FindAllDescendants(condition), elements => elements.Length > 0, session.ImplicitWaitTimeout); } - if (elements.Length == 0) - { - return NoSuchElement(findElementRequest); - } - var knownElements = elements.Select(session.GetOrAddKnownElement); return await Task.FromResult(WebDriverResult.Success( diff --git a/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs b/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs index 6f45aa4..881c2fc 100644 --- a/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs +++ b/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs @@ -1,8 +1,4 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System.IO; -using System.Threading.Tasks; -using System; using FlaUI.Core.AutomationElements; using System.Drawing; diff --git a/src/FlaUI.WebDriver/Controllers/SessionController.cs b/src/FlaUI.WebDriver/Controllers/SessionController.cs index 617faf5..4759dda 100644 --- a/src/FlaUI.WebDriver/Controllers/SessionController.cs +++ b/src/FlaUI.WebDriver/Controllers/SessionController.cs @@ -1,15 +1,9 @@ using FlaUI.WebDriver.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.RegularExpressions; -using System.Threading.Tasks; namespace FlaUI.WebDriver.Controllers { @@ -36,6 +30,7 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest .Select(capabillities => matchedCapabilities!); Core.Application? app; + var isAppOwnedBySession = false; var capabilities = matchingCapabilities.FirstOrDefault(); if (capabilities == null) { @@ -75,6 +70,8 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest throw WebDriverResponseException.InvalidArgument($"Starting app '{appPath}' with arguments '{appArguments}' threw an exception: {e.Message}"); } } + + isAppOwnedBySession = true; } else if (TryGetStringCapability(capabilities, "appium:appTopLevelWindow", out var appTopLevelWindowString)) { @@ -90,7 +87,7 @@ 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); + var session = new Session(app, isAppOwnedBySession); if(TryGetNumberCapability(capabilities, "appium:newCommandTimeout", out var newCommandTimeout)) { session.NewCommandTimeout = TimeSpan.FromSeconds(newCommandTimeout); diff --git a/src/FlaUI.WebDriver/Controllers/TimeoutsController.cs b/src/FlaUI.WebDriver/Controllers/TimeoutsController.cs index 78f6b24..af66246 100644 --- a/src/FlaUI.WebDriver/Controllers/TimeoutsController.cs +++ b/src/FlaUI.WebDriver/Controllers/TimeoutsController.cs @@ -1,6 +1,4 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System.Threading.Tasks; namespace FlaUI.WebDriver.Controllers { diff --git a/src/FlaUI.WebDriver/Controllers/WindowController.cs b/src/FlaUI.WebDriver/Controllers/WindowController.cs index 49569fe..ccbce9d 100644 --- a/src/FlaUI.WebDriver/Controllers/WindowController.cs +++ b/src/FlaUI.WebDriver/Controllers/WindowController.cs @@ -1,11 +1,6 @@ using FlaUI.Core.AutomationElements; using FlaUI.WebDriver.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; namespace FlaUI.WebDriver.Controllers { @@ -140,15 +135,8 @@ private IEnumerable GetWindowHandles(Session session) { return Enumerable.Empty(); } - var mainWindow = session.App.GetMainWindow(session.Automation, TimeSpan.Zero); - if (mainWindow == null) - { - return Enumerable.Empty(); - } - // GetAllTopLevelWindows sometimes times out, so we return only the main window and modal windows - // https://github.com/FlaUI/FlaUI/issues/596 - var knownWindows = mainWindow.ModalWindows.Prepend(mainWindow) + var knownWindows = session.App.GetAllTopLevelWindows(session.Automation) .Select(session.GetOrAddKnownWindow); return knownWindows.Select(knownWindows => knownWindows.WindowHandle); } diff --git a/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj b/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj index 6d93b78..ae81853 100644 --- a/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj +++ b/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj @@ -9,6 +9,7 @@ win-x64 true true + app.manifest diff --git a/src/FlaUI.WebDriver/InputSource.cs b/src/FlaUI.WebDriver/InputSource.cs new file mode 100644 index 0000000..271c5df --- /dev/null +++ b/src/FlaUI.WebDriver/InputSource.cs @@ -0,0 +1,12 @@ +namespace FlaUI.WebDriver; + +/// +/// An input source is a virtual device providing input events. +/// +/// +public class InputSource +{ + protected InputSource(string type) => Type = type; + + public string Type { get; } +} diff --git a/src/FlaUI.WebDriver/InputState.cs b/src/FlaUI.WebDriver/InputState.cs index 8c80238..1d2739f 100644 --- a/src/FlaUI.WebDriver/InputState.cs +++ b/src/FlaUI.WebDriver/InputState.cs @@ -1,14 +1,113 @@ -using System.Collections.Generic; +using System.Diagnostics; namespace FlaUI.WebDriver { public class InputState { + private readonly Dictionary _inputStateMap = new(); + public List InputCancelList = new List(); public void Reset() { InputCancelList.Clear(); + _inputStateMap.Clear(); + } + + /// + /// Creates an input source of the given type. + /// + /// + /// Implements "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. + /// + public InputSource CreateInputSource(string type) + { + return type switch + { + "none" => throw new NotImplementedException("Null input source is not implemented yet"), + "key" => new KeyInputSource(), + "pointer" => throw new NotImplementedException("Pointer input source is not implemented yet"), + "wheel" => throw new NotImplementedException("Wheel input source is not implemented yet"), + _ => throw new InvalidOperationException($"Unknown input source type: {type}") + }; + } + + /// + /// Tries to get an input source with the specified input ID. + /// + /// + /// Implements "get an input source" from https://www.w3.org/TR/webdriver2/#input-state + /// + public InputSource? GetInputSource(string inputId) + { + _inputStateMap.TryGetValue(inputId, out var result); + return result; + } + + /// + /// Tries to get an input source with the specified input ID. + /// + /// + /// Implements "get an input source" from https://www.w3.org/TR/webdriver2/#input-state + /// + public T? GetInputSource(string inputId) where T : InputSource + { + if (GetInputSource(inputId) is { } source) + { + if (source is T result) + { + return result; + } + else + { + throw WebDriverResponseException.InvalidArgument( + $"Input source with id '{inputId}' is not of the expected type: {typeof(T).Name}"); + } + } + + return null; + } + + /// + /// Gets an input source or creates a new one if it does not exist. + /// + /// + /// 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. + /// + public InputSource GetOrCreateInputSource(string type, string id) + { + var source = GetInputSource(id); + + if (source != null && source.Type != type) + { + throw WebDriverResponseException.InvalidArgument( + $"Input source with id '{id}' already exists and has a different type: {source.Type}"); + } + + return CreateInputSource(type); + } + + /// + /// Adds an input source. + /// + /// + /// Implements "add an input source" from https://www.w3.org/TR/webdriver2/#input-state + /// + public void AddInputSource(string inputId, InputSource inputSource) => _inputStateMap.Add(inputId, inputSource); + + /// + /// Removes an input source. + /// + /// + /// Implements "remove an input source" from https://www.w3.org/TR/webdriver2/#input-state + /// + public void RemoveInputSource(string inputId) + { + Debug.Assert(!InputCancelList.Any(x => x.Id == inputId)); + + _inputStateMap.Remove(inputId); } } } diff --git a/src/FlaUI.WebDriver/KeyInputSource.cs b/src/FlaUI.WebDriver/KeyInputSource.cs new file mode 100644 index 0000000..83f286e --- /dev/null +++ b/src/FlaUI.WebDriver/KeyInputSource.cs @@ -0,0 +1,24 @@ +namespace FlaUI.WebDriver; + +/// +/// A key input source is an input source that is associated with a keyboard-type device. +/// +/// +public class KeyInputSource() : InputSource("key") +{ + public HashSet Pressed = []; + + public bool Alt { get; set; } + public bool Ctrl{ get; set; } + public bool Meta { get; set; } + public bool Shift { get; set; } + + public void Reset() + { + Pressed.Clear(); + Alt = false; + Ctrl = false; + Meta = false; + Shift = false; + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Keys.cs b/src/FlaUI.WebDriver/Keys.cs new file mode 100644 index 0000000..cdc006a --- /dev/null +++ b/src/FlaUI.WebDriver/Keys.cs @@ -0,0 +1,505 @@ +using FlaUI.Core.WindowsAPI; + +namespace FlaUI.WebDriver; + +internal class Keys +{ + /// + /// Normalized key mapping from https://www.w3.org/TR/webdriver2/#keyboard-actions + /// + private static readonly Dictionary s_normalizedKeys = new Dictionary() + { + { '\uE000', "Unidentified" }, + { '\uE001', "Cancel" }, + { '\uE002', "Help" }, + { '\uE003', "Backspace" }, + { '\uE004', "Tab" }, + { '\uE005', "Clear" }, + { '\uE006', "Return" }, + { '\uE007', "Enter" }, + { '\uE008', "Shift" }, + { '\uE009', "Control" }, + { '\uE00A', "Alt" }, + { '\uE00B', "Pause" }, + { '\uE00C', "Escape" }, + { '\uE00D', " " }, + { '\uE00E', "PageUp" }, + { '\uE00F', "PageDown" }, + { '\uE010', "End" }, + { '\uE011', "Home" }, + { '\uE012', "ArrowLeft" }, + { '\uE013', "ArrowUp" }, + { '\uE014', "ArrowRight" }, + { '\uE015', "ArrowDown" }, + { '\uE016', "Insert" }, + { '\uE017', "Delete" }, + { '\uE018', ";" }, + { '\uE019', "=" }, + { '\uE01A', "0" }, + { '\uE01B', "1" }, + { '\uE01C', "2" }, + { '\uE01D', "3" }, + { '\uE01E', "4" }, + { '\uE01F', "5" }, + { '\uE020', "6" }, + { '\uE021', "7" }, + { '\uE022', "8" }, + { '\uE023', "9" }, + { '\uE024', "*" }, + { '\uE025', "+" }, + { '\uE026', "," }, + { '\uE027', "-" }, + { '\uE028', "." }, + { '\uE029', "/" }, + { '\uE031', "F1" }, + { '\uE032', "F2" }, + { '\uE033', "F3" }, + { '\uE034', "F4" }, + { '\uE035', "F5" }, + { '\uE036', "F6" }, + { '\uE037', "F7" }, + { '\uE038', "F8" }, + { '\uE039', "F9" }, + { '\uE03A', "F10" }, + { '\uE03B', "F11" }, + { '\uE03C', "F12" }, + { '\uE03D', "Meta" }, + { '\uE03E', "Command" }, + { '\uE040', "ZenkakuHankaku" }, + { '\uE050', "Shift" }, + { '\uE051', "Control" }, + { '\uE052', "Alt" }, + { '\uE053', "Meta" }, + { '\uE054', "PageUp" }, + { '\uE055', "PageDown" }, + { '\uE056', "End" }, + { '\uE057', "Home" }, + { '\uE058', "ArrowLeft" }, + { '\uE059', "ArrowUp" }, + { '\uE05A', "ArrowRight" }, + { '\uE05B', "ArrowDown" }, + { '\uE05C', "Insert" }, + { '\uE05D', "Delete" }, + }; + + private static readonly Dictionary s_keyToCode = new() + { + { '`', "Backquote" }, + { '\\', "Backslash" }, + { '\uE003', "Backspace" }, + { '[', "BracketLeft" }, + { ']', "BracketRight" }, + { ',', "Comma" }, + { '0', "Digit0" }, + { '1', "Digit1" }, + { '2', "Digit2" }, + { '3', "Digit3" }, + { '4', "Digit4" }, + { '5', "Digit5" }, + { '6', "Digit6" }, + { '7', "Digit7" }, + { '8', "Digit8" }, + { '9', "Digit9" }, + { '=', "Equal" }, + { '<', "IntlBackslash" }, + { 'a', "KeyA" }, + { 'b', "KeyB" }, + { 'c', "KeyC" }, + { 'd', "KeyD" }, + { 'e', "KeyE" }, + { 'f', "KeyF" }, + { 'g', "KeyG" }, + { 'h', "KeyH" }, + { 'i', "KeyI" }, + { 'j', "KeyJ" }, + { 'k', "KeyK" }, + { 'l', "KeyL" }, + { 'm', "KeyM" }, + { 'n', "KeyN" }, + { 'o', "KeyO" }, + { 'p', "KeyP" }, + { 'q', "KeyQ" }, + { 'r', "KeyR" }, + { 's', "KeyS" }, + { 't', "KeyT" }, + { 'u', "KeyU" }, + { 'v', "KeyV" }, + { 'w', "KeyW" }, + { 'x', "KeyX" }, + { 'y', "KeyY" }, + { 'z', "KeyZ" }, + { '-', "Minus" }, + { '.', "Period" }, + { '\'', "Quote" }, + { ';', "Semicolon" }, + { '/', "Slash" }, + { '\uE00A', "AltLeft" }, + { '\uE052', "AltRight" }, + { '\uE009', "ControlLeft" }, + { '\uE051', "ControlRight" }, + { '\uE006', "Enter" }, + { '\uE00B', "Pause" }, + { '\uE03D', "MetaLeft" }, + { '\uE053', "MetaRight" }, + { '\uE008', "ShiftLeft" }, + { '\uE050', "ShiftRight" }, + { ' ', "Space" }, + { '\uE004', "Tab" }, + { '\uE017', "Delete" }, + { '\uE010', "End" }, + { '\uE002', "Help" }, + { '\uE011', "Home" }, + { '\uE016', "Insert" }, + { '\uE00F', "PageDown" }, + { '\uE00E', "PageUp" }, + { '\uE015', "ArrowDown" }, + { '\uE012', "ArrowLeft" }, + { '\uE014', "ArrowRight" }, + { '\uE013', "ArrowUp" }, + { '\uE00C', "Escape" }, + { '\uE031', "F1" }, + { '\uE032', "F2" }, + { '\uE033', "F3" }, + { '\uE034', "F4" }, + { '\uE035', "F5" }, + { '\uE036', "F6" }, + { '\uE037', "F7" }, + { '\uE038', "F8" }, + { '\uE039', "F9" }, + { '\uE03A', "F10" }, + { '\uE03B', "F11" }, + { '\uE03C', "F12" }, + { '\uE019', "NumpadEqual" }, + { '\uE01A', "Numpad0" }, + { '\uE01B', "Numpad1" }, + { '\uE01C', "Numpad2" }, + { '\uE01D', "Numpad3" }, + { '\uE01E', "Numpad4" }, + { '\uE01F', "Numpad5" }, + { '\uE020', "Numpad6" }, + { '\uE021', "Numpad7" }, + { '\uE022', "Numpad8" }, + { '\uE023', "Numpad9" }, + { '\uE025', "NumpadAdd" }, + { '\uE026', "NumpadComma" }, + { '\uE028', "NumpadDecimal" }, + { '\uE029', "NumpadDivide" }, + { '\uE007', "NumpadEnter" }, + { '\uE024', "NumpadMultiply" }, + { '\uE027', "NumpadSubtract" }, + }; + + private static readonly Dictionary s_shiftedKeyToCode = new() + { + { '~', "Backquote" }, + { '|', "Backslash" }, + { '{', "BracketLeft" }, + { '}', "BracketRight" }, + { '<', "Comma" }, + { ')', "Digit0" }, + { '!', "Digit1" }, + { '@', "Digit2" }, + { '#', "Digit3" }, + { '$', "Digit4" }, + { '%', "Digit5" }, + { '^', "Digit6" }, + { '&', "Digit7" }, + { '*', "Digit8" }, + { '(', "Digit9" }, + { '+', "Equal" }, + { '>', "IntlBackslash" }, + { 'A', "KeyA" }, + { 'B', "KeyB" }, + { 'C', "KeyC" }, + { 'D', "KeyD" }, + { 'E', "KeyE" }, + { 'F', "KeyF" }, + { 'G', "KeyG" }, + { 'H', "KeyH" }, + { 'I', "KeyI" }, + { 'J', "KeyJ" }, + { 'K', "KeyK" }, + { 'L', "KeyL" }, + { 'M', "KeyM" }, + { 'N', "KeyN" }, + { 'O', "KeyO" }, + { 'P', "KeyP" }, + { 'Q', "KeyQ" }, + { 'R', "KeyR" }, + { 'S', "KeyS" }, + { 'T', "KeyT" }, + { 'U', "KeyU" }, + { 'V', "KeyV" }, + { 'W', "KeyW" }, + { 'X', "KeyX" }, + { 'Y', "KeyY" }, + { 'Z', "KeyZ" }, + { '_', "Minus" }, + { '.', "Period" }, + { '"', "Quote" }, + { ':', "Semicolon" }, + { '?', "Slash" }, + { '\uE00D', "Space" }, + { '\uE05C', "Numpad0" }, + { '\uE056', "Numpad1" }, + { '\uE05B', "Numpad2" }, + { '\uE055', "Numpad3" }, + { '\uE058', "Numpad4" }, + { '\uE05A', "Numpad6" }, + { '\uE057', "Numpad7" }, + { '\uE059', "Numpad8" }, + { '\uE054', "Numpad9" }, + { '\uE05D', "NumpadDecimal" }, + }; + + public const char Null = '\uE000'; + public const char Cancel = '\uE001'; + public const char Help = '\uE002'; + public const char Backspace = '\uE003'; + public const char Tab = '\uE004'; + public const char Clear = '\uE005'; + public const char Return = '\uE006'; + public const char Enter = '\uE007'; + public const char Shift = '\uE008'; + public const char LeftShift = '\uE008'; + public const char Control = '\uE009'; + public const char LeftControl = '\uE009'; + public const char Alt = '\uE00A'; + public const char LeftAlt = '\uE00A'; + public const char Pause = '\uE00B'; + public const char Escape = '\uE00C'; + public const char Space = '\uE00D'; + public const char PageUp = '\uE00E'; + public const char PageDown = '\uE00F'; + public const char End = '\uE010'; + public const char Home = '\uE011'; + public const char Left = '\uE012'; + public const char ArrowLeft = '\uE012'; + public const char Up = '\uE013'; + public const char ArrowUp = '\uE013'; + public const char Right = '\uE014'; + public const char ArrowRight = '\uE014'; + public const char Down = '\uE015'; + public const char ArrowDown = '\uE015'; + public const char Insert = '\uE016'; + public const char Delete = '\uE017'; + public const char Semicolon = '\uE018'; + public const char Equal = '\uE019'; + public const char NumberPad0 = '\uE01A'; + public const char NumberPad1 = '\uE01B'; + public const char NumberPad2 = '\uE01C'; + public const char NumberPad3 = '\uE01D'; + public const char NumberPad4 = '\uE01E'; + public const char NumberPad5 = '\uE01F'; + public const char NumberPad6 = '\uE020'; + public const char NumberPad7 = '\uE021'; + public const char NumberPad8 = '\uE022'; + public const char NumberPad9 = '\uE023'; + public const char Multiply = '\uE024'; + public const char Add = '\uE025'; + public const char Separator = '\uE026'; + public const char Subtract = '\uE027'; + public const char Decimal = '\uE028'; + public const char Divide = '\uE029'; + public const char F1 = '\uE031'; + public const char F2 = '\uE032'; + public const char F3 = '\uE033'; + public const char F4 = '\uE034'; + public const char F5 = '\uE035'; + public const char F6 = '\uE036'; + public const char F7 = '\uE037'; + public const char F8 = '\uE038'; + public const char F9 = '\uE039'; + public const char F10 = '\uE03A'; + public const char F11 = '\uE03B'; + public const char F12 = '\uE03C'; + public const char Meta = '\uE03D'; + public const char Command = '\uE03D'; + public const char ZenkakuHankaku = '\uE040'; + + /// + /// Gets a value indicating whether a key attribute value represents a modifier key. + /// + /// The key attribute value. + /// + /// Defined in https://www.w3.org/TR/uievents-key/#keys-modifier + /// + public static bool IsModifier(string key) + { + return key is "Alt" or "AltGraph" or "CapsLock" or "Control" or "Fn" or "FnLock" or + "Meta" or "NumLock" or "ScrollLock" or "Shift" or "Symbol" or "SymbolLock"; + } + + /// + /// Gets a value indicating whether a character is shifted. + /// + /// The character. + /// + /// Defined in https://www.w3.org/TR/webdriver2/#keyboard-actions + /// + internal static bool IsShiftedChar(char c) => s_shiftedKeyToCode.ContainsKey(c); + + /// + /// Gets a value indicating whether a graphene cluster is typeable. + /// + /// The graphene cluster + /// + /// Defined in https://www.w3.org/TR/webdriver2/#element-send-keys + /// + public static bool IsTypeable(string c) + { + return c.Length == 1 && (s_keyToCode.ContainsKey(c[0]) || s_shiftedKeyToCode.ContainsKey(c[0])); + } + + /// + /// Gets the code for a raw key. + /// + /// The raw key. + /// + /// Defined in https://www.w3.org/TR/webdriver2/#keyboard-actions + /// + public static string? GetCode(string key) + { + if (key.Length == 1) + { + var c = key[0]; + + if (s_keyToCode.TryGetValue(c, out var code)) + { + return code; + } + else if (s_shiftedKeyToCode.TryGetValue(c, out code)) + { + return code; + } + } + + return null; + } + + /// + /// Gets a normalized key value. + /// + /// The raw key. + /// + /// Defined in https://www.w3.org/TR/webdriver2/#keyboard-actions + /// + public static string GetNormalizedKeyValue(string key) + { + return key.Length == 1 && s_normalizedKeys.TryGetValue(key[0], out var value) ? value : key; + } + + /// + /// Gets the win32 virtual key code for a key code returned by . + /// + public static VirtualKeyShort GetVirtualKey(string? code) + { + return code switch + { + "Backquote" => VirtualKeyShort.OEM_3, + "Backslash" => VirtualKeyShort.OEM_5, + "Backspace" => VirtualKeyShort.BACK, + "BracketLeft" => VirtualKeyShort.OEM_4, + "BracketRight" => VirtualKeyShort.OEM_6, + "Comma" => VirtualKeyShort.OEM_COMMA, + "Digit0" => VirtualKeyShort.KEY_0, + "Digit1" => VirtualKeyShort.KEY_1, + "Digit2" => VirtualKeyShort.KEY_2, + "Digit3" => VirtualKeyShort.KEY_3, + "Digit4" => VirtualKeyShort.KEY_4, + "Digit5" => VirtualKeyShort.KEY_5, + "Digit6" => VirtualKeyShort.KEY_6, + "Digit7" => VirtualKeyShort.KEY_7, + "Digit8" => VirtualKeyShort.KEY_8, + "Digit9" => VirtualKeyShort.KEY_9, + "Equal" => VirtualKeyShort.OEM_PLUS, + "IntlBackslash" => VirtualKeyShort.OEM_102, + "KeyA" => VirtualKeyShort.KEY_A, + "KeyB" => VirtualKeyShort.KEY_B, + "KeyC" => VirtualKeyShort.KEY_C, + "KeyD" => VirtualKeyShort.KEY_D, + "KeyE" => VirtualKeyShort.KEY_E, + "KeyF" => VirtualKeyShort.KEY_F, + "KeyG" => VirtualKeyShort.KEY_G, + "KeyH" => VirtualKeyShort.KEY_H, + "KeyI" => VirtualKeyShort.KEY_I, + "KeyJ" => VirtualKeyShort.KEY_J, + "KeyK" => VirtualKeyShort.KEY_K, + "KeyL" => VirtualKeyShort.KEY_L, + "KeyM" => VirtualKeyShort.KEY_M, + "KeyN" => VirtualKeyShort.KEY_N, + "KeyO" => VirtualKeyShort.KEY_O, + "KeyP" => VirtualKeyShort.KEY_P, + "KeyQ" => VirtualKeyShort.KEY_Q, + "KeyR" => VirtualKeyShort.KEY_R, + "KeyS" => VirtualKeyShort.KEY_S, + "KeyT" => VirtualKeyShort.KEY_T, + "KeyU" => VirtualKeyShort.KEY_U, + "KeyV" => VirtualKeyShort.KEY_V, + "KeyW" => VirtualKeyShort.KEY_W, + "KeyX" => VirtualKeyShort.KEY_X, + "KeyY" => VirtualKeyShort.KEY_Y, + "KeyZ" => VirtualKeyShort.KEY_Z, + "Minus" => VirtualKeyShort.OEM_MINUS, + "Period" => VirtualKeyShort.OEM_PERIOD, + "Quote" => VirtualKeyShort.OEM_7, + "Semicolon" => VirtualKeyShort.OEM_1, + "Slash" => VirtualKeyShort.OEM_2, + "AltLeft" => VirtualKeyShort.ALT, + "AltRight" => VirtualKeyShort.ALT, + "ControlLeft" => VirtualKeyShort.CONTROL, + "ControlRight" => VirtualKeyShort.CONTROL, + "Enter" => VirtualKeyShort.ENTER, + "Pause" => VirtualKeyShort.PAUSE, + "MetaLeft" => VirtualKeyShort.LWIN, + "MetaRight" => VirtualKeyShort.RWIN, + "ShiftLeft" => VirtualKeyShort.LSHIFT, + "ShiftRight" => VirtualKeyShort.RSHIFT, + "Space" => VirtualKeyShort.SPACE, + "Tab" => VirtualKeyShort.TAB, + "Delete" => VirtualKeyShort.DELETE, + "End" => VirtualKeyShort.END, + "Help" => VirtualKeyShort.HELP, + "Home" => VirtualKeyShort.HOME, + "Insert" => VirtualKeyShort.INSERT, + "PageDown" => VirtualKeyShort.NEXT, + "PageUp" => VirtualKeyShort.PRIOR, + "ArrowDown" => VirtualKeyShort.DOWN, + "ArrowLeft" => VirtualKeyShort.LEFT, + "ArrowRight" => VirtualKeyShort.RIGHT, + "ArrowUp" => VirtualKeyShort.UP, + "Escape" => VirtualKeyShort.ESCAPE, + "F1" => VirtualKeyShort.F1, + "F2" => VirtualKeyShort.F2, + "F3" => VirtualKeyShort.F3, + "F4" => VirtualKeyShort.F4, + "F5" => VirtualKeyShort.F5, + "F6" => VirtualKeyShort.F6, + "F7" => VirtualKeyShort.F7, + "F8" => VirtualKeyShort.F8, + "F9" => VirtualKeyShort.F9, + "F10" => VirtualKeyShort.F10, + "F11" => VirtualKeyShort.F11, + "F12" => VirtualKeyShort.F12, + "NumpadEqual" => VirtualKeyShort.SEPARATOR, + "Numpad0" => VirtualKeyShort.NUMPAD0, + "Numpad1" => VirtualKeyShort.NUMPAD1, + "Numpad2" => VirtualKeyShort.NUMPAD2, + "Numpad3" => VirtualKeyShort.NUMPAD3, + "Numpad4" => VirtualKeyShort.NUMPAD4, + "Numpad5" => VirtualKeyShort.NUMPAD5, + "Numpad6" => VirtualKeyShort.NUMPAD6, + "Numpad7" => VirtualKeyShort.NUMPAD7, + "Numpad8" => VirtualKeyShort.NUMPAD8, + "Numpad9" => VirtualKeyShort.NUMPAD9, + "NumpadAdd" => VirtualKeyShort.ADD, + "NumpadComma" => VirtualKeyShort.OEM_COMMA, + "NumpadDecimal" => VirtualKeyShort.DECIMAL, + "NumpadDivide" => VirtualKeyShort.DIVIDE, + "NumpadEnter" => VirtualKeyShort.ENTER, + "NumpadMultiply" => VirtualKeyShort.MULTIPLY, + "NumpadSubtract" => VirtualKeyShort.SUBTRACT, + _ => throw WebDriverResponseException.UnsupportedOperation($"Key '{code}' is not supported"), + }; + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/KnownElement.cs b/src/FlaUI.WebDriver/KnownElement.cs index e34777e..0058ae7 100644 --- a/src/FlaUI.WebDriver/KnownElement.cs +++ b/src/FlaUI.WebDriver/KnownElement.cs @@ -1,5 +1,4 @@ using FlaUI.Core.AutomationElements; -using System; namespace FlaUI.WebDriver { diff --git a/src/FlaUI.WebDriver/KnownWindow.cs b/src/FlaUI.WebDriver/KnownWindow.cs index 6d03a96..0c91e77 100644 --- a/src/FlaUI.WebDriver/KnownWindow.cs +++ b/src/FlaUI.WebDriver/KnownWindow.cs @@ -1,5 +1,4 @@ using FlaUI.Core.AutomationElements; -using System; namespace FlaUI.WebDriver { diff --git a/src/FlaUI.WebDriver/Models/ActionItem.cs b/src/FlaUI.WebDriver/Models/ActionItem.cs index 1f4aeba..fa1cff3 100644 --- a/src/FlaUI.WebDriver/Models/ActionItem.cs +++ b/src/FlaUI.WebDriver/Models/ActionItem.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Text.Json.Serialization; namespace FlaUI.WebDriver.Models { @@ -7,7 +7,8 @@ public class ActionItem public string Type { get; set; } = null!; public int? Button { get; set; } public int? Duration { get; set; } - public string? Origin { get; set; } + [JsonConverter(typeof(StringOrDictionaryConverter))] + public object? Origin { get; set; } public int? X { get; set; } public int? Y { get; set; } public int? DeltaX { get; set; } diff --git a/src/FlaUI.WebDriver/Models/ActionSequence.cs b/src/FlaUI.WebDriver/Models/ActionSequence.cs index 0dbe4fb..90d1a4f 100644 --- a/src/FlaUI.WebDriver/Models/ActionSequence.cs +++ b/src/FlaUI.WebDriver/Models/ActionSequence.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; - -namespace FlaUI.WebDriver.Models +namespace FlaUI.WebDriver.Models { public class ActionSequence { diff --git a/src/FlaUI.WebDriver/Models/ActionsRequest.cs b/src/FlaUI.WebDriver/Models/ActionsRequest.cs index 4c5a98a..a5350d5 100644 --- a/src/FlaUI.WebDriver/Models/ActionsRequest.cs +++ b/src/FlaUI.WebDriver/Models/ActionsRequest.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; - -namespace FlaUI.WebDriver.Models +namespace FlaUI.WebDriver.Models { public class ActionsRequest { diff --git a/src/FlaUI.WebDriver/Models/Capabilities.cs b/src/FlaUI.WebDriver/Models/Capabilities.cs index c020fe7..0037537 100644 --- a/src/FlaUI.WebDriver/Models/Capabilities.cs +++ b/src/FlaUI.WebDriver/Models/Capabilities.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.Text.Json; +using System.Text.Json; namespace FlaUI.WebDriver.Models { diff --git a/src/FlaUI.WebDriver/Models/CreateSessionRequest.cs b/src/FlaUI.WebDriver/Models/CreateSessionRequest.cs index 2003987..66bd862 100644 --- a/src/FlaUI.WebDriver/Models/CreateSessionRequest.cs +++ b/src/FlaUI.WebDriver/Models/CreateSessionRequest.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; - -namespace FlaUI.WebDriver.Models +namespace FlaUI.WebDriver.Models { public class CreateSessionRequest { diff --git a/src/FlaUI.WebDriver/Models/CreateSessionResponse.cs b/src/FlaUI.WebDriver/Models/CreateSessionResponse.cs index fa9ed75..bda96e7 100644 --- a/src/FlaUI.WebDriver/Models/CreateSessionResponse.cs +++ b/src/FlaUI.WebDriver/Models/CreateSessionResponse.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.Text.Json; +using System.Text.Json; namespace FlaUI.WebDriver.Models { diff --git a/src/FlaUI.WebDriver/Models/ErrorResponse.cs b/src/FlaUI.WebDriver/Models/ErrorResponse.cs index b13cbe2..dd5a074 100644 --- a/src/FlaUI.WebDriver/Models/ErrorResponse.cs +++ b/src/FlaUI.WebDriver/Models/ErrorResponse.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace FlaUI.WebDriver.Models { diff --git a/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs b/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs index d1bc41c..94d62d6 100644 --- a/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs +++ b/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; - -namespace FlaUI.WebDriver.Models +namespace FlaUI.WebDriver.Models { public class ExecuteScriptRequest { diff --git a/src/FlaUI.WebDriver/Models/StringOrDictionaryConverter.cs b/src/FlaUI.WebDriver/Models/StringOrDictionaryConverter.cs new file mode 100644 index 0000000..775900a --- /dev/null +++ b/src/FlaUI.WebDriver/Models/StringOrDictionaryConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using System.Text.Json; + +namespace FlaUI.WebDriver.Models; + +internal class StringOrDictionaryConverter : JsonConverter +{ + public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return reader.GetString(); + } + else if (reader.TokenType == JsonTokenType.StartObject) + { + var dictionary = JsonSerializer.Deserialize>(ref reader, options); + return dictionary; + } + else + { + throw new JsonException("Unexpected JSON token type."); + } + } + + public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Program.cs b/src/FlaUI.WebDriver/Program.cs index 4cbba6c..152365f 100644 --- a/src/FlaUI.WebDriver/Program.cs +++ b/src/FlaUI.WebDriver/Program.cs @@ -1,9 +1,11 @@ using FlaUI.WebDriver; +using FlaUI.WebDriver.Services; using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.Configure(options => options.LowercaseUrls = true); builder.Services.AddControllers(options => diff --git a/src/FlaUI.WebDriver/Services/ActionsDispatcher.cs b/src/FlaUI.WebDriver/Services/ActionsDispatcher.cs new file mode 100644 index 0000000..65f328d --- /dev/null +++ b/src/FlaUI.WebDriver/Services/ActionsDispatcher.cs @@ -0,0 +1,424 @@ +using System.Drawing; +using System.Globalization; +using System.Text; +using FlaUI.Core.Input; +using FlaUI.WebDriver.Models; + +namespace FlaUI.WebDriver.Services +{ + public class ActionsDispatcher : IActionsDispatcher + { + private readonly ILogger _logger; + + public ActionsDispatcher(ILogger logger) + { + _logger = logger; + } + + public async Task DispatchAction(Session session, Action action) + { + switch (action.Type) + { + case "pointer": + await DispatchPointerAction(session, action); + return; + case "key": + await DispatchKeyAction(session, action); + return; + case "wheel": + await DispatchWheelAction(session, action); + return; + case "none": + await DispatchNullAction(session, action); + return; + default: + throw WebDriverResponseException.UnsupportedOperation($"Action type {action.Type} not supported"); + } + } + + /// + /// Implements "dispatch actions for a string" from https://www.w3.org/TR/webdriver2/#element-send-keys + /// + public async Task DispatchActionsForString( + Session session, + string inputId, + KeyInputSource source, + string text) + { + var clusters = StringInfo.GetTextElementEnumerator(text); + var currentTypeableText = new StringBuilder(); + + while (clusters.MoveNext()) + { + var cluster = clusters.GetTextElement(); + + if (cluster == Keys.Null.ToString()) + { + await DispatchTypeableString(session, inputId, source, currentTypeableText.ToString()); + currentTypeableText.Clear(); + await ClearModifierKeyState(session, inputId); + } + else if (Keys.IsModifier(Keys.GetNormalizedKeyValue(cluster))) + { + await DispatchTypeableString(session, inputId, source, currentTypeableText.ToString()); + currentTypeableText.Clear(); + + var keyDownAction = new Action( + new ActionSequence + { + Id = inputId, + Type = "key" + }, + new ActionItem + { + Type = "keyDown", + Value = cluster + }); + + await DispatchAction(session, keyDownAction); + + var undo = keyDownAction.Clone(); + undo.SubType = "keyUp"; + + // NOTE: According to the spec, the undo action should be added to an "undo actions" list, + // but that may be an oversight in the spec: we already have such a thing in the input cancel + // list which won't get cleared correctly if we're using a separate "undo actions" list. See + // https://github.com/w3c/webdriver/issues/1809. + session.InputState.InputCancelList.Add(undo); + } + else if (Keys.IsTypeable(cluster)) + { + currentTypeableText.Append(cluster); + } + else + { + await DispatchTypeableString(session, inputId, source, currentTypeableText.ToString()); + currentTypeableText.Clear(); + // TODO: Dispatch composition events. + } + } + + if (currentTypeableText.Length > 0) + { + await DispatchTypeableString(session, inputId, source, currentTypeableText.ToString()); + } + + await ClearModifierKeyState(session, inputId); + } + + /// + /// Dispatches the release actions for the given input ID. + /// + /// + /// The only part of the spec that mentions this is https://www.w3.org/TR/webdriver2/#release-actions, but the spec + /// mentions that the input cancel list must be empty before removing an input source in + /// https://www.w3.org/TR/webdriver2/#input-state so I can only assume that there was an oversight in the spec. + /// + public async Task DispatchReleaseActions(Session session, string inputId) + { + for (var i = session.InputState.InputCancelList.Count - 1; i >= 0; i--) + { + var cancelAction = session.InputState.InputCancelList[i]; + + if (cancelAction.Id == inputId) + { + await DispatchAction(session, cancelAction); + session.InputState.InputCancelList.RemoveAt(i); + } + } + } + + /// + /// Implements a variation on "clear the modifier key state" from + /// https://www.w3.org/TR/webdriver2/#element-send-keys. + /// + /// + /// https://github.com/w3c/webdriver/issues/1809 + /// + private Task ClearModifierKeyState(Session session, string inputId) => DispatchReleaseActions(session, inputId); + + /// + /// Implements "dispatch the events for a typeable string" from https://www.w3.org/TR/webdriver2/#element-send-keys + /// + private async Task DispatchTypeableString( + Session session, + string inputId, + KeyInputSource source, + string text) + { + foreach (var c in text) + { + var isShifted = Keys.IsShiftedChar(c); + + if (isShifted != source.Shift) + { + var action = new Action( + new ActionSequence + { + Id = inputId, + Type = "key" + }, + new ActionItem + { + Type = source.Shift ? "keyUp" : "keyDown", + Value = Keys.LeftShift.ToString(), + }); + await DispatchAction(session, action); + } + + var keyDownAction = new Action( + new ActionSequence + { + Id = inputId, + Type = "key" + }, + new ActionItem + { + Type = "keyDown", + Value = c.ToString(), + }); + + var keyUpAction = keyDownAction.Clone(); + keyUpAction.SubType = "keyUp"; + + await DispatchAction(session, keyDownAction); + await DispatchAction(session, keyUpAction); + } + } + + private static async Task DispatchNullAction(Session session, Action action) + { + switch (action.SubType) + { + case "pause": + await Task.Yield(); + return; + default: + throw WebDriverResponseException.InvalidArgument($"Null action subtype {action.SubType} unknown"); + } + } + + /// + /// Dispatches a keyDown, keyUp or pause action from https://www.w3.org/TR/webdriver2/#keyboard-actions + /// + private async Task DispatchKeyAction(Session session, Action action) + { + if (action.Value == null) + { + return; + } + + var source = session.InputState.GetInputSource(action.Id) ?? + throw WebDriverResponseException.UnknownError($"Input source for key action '{action.Id}' not found."); + + switch (action.SubType) + { + case "keyDown": + { + var key = Keys.GetNormalizedKeyValue(action.Value); + var code = Keys.GetCode(action.Value); + var virtualKey = Keys.GetVirtualKey(code); + _logger.LogDebug("Dispatching key down action, key '{Value}' with ID '{Id}'", code, action.Id); + + if (key == "Alt") + { + source.Alt = true; + } + else if (key == "Shift") + { + source.Shift = true; + } + else if (key == "Control") + { + source.Ctrl = true; + } + else if (key == "Meta") + { + source.Meta = true; + } + + source.Pressed.Add(action.Value); + + Keyboard.Press(virtualKey); + + var cancelAction = action.Clone(); + cancelAction.SubType = "keyUp"; + session.InputState.InputCancelList.Add(cancelAction); + + // HACK: Adding a small delay after each key press because otherwise the key press + // seems to sometimes appear after the key action completes. + await Task.Delay(10); + await Task.Yield(); + return; + } + case "keyUp": + { + var key = Keys.GetNormalizedKeyValue(action.Value); + var code = Keys.GetCode(action.Value); + var virtualKey = Keys.GetVirtualKey(code); + _logger.LogDebug("Dispatching key up action, key '{Value}' with ID '{Id}'", code, action.Id); + + if (key == "Alt") + { + source.Alt = false; + } + else if (key == "Shift") + { + source.Shift = false; + } + else if (key == "Control") + { + source.Ctrl = false; + } + else if (key == "Meta") + { + source.Meta = false; + } + + source.Pressed.Remove(action.Value); + + Keyboard.Release(virtualKey); + + // HACK: Adding a small delay after each key press because otherwise the key press + // seems to sometimes appear after the key action completes. + await Task.Delay(10); + + await Task.Yield(); + return; + } + case "pause": + await Task.Yield(); + return; + default: + throw WebDriverResponseException.InvalidArgument($"Pointer action subtype {action.SubType} unknown"); + } + } + + private async Task DispatchWheelAction(Session session, Action action) + { + + switch (action.SubType) + { + case "scroll": + _logger.LogDebug("Dispatching wheel scroll action, coordinates ({X},{Y}), delta ({DeltaX},{DeltaY}) with ID '{Id}'", action.X, action.Y, action.DeltaX, action.DeltaY, action.Id); + if (action.X == null || action.Y == null) + { + throw WebDriverResponseException.InvalidArgument("For wheel scroll, X and Y are required"); + } + Mouse.MoveTo(action.X.Value, action.Y.Value); + if (action.DeltaX == null || action.DeltaY == null) + { + throw WebDriverResponseException.InvalidArgument("For wheel scroll, delta X and delta Y are required"); + } + if (action.DeltaY != 0) + { + Mouse.Scroll(action.DeltaY.Value); + } + if (action.DeltaX != 0) + { + Mouse.HorizontalScroll(action.DeltaX.Value); + } + return; + case "pause": + await Task.Yield(); + return; + default: + throw WebDriverResponseException.InvalidArgument($"Wheel action subtype {action.SubType} unknown"); + } + } + + private async Task DispatchPointerAction(Session session, Action action) + { + + switch (action.SubType) + { + case "pointerMove": + _logger.LogDebug("Dispatching pointer move action, coordinates ({X},{Y}) with origin {Origin}, with ID '{Id}'", action.X, action.Y, action.Origin, action.Id); + var point = GetCoordinates(session, action); + Mouse.MoveTo(point); + await Task.Yield(); + return; + case "pointerDown": + _logger.LogDebug("Dispatching pointer down action, button {Button}, with ID '{Id}'", action.Button, action.Id); + Mouse.Down(GetMouseButton(action.Button)); + var cancelAction = action.Clone(); + cancelAction.SubType = "pointerUp"; + session.InputState.InputCancelList.Add(cancelAction); + await Task.Yield(); + return; + case "pointerUp": + _logger.LogDebug("Dispatching pointer up action, button {Button}, with ID '{Id}'", action.Button, action.Id); + Mouse.Up(GetMouseButton(action.Button)); + await Task.Yield(); + return; + case "pause": + await Task.Yield(); + return; + default: + throw WebDriverResponseException.UnsupportedOperation($"Pointer action subtype {action.Type} not supported"); + } + } + + private static Point GetCoordinates(Session session, Action action) + { + var origin = action.Origin ?? "viewport"; + + switch (origin) + { + case "viewport": + if (action.X == null || action.Y == null) + { + throw WebDriverResponseException.InvalidArgument("For pointer move, X and Y are required"); + } + + return new Point(action.X.Value, action.Y.Value); + case "pointer": + if (action.X == null || action.Y == null) + { + throw WebDriverResponseException.InvalidArgument("For pointer move, X and Y are required"); + } + + var current = Mouse.Position; + return new Point(current.X + action.X.Value, current.Y + action.Y.Value); + case Dictionary originMap: + if (originMap.TryGetValue("element-6066-11e4-a52e-4f735466cecf", out var elementId)) + { + if (session.FindKnownElementById(elementId) is { } element) + { + var bounds = element.BoundingRectangle; + var x = bounds.Left + (bounds.Width / 2) + (action.X ?? 0); + var y = bounds.Top + (bounds.Height / 2) + (action.Y ?? 0); + return new(x, y); + } + + throw WebDriverResponseException.InvalidArgument( + $"An unknown element ID '{elementId}' provided for action item '{action.Type}'."); + } + + throw WebDriverResponseException.InvalidArgument( + $"An unknown element '{origin}' provided for action item '{action.Type}'."); + default: + throw WebDriverResponseException.InvalidArgument( + $"Unknown origin type '{origin}' provided for action item '{action.Type}'."); + } + } + + private static MouseButton GetMouseButton(int? button) + { + if (button == null) + { + throw WebDriverResponseException.InvalidArgument($"Pointer action button argument missing"); + } + switch (button) + { + case 0: return MouseButton.Left; + case 1: return MouseButton.Middle; + case 2: return MouseButton.Right; + case 3: return MouseButton.XButton1; + case 4: return MouseButton.XButton2; + default: + throw WebDriverResponseException.UnsupportedOperation($"Pointer button {button} not supported"); + } + } + } +} diff --git a/src/FlaUI.WebDriver/Services/IActionsDispatcher.cs b/src/FlaUI.WebDriver/Services/IActionsDispatcher.cs new file mode 100644 index 0000000..c0824a4 --- /dev/null +++ b/src/FlaUI.WebDriver/Services/IActionsDispatcher.cs @@ -0,0 +1,9 @@ + +namespace FlaUI.WebDriver.Services +{ + public interface IActionsDispatcher + { + Task DispatchAction(Session session, Action action); + Task DispatchActionsForString(Session session, string inputId, KeyInputSource source, string text); + } +} diff --git a/src/FlaUI.WebDriver/Session.cs b/src/FlaUI.WebDriver/Session.cs index 834a21a..9991fd9 100644 --- a/src/FlaUI.WebDriver/Session.cs +++ b/src/FlaUI.WebDriver/Session.cs @@ -1,21 +1,19 @@ using FlaUI.Core; using FlaUI.Core.AutomationElements; using FlaUI.UIA3; -using System; -using System.Collections.Generic; -using System.Linq; namespace FlaUI.WebDriver { public class Session : IDisposable { - public Session(Application? app) + public Session(Application? app, bool isAppOwnedBySession) { App = app; SessionId = Guid.NewGuid().ToString(); Automation = new UIA3Automation(); InputState = new InputState(); TimeoutsConfiguration = new TimeoutsConfiguration(); + IsAppOwnedBySession = isAppOwnedBySession; if (app != null) { @@ -33,6 +31,7 @@ public Session(Application? app) 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; + public bool IsAppOwnedBySession { get; } public TimeoutsConfiguration TimeoutsConfiguration { get; set; } @@ -127,7 +126,7 @@ public void RemoveKnownWindow(Window window) public void Dispose() { - if (App != null && !App.HasExited) + if (IsAppOwnedBySession && App != null && !App.HasExited) { App.Close(); } diff --git a/src/FlaUI.WebDriver/SessionRepository.cs b/src/FlaUI.WebDriver/SessionRepository.cs index ba258c4..18d91f7 100644 --- a/src/FlaUI.WebDriver/SessionRepository.cs +++ b/src/FlaUI.WebDriver/SessionRepository.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; -using System.Linq; - -namespace FlaUI.WebDriver +namespace FlaUI.WebDriver { public class SessionRepository : ISessionRepository { diff --git a/src/FlaUI.WebDriver/Wait.cs b/src/FlaUI.WebDriver/Wait.cs index 04980f4..ce18d26 100644 --- a/src/FlaUI.WebDriver/Wait.cs +++ b/src/FlaUI.WebDriver/Wait.cs @@ -1,7 +1,4 @@ -using System.Threading.Tasks; -using System; - -namespace FlaUI.WebDriver +namespace FlaUI.WebDriver { public static class Wait { diff --git a/src/FlaUI.WebDriver/WebDriverExceptionFilter.cs b/src/FlaUI.WebDriver/WebDriverExceptionFilter.cs index 8a4134e..c7462f9 100644 --- a/src/FlaUI.WebDriver/WebDriverExceptionFilter.cs +++ b/src/FlaUI.WebDriver/WebDriverExceptionFilter.cs @@ -1,10 +1,6 @@ using Microsoft.AspNetCore.Mvc.Filters; using FlaUI.WebDriver.Models; using Microsoft.AspNetCore.Mvc; -using FlaUI.Core.Logging; -using Microsoft.Extensions.Logging; -using System.Windows.Forms.Design; -using Microsoft.Extensions.DependencyInjection; namespace FlaUI.WebDriver { diff --git a/src/FlaUI.WebDriver/WebDriverResponseException.cs b/src/FlaUI.WebDriver/WebDriverResponseException.cs index 52ccbcf..205cff5 100644 --- a/src/FlaUI.WebDriver/WebDriverResponseException.cs +++ b/src/FlaUI.WebDriver/WebDriverResponseException.cs @@ -1,6 +1,4 @@ -using System; - -namespace FlaUI.WebDriver +namespace FlaUI.WebDriver { public class WebDriverResponseException : Exception { diff --git a/src/FlaUI.WebDriver/WebDriverResult.cs b/src/FlaUI.WebDriver/WebDriverResult.cs index d39c39e..d251249 100644 --- a/src/FlaUI.WebDriver/WebDriverResult.cs +++ b/src/FlaUI.WebDriver/WebDriverResult.cs @@ -1,6 +1,5 @@ using FlaUI.WebDriver.Models; using Microsoft.AspNetCore.Mvc; -using System; namespace FlaUI.WebDriver { diff --git a/src/FlaUI.WebDriver/app.manifest b/src/FlaUI.WebDriver/app.manifest new file mode 100644 index 0000000..c7239fd --- /dev/null +++ b/src/FlaUI.WebDriver/app.manifest @@ -0,0 +1,9 @@ + + + + + true + PerMonitorV2 + + + \ No newline at end of file diff --git a/src/FlaUI.WebDriver/appsettings.Development.json b/src/FlaUI.WebDriver/appsettings.Development.json index 0c208ae..7d6149d 100644 --- a/src/FlaUI.WebDriver/appsettings.Development.json +++ b/src/FlaUI.WebDriver/appsettings.Development.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "FlaUI.WebDriver": "Debug" } } } diff --git a/src/FlaUI.WebDriver/appsettings.json b/src/FlaUI.WebDriver/appsettings.json index 5c19971..aff23f0 100644 --- a/src/FlaUI.WebDriver/appsettings.json +++ b/src/FlaUI.WebDriver/appsettings.json @@ -1,11 +1,11 @@ { + "AllowedHosts": "*", "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*", "SessionCleanup": { "ScheduledIntervalSeconds": 60 } diff --git a/src/TestApplications/WpfApplication/MainWindow.xaml b/src/TestApplications/WpfApplication/MainWindow.xaml index c5f4be3..0e2cc41 100644 --- a/src/TestApplications/WpfApplication/MainWindow.xaml +++ b/src/TestApplications/WpfApplication/MainWindow.xaml @@ -32,7 +32,9 @@ - + + + @@ -40,19 +42,19 @@