diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..08b8e55 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/src" + schedule: + interval: "daily" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c197635..066bae8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore @@ -45,7 +45,7 @@ jobs: working-directory: ./src - name: Upload build artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: FlaUI.WebDriver path: ./src/FlaUI.WebDriver/bin/Release/win-x64/publish diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 549e459..58b2954 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,9 @@ Use `dotnet test` to run tests. At the moment the tests are end-to-end UI tests Add UI tests for every feature added and every bug fixed, and feel free to improve existing test coverage. +Follow the [naming convention `UnitOfWork_StateUnderTest_ExpectedBehavior`](https://osherove.com/blog/2005/4/3/naming-standards-for-unit-tests.html) for test names. +Separate arrange/act/assert parts of the test by newlines. + ## Submitting changes Please send a [GitHub Pull Request](https://github.com/FlaUI/FlaUI.WebDriver/pulls) with a clear list of what you've done (read more about [pull requests](http://help.github.com/pull-requests/)). Please follow our coding conventions (below) and make sure all of your commits are atomic (one feature per commit). diff --git a/README.md b/README.md index 6caaaab..63661e2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # FlaUI.WebDriver [![build](https://github.com/FlaUI/FlaUI.WebDriver/actions/workflows/build.yml/badge.svg)](https://github.com/FlaUI/FlaUI.WebDriver/actions/workflows/build.yml) +[![CodeQL](https://github.com/FlaUI/FlaUI.WebDriver/actions/workflows/codeql.yml/badge.svg)](https://github.com/FlaUI/FlaUI.WebDriver/actions/workflows/codeql.yml) +![GitHub License](https://img.shields.io/badge/license-MIT-blue.svg) +![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen) FlaUI.WebDriver is a [W3C WebDriver2](https://www.w3.org/TR/webdriver2/) implementation using FlaUI's automation. It currently only supports UIA3. @@ -12,7 +15,7 @@ FlaUI.WebDriver is a [W3C WebDriver2](https://www.w3.org/TR/webdriver2/) impleme - [Microsoft's WinAppDriver](https://github.com/microsoft/WinAppDriver) used by [Appium Windows Driver](https://github.com/appium/appium-windows-driver) has many open issues, is [not actively maintained](https://github.com/microsoft/WinAppDriver/issues/1550) and [is not yet open source after many requests](https://github.com/microsoft/WinAppDriver/issues/1371). It implements [the obsolete JSON Wire Protocol](https://www.selenium.dev/documentation/legacy/json_wire_protocol/) by Selenium and not the new W3C WebDriver standard. When using it I stumbled upon various very basic issues, such as [that click doesn't always work](https://github.com/microsoft/WinAppDriver/issues/654). -- [kfrajtak/WinAppDriver](https://github.com/kfrajtak/WinAppDriver) is an open source alternative, but it's technology stack is outdated (.NET Framework, UIAComWrapper, AutoItX.Dotnet). +- [kfrajtak/WinAppDriver](https://github.com/kfrajtak/WinAppDriver) is an open source alternative, but its technology stack is outdated (.NET Framework, UIAComWrapper, AutoItX.Dotnet). - W3C WebDriver is a standard that gives many options of automation frameworks such as [WebdriverIO](https://github.com/webdriverio/webdriverio) and [Selenium](https://github.com/SeleniumHQ/selenium). It allows to write test automation in TypeScript, Java or other languages of preference (using FlaUI requires C# knowledge). - It is open source! Any missing command can be implemented quickly by raising a Pull Request. @@ -34,7 +37,7 @@ The following capabilities are supported: ## Getting Started -This driver currenlty can be downloaded as an executable. Start the web driver service with: +This driver currently can be downloaded as an executable. Start the web driver service with: ```PowerShell ./FlaUI.WebDriver.exe --urls=http://localhost:4723/ @@ -42,9 +45,31 @@ This driver currenlty can be downloaded as an executable. Start the web driver s After it has started, it can be used via WebDriver clients such as for example: +- [Appium.WebDriver](https://www.nuget.org/packages/Appium.WebDriver) - [Selenium.WebDriver](https://www.nuget.org/packages/Selenium.WebDriver) - [WebdriverIO](https://www.npmjs.com/package/webdriverio) +Using the [Appium.WebDriver](https://www.nuget.org/packages/Appium.WebDriver) C# client: + +```C# +using OpenQA.Selenium.Appium.Windows; + +public class FlaUIDriverOptions : AppiumOptions +{ + public static FlaUIDriverOptions ForApp(string path) + { + return new FlaUIDriverOptions() + { + PlatformName = "windows", + AutomationName = "flaui", + App = path + }; + } +} + +var driver = new WindowsDriver(new Uri("http://localhost:4723"), FlaUIDriverOptions.ForApp("C:\\YourApp.exe")) +``` + Using the [Selenium.WebDriver](https://www.nuget.org/packages/Selenium.WebDriver) C# client: ```C# @@ -52,7 +77,7 @@ using OpenQA.Selenium; public class FlaUIDriverOptions : DriverOptions { - public static FlaUIDriverOptions App(string path) + public static FlaUIDriverOptions ForApp(string path) { var options = new FlaUIDriverOptions() { @@ -69,7 +94,7 @@ public class FlaUIDriverOptions : DriverOptions } } -var driver = new RemoteWebDriver(new Uri("http://localhost:4723"), FlaUIDriverOptions.App("C:\\YourApp.exe")) +var driver = new RemoteWebDriver(new Uri("http://localhost:4723"), FlaUIDriverOptions.ForApp("C:\\YourApp.exe")) ``` Using the [WebdriverIO](https://www.npmjs.com/package/webdriverio) JavaScript client: @@ -142,7 +167,7 @@ The driver supports switching windows. The behavior of windows is as following ( The driver supports PowerShell commands. -Using the Selenium C# client: +Using the Selenium or Appium WebDriver C# client: ```C# var result = driver.ExecuteScript("powerShell", new Dictionary { ["command"] = "1+1" }); @@ -154,6 +179,10 @@ Using the WebdriverIO JavaScript client: const result = driver.executeScript("powerShell", [{ command: `1+1` }]); ``` +## Windows extensions + +To enable easy switching from appium-windows-driver, there is a rudimentary implementation of `windows: click`, `windows: hover`, `windows: scroll`, `windows: keys`, `windows: getClipboard`, `windows: setClipboard` and `windows: clearClipboard`. + ## Supported WebDriver Commands | Method | URI Template | Command | Implemented | @@ -190,8 +219,9 @@ const result = driver.executeScript("powerShell", [{ command: `1+1` }]); | 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}/displayed | Is Element Displayed | :white_check_mark: [^isdisplayed] | | 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}/property/{name} | Get Element Property | :white_check_mark: | | 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: | @@ -221,6 +251,7 @@ const result = driver.executeScript("powerShell", [{ command: `1+1` }]); | POST | /session/{session id}/print | Print Page | | [^getattribute]: In Selenium WebDriver, use `GetDomAttribute` because `GetAttribute` converts to javascript. +[^isdisplayed]: In Selenium WebDriver, the `Displayed` property converts to javascript. Use [Appium WebDriver](https://github.com/appium/dotnet-client) to use this functionality. It uses the [IsOffscreen](https://learn.microsoft.com/en-us/dotnet/api/system.windows.automation.automationelement.automationelementinformation.isoffscreen) property that however does not seem to take it into account if the element is blocked by another window. ### WebDriver Interpretation @@ -242,7 +273,7 @@ There is an interpretation to use the WebDriver specification to drive native au ### Deviations from W3C WebDriver Spec -https://www.w3.org/TR/webdriver2/#element-send-keys says: + says: > Set the text insertion caret using set selection range using current text length for both the start and end parameters. @@ -250,7 +281,7 @@ This is impossible using UIA, as there is no API to set the caret position: text ### 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: +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 or Appium WebDriver: ```C# var element = driver.FindElement(By.Id("TextBox")); diff --git a/src/FlaUI.WebDriver.UITests/ActionsTest.cs b/src/FlaUI.WebDriver.UITests/ActionsTests.cs similarity index 68% rename from src/FlaUI.WebDriver.UITests/ActionsTest.cs rename to src/FlaUI.WebDriver.UITests/ActionsTests.cs index de85390..80ccb71 100644 --- a/src/FlaUI.WebDriver.UITests/ActionsTest.cs +++ b/src/FlaUI.WebDriver.UITests/ActionsTests.cs @@ -31,10 +31,33 @@ public void PerformActions_KeyDownKeyUp_IsSupported() element.Click(); new Actions(_driver).KeyDown(Keys.Control).KeyDown(Keys.Backspace).KeyUp(Keys.Backspace).KeyUp(Keys.Control).Perform(); + string activeElementText = _driver.SwitchTo().ActiveElement().Text; Assert.That(activeElementText, Is.EqualTo("Test ")); } + [Test] + public void SendKeys_Default_IsSupported() + { + var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + element.Clear(); + + element.SendKeys("abc123"); + + Assert.That(element.Text, Is.EqualTo("abc123")); + } + + [Test] + public void SendKeys_ShiftedCharacter_IsSupported() + { + var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + element.Clear(); + + element.SendKeys("@TEST"); + + Assert.That(element.Text, Is.EqualTo("@TEST")); + } + [Test] public void ReleaseActions_Default_ReleasesKeys() { @@ -50,21 +73,33 @@ public void ReleaseActions_Default_ReleasesKeys() } [Test] - public void PerformActions_MoveToElement_Click() + public void PerformActions_MoveToElementAndClick_SelectsElement() { 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() + public void PerformActions_MoveToElement_IsSupported() + { + var element = _driver.FindElement(ExtendedBy.AccessibilityId("LabelWithHover")); + + new Actions(_driver).MoveToElement(element).Perform(); + + Assert.That(element.Text, Is.EqualTo("Hovered!")); + } + + [Test] + public void PerformActions_MoveToElementMoveByOffsetAndClick_SelectsElement() { var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 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 a658ee4..2f20192 100644 --- a/src/FlaUI.WebDriver.UITests/ElementTests.cs +++ b/src/FlaUI.WebDriver.UITests/ElementTests.cs @@ -1,7 +1,7 @@ -using System.Threading; -using FlaUI.WebDriver.UITests.TestUtil; +using FlaUI.WebDriver.UITests.TestUtil; using NUnit.Framework; using OpenQA.Selenium; +using OpenQA.Selenium.Appium.Windows; using OpenQA.Selenium.Interactions; using OpenQA.Selenium.Remote; @@ -23,23 +23,48 @@ public class ElementTests [TestCase("InvokableButton", "Invoke me!")] [TestCase("PopupToggleButton1", "Popup Toggle 1")] [TestCase("Label", "Menu Item Checked")] - public void GetText_Returns_Correct_Text(string elementAccessibilityId, string expectedValue) + public void GetText_DifferentElements_ReturnsCorrectText(string elementAccessibilityId, string expectedValue) { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); var element = driver.FindElement(ExtendedBy.AccessibilityId(elementAccessibilityId)); + var text = element.Text; Assert.That(text, Is.EqualTo(expectedValue)); } [Test] - public void GetText_Returns_Text_For_Multiple_Selection() + public void Displayed_UsingAppiumWebDriverElementVisible_ReturnsTrue() + { + var driverOptions = FlaUIAppiumDriverOptions.TestApp(); + using var driver = new WindowsDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + var result = element.Displayed; + + Assert.That(result, Is.True); + } + + [Test] + public void Displayed_UsingAppiumWebDriverElementHidden_ReturnsFalse() + { + var driverOptions = FlaUIAppiumDriverOptions.TestApp(); + using var driver = new WindowsDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + driver.FindElement(ExtendedBy.AccessibilityId("MoreControlsTab")).Click(); + + var result = element.Displayed; + + Assert.That(result, Is.False); + } + + [Test] + public void GetText_ListMultipleSelection_ReturnsCombinedText() { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); var element = driver.FindElement(ExtendedBy.AccessibilityId("ListBox")); - new Actions(driver) .MoveToElement(element) .Click() @@ -56,13 +81,12 @@ public void GetText_Returns_Text_For_Multiple_Selection() } [Test] - public void GetText_Returns_Empty_String_For_No_Selection() + public void GetText_ListNoSelection_ReturnsEmpty() { var driverOptions = FlaUIDriverOptions.TestApp(); using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); var element = driver.FindElement(ExtendedBy.AccessibilityId("ListBox")); var item = driver.FindElement(ExtendedBy.Name("ListBox Item #1")); - new Actions(driver) .MoveToElement(item) .KeyDown(Keys.Control) @@ -138,18 +162,63 @@ public void SendKeys_Default_IsSupported() } [Test] - public void SendKeys_ShiftedCharacter_ShiftIsReleased() + public void SendKeys_AfterShiftedCharacter_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_UpperAndLowercaseLetters_EntersIdenticalText() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + var qwerty = "qwertyuiopasdfghjklzxcvbnm"; + var qwertyS = "QWERTYUIOPASDFGHJKLZXCVBNM"; + var all = qwerty + qwertyS; + element.Clear(); + element.SendKeys(all); + + Assert.That(element.Text, Is.EqualTo(all)); + } + + [Test] + public void SendKeys_NumbersAndShiftedNumbers_EntersIdenticalText() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + var numberRow = "`1234567890-="; + var numberRowS = "~!@#$%^&*()_+"; + var all = numberRow + numberRowS; + element.Clear(); + element.SendKeys(all); + + Assert.That(element.Text, Is.EqualTo(all)); + } + + [Test] + public void SendKeys_OtherCharacters_EntersIdenticalText() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + var others = "[]\\;',./"; + var othersS = "{}|:\"<>?"; + var all = others + othersS; + element.Clear(); + element.SendKeys(all); + + Assert.That(element.Text, Is.EqualTo(all)); + } + [Test] public void SendKeys_DownArrow_IsSupported() { @@ -169,11 +238,8 @@ public void SendKeys_AltDownArrowEscape_IsSupported() 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); @@ -211,8 +277,8 @@ public void GetElementRect_Default_IsSupported() var driverOptions = FlaUIDriverOptions.TestApp(); 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; @@ -300,6 +366,32 @@ public void GetAttribute_TextBox_ReturnsValue(string attributeName, string expec Assert.That(value, Is.EqualTo(expectedValue)); } + [TestCase(["ClassName", "TextBox"])] + [TestCase(["FrameworkId", "WPF"])] + [TestCase(["NonExistent", null])] + public void GetProperty_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.GetDomProperty(attributeName); + + Assert.That(value, Is.EqualTo(expectedValue)); + } + + [Test] + public void GetAttribute_DesktopElement_ReturnsAttribute() + { + var driverOptions = FlaUIDriverOptions.RootApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(By.Name("Taskbar")); + + var result = element.GetDomAttribute("ClassName"); + + Assert.That(result, Is.EqualTo("Shell_TrayWnd")); + } + [Test] public void GetAttribute_PatternProperty_ReturnsValue() { @@ -316,8 +408,24 @@ public void GetAttribute_PatternProperty_ReturnsValue() value = element.GetDomAttribute("Toggle.ToggleState"); Assert.That(value, Is.EqualTo("On")); + } + + [Test] + public void GetProperty_PatternProperty_ReturnsValue() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("SimpleCheckBox")); + + var value = element.GetDomProperty("Toggle.ToggleState"); + + Assert.That(value, Is.EqualTo("Off")); element.Click(); + + value = element.GetDomProperty("Toggle.ToggleState"); + + Assert.That(value, Is.EqualTo("On")); } } } diff --git a/src/FlaUI.WebDriver.UITests/ExecuteTests.cs b/src/FlaUI.WebDriver.UITests/ExecuteTests.cs index 43c9a93..8fdeb02 100644 --- a/src/FlaUI.WebDriver.UITests/ExecuteTests.cs +++ b/src/FlaUI.WebDriver.UITests/ExecuteTests.cs @@ -1,5 +1,7 @@ using FlaUI.WebDriver.UITests.TestUtil; using NUnit.Framework; +using OpenQA.Selenium; +using OpenQA.Selenium.Interactions; using OpenQA.Selenium.Remote; using System.Collections.Generic; @@ -18,5 +20,96 @@ public void ExecuteScript_PowerShellCommand_ReturnsResult() Assert.That(executeScriptResult, Is.EqualTo("2\r\n")); } + + [Test] + public void ExecuteScript_WindowsClickXY_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + driver.ExecuteScript("windows: click", new Dictionary { ["x"] = element.Location.X + element.Size.Width / 2, ["y"] = element.Location.Y + element.Size.Height / 2}); + + string activeElementText = driver.SwitchTo().ActiveElement().Text; + Assert.That(activeElementText, Is.EqualTo("Test TextBox")); + } + + [Test] + public void ExecuteScript_WindowsGetClipboard_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + element.Click(); + new Actions(driver).KeyDown(Keys.Control).SendKeys("a").KeyUp(Keys.Control).Perform(); + new Actions(driver).KeyDown(Keys.Control).SendKeys("c").KeyUp(Keys.Control).Perform(); + + var result = driver.ExecuteScript("windows: getClipboard", new Dictionary {}); + + Assert.That(result, Is.EqualTo("Test TextBox")); + } + + [Test] + public void ExecuteScript_WindowsSetClipboard_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var result = driver.ExecuteScript("windows: setClipboard", new Dictionary { + ["b64Content"] = "Pasted!"}); + + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + element.Click(); + new Actions(driver).KeyDown(Keys.Control).SendKeys("v").KeyUp(Keys.Control).Perform(); + Assert.That(element.Text, Is.EqualTo("Test TextBoxPasted!")); + } + + [Test] + public void ExecuteScript_WindowsClearClipboard_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + driver.ExecuteScript("windows: setClipboard", new Dictionary { + ["b64Content"] = "Pasted!"}); + driver.ExecuteScript("windows: clearClipboard"); + var result = driver.ExecuteScript("windows: getClipboard", new Dictionary {}); + Assert.That(result, Is.EqualTo("")); + } + + [Test] + public void ExecuteScript_WindowsHoverXY_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("LabelWithHover")); + + driver.ExecuteScript("windows: hover", new Dictionary { + ["startX"] = element.Location.X + element.Size.Width / 2, + ["startY"] = element.Location.Y + element.Size.Height / 2, + ["endX"] = element.Location.X + element.Size.Width / 2, + ["endY"] = element.Location.Y + element.Size.Height / 2 + }); + + Assert.That(element.Text, Is.EqualTo("Hovered!")); + } + + [Test] + public void ExecuteScript_WindowsKeys_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + element.Click(); + + driver.ExecuteScript("windows: keys", new Dictionary { ["actions"] = new[] { + new Dictionary { ["virtualKeyCode"] = 0x11, ["down"]=true }, // CTRL + new Dictionary { ["virtualKeyCode"] = 0x08, ["down"]=true }, // BACKSPACE + new Dictionary { ["virtualKeyCode"] = 0x08, ["down"]=false }, + new Dictionary { ["virtualKeyCode"] = 0x11, ["down"]=false } + } }); + + string activeElementText = driver.SwitchTo().ActiveElement().Text; + Assert.That(activeElementText, Is.EqualTo("Test ")); + } } } diff --git a/src/FlaUI.WebDriver.UITests/FindElementsTests.cs b/src/FlaUI.WebDriver.UITests/FindElementsTests.cs index e2fe3b2..93341dc 100644 --- a/src/FlaUI.WebDriver.UITests/FindElementsTests.cs +++ b/src/FlaUI.WebDriver.UITests/FindElementsTests.cs @@ -8,6 +8,17 @@ namespace FlaUI.WebDriver.UITests { public class FindElementsTests { + [Test] + public void FindElement_FromDesktop_ReturnsElement() + { + var driverOptions = FlaUIDriverOptions.RootApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var element = driver.FindElement(By.Name("Taskbar")); + + Assert.That(element, Is.Not.Null); + } + [Test] public void FindElement_ByXPath_ReturnsElement() { diff --git a/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj b/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj index 5723505..32c91f4 100644 --- a/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj +++ b/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj @@ -1,18 +1,19 @@  - net6.0-windows + net8.0-windows false preview - - - - - + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/FlaUI.WebDriver.UITests/ScreenshotTests.cs b/src/FlaUI.WebDriver.UITests/ScreenshotTests.cs new file mode 100644 index 0000000..1754a62 --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/ScreenshotTests.cs @@ -0,0 +1,36 @@ +using FlaUI.WebDriver.UITests.TestUtil; +using NUnit.Framework; +using OpenQA.Selenium.Remote; +using System.Drawing; +using System.IO; + +namespace FlaUI.WebDriver.UITests +{ + public class ScreenshotTests + { + [Test] + public void GetScreenshot_FromDesktop_ReturnsScreenshot() + { + var driverOptions = FlaUIDriverOptions.RootApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var screenshot = driver.GetScreenshot(); + + Assert.That(screenshot.AsByteArray.Length, Is.Not.Zero); + } + + [Test] + public void GetScreenshot_FromApplication_ReturnsScreenshotOfCurrentWindow() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var screenshot = driver.GetScreenshot(); + + Assert.That(screenshot.AsByteArray.Length, Is.Not.Zero); + using var stream = new MemoryStream(screenshot.AsByteArray); + using var image = new Bitmap(stream); + Assert.That(image.Size, Is.EqualTo(driver.Manage().Window.Size)); + } + } +} diff --git a/src/FlaUI.WebDriver.UITests/SessionTests.cs b/src/FlaUI.WebDriver.UITests/SessionTests.cs index 0115396..fe64d94 100644 --- a/src/FlaUI.WebDriver.UITests/SessionTests.cs +++ b/src/FlaUI.WebDriver.UITests/SessionTests.cs @@ -3,7 +3,6 @@ using FlaUI.WebDriver.UITests.TestUtil; using OpenQA.Selenium; using System; -using System.Collections.Generic; namespace FlaUI.WebDriver.UITests { @@ -95,12 +94,10 @@ public void NewSession_AppWorkingDir_IsSupported() public void NewSession_Timeouts_IsSupported() { var driverOptions = FlaUIDriverOptions.TestApp(); - driverOptions.AddAdditionalOption("timeouts", new Dictionary() - { - ["script"] = 10000, - ["pageLoad"] = 50000, - ["implicit"] = 3000 - }); + driverOptions.ScriptTimeout = TimeSpan.FromSeconds(10); + driverOptions.PageLoadTimeout = TimeSpan.FromSeconds(50); + driverOptions.ImplicitWaitTimeout = TimeSpan.FromSeconds(3); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); Assert.That(driver.Manage().Timeouts().AsynchronousJavaScript, Is.EqualTo(TimeSpan.FromSeconds(10))); @@ -109,13 +106,23 @@ public void NewSession_Timeouts_IsSupported() } [Test] - public void NewSession_NotSupportedCapability_Throws() + public void NewSession_NotSupportedAppiumCapability_Throws() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + driverOptions.AddAdditionalOption("appium:unknown", "value"); + + Assert.That(() => { using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); }, + Throws.TypeOf().With.Message.EqualTo("The following capabilities could not be matched: 'appium:unknown' (SessionNotCreated)")); + } + + [Test] + public void NewSession_UnknownExtensionCapability_Ignores() { var driverOptions = FlaUIDriverOptions.TestApp(); driverOptions.AddAdditionalOption("unknown:unknown", "value"); - Assert.That(() => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions), - Throws.TypeOf().With.Message.EqualTo("The following capabilities could not be matched: 'unknown:unknown' (SessionNotCreated)")); + Assert.That(() => { using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); }, + Throws.Nothing); } [Test] @@ -129,6 +136,15 @@ public void NewSession_AppTopLevelWindow_IsSupported() var title = driver.Title; Assert.That(title, Is.EqualTo("FlaUI WPF Test App")); + } + + [Test] + public void EndSession_AppTopLevelWindow_DoesNotKillApp() + { + using var testAppProcess = new TestAppProcess(); + var windowHandle = string.Format("0x{0:x}", testAppProcess.Process.MainWindowHandle); + var driverOptions = FlaUIDriverOptions.AppTopLevelWindow(windowHandle); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); driver.Quit(); @@ -158,7 +174,6 @@ public void NewSession_AppTopLevelWindowZero_ReturnsError() Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Capability appium:appTopLevelWindow '0x0' should not be zero")); } - [Explicit("Sometimes multiple processes are left open")] [TestCase("FlaUI WPF Test App")] [TestCase("FlaUI WPF .*")] public void NewSession_AppTopLevelWindowTitleMatch_IsSupported(string match) @@ -170,13 +185,22 @@ public void NewSession_AppTopLevelWindowTitleMatch_IsSupported(string match) var title = driver.Title; Assert.That(title, Is.EqualTo("FlaUI WPF Test App")); + } + + [Explicit("Sometimes multiple processes are left open")] + [Test] + public void EndSession_AppTopLevelWindowTitleMatch_DoesNotKillApp() + { + using var testAppProcess = new TestAppProcess(); + var driverOptions = FlaUIDriverOptions.AppTopLevelWindowTitleMatch("FlaUI WPF Test App"); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); driver.Quit(); Assert.That(testAppProcess.Process.HasExited, Is.False); } - [Test, Explicit("Sometimes multiple processes are left open")] + [Test] public void NewSession_AppTopLevelWindowTitleMatchMultipleMatching_ReturnsError() { using var testAppProcess = new TestAppProcess(); @@ -323,7 +347,7 @@ public void NewCommandTimeout_NotExpired_DoesNotEndSession() Assert.That(() => driver.Title, Throws.Nothing); } - [Test, Explicit(("Sometimes multiple processes are left open"))] + [Test] public void NewCommandTimeout_SessionWithAppTopLevelWindowTitleMatch_ClosesSessionButDoesNotCloseApp() { using var testAppProcess = new TestAppProcess(); @@ -363,5 +387,15 @@ public void NewCommandTimeout_InvalidValue_Throws(object value) Assert.That(() => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions), Throws.TypeOf().With.Message.EqualTo("Capability appium:newCommandTimeout must be a number")); } + + [Test] + public void UnknownCommand_Default_ReturnsError() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + Assert.That(() => driver.Manage().Cookies.DeleteAllCookies(), + Throws.TypeOf().With.Message.EqualTo("Unknown command")); + } } } diff --git a/src/FlaUI.WebDriver.UITests/TestUtil/FlaUIAppiumDriverOptions.cs b/src/FlaUI.WebDriver.UITests/TestUtil/FlaUIAppiumDriverOptions.cs new file mode 100644 index 0000000..12ca4c4 --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/TestUtil/FlaUIAppiumDriverOptions.cs @@ -0,0 +1,48 @@ +using OpenQA.Selenium.Appium; + +namespace FlaUI.WebDriver.UITests.TestUtil +{ + internal class FlaUIAppiumDriverOptions : AppiumOptions + { + public static FlaUIAppiumDriverOptions TestApp() => ForApp(TestApplication.FullPath); + + public static FlaUIAppiumDriverOptions RootApp() => ForApp("Root"); + + public static FlaUIAppiumDriverOptions ForApp(string path) + { + return new FlaUIAppiumDriverOptions() + { + AutomationName = "FlaUI", + PlatformName = "Windows", + App = path + }; + } + + public static FlaUIAppiumDriverOptions ForAppTopLevelWindow(string windowHandle) + { + var options = new FlaUIAppiumDriverOptions() + { + AutomationName = "FlaUI", + PlatformName = "Windows" + }; + options.AddAdditionalAppiumOption("appium:appTopLevelWindow", windowHandle); + return options; + } + + public static FlaUIAppiumDriverOptions ForAppTopLevelWindowTitleMatch(string match) + { + var options = new FlaUIAppiumDriverOptions() + { + AutomationName = "FlaUI", + PlatformName = "Windows" + }; + options.AddAdditionalAppiumOption("appium:appTopLevelWindowTitleMatch", match); + return options; + } + + public static FlaUIAppiumDriverOptions Empty() + { + return new FlaUIAppiumDriverOptions(); + } + } +} diff --git a/src/FlaUI.WebDriver.UITests/WindowTests.cs b/src/FlaUI.WebDriver.UITests/WindowTests.cs index 63df71d..47c095a 100644 --- a/src/FlaUI.WebDriver.UITests/WindowTests.cs +++ b/src/FlaUI.WebDriver.UITests/WindowTests.cs @@ -14,15 +14,15 @@ 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 * scaling, 630 * scaling)); - Assert.That(size.Height, Is.InRange(515 * scaling, 516 * scaling)); + Assert.That(size.Height, Is.InRange(550 * scaling, 551 * scaling)); } [Test] diff --git a/src/FlaUI.WebDriver.UnitTests/FlaUI.WebDriver.UnitTests.csproj b/src/FlaUI.WebDriver.UnitTests/FlaUI.WebDriver.UnitTests.csproj new file mode 100644 index 0000000..20fc5ad --- /dev/null +++ b/src/FlaUI.WebDriver.UnitTests/FlaUI.WebDriver.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + net8.0-windows + + false + preview + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/FlaUI.WebDriver.UnitTests/Services/ConditionParserTests.cs b/src/FlaUI.WebDriver.UnitTests/Services/ConditionParserTests.cs new file mode 100644 index 0000000..f552344 --- /dev/null +++ b/src/FlaUI.WebDriver.UnitTests/Services/ConditionParserTests.cs @@ -0,0 +1,22 @@ +using FlaUI.WebDriver.Services; +using NUnit.Framework; + +namespace FlaUI.WebDriver.UnitTests.Services +{ + public class ConditionParserTests + { + [TestCase("[name=\"2\"]")] + [TestCase("*[name=\"2\"]")] + [TestCase("*[name = \"2\"]")] + public void ParseCondition_ByCssAttributeName_ReturnsCondition(string selector) + { + var parser = new ConditionParser(); + var uia3 = new UIA3.UIA3Automation(); + + var result = parser.ParseCondition(uia3.ConditionFactory, "css selector", selector); + + Assert.That(result.Property, Is.EqualTo(uia3.PropertyLibrary.Element.Name)); + Assert.That(result.Value, Is.EqualTo("2")); + } + } +} diff --git a/src/FlaUI.WebDriver.sln b/src/FlaUI.WebDriver.sln index 9e76c41..fe703c1 100644 --- a/src/FlaUI.WebDriver.sln +++ b/src/FlaUI.WebDriver.sln @@ -15,9 +15,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WpfApplication", "TestAppli EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4F6F2546-27D9-468C-AD80-1629B54139DA}" ProjectSection(SolutionItems) = preProject + ..\CONTRIBUTING.md = ..\CONTRIBUTING.md ..\README.md = ..\README.md EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FlaUI.WebDriver.UnitTests", "FlaUI.WebDriver.UnitTests\FlaUI.WebDriver.UnitTests.csproj", "{22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -92,6 +95,26 @@ Global {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|x64.Build.0 = Release|Any CPU {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|x86.ActiveCfg = Release|Any CPU {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|x86.Build.0 = Release|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|ARM.ActiveCfg = Debug|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|ARM.Build.0 = Debug|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|ARM64.Build.0 = Debug|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|x64.ActiveCfg = Debug|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|x64.Build.0 = Debug|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|x86.ActiveCfg = Debug|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|x86.Build.0 = Debug|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|Any CPU.Build.0 = Release|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|ARM.ActiveCfg = Release|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|ARM.Build.0 = Release|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|ARM64.ActiveCfg = Release|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|ARM64.Build.0 = Release|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|x64.ActiveCfg = Release|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|x64.Build.0 = Release|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|x86.ActiveCfg = Release|Any CPU + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -99,6 +122,7 @@ Global GlobalSection(NestedProjects) = preSolution {5315D9CF-DDA4-49AE-BA92-AB5814E61901} = {3DFE78D4-89EB-4CEE-A5D1-F5FDDED10959} {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0} = {00BCF82A-388A-4DC9-A1E2-6D6D983BAEE3} + {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3} = {3DFE78D4-89EB-4CEE-A5D1-F5FDDED10959} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F2B64231-45B2-4129-960A-9F26AFFD16AE} diff --git a/src/FlaUI.WebDriver/Controllers/ElementController.cs b/src/FlaUI.WebDriver/Controllers/ElementController.cs index aa9ec29..de6b836 100644 --- a/src/FlaUI.WebDriver/Controllers/ElementController.cs +++ b/src/FlaUI.WebDriver/Controllers/ElementController.cs @@ -37,7 +37,15 @@ public async Task IsElementDisplayed([FromRoute] string sessionId, { var session = GetActiveSession(sessionId); var element = GetElement(session, elementId); - return await Task.FromResult(WebDriverResult.Success(!element.IsOffscreen)); + + if (element.Properties.IsOffscreen.IsSupported) + { + return await Task.FromResult(WebDriverResult.Success(!element.IsOffscreen)); + } + else + { + return await Task.FromResult(WebDriverResult.Success(true)); + } } [HttpGet("{elementId}/enabled")] @@ -63,10 +71,15 @@ public async Task ElementClick([FromRoute] string sessionId, [From var element = GetElement(session, elementId); ScrollElementContainerIntoView(element); - if (!await Wait.Until(() => !element.IsOffscreen, session.ImplicitWaitTimeout)) + + if (element.Properties.IsOffscreen.IsSupported) { - return ElementNotInteractable(elementId); + if (!await Wait.Until(() => !element.IsOffscreen, session.ImplicitWaitTimeout)) + { + return ElementNotInteractable(elementId); + } } + element.Click(); return WebDriverResult.Success(); @@ -179,9 +192,13 @@ public async Task ElementSendKeys([FromRoute] string sessionId, [F var element = GetElement(session, elementId); ScrollElementContainerIntoView(element); - if (!await Wait.Until(() => !element.IsOffscreen, session.ImplicitWaitTimeout)) + + if (element.Properties.IsOffscreen.IsSupported) { - return ElementNotInteractable(elementId); + if (!await Wait.Until(() => !element.IsOffscreen, session.ImplicitWaitTimeout)) + { + return ElementNotInteractable(elementId); + } } element.Focus(); @@ -212,6 +229,7 @@ public async Task ElementSendKeys([FromRoute] string sessionId, [F } [HttpGet("{elementId}/attribute/{attributeId}")] + [HttpGet("{elementId}/property/{attributeId}")] public async Task GetAttribute([FromRoute] string sessionId, [FromRoute] string elementId, [FromRoute] string attributeId) { var session = GetSession(sessionId); @@ -254,9 +272,17 @@ public async Task GetElementRect([FromRoute] string sessionId, [Fr return await Task.FromResult(WebDriverResult.Success(elementRect)); } - private static void ScrollElementContainerIntoView(AutomationElement element) + private void ScrollElementContainerIntoView(AutomationElement element) { - element.Patterns.ScrollItem.PatternOrDefault?.ScrollIntoView(); + try + { + element.Patterns.ScrollItem.PatternOrDefault?.ScrollIntoView(); + } + catch (InvalidOperationException e) + { + // Ignore if scroll fails because of "Operation is not valid due to the current state of the object" + _logger.LogDebug(e, "Ignoring exception: Could not scroll element {Element} into view", element); + } } private static ActionResult ElementNotInteractable(string elementId) @@ -281,7 +307,7 @@ private AutomationElement GetElement(Session session, string elementId) private Session GetActiveSession(string sessionId) { var session = GetSession(sessionId); - if (session.App == null || session.App.HasExited) + if (session.App != null && session.App.HasExited) { throw WebDriverResponseException.NoWindowsOpenForSession(); } diff --git a/src/FlaUI.WebDriver/Controllers/ErrorController.cs b/src/FlaUI.WebDriver/Controllers/ErrorController.cs new file mode 100644 index 0000000..5c83f92 --- /dev/null +++ b/src/FlaUI.WebDriver/Controllers/ErrorController.cs @@ -0,0 +1,33 @@ +using FlaUI.WebDriver.Models; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace FlaUI.WebDriver.Controllers +{ + [ApiController] + [ApiExplorerSettings(IgnoreApi = true)] + public class ErrorController : ControllerBase + { + private readonly ILogger _logger; + + + public ErrorController(ILogger logger) { + _logger = logger; + } + + [Route("/error")] + public IActionResult HandleError() { + var exceptionHandlerFeature = HttpContext.Features.Get()!; + + _logger.LogError(exceptionHandlerFeature.Error, "Returning WebDriver error response with error code 'unknown error'"); + + return new ObjectResult(new ResponseWithValue(new ErrorResponse { + ErrorCode = "unknown error", + Message = exceptionHandlerFeature.Error.Message, + StackTrace = exceptionHandlerFeature.Error.StackTrace ?? "" })) + { + StatusCode = 500 + }; + } + } +} diff --git a/src/FlaUI.WebDriver/Controllers/ExecuteController.cs b/src/FlaUI.WebDriver/Controllers/ExecuteController.cs index 85931c2..170e8cc 100644 --- a/src/FlaUI.WebDriver/Controllers/ExecuteController.cs +++ b/src/FlaUI.WebDriver/Controllers/ExecuteController.cs @@ -1,6 +1,8 @@ using FlaUI.WebDriver.Models; +using FlaUI.WebDriver.Services; using Microsoft.AspNetCore.Mvc; using System.Diagnostics; +using System.Text.Json; namespace FlaUI.WebDriver.Controllers { @@ -10,10 +12,12 @@ public class ExecuteController : ControllerBase { private readonly ILogger _logger; private readonly ISessionRepository _sessionRepository; + private readonly IWindowsExtensionService _windowsExtensionService; - public ExecuteController(ISessionRepository sessionRepository, ILogger logger) + public ExecuteController(ISessionRepository sessionRepository, IWindowsExtensionService windowsExtensionService, ILogger logger) { _sessionRepository = sessionRepository; + _windowsExtensionService = windowsExtensionService; _logger = logger; } @@ -25,10 +29,26 @@ public async Task ExecuteScript([FromRoute] string sessionId, [Fro { case "powerShell": return await ExecutePowerShellScript(session, executeScriptRequest); + case "windows: keys": + return await ExecuteWindowsKeysScript(session, executeScriptRequest); + case "windows: click": + return await ExecuteWindowsClickScript(session, executeScriptRequest); + case "windows: hover": + return await ExecuteWindowsHoverScript(session, executeScriptRequest); + case "windows: scroll": + return await ExecuteWindowsScrollScript(session, executeScriptRequest); + case "windows: setClipboard": + return await ExecuteWindowsSetClipboardScript(session, executeScriptRequest); + case "windows: getClipboard": + return await ExecuteWindowsGetClipboardScript(session, executeScriptRequest); + case "windows: clearClipboard": + return await ExecuteWindowsClearClipboardScript(session, executeScriptRequest); default: - throw WebDriverResponseException.UnsupportedOperation("Only 'powerShell' scripts are supported"); + throw WebDriverResponseException.UnsupportedOperation("Only 'powerShell', 'windows: keys', 'windows: click', 'windows: hover', 'windows: scroll', " + + "'windows: setClipboard', 'windows: getClipboard', 'windows: clearClipboard' scripts are supported"); } } + private async Task ExecutePowerShellScript(Session session, ExecuteScriptRequest executeScriptRequest) { if (executeScriptRequest.Args.Count != 1) @@ -36,10 +56,19 @@ private async Task ExecutePowerShellScript(Session session, Execut throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the PowerShell script, but got {executeScriptRequest.Args.Count} arguments"); } var powerShellArgs = executeScriptRequest.Args[0]; - if (!powerShellArgs.TryGetValue("command", out var powerShellCommand)) + if (!powerShellArgs.TryGetProperty("command", out var powerShellCommandJson)) { throw WebDriverResponseException.InvalidArgument("Expected a \"command\" property of the first argument for the PowerShell script"); } + if (powerShellCommandJson.ValueKind != JsonValueKind.String) + { + throw WebDriverResponseException.InvalidArgument($"Powershell \"command\" property must be a string"); + } + string? powerShellCommand = powerShellCommandJson.GetString(); + if (string.IsNullOrEmpty(powerShellCommand)) + { + throw WebDriverResponseException.InvalidArgument($"Powershell \"command\" property must be non-empty"); + } _logger.LogInformation("Executing PowerShell command {Command} (session {SessionId})", powerShellCommand, session.SessionId); @@ -68,6 +97,128 @@ private async Task ExecutePowerShellScript(Session session, Execut return WebDriverResult.Success(result); } + private async Task ExecuteWindowsSetClipboardScript(Session session, ExecuteScriptRequest executeScriptRequest) + { + if (executeScriptRequest.Args.Count != 1) + { + throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: setClipboard script, but got {executeScriptRequest.Args.Count} arguments"); + } + var action = JsonSerializer.Deserialize(executeScriptRequest.Args[0], new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (action == null) + { + throw WebDriverResponseException.InvalidArgument("Action cannot be null"); + } + await _windowsExtensionService.ExecuteSetClipboardScript(session, action); + return WebDriverResult.Success(); + } + + private async Task ExecuteWindowsGetClipboardScript(Session session, ExecuteScriptRequest executeScriptRequest) + { + if (executeScriptRequest.Args.Count != 1) + { + throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: getClipboard script, but got {executeScriptRequest.Args.Count} arguments"); + } + var action = JsonSerializer.Deserialize(executeScriptRequest.Args[0], new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (action == null) + { + throw WebDriverResponseException.InvalidArgument("Action cannot be null"); + } + var result = await _windowsExtensionService.ExecuteGetClipboardScript(session, action); + return WebDriverResult.Success(result); + } + + private async Task ExecuteWindowsClearClipboardScript(Session session, ExecuteScriptRequest executeScriptRequest) + { + if (executeScriptRequest.Args.Count != 0) + { + throw WebDriverResponseException.InvalidArgument($"No arguments expected for the windows: getClipboard script, but got {executeScriptRequest.Args.Count} arguments"); + } + + await _windowsExtensionService.ExecuteClearClipboardScript(session); + return WebDriverResult.Success(); + } + + private async Task ExecuteWindowsClickScript(Session session, ExecuteScriptRequest executeScriptRequest) + { + if (executeScriptRequest.Args.Count != 1) + { + throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: click script, but got {executeScriptRequest.Args.Count} arguments"); + } + var action = JsonSerializer.Deserialize(executeScriptRequest.Args[0], new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (action == null) + { + throw WebDriverResponseException.InvalidArgument("Action cannot be null"); + } + await _windowsExtensionService.ExecuteClickScript(session, action); + return WebDriverResult.Success(); + } + + private async Task ExecuteWindowsScrollScript(Session session, ExecuteScriptRequest executeScriptRequest) + { + if (executeScriptRequest.Args.Count != 1) + { + throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: click script, but got {executeScriptRequest.Args.Count} arguments"); + } + var action = JsonSerializer.Deserialize(executeScriptRequest.Args[0], new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (action == null) + { + throw WebDriverResponseException.InvalidArgument("Action cannot be null"); + } + await _windowsExtensionService.ExecuteScrollScript(session, action); + return WebDriverResult.Success(); + } + + private async Task ExecuteWindowsHoverScript(Session session, ExecuteScriptRequest executeScriptRequest) + { + if (executeScriptRequest.Args.Count != 1) + { + throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: hover script, but got {executeScriptRequest.Args.Count} arguments"); + } + var action = JsonSerializer.Deserialize(executeScriptRequest.Args[0], new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (action == null) + { + throw WebDriverResponseException.InvalidArgument("Action cannot be null"); + } + await _windowsExtensionService.ExecuteHoverScript(session, action); + return WebDriverResult.Success(); + } + + private async Task ExecuteWindowsKeysScript(Session session, ExecuteScriptRequest executeScriptRequest) + { + if (executeScriptRequest.Args.Count != 1) + { + throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: keys script, but got {executeScriptRequest.Args.Count} arguments"); + } + var windowsKeysArgs = executeScriptRequest.Args[0]; + if (!windowsKeysArgs.TryGetProperty("actions", out var actionsJson)) + { + throw WebDriverResponseException.InvalidArgument("Expected a \"actions\" property of the first argument for the windows: keys script"); + } + session.CurrentWindow.FocusNative(); + if (actionsJson.ValueKind == JsonValueKind.Array) + { + var actions = JsonSerializer.Deserialize>(actionsJson, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (actions == null) + { + throw WebDriverResponseException.InvalidArgument("Argument \"actions\" cannot be null"); + } + foreach (var action in actions) + { + await _windowsExtensionService.ExecuteKeyScript(session, action); + } + } + else + { + var action = JsonSerializer.Deserialize(actionsJson, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (action == null) + { + throw WebDriverResponseException.InvalidArgument("Action cannot be null"); + } + await _windowsExtensionService.ExecuteKeyScript(session, action); + } + return WebDriverResult.Success(); + } + private Session GetSession(string sessionId) { var session = _sessionRepository.FindById(sessionId); diff --git a/src/FlaUI.WebDriver/Controllers/FindElementsController.cs b/src/FlaUI.WebDriver/Controllers/FindElementsController.cs index 9247a00..528ddb9 100644 --- a/src/FlaUI.WebDriver/Controllers/FindElementsController.cs +++ b/src/FlaUI.WebDriver/Controllers/FindElementsController.cs @@ -4,6 +4,7 @@ using FlaUI.Core.Definitions; using FlaUI.Core.AutomationElements; using System.Text.RegularExpressions; +using FlaUI.WebDriver.Services; namespace FlaUI.WebDriver.Controllers { @@ -13,18 +14,20 @@ public class FindElementsController : ControllerBase { private readonly ILogger _logger; private readonly ISessionRepository _sessionRepository; + private readonly IConditionParser _conditionParser; - public FindElementsController(ILogger logger, ISessionRepository sessionRepository) + public FindElementsController(ILogger logger, ISessionRepository sessionRepository, IConditionParser conditionParser) { _logger = logger; _sessionRepository = sessionRepository; + _conditionParser = conditionParser; } [HttpPost("element")] public async Task FindElement([FromRoute] string sessionId, [FromBody] FindElementRequest findElementRequest) { var session = GetActiveSession(sessionId); - return await FindElementFrom(() => session.CurrentWindow, findElementRequest, session); + return await FindElementFrom(() => session.App == null ? session.Automation.GetDesktop() : session.CurrentWindow, findElementRequest, session); } [HttpPost("element/{elementId}/element")] @@ -39,7 +42,7 @@ public async Task FindElementFromElement([FromRoute] string sessio public async Task FindElements([FromRoute] string sessionId, [FromBody] FindElementRequest findElementRequest) { var session = GetActiveSession(sessionId); - return await FindElementsFrom(() => session.CurrentWindow, findElementRequest, session); + return await FindElementsFrom(() => session.App == null ? session.Automation.GetDesktop() : session.CurrentWindow, findElementRequest, session); } [HttpPost("element/{elementId}/elements")] @@ -50,7 +53,7 @@ public async Task FindElementsFromElement([FromRoute] string sessi return await FindElementsFrom(() => element, findElementRequest, session); } - private static async Task FindElementFrom(Func startNode, FindElementRequest findElementRequest, Session session) + private async Task FindElementFrom(Func startNode, FindElementRequest findElementRequest, Session session) { AutomationElement? element; if (findElementRequest.Using == "xpath") @@ -59,7 +62,7 @@ private static async Task FindElementFrom(Func } else { - var condition = GetCondition(session.Automation.ConditionFactory, findElementRequest.Using, findElementRequest.Value); + var condition = _conditionParser.ParseCondition(session.Automation.ConditionFactory, findElementRequest.Using, findElementRequest.Value); element = await Wait.Until(() => startNode().FindFirstDescendant(condition), element => element != null, session.ImplicitWaitTimeout); } @@ -75,7 +78,7 @@ private static async Task FindElementFrom(Func })); } - private static async Task FindElementsFrom(Func startNode, FindElementRequest findElementRequest, Session session) + private async Task FindElementsFrom(Func startNode, FindElementRequest findElementRequest, Session session) { AutomationElement[] elements; if (findElementRequest.Using == "xpath") @@ -84,7 +87,7 @@ private static async Task FindElementsFrom(Func } else { - var condition = GetCondition(session.Automation.ConditionFactory, findElementRequest.Using, findElementRequest.Value); + var condition = _conditionParser.ParseCondition(session.Automation.ConditionFactory, findElementRequest.Using, findElementRequest.Value); elements = await Wait.Until(() => startNode().FindAllDescendants(condition), elements => elements.Length > 0, session.ImplicitWaitTimeout); } @@ -98,89 +101,6 @@ private static async Task FindElementsFrom(Func )); } - /// - /// Based on https://www.w3.org/TR/CSS21/grammar.html (see also https://www.w3.org/TR/CSS22/grammar.html) - /// Limitations: - /// - Unicode escape characters are not supported. - /// - Multiple selectors are not supported. - /// - private static Regex SimpleCssIdSelectorRegex = new Regex(@"^#(?(?[_a-z0-9-]|[\240-\377]|(?\\[^\r\n\f0-9a-f]))+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - - /// - /// Based on https://www.w3.org/TR/CSS21/grammar.html (see also https://www.w3.org/TR/CSS22/grammar.html) - /// Limitations: - /// - Unicode escape characters are not supported. - /// - Multiple selectors are not supported. - /// - private static Regex SimpleCssClassSelectorRegex = new Regex(@"^\.(?-?(?[_a-z]|[\240-\377])(?[_a-z0-9-]|[\240-\377]|(?\\[^\r\n\f0-9a-f]))*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - - /// - /// Based on https://www.w3.org/TR/CSS21/grammar.html (see also https://www.w3.org/TR/CSS22/grammar.html) - /// Limitations: - /// - Unicode escape characters or escape characters in the attribute name are not supported. - /// - Multiple selectors are not supported. - /// - Attribute presence selector (e.g. `[name]`) not supported. - /// - Attribute equals attribute (e.g. `[name=value]`) not supported. - /// - ~= or |= not supported. - /// - private static Regex SimpleCssAttributeSelectorRegex = new Regex(@"^\*?\[\s*(?-?(?[_a-z]|[\240-\377])(?[_a-z0-9-]|[\240-\377])*)\s*=\s*(?(?""(?([^\n\r\f\\""]|(?\\[^\r\n\f0-9a-f]))*)"")|(?'(?([^\n\r\f\\']|(?\\[^\r\n\f0-9a-f]))*)'))\s*\]$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - - /// - /// Based on https://www.w3.org/TR/CSS21/grammar.html (see also https://www.w3.org/TR/CSS22/grammar.html) - /// Limitations: - /// - Unicode escape characters are not supported. - /// - private static Regex SimpleCssEscapeCharacterRegex = new Regex(@"\\[^\r\n\f0-9a-f]", RegexOptions.Compiled | RegexOptions.IgnoreCase); - - private static PropertyCondition GetCondition(ConditionFactory conditionFactory, string @using, string value) - { - switch (@using) - { - case "accessibility id": - return conditionFactory.ByAutomationId(value); - case "name": - return conditionFactory.ByName(value); - case "class name": - return conditionFactory.ByClassName(value); - case "link text": - return conditionFactory.ByText(value); - case "partial link text": - return conditionFactory.ByText(value, PropertyConditionFlags.MatchSubstring); - case "tag name": - return conditionFactory.ByControlType(Enum.Parse(value)); - case "css selector": - var cssIdSelectorMatch = SimpleCssIdSelectorRegex.Match(value); - if (cssIdSelectorMatch.Success) - { - return conditionFactory.ByAutomationId(ReplaceCssEscapedCharacters(value.Substring(1))); - } - var cssClassSelectorMatch = SimpleCssClassSelectorRegex.Match(value); - if (cssClassSelectorMatch.Success) - { - return conditionFactory.ByClassName(ReplaceCssEscapedCharacters(value.Substring(1))); - } - var cssAttributeSelectorMatch = SimpleCssAttributeSelectorRegex.Match(value); - if (cssAttributeSelectorMatch.Success) - { - var attributeValue = ReplaceCssEscapedCharacters(cssAttributeSelectorMatch.Groups["string1value"].Success ? - cssAttributeSelectorMatch.Groups["string1value"].Value : - cssAttributeSelectorMatch.Groups["string2value"].Value); - if (cssAttributeSelectorMatch.Groups["ident"].Value == "name") - { - return conditionFactory.ByName(attributeValue); - } - } - throw WebDriverResponseException.UnsupportedOperation($"Selector strategy 'css selector' with value '{value}' is not supported"); - default: - throw WebDriverResponseException.UnsupportedOperation($"Selector strategy '{@using}' is not supported"); - } - } - - private static string ReplaceCssEscapedCharacters(string value) - { - return SimpleCssEscapeCharacterRegex.Replace(value, match => match.Value.Substring(1)); - } - private static ActionResult NoSuchElement(FindElementRequest findElementRequest) { return WebDriverResult.NotFound(new ErrorResponse() @@ -203,7 +123,7 @@ private AutomationElement GetElement(Session session, string elementId) private Session GetActiveSession(string sessionId) { var session = GetSession(sessionId); - if (session.App == null || session.App.HasExited) + if (session.App != null && session.App.HasExited) { throw WebDriverResponseException.NoWindowsOpenForSession(); } diff --git a/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs b/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs index 881c2fc..e7b51c8 100644 --- a/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs +++ b/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs @@ -21,9 +21,19 @@ public ScreenshotController(ILogger logger, ISessionReposi public async Task TakeScreenshot([FromRoute] string sessionId) { var session = GetActiveSession(sessionId); - var currentWindow = session.CurrentWindow; - _logger.LogInformation("Taking screenshot of window with title {WindowTitle} (session {SessionId})", currentWindow.Title, session.SessionId); - using var bitmap = currentWindow.Capture(); + AutomationElement screenshotElement; + if (session.App == null) + { + _logger.LogInformation("Taking screenshot of desktop (session {SessionId})", session.SessionId); + screenshotElement = session.Automation.GetDesktop(); + } + else + { + var currentWindow = session.CurrentWindow; + _logger.LogInformation("Taking screenshot of window with title {WindowTitle} (session {SessionId})", currentWindow.Title, session.SessionId); + screenshotElement = currentWindow; + } + using var bitmap = screenshotElement.Capture(); return await Task.FromResult(WebDriverResult.Success(GetBase64Data(bitmap))); } @@ -57,7 +67,7 @@ private AutomationElement GetElement(Session session, string elementId) private Session GetActiveSession(string sessionId) { var session = GetSession(sessionId); - if (session.App == null || session.App.HasExited) + if (session.App != null && session.App.HasExited) { throw WebDriverResponseException.NoWindowsOpenForSession(); } diff --git a/src/FlaUI.WebDriver/Controllers/SessionController.cs b/src/FlaUI.WebDriver/Controllers/SessionController.cs index b612817..aaa4ef0 100644 --- a/src/FlaUI.WebDriver/Controllers/SessionController.cs +++ b/src/FlaUI.WebDriver/Controllers/SessionController.cs @@ -1,3 +1,4 @@ +using FlaUI.Core; using FlaUI.WebDriver.Models; using Microsoft.AspNetCore.Mvc; using System.Diagnostics; @@ -29,8 +30,6 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest .Where(capabilities => TryMatchCapabilities(capabilities, out matchedCapabilities, out _)) .Select(capabillities => matchedCapabilities!); - Core.Application? app; - var isAppOwnedBySession = false; var capabilities = matchingCapabilities.FirstOrDefault(); if (capabilities == null) { @@ -43,69 +42,87 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest Message = string.Join("; ", mismatchIndications) }); } + + var timeoutsConfiguration = new TimeoutsConfiguration(); + if (capabilities.TryGetCapability("timeouts", out var timeoutsConfigurationFromCapabilities)) + { + timeoutsConfiguration = timeoutsConfigurationFromCapabilities!; + } + var newCommandTimeout = TimeSpan.FromSeconds(60); + if (capabilities.TryGetNumberCapability("appium:newCommandTimeout", out var newCommandTimeoutFromCapabilities)) + { + newCommandTimeout = TimeSpan.FromSeconds(newCommandTimeoutFromCapabilities); + } + + Application? app = GetApp(capabilities, out bool isAppOwnedBySession); + Session session; + try + { + session = new Session(app, isAppOwnedBySession, timeoutsConfiguration, newCommandTimeout); + } + catch (Exception) + { + app?.Dispose(); + throw; + } + + _sessionRepository.Add(session); + _logger.LogInformation("Created session with ID {SessionId} and capabilities {Capabilities}", session.SessionId, capabilities); + return await Task.FromResult(WebDriverResult.Success(new CreateSessionResponse() + { + SessionId = session.SessionId, + Capabilities = capabilities.Capabilities + })); + } + + private static Application? GetApp(MergedCapabilities capabilities, out bool isAppOwnedBySession) + { if (capabilities.TryGetStringCapability("appium:app", out var appPath)) { if (appPath == "Root") { - app = null; + isAppOwnedBySession = false; + return null; } - else + + capabilities.TryGetStringCapability("appium:appArguments", out var appArguments); + try { - capabilities.TryGetStringCapability("appium:appArguments", out var appArguments); - try + if (appPath.EndsWith("!App")) { - if (appPath.EndsWith("!App")) - { - app = Core.Application.LaunchStoreApp(appPath, appArguments); - } - else - { - var processStartInfo = new ProcessStartInfo(appPath, appArguments ?? ""); - if(capabilities.TryGetStringCapability("appium:appWorkingDir", out var appWorkingDir)) - { - processStartInfo.WorkingDirectory = appWorkingDir; - } - app = Core.Application.Launch(processStartInfo); - } + isAppOwnedBySession = true; + return Application.LaunchStoreApp(appPath, appArguments); } - catch(Exception e) + + var processStartInfo = new ProcessStartInfo(appPath, appArguments ?? ""); + if (capabilities.TryGetStringCapability("appium:appWorkingDir", out var appWorkingDir)) { - throw WebDriverResponseException.InvalidArgument($"Starting app '{appPath}' with arguments '{appArguments}' threw an exception: {e.Message}"); + processStartInfo.WorkingDirectory = appWorkingDir; } + isAppOwnedBySession = true; + return Application.Launch(processStartInfo); + } + catch (Exception e) + { + throw WebDriverResponseException.InvalidArgument($"Starting app '{appPath}' with arguments '{appArguments}' threw an exception: {e.Message}"); } - - isAppOwnedBySession = true; } - else if (capabilities.TryGetStringCapability("appium:appTopLevelWindow", out var appTopLevelWindowString)) + + if (capabilities.TryGetStringCapability("appium:appTopLevelWindow", out var appTopLevelWindowString)) { + isAppOwnedBySession = false; Process process = GetProcessByMainWindowHandle(appTopLevelWindowString); - app = Core.Application.Attach(process); + return Application.Attach(process); } - else if (capabilities.TryGetStringCapability("appium:appTopLevelWindowTitleMatch", out var appTopLevelWindowTitleMatch)) + + if (capabilities.TryGetStringCapability("appium:appTopLevelWindowTitleMatch", out var appTopLevelWindowTitleMatch)) { + isAppOwnedBySession = false; Process? process = GetProcessByMainWindowTitle(appTopLevelWindowTitleMatch); - app = Core.Application.Attach(process); - } - else - { - throw WebDriverResponseException.InvalidArgument("One of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability"); - } - var session = new Session(app, isAppOwnedBySession); - if(capabilities.TryGetNumberCapability("appium:newCommandTimeout", out var newCommandTimeout)) - { - session.NewCommandTimeout = TimeSpan.FromSeconds(newCommandTimeout); - } - if (capabilities.TryGetCapability("timeouts", out var timeoutsConfiguration)) - { - session.TimeoutsConfiguration = timeoutsConfiguration!; + return Application.Attach(process); } - _sessionRepository.Add(session); - _logger.LogInformation("Created session with ID {SessionId} and capabilities {Capabilities}", session.SessionId, capabilities); - return await Task.FromResult(WebDriverResult.Success(new CreateSessionResponse() - { - SessionId = session.SessionId, - Capabilities = capabilities.Capabilities - })); + + throw WebDriverResponseException.InvalidArgument("One of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability"); } private string? GetMismatchIndication(MergedCapabilities capabilities) @@ -163,7 +180,7 @@ private bool TryMatchCapabilities(MergedCapabilities capabilities, [MaybeNullWhe matchedCapabilities.Copy("appium:appTopLevelWindow", capabilities); } else if (capabilities.Contains("appium:appTopLevelWindowTitleMatch")) - { + { matchedCapabilities.Copy("appium:appTopLevelWindowTitleMatch", capabilities); } else @@ -183,10 +200,16 @@ private bool TryMatchCapabilities(MergedCapabilities capabilities, [MaybeNullWhe } var notMatchedCapabilities = capabilities.Capabilities.Keys.Except(matchedCapabilities.Capabilities.Keys); + var notMatchedStandardOrAppiumCapabilities = notMatchedCapabilities.Where(c => !c.Contains(":") || c.StartsWith("appium:")); + if (notMatchedStandardOrAppiumCapabilities.Any()) + { + // Do not ignore non-extension capabilities or appium capabilities + mismatchIndication = $"The following capabilities could not be matched: '{string.Join("', '", notMatchedStandardOrAppiumCapabilities)}'"; + return false; + } if (notMatchedCapabilities.Any()) { - mismatchIndication = $"The following capabilities could not be matched: '{string.Join("', '", notMatchedCapabilities)}'"; - return false; + _logger.LogDebug("The following capabilities could not be matched and are ignored: '{NotMatchedCapabilities}'", string.Join("', '", notMatchedCapabilities)); } mismatchIndication = null; @@ -199,8 +222,8 @@ private static Process GetProcessByMainWindowTitle(string appTopLevelWindowTitle try { appMainWindowTitleRegex = new Regex(appTopLevelWindowTitleMatch); - } - catch(ArgumentException e) + } + catch (ArgumentException e) { throw WebDriverResponseException.InvalidArgument($"Capability appium:appTopLevelWindowTitleMatch '{appTopLevelWindowTitleMatch}' is not a valid regular expression: {e.Message}"); } @@ -209,7 +232,7 @@ private static Process GetProcessByMainWindowTitle(string appTopLevelWindowTitle { throw WebDriverResponseException.InvalidArgument($"Process with main window title matching '{appTopLevelWindowTitleMatch}' could not be found"); } - else if (processes.Length > 1) + if (processes.Length > 1) { throw WebDriverResponseException.InvalidArgument($"Found multiple ({processes.Length}) processes with main window title matching '{appTopLevelWindowTitleMatch}'"); } diff --git a/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj b/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj index ae81853..f920a59 100644 --- a/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj +++ b/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj @@ -1,7 +1,7 @@  - net6.0-windows + net8.0-windows enable enable preview @@ -10,15 +10,16 @@ true true app.manifest + net8.0 - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/FlaUI.WebDriver/Keys.cs b/src/FlaUI.WebDriver/Keys.cs index cdc006a..86bda4e 100644 --- a/src/FlaUI.WebDriver/Keys.cs +++ b/src/FlaUI.WebDriver/Keys.cs @@ -101,7 +101,6 @@ internal class Keys { '8', "Digit8" }, { '9', "Digit9" }, { '=', "Equal" }, - { '<', "IntlBackslash" }, { 'a', "KeyA" }, { 'b', "KeyB" }, { 'c', "KeyC" }, @@ -207,7 +206,6 @@ internal class Keys { '*', "Digit8" }, { '(', "Digit9" }, { '+', "Equal" }, - { '>', "IntlBackslash" }, { 'A', "KeyA" }, { 'B', "KeyB" }, { 'C', "KeyC" }, @@ -235,7 +233,7 @@ internal class Keys { 'Y', "KeyY" }, { 'Z', "KeyZ" }, { '_', "Minus" }, - { '.', "Period" }, + { '>', "Period" }, { '"', "Quote" }, { ':', "Semicolon" }, { '?', "Slash" }, diff --git a/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs b/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs index 94d62d6..8a09ca6 100644 --- a/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs +++ b/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs @@ -1,8 +1,10 @@ -namespace FlaUI.WebDriver.Models +using System.Text.Json; + +namespace FlaUI.WebDriver.Models { public class ExecuteScriptRequest { public string Script { get; set; } = null!; - public List> Args { get; set; } = new List>(); + public List Args { get; set; } = new List(); } } diff --git a/src/FlaUI.WebDriver/Models/WindowsClickScript.cs b/src/FlaUI.WebDriver/Models/WindowsClickScript.cs new file mode 100644 index 0000000..e960fca --- /dev/null +++ b/src/FlaUI.WebDriver/Models/WindowsClickScript.cs @@ -0,0 +1,14 @@ +namespace FlaUI.WebDriver.Models +{ + public class WindowsClickScript + { + public string? ElementId { get; set; } + public int? X { get; set; } + public int? Y { get; set; } + public string? Button { get; set; } + public string[]? ModifierKeys { get; set; } + public int? DurationMs { get; set; } + public int? Times { get; set; } + public int? InterClickDelayMs { get; set; } + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Models/WindowsGetClipboardScript.cs b/src/FlaUI.WebDriver/Models/WindowsGetClipboardScript.cs new file mode 100644 index 0000000..286101a --- /dev/null +++ b/src/FlaUI.WebDriver/Models/WindowsGetClipboardScript.cs @@ -0,0 +1,7 @@ +namespace FlaUI.WebDriver.Models +{ + public class WindowsGetClipboardScript + { + public string? ContentType { get; set; } + } +} diff --git a/src/FlaUI.WebDriver/Models/WindowsHoverScript.cs b/src/FlaUI.WebDriver/Models/WindowsHoverScript.cs new file mode 100644 index 0000000..d5fa505 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/WindowsHoverScript.cs @@ -0,0 +1,14 @@ +namespace FlaUI.WebDriver.Models +{ + public class WindowsHoverScript + { + public string? StartElementId { get; set; } + public int? StartX { get; set; } + public int? StartY { get; set; } + public int? EndX { get; set; } + public int? EndY { get; set; } + public string? EndElementId { get; set; } + public int? DurationMs { get; set; } + public string[]? ModifierKeys { get; set; } + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Models/WindowsKeyScript.cs b/src/FlaUI.WebDriver/Models/WindowsKeyScript.cs new file mode 100644 index 0000000..ae675ad --- /dev/null +++ b/src/FlaUI.WebDriver/Models/WindowsKeyScript.cs @@ -0,0 +1,10 @@ +namespace FlaUI.WebDriver.Models +{ + public class WindowsKeyScript + { + public int? Pause { get; set; } + public string? Text { get; set; } + public ushort? VirtualKeyCode { get; set; } + public bool? Down { get; set; } + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Models/WindowsScrollScript.cs b/src/FlaUI.WebDriver/Models/WindowsScrollScript.cs new file mode 100644 index 0000000..08f536d --- /dev/null +++ b/src/FlaUI.WebDriver/Models/WindowsScrollScript.cs @@ -0,0 +1,12 @@ +namespace FlaUI.WebDriver.Models +{ + public class WindowsScrollScript + { + public string? ElementId { get; set; } + public int? X { get; set; } + public int? Y { get; set; } + public int? DeltaX { get; set; } + public int? DeltaY { get; set; } + public string[]? ModifierKeys { get; set; } + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Models/WindowsSetClipboardScript.cs b/src/FlaUI.WebDriver/Models/WindowsSetClipboardScript.cs new file mode 100644 index 0000000..3aad501 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/WindowsSetClipboardScript.cs @@ -0,0 +1,8 @@ +namespace FlaUI.WebDriver.Models +{ + public class WindowsSetClipboardScript + { + public string B64Content { get; set; } = ""; + public string? ContentType { get; set; } + } +} diff --git a/src/FlaUI.WebDriver/NotFoundMiddleware.cs b/src/FlaUI.WebDriver/NotFoundMiddleware.cs new file mode 100644 index 0000000..49f2941 --- /dev/null +++ b/src/FlaUI.WebDriver/NotFoundMiddleware.cs @@ -0,0 +1,47 @@ +using FlaUI.WebDriver.Models; + +namespace FlaUI.WebDriver +{ + public class NotFoundMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public NotFoundMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task Invoke(HttpContext httpContext) + { + await _next(httpContext); + if (!httpContext.Response.HasStarted) + { + if (httpContext.Response.StatusCode == StatusCodes.Status404NotFound) + { + _logger.LogError("Unknown endpoint {Path}", SanitizeForLog(httpContext.Request.Path.ToString())); + await httpContext.Response.WriteAsJsonAsync(new ResponseWithValue(new ErrorResponse + { + ErrorCode = "unknown command", + Message = "Unknown command" + })); + } + else if (httpContext.Response.StatusCode == StatusCodes.Status405MethodNotAllowed) + { + _logger.LogError("Unknown method {Method} for endpoint {Path}", SanitizeForLog(httpContext.Request.Method), SanitizeForLog(httpContext.Request.Path.ToString())); + await httpContext.Response.WriteAsJsonAsync(new ResponseWithValue(new ErrorResponse + { + ErrorCode = "unknown method", + Message = "Unknown method for this endpoint" + })); + } + } + } + + private static string SanitizeForLog(string str) + { + return str.Replace("\r", "").Replace("\n", ""); + } + } +} diff --git a/src/FlaUI.WebDriver/Program.cs b/src/FlaUI.WebDriver/Program.cs index 152365f..9100310 100644 --- a/src/FlaUI.WebDriver/Program.cs +++ b/src/FlaUI.WebDriver/Program.cs @@ -6,6 +6,8 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure(options => options.LowercaseUrls = true); builder.Services.AddControllers(options => @@ -23,6 +25,10 @@ var app = builder.Build(); +app.UseMiddleware(); + +app.UseExceptionHandler("/error"); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { diff --git a/src/FlaUI.WebDriver/Services/ConditionParser.cs b/src/FlaUI.WebDriver/Services/ConditionParser.cs new file mode 100644 index 0000000..16c6d9c --- /dev/null +++ b/src/FlaUI.WebDriver/Services/ConditionParser.cs @@ -0,0 +1,93 @@ +using FlaUI.Core.Conditions; +using FlaUI.Core.Definitions; +using System.Text.RegularExpressions; + +namespace FlaUI.WebDriver.Services +{ + public class ConditionParser : IConditionParser + { + /// + /// Based on https://www.w3.org/TR/CSS21/grammar.html (see also https://www.w3.org/TR/CSS22/grammar.html) + /// Limitations: + /// - Unicode escape characters are not supported. + /// - Multiple selectors are not supported. + /// + private static Regex SimpleCssIdSelectorRegex = new Regex(@"^#(?(?[_a-z0-9-]|[\240-\377]|(?\\[^\r\n\f0-9a-f]))+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// + /// Based on https://www.w3.org/TR/CSS21/grammar.html (see also https://www.w3.org/TR/CSS22/grammar.html) + /// Limitations: + /// - Unicode escape characters are not supported. + /// - Multiple selectors are not supported. + /// + private static Regex SimpleCssClassSelectorRegex = new Regex(@"^\.(?-?(?[_a-z]|[\240-\377])(?[_a-z0-9-]|[\240-\377]|(?\\[^\r\n\f0-9a-f]))*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// + /// Based on https://www.w3.org/TR/CSS21/grammar.html (see also https://www.w3.org/TR/CSS22/grammar.html) + /// Limitations: + /// - Unicode escape characters or escape characters in the attribute name are not supported. + /// - Multiple selectors are not supported. + /// - Attribute presence selector (e.g. `[name]`) not supported. + /// - Attribute equals attribute (e.g. `[name=value]`) not supported. + /// - ~= or |= not supported. + /// + private static Regex SimpleCssAttributeSelectorRegex = new Regex(@"^\*?\[\s*(?-?(?[_a-z]|[\240-\377])(?[_a-z0-9-]|[\240-\377])*)\s*=\s*(?(?""(?([^\n\r\f\\""]|(?\\[^\r\n\f0-9a-f]))*)"")|(?'(?([^\n\r\f\\']|(?\\[^\r\n\f0-9a-f]))*)'))\s*\]$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// + /// Based on https://www.w3.org/TR/CSS21/grammar.html (see also https://www.w3.org/TR/CSS22/grammar.html) + /// Limitations: + /// - Unicode escape characters are not supported. + /// + private static Regex SimpleCssEscapeCharacterRegex = new Regex(@"\\[^\r\n\f0-9a-f]", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public PropertyCondition ParseCondition(ConditionFactory conditionFactory, string @using, string value) + { + switch (@using) + { + case "accessibility id": + return conditionFactory.ByAutomationId(value); + case "name": + return conditionFactory.ByName(value); + case "class name": + return conditionFactory.ByClassName(value); + case "link text": + return conditionFactory.ByText(value); + case "partial link text": + return conditionFactory.ByText(value, PropertyConditionFlags.MatchSubstring); + case "tag name": + return conditionFactory.ByControlType(Enum.Parse(value)); + case "css selector": + var cssIdSelectorMatch = SimpleCssIdSelectorRegex.Match(value); + if (cssIdSelectorMatch.Success) + { + return conditionFactory.ByAutomationId(ReplaceCssEscapedCharacters(value.Substring(1))); + } + var cssClassSelectorMatch = SimpleCssClassSelectorRegex.Match(value); + if (cssClassSelectorMatch.Success) + { + return conditionFactory.ByClassName(ReplaceCssEscapedCharacters(value.Substring(1))); + } + var cssAttributeSelectorMatch = SimpleCssAttributeSelectorRegex.Match(value); + if (cssAttributeSelectorMatch.Success) + { + var attributeValue = ReplaceCssEscapedCharacters(cssAttributeSelectorMatch.Groups["string1value"].Success ? + cssAttributeSelectorMatch.Groups["string1value"].Value : + cssAttributeSelectorMatch.Groups["string2value"].Value); + if (cssAttributeSelectorMatch.Groups["ident"].Value == "name") + { + return conditionFactory.ByName(attributeValue); + } + } + throw WebDriverResponseException.UnsupportedOperation($"Selector strategy 'css selector' with value '{value}' is not supported"); + default: + throw WebDriverResponseException.UnsupportedOperation($"Selector strategy '{@using}' is not supported"); + } + } + + private static string ReplaceCssEscapedCharacters(string value) + { + return SimpleCssEscapeCharacterRegex.Replace(value, match => match.Value.Substring(1)); + } + + } +} diff --git a/src/FlaUI.WebDriver/Services/IConditionParser.cs b/src/FlaUI.WebDriver/Services/IConditionParser.cs new file mode 100644 index 0000000..ee823c8 --- /dev/null +++ b/src/FlaUI.WebDriver/Services/IConditionParser.cs @@ -0,0 +1,9 @@ +using FlaUI.Core.Conditions; + +namespace FlaUI.WebDriver.Services +{ + public interface IConditionParser + { + PropertyCondition ParseCondition(ConditionFactory conditionFactory, string @using, string value); + } +} diff --git a/src/FlaUI.WebDriver/Services/IWindowsExtensionService.cs b/src/FlaUI.WebDriver/Services/IWindowsExtensionService.cs new file mode 100644 index 0000000..bdbae2e --- /dev/null +++ b/src/FlaUI.WebDriver/Services/IWindowsExtensionService.cs @@ -0,0 +1,15 @@ +using FlaUI.WebDriver.Models; + +namespace FlaUI.WebDriver.Services +{ + public interface IWindowsExtensionService + { + Task ExecuteClickScript(Session session, WindowsClickScript action); + Task ExecuteScrollScript(Session session, WindowsScrollScript action); + Task ExecuteHoverScript(Session session, WindowsHoverScript action); + Task ExecuteKeyScript(Session session, WindowsKeyScript action); + Task ExecuteGetClipboardScript(Session session, WindowsGetClipboardScript action); + Task ExecuteSetClipboardScript(Session session, WindowsSetClipboardScript action); + Task ExecuteClearClipboardScript(Session session); + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Services/WindowsExtensionService.cs b/src/FlaUI.WebDriver/Services/WindowsExtensionService.cs new file mode 100644 index 0000000..c8e1b56 --- /dev/null +++ b/src/FlaUI.WebDriver/Services/WindowsExtensionService.cs @@ -0,0 +1,234 @@ +using FlaUI.Core.Input; +using FlaUI.Core.Tools; +using FlaUI.Core.WindowsAPI; +using FlaUI.WebDriver.Models; +using System.Drawing; + +namespace FlaUI.WebDriver.Services +{ + public class WindowsExtensionService : IWindowsExtensionService + { + private readonly ILogger _logger; + + public WindowsExtensionService(ILogger logger) + { + _logger = logger; + } + + public Task ExecuteGetClipboardScript(Session session, WindowsGetClipboardScript action) + { + switch (action.ContentType) + { + default: + case "plaintext": + return Task.FromResult(ExecuteOnClipboardThread( + () => System.Windows.Forms.Clipboard.GetText(System.Windows.Forms.TextDataFormat.UnicodeText) + )); + case "image": + return Task.FromResult(ExecuteOnClipboardThread(() => + { + using var image = System.Windows.Forms.Clipboard.GetImage(); + if (image == null) + { + return ""; + } + using var stream = new MemoryStream(); + image.Save(stream, System.Drawing.Imaging.ImageFormat.Png); + return Convert.ToBase64String(stream.ToArray()); + })); + } + } + + public Task ExecuteSetClipboardScript(Session session, WindowsSetClipboardScript action) + { + switch (action.ContentType) + { + default: + case "plaintext": + ExecuteOnClipboardThread(() => System.Windows.Forms.Clipboard.SetText(action.B64Content)); + break; + case "image": + ExecuteOnClipboardThread(() => + { + using var stream = new MemoryStream(Convert.FromBase64String(action.B64Content)); + using var image = Image.FromStream(stream); + System.Windows.Forms.Clipboard.SetImage(image); + }); + break; + } + return Task.CompletedTask; + } + + public Task ExecuteClearClipboardScript(Session session) + { + ExecuteOnClipboardThread(() => System.Windows.Forms.Clipboard.Clear()); + return Task.CompletedTask; + } + + private void ExecuteOnClipboardThread(System.Action action) + { + // See https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.clipboard?view=windowsdesktop-8.0#remarks + var thread = new Thread(() => action()); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + } + + private string ExecuteOnClipboardThread(Func method) + { + // See https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.clipboard?view=windowsdesktop-8.0#remarks + string result = ""; + var thread = new Thread(() => { result = method(); }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + return result; + } + + public async Task ExecuteClickScript(Session session, WindowsClickScript action) + { + if (action.DurationMs.HasValue) + { + throw WebDriverResponseException.UnsupportedOperation("Duration is not yet supported"); + } + if (action.Times.HasValue) + { + throw WebDriverResponseException.UnsupportedOperation("Times is not yet supported"); + } + if (action.ModifierKeys != null) + { + throw WebDriverResponseException.UnsupportedOperation("Modifier keys are not yet supported"); + } + var point = GetPoint(action.ElementId, action.X, action.Y, session); + var mouseButton = action.Button != null ? Enum.Parse(action.Button, true) : MouseButton.Left; + _logger.LogDebug("Clicking point ({X}, {Y}) with mouse button {MouseButton}", point.X, point.Y, mouseButton); + Mouse.Click(point, mouseButton); + await Task.Yield(); + } + + public async Task ExecuteScrollScript(Session session, WindowsScrollScript action) + { + if (action.ModifierKeys != null) + { + throw WebDriverResponseException.UnsupportedOperation("Modifier keys are not yet supported"); + } + var point = GetPoint(action.ElementId, action.X, action.Y, session); + _logger.LogDebug("Scrolling at point ({X}, {Y})", point.X, point.Y); + Mouse.Position = point; + if (action.DeltaY.HasValue && action.DeltaY.Value != 0) + { + Mouse.Scroll(action.DeltaY.Value); + } + if (action.DeltaX.HasValue && action.DeltaX.Value != 0) + { + Mouse.HorizontalScroll(-action.DeltaX.Value); + } + await Task.Yield(); + } + + private Point GetPoint(string? elementId, int? x, int? y, Session session) + { + if (elementId != null) + { + var element = session.FindKnownElementById(elementId); + if (element == null) + { + throw WebDriverResponseException.ElementNotFound(elementId); + } + + if (x.HasValue && y.HasValue) + { + return new Point + { + X = element.BoundingRectangle.Left + x.Value, + Y = element.BoundingRectangle.Top + y.Value + }; + } + + return element.BoundingRectangle.Center(); + } + + if (x.HasValue && y.HasValue) + { + return new Point { X = x.Value, Y = y.Value }; + } + + throw WebDriverResponseException.InvalidArgument("Either element ID or x and y must be provided"); + } + + public async Task ExecuteHoverScript(Session session, WindowsHoverScript action) + { + if (action.ModifierKeys != null) + { + throw WebDriverResponseException.UnsupportedOperation("Modifier keys are not yet supported"); + } + var startingPoint = GetPoint(action.StartElementId, action.StartX, action.StartY, session); + var endPoint = GetPoint(action.EndElementId, action.EndX, action.EndY, session); + + _logger.LogDebug("Moving mouse to starting point ({X}, {Y})", startingPoint.X, startingPoint.Y); + Mouse.Position = startingPoint; + + if (endPoint == startingPoint) + { + // Hover for specified time + await Task.Delay(action.DurationMs ?? 100); + return; + } + + _logger.LogDebug("Moving mouse to end point ({X}, {Y})", endPoint.X, endPoint.Y); + if (action.DurationMs.HasValue) + { + if (action.DurationMs.Value <= 0) + { + throw WebDriverResponseException.UnsupportedOperation("Duration less than or equal to zero is not supported"); + } + Mouse.MovePixelsPerMillisecond = endPoint.Distance(startingPoint) / action.DurationMs.Value; + } + Mouse.MoveTo(endPoint); + } + + public async Task ExecuteKeyScript(Session session, WindowsKeyScript action) + { + if (action.VirtualKeyCode.HasValue) + { + if (action.Down.HasValue) + { + if (action.Down.Value == true) + { + _logger.LogDebug("Pressing key {VirtualKeyCode}", action.VirtualKeyCode.Value); + Keyboard.Press((VirtualKeyShort)action.VirtualKeyCode.Value); + await Task.Delay(10); + } + else + { + _logger.LogDebug("Releasing key {VirtualKeyCode}", action.VirtualKeyCode.Value); + Keyboard.Release((VirtualKeyShort)action.VirtualKeyCode.Value); + await Task.Delay(10); + } + } + else + { + _logger.LogDebug("Pressing and releasing key {VirtualKeyCode}", action.VirtualKeyCode.Value); + Keyboard.Press((VirtualKeyShort)action.VirtualKeyCode.Value); + await Task.Delay(10); + Keyboard.Release((VirtualKeyShort)action.VirtualKeyCode.Value); + await Task.Delay(10); + } + } + else if (action.Text != null) + { + _logger.LogDebug("Typing {Text}", action.Text); + Keyboard.Type(action.Text); + } + else if (action.Pause.HasValue) + { + _logger.LogDebug("Pausing for {Pause} milliseconds", action.Pause.Value); + await Task.Delay(action.Pause.Value); + } + else + { + throw WebDriverResponseException.InvalidArgument("Action must have either \"text\", \"virtualKeyCode\" or \"pause\""); + } + } + } +} diff --git a/src/FlaUI.WebDriver/Session.cs b/src/FlaUI.WebDriver/Session.cs index 52bc1b7..305cf8c 100644 --- a/src/FlaUI.WebDriver/Session.cs +++ b/src/FlaUI.WebDriver/Session.cs @@ -8,19 +8,25 @@ namespace FlaUI.WebDriver { public class Session : IDisposable { - public Session(Application? app, bool isAppOwnedBySession) + public Session(Application? app, bool isAppOwnedBySession, TimeoutsConfiguration timeoutsConfiguration, TimeSpan newCommandTimeout) { App = app; SessionId = Guid.NewGuid().ToString(); Automation = new UIA3Automation(); InputState = new InputState(); - TimeoutsConfiguration = new TimeoutsConfiguration(); + TimeoutsConfiguration = timeoutsConfiguration; IsAppOwnedBySession = isAppOwnedBySession; + NewCommandTimeout = newCommandTimeout; if (app != null) { // We have to capture the initial window handle to be able to keep it stable - CurrentWindowWithHandle = GetOrAddKnownWindow(app.GetMainWindow(Automation, PageLoadTimeout)); + var mainWindow = app.GetMainWindow(Automation, PageLoadTimeout); + if (mainWindow == null) + { + throw WebDriverResponseException.Timeout($"Could not get the main window of the app within the page load timeout (${PageLoadTimeout.TotalMilliseconds}ms)"); + } + CurrentWindowWithHandle = GetOrAddKnownWindow(mainWindow); } } diff --git a/src/FlaUI.WebDriver/TimeoutsConfiguration.cs b/src/FlaUI.WebDriver/TimeoutsConfiguration.cs index 47ff012..27feb4d 100644 --- a/src/FlaUI.WebDriver/TimeoutsConfiguration.cs +++ b/src/FlaUI.WebDriver/TimeoutsConfiguration.cs @@ -5,10 +5,10 @@ namespace FlaUI.WebDriver public class TimeoutsConfiguration { [JsonPropertyName("script")] - public int? ScriptTimeoutMs { get; set; } = 30000; + public double? ScriptTimeoutMs { get; set; } = 30000; [JsonPropertyName("pageLoad")] - public int PageLoadTimeoutMs { get; set; } = 300000; + public double PageLoadTimeoutMs { get; set; } = 300000; [JsonPropertyName("implicit")] - public int ImplicitWaitTimeoutMs { get; set; } = 0; + public double ImplicitWaitTimeoutMs { get; set; } = 0; } } diff --git a/src/FlaUI.WebDriver/WebDriverExceptionFilter.cs b/src/FlaUI.WebDriver/WebDriverExceptionFilter.cs index c7462f9..c01be45 100644 --- a/src/FlaUI.WebDriver/WebDriverExceptionFilter.cs +++ b/src/FlaUI.WebDriver/WebDriverExceptionFilter.cs @@ -15,13 +15,14 @@ public void OnActionExecuted(ActionExecutedContext context) if (context.Exception is WebDriverResponseException exception) { var logger = context.HttpContext.RequestServices.GetRequiredService>(); - logger.LogError(exception, "Returning WebDriver error response with error code {ErrorCode}", exception.ErrorCode); + logger.LogError(exception, "Returning WebDriver error response with error code '{ErrorCode}'", exception.ErrorCode); - context.Result = new ObjectResult(new ResponseWithValue(new ErrorResponse { ErrorCode = exception.ErrorCode, Message = exception.Message })) { + context.Result = new ObjectResult(new ResponseWithValue(new ErrorResponse { ErrorCode = exception.ErrorCode, Message = exception.Message })) + { StatusCode = exception.StatusCode }; context.ExceptionHandled = true; } } } -} +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/WebDriverResponseException.cs b/src/FlaUI.WebDriver/WebDriverResponseException.cs index 205cff5..97d74bb 100644 --- a/src/FlaUI.WebDriver/WebDriverResponseException.cs +++ b/src/FlaUI.WebDriver/WebDriverResponseException.cs @@ -24,5 +24,7 @@ private WebDriverResponseException(string message, string errorCode, int statusC public static WebDriverResponseException WindowNotFoundByHandle(string windowHandle) => new WebDriverResponseException($"No window found with handle '{windowHandle}'", "no such window", 404); public static WebDriverResponseException NoWindowsOpenForSession() => new WebDriverResponseException($"No windows are open for the current session", "no such window", 404); + + public static WebDriverResponseException Timeout(string message) => new WebDriverResponseException($"Timeout: ${message}", "timeout", 500); } } diff --git a/src/TestApplications/WpfApplication/MainWindow.xaml b/src/TestApplications/WpfApplication/MainWindow.xaml index 0e2cc41..b9b8839 100644 --- a/src/TestApplications/WpfApplication/MainWindow.xaml +++ b/src/TestApplications/WpfApplication/MainWindow.xaml @@ -6,7 +6,7 @@ xmlns:local="clr-namespace:WpfApplication" mc:Ignorable="d" Title="FlaUI WPF Test App" - Height="515.931" Width="629.303" + Height="550" Width="629.303" ResizeMode="CanResize"> @@ -98,6 +98,7 @@ PlacementTarget="{Binding ElementName=PopupToggleButton2}" StaysOpen="False" />