From 43fc8d56c7e6c3029fc69338e77368915cccacd8 Mon Sep 17 00:00:00 2001 From: aristotelos Date: Wed, 20 Mar 2024 15:07:56 +0100 Subject: [PATCH] Add GitHub actions to build and package. Based on https://github.com/actions/starter-workflows/blob/main/ci/dotnet.yml. This requires the WpfApplication to be converted to a .NET 6 project and added to the UITests project dependencies so that it is built before it is used in the tests. --- .github/workflows/build.yml | 50 +++ .gitignore | 398 ++++++++++++++++++ LICENSE | 21 + README.md | 240 +++++++++++ src/FlaUI.WebDriver.UITests/ActionsTest.cs | 40 ++ src/FlaUI.WebDriver.UITests/ElementTests.cs | 214 ++++++++++ src/FlaUI.WebDriver.UITests/ExecuteTests.cs | 22 + .../FindElementsTests.cs | 234 ++++++++++ .../FlaUI.WebDriver.UITests.csproj | 26 ++ src/FlaUI.WebDriver.UITests/SessionTests.cs | 139 ++++++ .../TestUtil/ExtendedBy.cs | 17 + .../TestUtil/FlaUIDriverOptions.cs | 54 +++ .../TestUtil/TestAppProcess.cs | 28 ++ src/FlaUI.WebDriver.UITests/TimeoutsTests.cs | 35 ++ .../WebDriverFixture.cs | 71 ++++ src/FlaUI.WebDriver.UITests/WindowTests.cs | 163 +++++++ src/FlaUI.WebDriver.sln | 101 +++++ src/FlaUI.WebDriver/.config/dotnet-tools.json | 12 + src/FlaUI.WebDriver/Action.cs | 79 ++++ .../Controllers/ActionsController.cs | 339 +++++++++++++++ .../Controllers/ElementController.cs | 225 ++++++++++ .../Controllers/ExecuteController.cs | 84 ++++ .../Controllers/FindElementsController.cs | 216 ++++++++++ .../Controllers/ScreenshotController.cs | 81 ++++ .../Controllers/SessionController.cs | 182 ++++++++ .../Controllers/StatusController.cs | 20 + .../Controllers/TimeoutsController.cs | 48 +++ .../Controllers/WindowController.cs | 178 ++++++++ src/FlaUI.WebDriver/FlaUI.WebDriver.csproj | 23 + src/FlaUI.WebDriver/ISessionRepository.cs | 9 + src/FlaUI.WebDriver/InputState.cs | 14 + src/FlaUI.WebDriver/KnownElement.cs | 17 + src/FlaUI.WebDriver/KnownWindow.cs | 16 + src/FlaUI.WebDriver/Models/ActionItem.cs | 26 ++ src/FlaUI.WebDriver/Models/ActionSequence.cs | 12 + src/FlaUI.WebDriver/Models/ActionsRequest.cs | 9 + src/FlaUI.WebDriver/Models/Capabilities.cs | 10 + .../Models/CreateSessionRequest.cs | 9 + .../Models/CreateSessionResponse.cs | 10 + src/FlaUI.WebDriver/Models/ElementRect.cs | 10 + .../Models/ElementSendKeysRequest.cs | 7 + src/FlaUI.WebDriver/Models/ErrorResponse.cs | 13 + .../Models/ExecuteScriptRequest.cs | 10 + .../Models/FindElementRequest.cs | 8 + .../Models/FindElementResponse.cs | 13 + .../Models/ResponseWithValue.cs | 12 + src/FlaUI.WebDriver/Models/StatusResponse.cs | 8 + .../Models/SwitchWindowRequest.cs | 7 + src/FlaUI.WebDriver/Models/WindowRect.cs | 10 + src/FlaUI.WebDriver/Program.cs | 25 ++ .../Properties/launchSettings.json | 31 ++ src/FlaUI.WebDriver/Session.cs | 128 ++++++ src/FlaUI.WebDriver/SessionRepository.cs | 25 ++ src/FlaUI.WebDriver/Startup.cs | 65 +++ src/FlaUI.WebDriver/TimeoutsConfiguration.cs | 14 + src/FlaUI.WebDriver/Wait.cs | 32 ++ .../WebDriverExceptionFilter.cs | 31 ++ .../WebDriverResponseException.cs | 30 ++ src/FlaUI.WebDriver/WebDriverResult.cs | 37 ++ .../appsettings.Development.json | 8 + src/FlaUI.WebDriver/appsettings.json | 9 + src/TestApplications/WpfApplication/App.xaml | 9 + .../WpfApplication/App.xaml.cs | 14 + .../WpfApplication/AssemblyInfo.cs | 10 + .../WpfApplication/DataGridItem.cs | 25 ++ .../Infrastructure/ObservableObject.cs | 83 ++++ .../Infrastructure/RelayCommand.cs | 38 ++ .../WpfApplication/ListViewItem.cs | 19 + .../WpfApplication/MainViewModel.cs | 31 ++ .../WpfApplication/MainWindow.xaml | 199 +++++++++ .../WpfApplication/MainWindow.xaml.cs | 68 +++ .../WpfApplication/Window1.xaml | 13 + .../WpfApplication/Window1.xaml.cs | 27 ++ .../WpfApplication/WpfApplication.csproj | 12 + 74 files changed, 4553 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/FlaUI.WebDriver.UITests/ActionsTest.cs create mode 100644 src/FlaUI.WebDriver.UITests/ElementTests.cs create mode 100644 src/FlaUI.WebDriver.UITests/ExecuteTests.cs create mode 100644 src/FlaUI.WebDriver.UITests/FindElementsTests.cs create mode 100644 src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj create mode 100644 src/FlaUI.WebDriver.UITests/SessionTests.cs create mode 100644 src/FlaUI.WebDriver.UITests/TestUtil/ExtendedBy.cs create mode 100644 src/FlaUI.WebDriver.UITests/TestUtil/FlaUIDriverOptions.cs create mode 100644 src/FlaUI.WebDriver.UITests/TestUtil/TestAppProcess.cs create mode 100644 src/FlaUI.WebDriver.UITests/TimeoutsTests.cs create mode 100644 src/FlaUI.WebDriver.UITests/WebDriverFixture.cs create mode 100644 src/FlaUI.WebDriver.UITests/WindowTests.cs create mode 100644 src/FlaUI.WebDriver.sln create mode 100644 src/FlaUI.WebDriver/.config/dotnet-tools.json create mode 100644 src/FlaUI.WebDriver/Action.cs create mode 100644 src/FlaUI.WebDriver/Controllers/ActionsController.cs create mode 100644 src/FlaUI.WebDriver/Controllers/ElementController.cs create mode 100644 src/FlaUI.WebDriver/Controllers/ExecuteController.cs create mode 100644 src/FlaUI.WebDriver/Controllers/FindElementsController.cs create mode 100644 src/FlaUI.WebDriver/Controllers/ScreenshotController.cs create mode 100644 src/FlaUI.WebDriver/Controllers/SessionController.cs create mode 100644 src/FlaUI.WebDriver/Controllers/StatusController.cs create mode 100644 src/FlaUI.WebDriver/Controllers/TimeoutsController.cs create mode 100644 src/FlaUI.WebDriver/Controllers/WindowController.cs create mode 100644 src/FlaUI.WebDriver/FlaUI.WebDriver.csproj create mode 100644 src/FlaUI.WebDriver/ISessionRepository.cs create mode 100644 src/FlaUI.WebDriver/InputState.cs create mode 100644 src/FlaUI.WebDriver/KnownElement.cs create mode 100644 src/FlaUI.WebDriver/KnownWindow.cs create mode 100644 src/FlaUI.WebDriver/Models/ActionItem.cs create mode 100644 src/FlaUI.WebDriver/Models/ActionSequence.cs create mode 100644 src/FlaUI.WebDriver/Models/ActionsRequest.cs create mode 100644 src/FlaUI.WebDriver/Models/Capabilities.cs create mode 100644 src/FlaUI.WebDriver/Models/CreateSessionRequest.cs create mode 100644 src/FlaUI.WebDriver/Models/CreateSessionResponse.cs create mode 100644 src/FlaUI.WebDriver/Models/ElementRect.cs create mode 100644 src/FlaUI.WebDriver/Models/ElementSendKeysRequest.cs create mode 100644 src/FlaUI.WebDriver/Models/ErrorResponse.cs create mode 100644 src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs create mode 100644 src/FlaUI.WebDriver/Models/FindElementRequest.cs create mode 100644 src/FlaUI.WebDriver/Models/FindElementResponse.cs create mode 100644 src/FlaUI.WebDriver/Models/ResponseWithValue.cs create mode 100644 src/FlaUI.WebDriver/Models/StatusResponse.cs create mode 100644 src/FlaUI.WebDriver/Models/SwitchWindowRequest.cs create mode 100644 src/FlaUI.WebDriver/Models/WindowRect.cs create mode 100644 src/FlaUI.WebDriver/Program.cs create mode 100644 src/FlaUI.WebDriver/Properties/launchSettings.json create mode 100644 src/FlaUI.WebDriver/Session.cs create mode 100644 src/FlaUI.WebDriver/SessionRepository.cs create mode 100644 src/FlaUI.WebDriver/Startup.cs create mode 100644 src/FlaUI.WebDriver/TimeoutsConfiguration.cs create mode 100644 src/FlaUI.WebDriver/Wait.cs create mode 100644 src/FlaUI.WebDriver/WebDriverExceptionFilter.cs create mode 100644 src/FlaUI.WebDriver/WebDriverResponseException.cs create mode 100644 src/FlaUI.WebDriver/WebDriverResult.cs create mode 100644 src/FlaUI.WebDriver/appsettings.Development.json create mode 100644 src/FlaUI.WebDriver/appsettings.json create mode 100644 src/TestApplications/WpfApplication/App.xaml create mode 100644 src/TestApplications/WpfApplication/App.xaml.cs create mode 100644 src/TestApplications/WpfApplication/AssemblyInfo.cs create mode 100644 src/TestApplications/WpfApplication/DataGridItem.cs create mode 100644 src/TestApplications/WpfApplication/Infrastructure/ObservableObject.cs create mode 100644 src/TestApplications/WpfApplication/Infrastructure/RelayCommand.cs create mode 100644 src/TestApplications/WpfApplication/ListViewItem.cs create mode 100644 src/TestApplications/WpfApplication/MainViewModel.cs create mode 100644 src/TestApplications/WpfApplication/MainWindow.xaml create mode 100644 src/TestApplications/WpfApplication/MainWindow.xaml.cs create mode 100644 src/TestApplications/WpfApplication/Window1.xaml create mode 100644 src/TestApplications/WpfApplication/Window1.xaml.cs create mode 100644 src/TestApplications/WpfApplication/WpfApplication.csproj diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..28e27c3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,50 @@ +name: .NET + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + + runs-on: windows-latest + + env: + Configuration: Release + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 6.0.x + + - name: Restore dependencies + run: dotnet restore + working-directory: ./src + + - name: Build + run: dotnet build --no-restore --configuration $env:Configuration + working-directory: ./src + + - name: Test + run: dotnet test --no-build --configuration $env:Configuration --verbosity normal + working-directory: ./src + + # Unfortunately, --no-build does not seem to work when we publish a specific project, so we use --no-restore instead + - name: Publish + run: dotnet publish FlaUI.WebDriver/FlaUI.WebDriver.csproj --no-restore --configuration $env:Configuration --self-contained + working-directory: ./src + + - name: Upload build artifacts + uses: actions/upload-artifact@v3 + with: + name: package + path: ./src/FlaUI.WebDriver/bin/Release/win-x64/publish diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8dd4607 --- /dev/null +++ b/.gitignore @@ -0,0 +1,398 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..20f7cee --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 FlaUI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d016d2c --- /dev/null +++ b/README.md @@ -0,0 +1,240 @@ +# 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) + +FlaUI.WebDriver is a [W3C WebDriver2](https://www.w3.org/TR/webdriver2/) implementation using FlaUI's automation. It currently only supports UIA3. + +> [!IMPORTANT] +> This WebDriver implementation is EXPERIMENTAL. It is not feature complete and may not implement all features correctly. + +## Motivation + +- [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). +- 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. + +## Capabilities + +The following capabilities are supported: + +| Capability Name | Description | Example value | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| platformName | Must be set to `windows` (case-insensitive). | `windows` | +| appium:app | The path to the application. It is also possible to set app to `Root`. In such case the session will be invoked without any explicit target application. Either this capability, `appTopLevelWindow` or `appTopLevelWindowTitleMatch` must be provided on session startup. | `C:\Windows\System32\notepad.exe` | +| appium:appArguments | Application arguments string, for example `/?`. | +| appium:appTopLevelWindow | The hexadecimal handle of an existing application top level window to attach to, for example `0x12345` (should be of string type). Either this capability, `appTopLevelWindowTitleMatch` or `app` must be provided on session startup. | `0xC0B46` | +| appium:appTopLevelWindowTitleMatch | The title of an existing application top level window to attach to, for example `My App Window Title` (should be of string type). Either this capability, `appTopLevelWindow` or `app` must be provided on session startup. | `My App Window Title` or `My App Window Title - .*` | + +## Getting Started + +This driver currenlty is only available by building from source. Start the web driver service with: + +```PowerShell +./FlaUI.WebDriver.exe --urls=http://localhost:4723/ +``` + +After it has started, it can be used via WebDriver clients such as for example: + +- [Selenium.WebDriver](https://www.nuget.org/packages/Selenium.WebDriver) +- [WebdriverIO](https://www.npmjs.com/package/webdriverio) + +Using the [Selenium.WebDriver](https://www.nuget.org/packages/Selenium.WebDriver) C# client: + +```C# +using OpenQA.Selenium; + +public class FlaUIDriverOptions : DriverOptions +{ + public static FlaUIDriverOptions App(string path) + { + var options = new FlaUIDriverOptions() + { + PlatformName = "windows" + }; + options.AddAdditionalOption("appium:app", path); + return options; + } + + public override ICapabilities ToCapabilities() + { + return GenerateDesiredCapabilities(true); + } +} + +var driver = new RemoteWebDriver(new Uri("http://localhost:4723"), FlaUIDriverOptions.App("C:\\YourApp.exe")) +``` + +Using the [WebdriverIO](https://www.npmjs.com/package/webdriverio) JavaScript client: + +```JavaScript +import { remote } from 'webdriverio' + +const driver = await remote({ + capabilities: { + platformName: 'windows', + 'appium:app': 'C:\\YourApp.exe' + } +}); +``` + +## Selectors + +On Windows, the recommended selectors, in order of reliability are: + +| Selector | Locator strategy keyword | Supported? | +| -------------------------- | ------------------------ | ------------------------------------------------------------------------------------ | +| Automation ID | `"accessibility id"` | :white_check_mark: | +| Name | `"name"` | :white_check_mark: | +| Class name | `"class name"` | :white_check_mark: | +| Link text selector | `"link text"` | :white_check_mark: | +| Partial link text selector | `"partial link text"` | :white_check_mark: | +| Tag name | `"tag name"` | :white_check_mark: | +| XPath selector | `"xpath"` | | +| CSS selector | `"css selector"` | Only ID, class or `name` attribute selectors. IDs are interpreted as automation IDs. | + +Using the Selenium C# client, the selectors are: + +```C# +driver.FindElement(By.Id("TextBox")).Click(); // Matches by automation ID +driver.FindElement(By.Name("TextBox")).Click(); +driver.FindElement(By.ClassName("TextBox")).Click(); +driver.FindElement(By.LinkText("Button")).Click(); +driver.FindElement(By.PartialLinkText("Button")).Click(); +driver.FindElement(By.TagName("RadioButton")).Click(); +``` + +Using the WebdriverIO JavaScript client (see [WebdriverIO Selectors guide](https://webdriver.io/docs/selectors): + +```JavaScript +await driver.$('~automationId').click(); +await driver.$('[name="Name"]').click(); +await driver.$('.TextBox').click(); +await driver.$('=Button').click(); +await driver.$('*=Button').click(); +await driver.$('').click(); +``` + +## Windows + +The driver supports switching windows. The behavior of windows is as following (identical to behavior of e.g. the Chrome driver): + +- By default, the window is the window that the application was started with. +- The window does not change if the app/user opens another window, also not if that window happens to be on the foreground. +- ~~All open window handles from the same app process (same process ID in Windows) can be retrieved.~~ Currently only the main window and modal windows are returned when getting window handles. See issue below. +- Other processes spawned by the app that open windows are not visible as window handles. + Those can be automated by starting a new driver session with e.g. the `appium:appTopLevelWindow` capability. +- Closing a window does not automatically switch the window handle. + That means that after closing a window, most commands will return an error "no such window" until the window is switched. +- Switching to a window will set that window in the foreground. + +> [!IMPORTANT] +> Currently only the main window and modal windows are returned when getting window handles. See + +## Running scripts + +The driver supports PowerShell commands. + +Using the Selenium C# client: + +```C# +var result = driver.ExecuteScript("powerShell", new Dictionary { ["command"] = "1+1" }); +``` + +Using the WebdriverIO JavaScript client: + +```JavaScript +const result = driver.executeScript("powerShell", [{ command: `1+1` }]); +``` + +## Supported WebDriver Commands + +| Method | URI Template | Command | Implemented | +| ------ | -------------------------------------------------------------- | ------------------------------ | ------------------ | +| POST | /session | New Session | :white_check_mark: | +| DELETE | /session/{session id} | Delete Session | :white_check_mark: | +| GET | /status | Status | :white_check_mark: | +| GET | /session/{session id}/timeouts | Get Timeouts | :white_check_mark: | +| POST | /session/{session id}/timeouts | Set Timeouts | :white_check_mark: | +| POST | /session/{session id}/url | Navigate To | N/A | +| GET | /session/{session id}/url | Get Current URL | N/A | +| POST | /session/{session id}/back | Back | N/A | +| POST | /session/{session id}/forward | Forward | N/A | +| POST | /session/{session id}/refresh | Refresh | N/A | +| GET | /session/{session id}/title | Get Title | :white_check_mark: | +| GET | /session/{session id}/window | Get Window Handle | :white_check_mark: | +| DELETE | /session/{session id}/window | Close Window | :white_check_mark: | +| POST | /session/{session id}/window | Switch To Window | :white_check_mark: | +| GET | /session/{session id}/window/handles | Get Window Handles | :white_check_mark: | +| POST | /session/{session id}/window/new | New Window | | +| POST | /session/{session id}/frame | Switch To Frame | N/A | +| POST | /session/{session id}/frame/parent | Switch To Parent Frame | N/A | +| GET | /session/{session id}/window/rect | Get Window Rect | :white_check_mark: | +| POST | /session/{session id}/window/rect | Set Window Rect | :white_check_mark: | +| POST | /session/{session id}/window/maximize | Maximize Window | | +| POST | /session/{session id}/window/minimize | Minimize Window | | +| POST | /session/{session id}/window/fullscreen | Fullscreen Window | | +| GET | /session/{session id}/element/active | Get Active Element | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/shadow | Get Element Shadow Root | N/A | +| POST | /session/{session id}/element | Find Element | :white_check_mark: | +| POST | /session/{session id}/elements | Find Elements | :white_check_mark: | +| POST | /session/{session id}/element/{element id}/element | Find Element From Element | :white_check_mark: | +| POST | /session/{session id}/element/{element id}/elements | Find Elements From Element | :white_check_mark: | +| POST | /session/{session id}/shadow/{shadow id}/element | Find Element From Shadow Root | N/A | +| POST | /session/{session id}/shadow/{shadow id}/elements | Find Elements From Shadow Root | N/A | +| GET | /session/{session id}/element/{element id}/selected | Is Element Selected | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/attribute/{name} | Get Element Attribute | | +| GET | /session/{session id}/element/{element id}/property/{name} | Get Element Property | | +| GET | /session/{session id}/element/{element id}/css/{property name} | Get Element CSS Value | N/A | +| GET | /session/{session id}/element/{element id}/text | Get Element Text | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/name | Get Element Tag Name | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/rect | Get Element Rect | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/enabled | Is Element Enabled | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/computedrole | Get Computed Role | | +| GET | /session/{session id}/element/{element id}/computedlabel | Get Computed Label | | +| POST | /session/{session id}/element/{element id}/click | Element Click | :white_check_mark: | +| POST | /session/{session id}/element/{element id}/clear | Element Clear | :white_check_mark: | +| POST | /session/{session id}/element/{element id}/value | Element Send Keys | :white_check_mark: | +| GET | /session/{session id}/source | Get Page Source | N/A | +| POST | /session/{session id}/execute/sync | Execute Script | :white_check_mark: | +| POST | /session/{session id}/execute/async | Execute Async Script | | +| GET | /session/{session id}/cookie | Get All Cookies | N/A | +| GET | /session/{session id}/cookie/{name} | Get Named Cookie | N/A | +| POST | /session/{session id}/cookie | Add Cookie | N/A | +| DELETE | /session/{session id}/cookie/{name} | Delete Cookie | N/A | +| DELETE | /session/{session id}/cookie | Delete All Cookies | N/A | +| POST | /session/{session id}/actions | Perform Actions | :white_check_mark: | +| DELETE | /session/{session id}/actions | Release Actions | :white_check_mark: | +| POST | /session/{session id}/alert/dismiss | Dismiss Alert | | +| POST | /session/{session id}/alert/accept | Accept Alert | | +| GET | /session/{session id}/alert/text | Get Alert Text | | +| POST | /session/{session id}/alert/text | Send Alert Text | | +| GET | /session/{session id}/screenshot | Take Screenshot | :white_check_mark: | +| GET | /session/{session id}/element/{element id}/screenshot | Take Element Screenshot | :white_check_mark: | +| POST | /session/{session id}/print | Print Page | | + +### WebDriver Interpretation + +There is an interpretation to use the WebDriver specification to drive native automation. Appium does not seem to describe that interpretation and leaves it up to the implementer as well. Therefore we describe it here: + +| WebDriver term | Interpretation | +| ---------------------------------- | ------------------------------------------------------------------------------------------------- | +| browser | The Windows OS on which the FlaUI.WebDriver instance is running | +| top-level browsing contexts | Any window of the app under test (modal windows too) | +| current top-level browsing context | The current selected window of the app under test | +| browsing contexts | Any window of the app under test (equal to "top-level browsing contexts") | +| current browsing context | The current selected window of the app under test (equal to "current top-level browsing context") | +| window | Any window of the app under test (modal windows too) | +| frame | Not implemented - frames are only relevant for web browsers | +| shadow root | Not implemented - shadow DOM is only relevant for web browsers | +| cookie | Not implemented - cookies are only relevant for web browsers | +| tag name | Control type in Windows | + +## Next Steps + +Possible next steps for this project: + +- Distribute as [Appium driver](http://appium.io/docs/en/2.1/ecosystem/build-drivers/) diff --git a/src/FlaUI.WebDriver.UITests/ActionsTest.cs b/src/FlaUI.WebDriver.UITests/ActionsTest.cs new file mode 100644 index 0000000..7eef541 --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/ActionsTest.cs @@ -0,0 +1,40 @@ +using FlaUI.WebDriver.UITests.TestUtil; +using NUnit.Framework; +using OpenQA.Selenium; +using OpenQA.Selenium.Interactions; +using OpenQA.Selenium.Remote; + +namespace FlaUI.WebDriver.UITests +{ + [TestFixture] + public class ActionsTests + { + [Test] + public void PerformActions_KeyDownKeyUp_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).KeyDown(Keys.Backspace).KeyUp(Keys.Backspace).KeyUp(Keys.Control).Perform(); + + Assert.That(driver.SwitchTo().ActiveElement().Text, Is.EqualTo("Test ")); + } + + [Test] + public void ReleaseActions_Default_ReleasesKeys() + { + 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).Perform(); + + driver.ResetInputState(); + + new Actions(driver).KeyDown(Keys.Backspace).KeyUp(Keys.Backspace).Perform(); + Assert.That(driver.SwitchTo().ActiveElement().Text, Is.EqualTo("Test TextBo")); + } + } +} diff --git a/src/FlaUI.WebDriver.UITests/ElementTests.cs b/src/FlaUI.WebDriver.UITests/ElementTests.cs new file mode 100644 index 0000000..7088640 --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/ElementTests.cs @@ -0,0 +1,214 @@ +using FlaUI.WebDriver.UITests.TestUtil; +using NUnit.Framework; +using OpenQA.Selenium; +using OpenQA.Selenium.Remote; +using System; + +namespace FlaUI.WebDriver.UITests +{ + [TestFixture] + public class ElementTests + { + [Test] + public void GetText_Label_ReturnsRenderedText() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("Label")); + + var text = element.Text; + + Assert.That(text, Is.EqualTo("Menu Item Checked")); + } + + [Test] + public void GetText_TextBox_ReturnsTextBoxText() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + var text = element.Text; + + Assert.That(text, Is.EqualTo("Test TextBox")); + } + + [Test] + public void GetText_Button_ReturnsButtonText() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("InvokableButton")); + + var text = element.Text; + + Assert.That(text, Is.EqualTo("Invoke me!")); + } + + [Test] + public void Selected_NotCheckedCheckbox_ReturnsFalse() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("SimpleCheckBox")); + + var selected = element.Selected; + + Assert.That(selected, Is.False); + } + + [Test] + public void Selected_CheckedCheckbox_ReturnsTrue() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("SimpleCheckBox")); + element.Click(); + + var selected = element.Selected; + + Assert.That(selected, Is.True); + } + + [Test] + public void Selected_NotCheckedRadioButton_ReturnsFalse() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("RadioButton1")); + + var selected = element.Selected; + + Assert.That(selected, Is.False); + } + + [Test] + public void Selected_CheckedRadioButton_ReturnsTrue() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("RadioButton1")); + element.Click(); + + var selected = element.Selected; + + Assert.That(selected, Is.True); + } + + [Test] + public void SendKeys_Default_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + element.SendKeys("Hello World!"); + + Assert.That(element.Text, Is.EqualTo("Hello World!")); + } + + [Test] + public void Clear_Default_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + element.Clear(); + + Assert.That(element.Text, Is.EqualTo("")); + } + + [Test] + public void Click_Default_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("InvokableButton")); + + element.Click(); + + Assert.That(element.Text, Is.EqualTo("Invoked!")); + } + + [Test] + 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 location = element.Location; + var size = element.Size; + + var windowLocation = driver.Manage().Window.Position; + Assert.That(location.X, Is.InRange(windowLocation.X + 253, windowLocation.X + 257)); + Assert.That(location.Y, Is.InRange(windowLocation.Y + 132, windowLocation.Y + 136)); + Assert.That(size.Width, Is.EqualTo(120)); + Assert.That(size.Height, Is.EqualTo(22)); + } + + [TestCase("TextBox")] + [TestCase("PasswordBox")] + [TestCase("EditableCombo")] + [TestCase("NonEditableCombo")] + [TestCase("ListBox")] + [TestCase("SimpleCheckBox")] + [TestCase("ThreeStateCheckBox")] + [TestCase("RadioButton1")] + [TestCase("RadioButton2")] + [TestCase("Slider")] + [TestCase("InvokableButton")] + [TestCase("PopupToggleButton1")] + [TestCase("Label")] + public void GetElementEnabled_Enabled_ReturnsTrue(string elementAccessibilityId) + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId(elementAccessibilityId)); + + var enabled = element.Enabled; + + Assert.That(enabled, Is.True); + } + + [TestCase("TextBox")] + [TestCase("PasswordBox")] + [TestCase("EditableCombo")] + [TestCase("NonEditableCombo")] + [TestCase("ListBox")] + [TestCase("SimpleCheckBox")] + [TestCase("ThreeStateCheckBox")] + [TestCase("RadioButton1")] + [TestCase("RadioButton2")] + [TestCase("Slider")] + [TestCase("InvokableButton")] + [TestCase("PopupToggleButton1")] + [TestCase("Label")] + public void GetElementEnabled_Disabled_ReturnsFalse(string elementAccessibilityId) + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + driver.FindElement(ExtendedBy.NonCssName("_Edit")).Click(); + driver.FindElement(ExtendedBy.NonCssName("Disable Form")).Click(); + var element = driver.FindElement(ExtendedBy.AccessibilityId(elementAccessibilityId)); + + var enabled = element.Enabled; + + Assert.That(enabled, Is.False); + } + + [Test] + public void ActiveElement_Default_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("InvokableButton")); + element.Click(); + + var activeElement = driver.SwitchTo().ActiveElement(); + + Assert.That(activeElement.Text, Is.EqualTo("Invoked!")); + } + } +} diff --git a/src/FlaUI.WebDriver.UITests/ExecuteTests.cs b/src/FlaUI.WebDriver.UITests/ExecuteTests.cs new file mode 100644 index 0000000..43c9a93 --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/ExecuteTests.cs @@ -0,0 +1,22 @@ +using FlaUI.WebDriver.UITests.TestUtil; +using NUnit.Framework; +using OpenQA.Selenium.Remote; +using System.Collections.Generic; + +namespace FlaUI.WebDriver.UITests +{ + [TestFixture] + public class ExecuteTests + { + [Test] + public void ExecuteScript_PowerShellCommand_ReturnsResult() + { + var driverOptions = FlaUIDriverOptions.RootApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var executeScriptResult = driver.ExecuteScript("powerShell", new Dictionary { ["command"] = "1+1" }); + + Assert.That(executeScriptResult, Is.EqualTo("2\r\n")); + } + } +} diff --git a/src/FlaUI.WebDriver.UITests/FindElementsTests.cs b/src/FlaUI.WebDriver.UITests/FindElementsTests.cs new file mode 100644 index 0000000..f84dce2 --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/FindElementsTests.cs @@ -0,0 +1,234 @@ +using FlaUI.WebDriver.UITests.TestUtil; +using NUnit.Framework; +using OpenQA.Selenium; +using OpenQA.Selenium.Remote; +using System.Linq; + +namespace FlaUI.WebDriver.UITests +{ + public class FindElementsTests + { + [Test] + public void FindElement_ByAccessibilityId_ReturnsElement() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + Assert.That(element, Is.Not.Null); + } + + [Test] + public void FindElement_ById_ReturnsElement() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var element = driver.FindElement(By.Id("TextBox")); + + Assert.That(element, Is.Not.Null); + } + + [Test] + public void FindElement_ByName_ReturnsElement() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var element = driver.FindElement(By.Name("Test Label")); + + Assert.That(element, Is.Not.Null); + } + + [Test] + public void FindElement_ByNativeName_ReturnsElement() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var element = driver.FindElement(ExtendedBy.NonCssName("Test Label")); + + Assert.That(element, Is.Not.Null); + } + + [Test] + public void FindElement_ByNativeClassName_ReturnsElement() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var element = driver.FindElement(ExtendedBy.NonCssClassName("TextBlock")); + + Assert.That(element, Is.Not.Null); + } + + [Test] + public void FindElement_ByClassName_ReturnsElement() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var element = driver.FindElement(By.ClassName("TextBlock")); + + Assert.That(element, Is.Not.Null); + } + + [Test] + public void FindElement_ByLinkText_ReturnsElement() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var element = driver.FindElement(By.LinkText("Invoke me!")); + + Assert.That(element, Is.Not.Null); + } + + [Test] + public void FindElement_ByPartialLinkText_ReturnsElement() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var element = driver.FindElement(By.PartialLinkText("Invoke")); + + Assert.That(element, Is.Not.Null); + } + + [Test] + public void FindElement_ByTagName_ReturnsElement() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var element = driver.FindElement(By.TagName("Text")); + + Assert.That(element, Is.Not.Null); + } + + [Test] + public void FindElement_NotExisting_TimesOut() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var findElement = () => driver.FindElement(ExtendedBy.AccessibilityId("NotExisting")); + + Assert.That(findElement, Throws.TypeOf()); + } + + [Test] + public void FindElementFromElement_InsideElement_ReturnsElement() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var fromElement = driver.FindElement(By.TagName("Tab")); + + var foundElement = fromElement.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + Assert.That(foundElement, Is.Not.Null); + } + + [Test] + public void FindElementFromElement_OutsideElement_TimesOut() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var fromElement = driver.FindElement(ExtendedBy.AccessibilityId("ListBox")); + + var findElement = () => fromElement.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + Assert.That(findElement, Throws.TypeOf()); + } + + [Test] + public void FindElementsFromElement_InsideElement_ReturnsElement() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var fromElement = driver.FindElement(By.TagName("Tab")); + + var foundElements = fromElement.FindElements(By.TagName("RadioButton")); + + Assert.That(foundElements, Has.Count.EqualTo(2)); + } + + [Test] + public void FindElementsFromElement_OutsideElement_TimesOut() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var fromElement = driver.FindElement(ExtendedBy.AccessibilityId("ListBox")); + + var findElements = () => fromElement.FindElements(ExtendedBy.AccessibilityId("TextBox")); + + Assert.That(findElements, Throws.TypeOf()); + } + + [Test] + public void FindElements_Default_ReturnsElements() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var elements = driver.FindElements(By.TagName("RadioButton")); + + Assert.That(elements, Has.Count.EqualTo(2)); + } + + [Test] + public void FindElements_NotExisting_TimesOut() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var findElements = () => driver.FindElements(ExtendedBy.AccessibilityId("NotExisting")); + + Assert.That(findElements, Throws.TypeOf()); + } + + [Test] + public void FindElement_InOtherWindow_TimesOut() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + OpenAndSwitchToAnotherWindow(driver); + + var findElement = () => driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + Assert.That(findElement, Throws.TypeOf()); + var elementInNewWindow = driver.FindElement(ExtendedBy.AccessibilityId("Window1TextBox")); + Assert.That(elementInNewWindow, Is.Not.Null); + } + + [Test] + public void FindElements_InOtherWindow_TimesOut() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + OpenAndSwitchToAnotherWindow(driver); + + var findElements = () => driver.FindElements(ExtendedBy.AccessibilityId("TextBox")); + + Assert.That(findElements, Throws.TypeOf()); + var elementsInNewWindow = driver.FindElements(ExtendedBy.AccessibilityId("Window1TextBox")); + Assert.That(elementsInNewWindow, Has.Count.EqualTo(1)); + } + + private static void OpenAndSwitchToAnotherWindow(RemoteWebDriver driver) + { + var initialWindowHandles = new[] { driver.CurrentWindowHandle }; + OpenAnotherWindow(driver); + var windowHandlesAfterOpen = driver.WindowHandles; + var newWindowHandle = windowHandlesAfterOpen.Except(initialWindowHandles).Single(); + driver.SwitchTo().Window(newWindowHandle); + } + + private static void OpenAnotherWindow(RemoteWebDriver driver) + { + driver.FindElement(ExtendedBy.NonCssName("_File")).Click(); + driver.FindElement(ExtendedBy.NonCssName("Open Window 1")).Click(); + } + } +} diff --git a/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj b/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj new file mode 100644 index 0000000..8206e73 --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj @@ -0,0 +1,26 @@ + + + + net6.0-windows + + false + preview + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/src/FlaUI.WebDriver.UITests/SessionTests.cs b/src/FlaUI.WebDriver.UITests/SessionTests.cs new file mode 100644 index 0000000..b925a97 --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/SessionTests.cs @@ -0,0 +1,139 @@ +using NUnit.Framework; +using OpenQA.Selenium.Remote; +using FlaUI.WebDriver.UITests.TestUtil; +using OpenQA.Selenium; +using System; + +namespace FlaUI.WebDriver.UITests +{ + [TestFixture] + public class SessionTests + { + [Test] + public void NewSession_CapabilitiesDoNotMatch_ReturnsError() + { + var emptyOptions = FlaUIDriverOptions.Empty(); + + var newSession = () => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, emptyOptions); + + Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Required capabilities did not match. Capability `platformName` with value `windows` is required (SessionNotCreated)")); + } + + [Test] + public void NewSession_App_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var title = driver.Title; + + Assert.That(title, Is.EqualTo("FlaUI WPF Test App")); + } + + [Test] + public void NewSession_AppNotExists_ReturnsError() + { + var driverOptions = FlaUIDriverOptions.App("C:\\NotExisting.exe"); + + var newSession = () => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Starting app 'C:\\NotExisting.exe' with arguments '' threw an exception: An error occurred trying to start process 'C:\\NotExisting.exe' with working directory '.'. The system cannot find the file specified.")); + } + + [Test] + public void NewSession_AppTopLevelWindow_IsSupported() + { + 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); + + var title = driver.Title; + + Assert.That(title, Is.EqualTo("FlaUI WPF Test App")); + } + + [Test] + public void NewSession_AppTopLevelWindowNotFound_ReturnsError() + { + 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); + + var title = driver.Title; + + Assert.That(title, Is.EqualTo("FlaUI WPF Test App")); + } + + [Test] + public void NewSession_AppTopLevelWindowZero_ReturnsError() + { + var driverOptions = FlaUIDriverOptions.AppTopLevelWindow("0x0"); + + var newSession = () => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Capability appium:appTopLevelWindow '0x0' should not be zero")); + } + + [Ignore("Sometimes multiple processes are left open")] + [TestCase("FlaUI WPF Test App")] + [TestCase("FlaUI WPF .*")] + public void NewSession_AppTopLevelWindowTitleMatch_IsSupported(string match) + { + using var testAppProcess = new TestAppProcess(); + var driverOptions = FlaUIDriverOptions.AppTopLevelWindowTitleMatch(match); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var title = driver.Title; + + Assert.That(title, Is.EqualTo("FlaUI WPF Test App")); + } + + [Test, Ignore("Sometimes multiple processes are left open")] + public void NewSession_MultipleMatchingAppTopLevelWindowTitleMatch_ReturnsError() + { + using var testAppProcess = new TestAppProcess(); + using var testAppProcess1 = new TestAppProcess(); + var driverOptions = FlaUIDriverOptions.AppTopLevelWindowTitleMatch("FlaUI WPF Test App"); + + var newSession = () => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Found multiple (2) processes with main window title matching 'FlaUI WPF Test App'")); + } + + [Test] + public void NewSession_AppTopLevelWindowTitleMatchNotFound_ReturnsError() + { + using var testAppProcess = new TestAppProcess(); + using var testAppProcess1 = new TestAppProcess(); + var driverOptions = FlaUIDriverOptions.AppTopLevelWindowTitleMatch("FlaUI Not Existing"); + + var newSession = () => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Process with main window title matching 'FlaUI Not Existing' could not be found")); + } + + [TestCase("")] + [TestCase("FlaUI")] + public void NewSession_AppTopLevelWindowInvalidFormat_ReturnsError(string appTopLevelWindowString) + { + var driverOptions = FlaUIDriverOptions.AppTopLevelWindow(appTopLevelWindowString); + + var newSession = () => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo($"Capability appium:appTopLevelWindow '{appTopLevelWindowString}' is not a valid hexadecimal string")); + } + + [Test] + public void GetTitle_Default_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var title = driver.Title; + + Assert.That(title, Is.EqualTo("FlaUI WPF Test App")); + } + } +} diff --git a/src/FlaUI.WebDriver.UITests/TestUtil/ExtendedBy.cs b/src/FlaUI.WebDriver.UITests/TestUtil/ExtendedBy.cs new file mode 100644 index 0000000..276da0b --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/TestUtil/ExtendedBy.cs @@ -0,0 +1,17 @@ +using OpenQA.Selenium; + +namespace FlaUI.WebDriver.UITests.TestUtil +{ + internal class ExtendedBy : By + { + public ExtendedBy(string mechanism, string criteria) : base(mechanism, criteria) + { + } + + public static ExtendedBy AccessibilityId(string accessibilityId) => new ExtendedBy("accessibility id", accessibilityId); + + public static ExtendedBy NonCssName(string name) => new ExtendedBy("name", name); + + public static ExtendedBy NonCssClassName(string name) => new ExtendedBy("class name", name); + } +} diff --git a/src/FlaUI.WebDriver.UITests/TestUtil/FlaUIDriverOptions.cs b/src/FlaUI.WebDriver.UITests/TestUtil/FlaUIDriverOptions.cs new file mode 100644 index 0000000..26e2f7e --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/TestUtil/FlaUIDriverOptions.cs @@ -0,0 +1,54 @@ +using OpenQA.Selenium; +using System; + +namespace FlaUI.WebDriver.UITests.TestUtil +{ + internal class FlaUIDriverOptions : DriverOptions + { + public const string TestAppPath = "..\\..\\..\\TestApplications\\WpfApplication\\bin\\Release\\WpfApplication.exe"; + + public override ICapabilities ToCapabilities() + { + return GenerateDesiredCapabilities(true); + } + + public static FlaUIDriverOptions TestApp() => App(TestAppPath); + + public static DriverOptions RootApp() => App("Root"); + + public static FlaUIDriverOptions App(string path) + { + var options = new FlaUIDriverOptions() + { + PlatformName = "Windows" + }; + options.AddAdditionalOption("appium:app", path); + return options; + } + + public static DriverOptions AppTopLevelWindow(string windowHandle) + { + var options = new FlaUIDriverOptions() + { + PlatformName = "Windows" + }; + options.AddAdditionalOption("appium:appTopLevelWindow", windowHandle); + return options; + } + + public static DriverOptions AppTopLevelWindowTitleMatch(string match) + { + var options = new FlaUIDriverOptions() + { + PlatformName = "Windows" + }; + options.AddAdditionalOption("appium:appTopLevelWindowTitleMatch", match); + return options; + } + + public static DriverOptions Empty() + { + return new FlaUIDriverOptions(); + } + } +} diff --git a/src/FlaUI.WebDriver.UITests/TestUtil/TestAppProcess.cs b/src/FlaUI.WebDriver.UITests/TestUtil/TestAppProcess.cs new file mode 100644 index 0000000..78f2058 --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/TestUtil/TestAppProcess.cs @@ -0,0 +1,28 @@ +using System; +using System.Diagnostics; + +namespace FlaUI.WebDriver.UITests.TestUtil +{ + public class TestAppProcess : IDisposable + { + private const string TestAppPath = "..\\..\\..\\..\\TestApplications\\WpfApplication\\bin\\Release\\WpfApplication.exe"; + private readonly Process _process; + + public TestAppProcess() + { + _process = Process.Start(TestAppPath); + while (_process.MainWindowHandle == IntPtr.Zero) + { + System.Threading.Thread.Sleep(100); + } + } + + public Process Process => _process; + + public void Dispose() + { + _process.Kill(); + _process.Dispose(); + } + } +} diff --git a/src/FlaUI.WebDriver.UITests/TimeoutsTests.cs b/src/FlaUI.WebDriver.UITests/TimeoutsTests.cs new file mode 100644 index 0000000..df8f804 --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/TimeoutsTests.cs @@ -0,0 +1,35 @@ +using FlaUI.WebDriver.UITests.TestUtil; +using NUnit.Framework; +using OpenQA.Selenium.Remote; +using System; + +namespace FlaUI.WebDriver.UITests +{ + [TestFixture] + public class TimeoutsTests + { + [Test] + public void SetTimeouts_Default_IsSupported() + { + var driverOptions = FlaUIDriverOptions.RootApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(3); + + Assert.That(driver.Manage().Timeouts().ImplicitWait, Is.EqualTo(TimeSpan.FromSeconds(3))); + } + + [Test] + public void GetTimeouts_Default_ReturnsDefaultTimeouts() + { + var driverOptions = FlaUIDriverOptions.RootApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var timeouts = driver.Manage().Timeouts(); + + Assert.That(timeouts.ImplicitWait, Is.EqualTo(TimeSpan.Zero)); + Assert.That(timeouts.PageLoad, Is.EqualTo(TimeSpan.FromMilliseconds(300000))); + Assert.That(timeouts.AsynchronousJavaScript, Is.EqualTo(TimeSpan.FromMilliseconds(30000))); + } + } +} diff --git a/src/FlaUI.WebDriver.UITests/WebDriverFixture.cs b/src/FlaUI.WebDriver.UITests/WebDriverFixture.cs new file mode 100644 index 0000000..e37eaca --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/WebDriverFixture.cs @@ -0,0 +1,71 @@ +using NUnit.Framework; +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; + +namespace FlaUI.WebDriver.UITests +{ + [SetUpFixture] + public class WebDriverFixture + { + public static readonly Uri WebDriverUrl = new Uri("http://localhost:9723/"); + + private Process _webDriverProcess; + + [OneTimeSetUp] + public void Setup() + { + string assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + Directory.SetCurrentDirectory(assemblyDir); + + var assemblyConfigurationAttribute = Assembly.GetExecutingAssembly().GetCustomAttribute(); + var buildConfigurationName = assemblyConfigurationAttribute?.Configuration; + + var webDriverPath = $"..\\..\\..\\..\\FlaUI.WebDriver\\bin\\{buildConfigurationName}\\FlaUI.WebDriver.exe"; + var webDriverArguments = $"--urls={WebDriverUrl}"; + var webDriverProcessStartInfo = new ProcessStartInfo(webDriverPath, webDriverArguments) + { + RedirectStandardError = true, + RedirectStandardOutput = true + }; + _webDriverProcess = new Process() + { + StartInfo = webDriverProcessStartInfo + }; + TestContext.Progress.WriteLine($"Attempting to start web driver with command {webDriverPath} {webDriverArguments}"); + _webDriverProcess.Start(); + + System.Threading.Thread.Sleep(5000); + if (_webDriverProcess.HasExited) + { + var error = _webDriverProcess.StandardError.ReadToEnd(); + if (error.Contains("address already in use")) + { + // For manual debugging of FlaUI.WebDriver it is nice to be able to start it separately + TestContext.Progress.WriteLine("Using already running web driver instead"); + return; + } + throw new Exception($"Could not start WebDriver: {error}"); + } + } + + [OneTimeTearDown] + public void Dispose() + { + if (_webDriverProcess.HasExited) + { + var error = _webDriverProcess.StandardError.ReadToEnd(); + Console.Error.WriteLine($"WebDriver has exited before end of the test: {error}"); + } + else + { + TestContext.Progress.WriteLine("Killing web driver"); + _webDriverProcess.Kill(true); + } + TestContext.Progress.WriteLine("Disposing web driver"); + _webDriverProcess.Dispose(); + TestContext.Progress.WriteLine("Finished disposing web driver"); + } + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver.UITests/WindowTests.cs b/src/FlaUI.WebDriver.UITests/WindowTests.cs new file mode 100644 index 0000000..ee803fb --- /dev/null +++ b/src/FlaUI.WebDriver.UITests/WindowTests.cs @@ -0,0 +1,163 @@ +using FlaUI.WebDriver.UITests.TestUtil; +using NUnit.Framework; +using OpenQA.Selenium; +using OpenQA.Selenium.Remote; +using System.Linq; + +namespace FlaUI.WebDriver.UITests +{ + [TestFixture] + public class WindowTests + { + [Test] + public void GetWindowRect_Default_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + var position = driver.Manage().Window.Position; + var size = driver.Manage().Window.Size; + + Assert.That(position.X, Is.GreaterThanOrEqualTo(0)); + Assert.That(position.Y, Is.GreaterThanOrEqualTo(0)); + Assert.That(size.Width, Is.InRange(629, 630)); + Assert.That(size.Height, Is.InRange(515, 516)); + } + + [Test] + public void SetWindowRect_Position_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + driver.Manage().Window.Position = new System.Drawing.Point(100, 100); + + var newPosition = driver.Manage().Window.Position; + Assert.That(newPosition.X, Is.EqualTo(100)); + Assert.That(newPosition.Y, Is.EqualTo(100)); + } + + [Test] + public void SetWindowRect_Size_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + driver.Manage().Window.Size = new System.Drawing.Size(650, 650); + + var newSize = driver.Manage().Window.Size; + Assert.That(newSize.Width, Is.EqualTo(650)); + Assert.That(newSize.Height, Is.EqualTo(650)); + } + + [Test] + public void GetWindowHandle_AppOpensNewWindow_DoesNotSwitchToNewWindow() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var initialWindowHandle = driver.CurrentWindowHandle; + OpenAnotherWindow(driver); + + var windowHandleAfterOpenCloseOtherWindow = driver.CurrentWindowHandle; + + Assert.That(windowHandleAfterOpenCloseOtherWindow, Is.EqualTo(initialWindowHandle)); + } + + [Test, Ignore("https://github.com/FlaUI/FlaUI/issues/596")] + public void GetWindowHandle_WindowClosed_ReturnsNoSuchWindow() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + OpenAndSwitchToNewWindow(driver); + driver.Close(); + + var getWindowHandle = () => driver.CurrentWindowHandle; + + Assert.That(getWindowHandle, Throws.TypeOf().With.Message.EqualTo("Test")); + } + + [Test, Ignore("https://github.com/FlaUI/FlaUI/issues/596")] + public void GetWindowHandles_Default_ReturnsUniqueHandlePerWindow() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var initialWindowHandle = driver.CurrentWindowHandle; + OpenAnotherWindow(driver); + + var windowHandles = driver.WindowHandles; + + Assert.That(windowHandles, Has.Count.EqualTo(2)); + Assert.That(windowHandles[1], Is.Not.EqualTo(windowHandles[0])); + } + + [Test] + public void Close_Default_DoesNotChangeWindowHandle() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var initialWindowHandle = driver.CurrentWindowHandle; + OpenAnotherWindow(driver); + + driver.Close(); + + // See https://www.w3.org/TR/webdriver2/#get-window-handle: Should throw if window does not exist + Assert.Throws(() => _ = driver.CurrentWindowHandle); + } + + [Test] + public void Close_LastWindow_EndsSession() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + driver.Close(); + + var currentWindowHandle = () => driver.CurrentWindowHandle; + Assert.That(currentWindowHandle, Throws.TypeOf().With.Message.StartsWith("No active session")); + } + + [Test] + public void SwitchWindow_Default_SwitchesToWindow() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var initialWindowHandle = driver.CurrentWindowHandle; + OpenAnotherWindow(driver); + var newWindowHandle = driver.WindowHandles.Except(new[] { initialWindowHandle }).Single(); + + driver.SwitchTo().Window(newWindowHandle); + + Assert.That(driver.CurrentWindowHandle, Is.EqualTo(newWindowHandle)); + } + + [Test] + public void SwitchWindow_Default_MovesWindowToForeground() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var initialWindowHandle = driver.CurrentWindowHandle; + OpenAnotherWindow(driver); + + driver.SwitchTo().Window(initialWindowHandle); + + // We assert that it is in the foreground by checking if a button can be clicked without an error + var element = driver.FindElement(ExtendedBy.AccessibilityId("InvokableButton")); + element.Click(); + Assert.That(element.Text, Is.EqualTo("Invoked!")); + } + + private static void OpenAndSwitchToNewWindow(RemoteWebDriver driver) + { + var initialWindowHandle = driver.CurrentWindowHandle; + OpenAnotherWindow(driver); + var newWindowHandle = driver.WindowHandles.Except(new[] { initialWindowHandle }).Single(); + driver.SwitchTo().Window(newWindowHandle); + } + + private static void OpenAnotherWindow(RemoteWebDriver driver) + { + driver.FindElement(ExtendedBy.NonCssName("_File")).Click(); + driver.FindElement(ExtendedBy.NonCssName("Open Window 1")).Click(); + } + } +} diff --git a/src/FlaUI.WebDriver.sln b/src/FlaUI.WebDriver.sln new file mode 100644 index 0000000..75dcbf4 --- /dev/null +++ b/src/FlaUI.WebDriver.sln @@ -0,0 +1,101 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32901.215 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3DFE78D4-89EB-4CEE-A5D1-F5FDDED10959}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TestApplications", "TestApplications", "{00BCF82A-388A-4DC9-A1E2-6D6D983BAEE3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FlaUI.WebDriver", "FlaUI.WebDriver\FlaUI.WebDriver.csproj", "{07FE5EE9-0104-42CE-A79D-88FD7D79B542}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FlaUI.WebDriver.UITests", "FlaUI.WebDriver.UITests\FlaUI.WebDriver.UITests.csproj", "{5315D9CF-DDA4-49AE-BA92-AB5814E61901}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WpfApplication", "TestApplications\WpfApplication\WpfApplication.csproj", "{23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|ARM = Debug|ARM + Debug|ARM64 = Debug|ARM64 + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|ARM = Release|ARM + Release|ARM64 = Release|ARM64 + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|ARM.ActiveCfg = Debug|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|ARM.Build.0 = Debug|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|ARM64.Build.0 = Debug|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|x64.ActiveCfg = Debug|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|x64.Build.0 = Debug|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|x86.ActiveCfg = Debug|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|x86.Build.0 = Debug|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|Any CPU.Build.0 = Release|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|ARM.ActiveCfg = Release|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|ARM.Build.0 = Release|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|ARM64.ActiveCfg = Release|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|ARM64.Build.0 = Release|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|x64.ActiveCfg = Release|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|x64.Build.0 = Release|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|x86.ActiveCfg = Release|Any CPU + {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|x86.Build.0 = Release|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|ARM.ActiveCfg = Debug|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|ARM.Build.0 = Debug|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|ARM64.Build.0 = Debug|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|x64.ActiveCfg = Debug|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|x64.Build.0 = Debug|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|x86.ActiveCfg = Debug|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|x86.Build.0 = Debug|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|Any CPU.Build.0 = Release|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|ARM.ActiveCfg = Release|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|ARM.Build.0 = Release|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|ARM64.ActiveCfg = Release|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|ARM64.Build.0 = Release|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|x64.ActiveCfg = Release|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|x64.Build.0 = Release|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|x86.ActiveCfg = Release|Any CPU + {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|x86.Build.0 = Release|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|ARM.ActiveCfg = Debug|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|ARM.Build.0 = Debug|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|ARM64.Build.0 = Debug|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|x64.ActiveCfg = Debug|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|x64.Build.0 = Debug|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|x86.ActiveCfg = Debug|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|x86.Build.0 = Debug|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|Any CPU.Build.0 = Release|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|ARM.ActiveCfg = Release|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|ARM.Build.0 = Release|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|ARM64.ActiveCfg = Release|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|ARM64.Build.0 = Release|Any CPU + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|x64.ActiveCfg = Release|Any CPU + {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 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {5315D9CF-DDA4-49AE-BA92-AB5814E61901} = {3DFE78D4-89EB-4CEE-A5D1-F5FDDED10959} + {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0} = {00BCF82A-388A-4DC9-A1E2-6D6D983BAEE3} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F2B64231-45B2-4129-960A-9F26AFFD16AE} + EndGlobalSection +EndGlobal diff --git a/src/FlaUI.WebDriver/.config/dotnet-tools.json b/src/FlaUI.WebDriver/.config/dotnet-tools.json new file mode 100644 index 0000000..d9d129c --- /dev/null +++ b/src/FlaUI.WebDriver/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "8.0.3", + "commands": [ + "dotnet-ef" + ] + } + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Action.cs b/src/FlaUI.WebDriver/Action.cs new file mode 100644 index 0000000..871253e --- /dev/null +++ b/src/FlaUI.WebDriver/Action.cs @@ -0,0 +1,79 @@ +using FlaUI.WebDriver.Models; +using System; + +namespace FlaUI.WebDriver +{ + public class Action + { + public Action(ActionSequence actionSequence, ActionItem actionItem) + { + Type = actionSequence.Type; + SubType = actionItem.Type; + Button = actionItem.Button; + Duration = actionItem.Duration; + Origin = actionItem.Origin; + X = actionItem.X; + Y = actionItem.Y; + DeltaX = actionItem.DeltaX; + DeltaY = actionItem.DeltaY; + Width = actionItem.Width; + Height = actionItem.Height; + Pressure = actionItem.Pressure; + TangentialPressure = actionItem.TangentialPressure; + TiltX = actionItem.TiltX; + TiltY = actionItem.TiltY; + Twist = actionItem.Twist; + AltitudeAngle = actionItem.AltitudeAngle; + AzimuthAngle = actionItem.AzimuthAngle; + Value = actionItem.Value; + } + + public Action(Action action) + { + Type = action.Type; + SubType = action.SubType; + Button = action.Button; + Duration = action.Duration; + Origin = action.Origin; + X = action.X; + Y = action.Y; + DeltaX = action.DeltaX; + DeltaY = action.DeltaY; + Width = action.Width; + Height = action.Height; + Pressure = action.Pressure; + TangentialPressure = action.TangentialPressure; + TiltX = action.TiltX; + TiltY = action.TiltY; + Twist = action.Twist; + AltitudeAngle = action.AltitudeAngle; + AzimuthAngle = action.AzimuthAngle; + Value = action.Value; + } + + public string Type { get; set; } + public string SubType { get; set; } + public int? Button { get; set; } + public int? Duration { get; set; } + public string? Origin { get; set; } + public int? X { get; set; } + public int? Y { get; set; } + public int? DeltaX { get; set; } + public int? DeltaY { get; set; } + public int? Width { get; set; } + public int? Height { get; set; } + public int? Pressure { get; set; } + public int? TangentialPressure { get; set; } + public int? TiltX { get; set; } + public int? TiltY { get; set; } + public int? Twist { get; set; } + public int? AltitudeAngle { get; set; } + public int? AzimuthAngle { get; set; } + public string? Value { get; set; } + + public Action Clone() + { + return new Action(this); + } + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Controllers/ActionsController.cs b/src/FlaUI.WebDriver/Controllers/ActionsController.cs new file mode 100644 index 0000000..d7202e0 --- /dev/null +++ b/src/FlaUI.WebDriver/Controllers/ActionsController.cs @@ -0,0 +1,339 @@ +using FlaUI.Core.Input; +using FlaUI.Core.WindowsAPI; +using FlaUI.WebDriver.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Threading.Tasks; + +namespace FlaUI.WebDriver.Controllers +{ + [Route("session/{sessionId}/[controller]")] + [ApiController] + public class ActionsController : ControllerBase + { + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepository; + + public ActionsController(ILogger logger, ISessionRepository sessionRepository) + { + _logger = logger; + _sessionRepository = sessionRepository; + } + + [HttpPost] + public async Task PerformActions([FromRoute] string sessionId, [FromBody] ActionsRequest actionsRequest) + { + var session = GetSession(sessionId); + var actionsByTick = ExtractActionSequence(actionsRequest); + foreach (var tickActions in actionsByTick) + { + var tickDuration = tickActions.Max(tickAction => tickAction.Duration) ?? 0; + var dispatchTickActionTasks = tickActions.Select(tickAction => DispatchAction(session, tickAction)); + if (tickDuration > 0) + { + dispatchTickActionTasks = dispatchTickActionTasks.Concat(new[] { Task.Delay(tickDuration) }); + } + await Task.WhenAll(dispatchTickActionTasks); + } + + return WebDriverResult.Success(); + } + + [HttpDelete] + public async Task ReleaseActions([FromRoute] string sessionId) + { + var session = GetSession(sessionId); + + foreach (var cancelAction in session.InputState.InputCancelList) + { + await DispatchAction(session, cancelAction); + } + session.InputState.Reset(); + + return WebDriverResult.Success(); + } + + /// + /// See https://www.w3.org/TR/webdriver2/#dfn-extract-an-action-sequence. + /// Returns all sequence actions synchronized by index. + /// + /// + /// + private static List> ExtractActionSequence(ActionsRequest actionsRequest) + { + var actionsByTick = new List>(); + foreach (var actionSequence in actionsRequest.Actions) + { + for (var tickIndex = 0; tickIndex < actionSequence.Actions.Count; tickIndex++) + { + var actionItem = actionSequence.Actions[tickIndex]; + var action = new Action(actionSequence, actionItem); + if (actionsByTick.Count < tickIndex + 1) + { + actionsByTick.Add(new List()); + } + actionsByTick[tickIndex].Add(action); + } + } + return actionsByTick; + } + + private static async Task DispatchAction(Session session, Action action) + { + switch (action.Type) + { + case "pointer": + await DispatchPointerAction(session, action); + return; + case "key": + await DispatchKeyAction(session, action); + return; + case "wheel": + await DispatchWheelAction(session, action); + return; + case "none": + await DispatchNullAction(session, action); + return; + default: + throw WebDriverResponseException.UnsupportedOperation($"Action type {action.Type} not supported"); + } + } + + private static async Task DispatchNullAction(Session session, Action action) + { + switch (action.SubType) + { + case "pause": + await Task.Yield(); + return; + default: + throw WebDriverResponseException.InvalidArgument($"Null action subtype {action.SubType} unknown"); + } + } + + private static async Task DispatchKeyAction(Session session, Action action) + { + switch (action.SubType) + { + case "keyDown": + var keyToPress = GetKey(action.Value); + Keyboard.Press(keyToPress); + var cancelAction = action.Clone(); + cancelAction.SubType = "keyUp"; + session.InputState.InputCancelList.Add(cancelAction); + await Task.Yield(); + return; + case "keyUp": + var keyToRelease = GetKey(action.Value); + Keyboard.Release(keyToRelease); + await Task.Yield(); + return; + case "pause": + await Task.Yield(); + return; + default: + throw WebDriverResponseException.InvalidArgument($"Pointer action subtype {action.SubType} unknown"); + } + } + + private static async Task DispatchWheelAction(Session session, Action action) + { + switch (action.SubType) + { + case "scroll": + if (action.X == null || action.Y == null) + { + throw WebDriverResponseException.InvalidArgument("For wheel scroll, X and Y are required"); + } + Mouse.MoveTo(action.X.Value, action.Y.Value); + if (action.DeltaX == null || action.DeltaY == null) + { + throw WebDriverResponseException.InvalidArgument("For wheel scroll, delta X and delta Y are required"); + } + if (action.DeltaY != 0) + { + Mouse.Scroll(action.DeltaY.Value); + } + if (action.DeltaX != 0) + { + Mouse.HorizontalScroll(action.DeltaX.Value); + } + return; + case "pause": + await Task.Yield(); + return; + default: + throw WebDriverResponseException.InvalidArgument($"Wheel action subtype {action.SubType} unknown"); + } + } + + private static VirtualKeyShort GetKey(string? value) + { + if (value == null || value.Length != 1) + { + throw WebDriverResponseException.InvalidArgument($"Key action value argument should be exactly one character"); + } + switch (value[0]) + { + case '\uE001': return VirtualKeyShort.CANCEL; + case '\uE002': return VirtualKeyShort.HELP; + case '\uE003': return VirtualKeyShort.BACK; + case '\uE004': return VirtualKeyShort.TAB; + case '\uE005': return VirtualKeyShort.CLEAR; + case '\uE006': return VirtualKeyShort.RETURN; + case '\uE007': return VirtualKeyShort.ENTER; + case '\uE008': return VirtualKeyShort.LSHIFT; + case '\uE009': return VirtualKeyShort.LCONTROL; + case '\uE00A': return VirtualKeyShort.ALT; + case '\uE00B': return VirtualKeyShort.PAUSE; + case '\uE00C': return VirtualKeyShort.ESCAPE; + case '\uE00D': return VirtualKeyShort.SPACE; + case '\uE00E': return VirtualKeyShort.PRIOR; + case '\uE00F': return VirtualKeyShort.NEXT; + case '\uE010': return VirtualKeyShort.END; + case '\uE011': return VirtualKeyShort.HOME; + case '\uE012': return VirtualKeyShort.LEFT; + case '\uE013': return VirtualKeyShort.UP; + case '\uE014': return VirtualKeyShort.RIGHT; + case '\uE015': return VirtualKeyShort.DOWN; + case '\uE016': return VirtualKeyShort.INSERT; + case '\uE017': return VirtualKeyShort.DELETE; + // case '\uE018': ";" + // case '\uE019': "=" + case '\uE01A': return VirtualKeyShort.NUMPAD0; + case '\uE01B': return VirtualKeyShort.NUMPAD1; + case '\uE01C': return VirtualKeyShort.NUMPAD2; + case '\uE01D': return VirtualKeyShort.NUMPAD3; + case '\uE01E': return VirtualKeyShort.NUMPAD4; + case '\uE01F': return VirtualKeyShort.NUMPAD5; + case '\uE020': return VirtualKeyShort.NUMPAD6; + case '\uE021': return VirtualKeyShort.NUMPAD7; + case '\uE022': return VirtualKeyShort.NUMPAD8; + case '\uE023': return VirtualKeyShort.NUMPAD9; + case '\uE024': return VirtualKeyShort.ADD; + case '\uE025': return VirtualKeyShort.MULTIPLY; + case '\uE026': return VirtualKeyShort.SEPARATOR; + case '\uE027': return VirtualKeyShort.SUBTRACT; + case '\uE028': return VirtualKeyShort.DECIMAL; + case '\uE029': return VirtualKeyShort.DIVIDE; + case '\uE031': return VirtualKeyShort.F1; + case '\uE032': return VirtualKeyShort.F2; + case '\uE033': return VirtualKeyShort.F3; + case '\uE034': return VirtualKeyShort.F4; + case '\uE035': return VirtualKeyShort.F5; + case '\uE036': return VirtualKeyShort.F6; + case '\uE037': return VirtualKeyShort.F7; + case '\uE038': return VirtualKeyShort.F8; + case '\uE039': return VirtualKeyShort.F9; + case '\uE03A': return VirtualKeyShort.F10; + case '\uE03B': return VirtualKeyShort.F11; + case '\uE03C': return VirtualKeyShort.F12; + // case '\uE03D': "Meta" + // case '\uE040': "ZenkakuHankaku" + case '\uE050': return VirtualKeyShort.RSHIFT; + case '\uE051': return VirtualKeyShort.RCONTROL; + case '\uE052': return VirtualKeyShort.ALT; + // case '\uE053': "Meta" + case '\uE054': return VirtualKeyShort.PRIOR; + case '\uE055': return VirtualKeyShort.NEXT; + case '\uE056': return VirtualKeyShort.END; + case '\uE057': return VirtualKeyShort.HOME; + case '\uE058': return VirtualKeyShort.LEFT; + case '\uE059': return VirtualKeyShort.UP; + case '\uE05A': return VirtualKeyShort.RIGHT; + case '\uE05B': return VirtualKeyShort.DOWN; + case '\uE05C': return VirtualKeyShort.INSERT; + case '\uE05D': return VirtualKeyShort.DELETE; + case 'a': return VirtualKeyShort.KEY_A; + case 'b': return VirtualKeyShort.KEY_B; + case 'c': return VirtualKeyShort.KEY_C; + case 'd': return VirtualKeyShort.KEY_D; + case 'e': return VirtualKeyShort.KEY_E; + case 'f': return VirtualKeyShort.KEY_F; + case 'g': return VirtualKeyShort.KEY_G; + case 'h': return VirtualKeyShort.KEY_H; + case 'i': return VirtualKeyShort.KEY_I; + case 'j': return VirtualKeyShort.KEY_J; + case 'k': return VirtualKeyShort.KEY_K; + case 'l': return VirtualKeyShort.KEY_L; + case 'm': return VirtualKeyShort.KEY_M; + case 'n': return VirtualKeyShort.KEY_N; + case 'o': return VirtualKeyShort.KEY_O; + case 'p': return VirtualKeyShort.KEY_P; + case 'q': return VirtualKeyShort.KEY_Q; + case 'r': return VirtualKeyShort.KEY_R; + case 's': return VirtualKeyShort.KEY_S; + case 't': return VirtualKeyShort.KEY_T; + case 'u': return VirtualKeyShort.KEY_U; + case 'v': return VirtualKeyShort.KEY_V; + case 'w': return VirtualKeyShort.KEY_W; + case 'x': return VirtualKeyShort.KEY_X; + case 'y': return VirtualKeyShort.KEY_Y; + case 'z': return VirtualKeyShort.KEY_Z; + default: throw WebDriverResponseException.UnsupportedOperation($"Key {value} is not supported"); + } + } + + private static async Task DispatchPointerAction(Session session, Action action) + { + switch (action.SubType) + { + case "pointerMove": + if (action.X == null || action.Y == null) + { + throw WebDriverResponseException.InvalidArgument("For pointer move, X and Y are required"); + } + Mouse.MoveTo(action.X.Value, action.Y.Value); + await Task.Yield(); + return; + case "pointerDown": + Mouse.Down(GetMouseButton(action.Button)); + var cancelAction = action.Clone(); + cancelAction.SubType = "pointerUp"; + session.InputState.InputCancelList.Add(cancelAction); + await Task.Yield(); + return; + case "pointerUp": + Mouse.Up(GetMouseButton(action.Button)); + await Task.Yield(); + return; + case "pause": + await Task.Yield(); + return; + default: + throw WebDriverResponseException.UnsupportedOperation($"Pointer action subtype {action.Type} not supported"); + } + } + + private static MouseButton GetMouseButton(int? button) + { + if(button == null) + { + throw WebDriverResponseException.InvalidArgument($"Pointer action button argument missing"); + } + switch(button) + { + case 0: return MouseButton.Left; + case 1: return MouseButton.Middle; + case 2: return MouseButton.Right; + case 3: return MouseButton.XButton1; + case 4: return MouseButton.XButton2; + default: + throw WebDriverResponseException.UnsupportedOperation($"Pointer button {button} not supported"); + } + } + + private Session GetSession(string sessionId) + { + var session = _sessionRepository.FindById(sessionId); + if (session == null) + { + throw WebDriverResponseException.SessionNotFound(sessionId); + } + return session; + } + } +} diff --git a/src/FlaUI.WebDriver/Controllers/ElementController.cs b/src/FlaUI.WebDriver/Controllers/ElementController.cs new file mode 100644 index 0000000..6e9d5a7 --- /dev/null +++ b/src/FlaUI.WebDriver/Controllers/ElementController.cs @@ -0,0 +1,225 @@ +using FlaUI.Core.AutomationElements; +using FlaUI.WebDriver.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System.Text; +using System.Threading.Tasks; + +namespace FlaUI.WebDriver.Controllers +{ + [Route("session/{sessionId}/[controller]")] + [ApiController] + public class ElementController : ControllerBase + { + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepository; + + public ElementController(ILogger logger, ISessionRepository sessionRepository) + { + _logger = logger; + _sessionRepository = sessionRepository; + } + + [HttpGet("active")] + public async Task GetActiveElement([FromRoute] string sessionId) + { + var session = GetActiveSession(sessionId); + var element = session.GetOrAddKnownElement(session.Automation.FocusedElement()); + return await Task.FromResult(WebDriverResult.Success(new FindElementResponse() + { + ElementReference = element.ElementReference + })); + } + + [HttpGet("{elementId}/displayed")] + public async Task IsElementDisplayed([FromRoute] string sessionId, [FromRoute] string elementId) + { + var session = GetActiveSession(sessionId); + var element = GetElement(session, elementId); + return await Task.FromResult(WebDriverResult.Success(!element.IsOffscreen)); + } + + [HttpGet("{elementId}/enabled")] + public async Task IsElementEnabled([FromRoute] string sessionId, [FromRoute] string elementId) + { + var session = GetActiveSession(sessionId); + var element = GetElement(session, elementId); + return await Task.FromResult(WebDriverResult.Success(element.IsEnabled)); + } + + [HttpGet("{elementId}/name")] + public async Task GetElementTagName([FromRoute] string sessionId, [FromRoute] string elementId) + { + var session = GetActiveSession(sessionId); + var element = GetElement(session, elementId); + return await Task.FromResult(WebDriverResult.Success(element.ControlType)); + } + + [HttpPost("{elementId}/click")] + public async Task ElementClick([FromRoute] string sessionId, [FromRoute] string elementId) + { + var session = GetActiveSession(sessionId); + var element = GetElement(session, elementId); + + ScrollElementContainerIntoView(element); + if (!await Wait.Until(() => !element.IsOffscreen, session.ImplicitWaitTimeout)) + { + return ElementNotInteractable(elementId); + } + element.Click(); + + return WebDriverResult.Success(); + } + + [HttpPost("{elementId}/clear")] + public async Task ElementClear([FromRoute] string sessionId, [FromRoute] string elementId) + { + var session = GetActiveSession(sessionId); + var element = GetElement(session, elementId); + + element.AsTextBox().Text = ""; + + return await Task.FromResult(WebDriverResult.Success()); + } + + [HttpGet("{elementId}/text")] + public async Task GetElementText([FromRoute] string sessionId, [FromRoute] string elementId) + { + var session = GetActiveSession(sessionId); + var element = GetElement(session, elementId); + + string text; + if (element.Patterns.Text.IsSupported) + { + text = element.Patterns.Text.Pattern.DocumentRange.GetText(int.MaxValue); + } + else + { + text = GetRenderedText(element); + } + + return await Task.FromResult(WebDriverResult.Success(text)); + } + + private static string GetRenderedText(AutomationElement element) + { + var result = new StringBuilder(); + AddRenderedText(element, result); + return result.ToString(); + } + + private static void AddRenderedText(AutomationElement element, StringBuilder stringBuilder) + { + if (!string.IsNullOrWhiteSpace(element.Name)) + { + if(stringBuilder.Length > 0) + { + stringBuilder.Append(' '); + } + stringBuilder.Append(element.Name); + } + foreach (var child in element.FindAllChildren()) + { + if (child.Properties.ClassName.ValueOrDefault == "TextBlock") + { + // Text blocks set the `Name` of their parent element already + continue; + } + AddRenderedText(child, stringBuilder); + } + } + + [HttpGet("{elementId}/selected")] + public async Task IsElementSelected([FromRoute] string sessionId, [FromRoute] string elementId) + { + var session = GetActiveSession(sessionId); + var element = GetElement(session, elementId); + var isSelected = false; + if (element.Patterns.SelectionItem.IsSupported) + { + isSelected = element.Patterns.SelectionItem.PatternOrDefault.IsSelected.ValueOrDefault; + } + else if (element.Patterns.Toggle.IsSupported) + { + isSelected = element.Patterns.Toggle.PatternOrDefault.ToggleState.ValueOrDefault == Core.Definitions.ToggleState.On; + } + return await Task.FromResult(WebDriverResult.Success(isSelected)); + } + + [HttpPost("{elementId}/value")] + public async Task ElementSendKeys([FromRoute] string sessionId, [FromRoute] string elementId, [FromBody] ElementSendKeysRequest elementSendKeysRequest) + { + var session = GetActiveSession(sessionId); + var element = GetElement(session, elementId); + + ScrollElementContainerIntoView(element); + if (!await Wait.Until(() => !element.IsOffscreen, session.ImplicitWaitTimeout)) + { + return ElementNotInteractable(elementId); + } + element.AsTextBox().Text = elementSendKeysRequest.Text; + + return WebDriverResult.Success(); + } + + [HttpGet("{elementId}/rect")] + public async Task GetElementRect([FromRoute] string sessionId, [FromRoute] string elementId) + { + var session = GetSession(sessionId); + var element = GetElement(session, elementId); + var elementBoundingRect = element.BoundingRectangle; + var elementRect = new ElementRect + { + X = elementBoundingRect.X, + Y = elementBoundingRect.Y, + Width = elementBoundingRect.Width, + Height = elementBoundingRect.Height + }; + return await Task.FromResult(WebDriverResult.Success(elementRect)); + } + + private static void ScrollElementContainerIntoView(AutomationElement element) + { + element.Patterns.ScrollItem.PatternOrDefault?.ScrollIntoView(); + } + + private static ActionResult ElementNotInteractable(string elementId) + { + return WebDriverResult.BadRequest(new ErrorResponse() + { + ErrorCode = "element not interactable", + Message = $"Element with ID {elementId} is off screen" + }); + } + + private AutomationElement GetElement(Session session, string elementId) + { + var element = session.FindKnownElementById(elementId); + if (element == null) + { + throw WebDriverResponseException.ElementNotFound(elementId); + } + return element; + } + + private Session GetActiveSession(string sessionId) + { + var session = GetSession(sessionId); + if (session.App == null || session.App.HasExited) + { + throw WebDriverResponseException.NoWindowsOpenForSession(); + } + return session; + } + + private Session GetSession(string sessionId) + { + var session = _sessionRepository.FindById(sessionId); + if (session == null) + { + throw WebDriverResponseException.SessionNotFound(sessionId); + } + return session; + } + } +} diff --git a/src/FlaUI.WebDriver/Controllers/ExecuteController.cs b/src/FlaUI.WebDriver/Controllers/ExecuteController.cs new file mode 100644 index 0000000..36495f6 --- /dev/null +++ b/src/FlaUI.WebDriver/Controllers/ExecuteController.cs @@ -0,0 +1,84 @@ +using FlaUI.WebDriver.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace FlaUI.WebDriver.Controllers +{ + [Route("session/{sessionId}/[controller]")] + [ApiController] + public class ExecuteController : ControllerBase + { + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepository; + + public ExecuteController(ISessionRepository sessionRepository, ILogger logger) + { + _sessionRepository = sessionRepository; + _logger = logger; + } + + [HttpPost("sync")] + public async Task ExecuteScript([FromRoute] string sessionId, [FromBody] ExecuteScriptRequest executeScriptRequest) + { + var session = GetSession(sessionId); + switch (executeScriptRequest.Script) + { + case "powerShell": + return await ExecutePowerShellScript(session, executeScriptRequest); + default: + throw WebDriverResponseException.UnsupportedOperation("Only 'powerShell' scripts are supported"); + } + } + private async Task ExecutePowerShellScript(Session session, ExecuteScriptRequest executeScriptRequest) + { + if (executeScriptRequest.Args.Count != 1) + { + 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)) + { + throw WebDriverResponseException.InvalidArgument("Expected a \"command\" property of the first argument for the PowerShell script"); + } + + _logger.LogInformation("Executing PowerShell command {Command} (session {SessionId})", powerShellCommand, session.SessionId); + + var processStartInfo = new ProcessStartInfo("powershell.exe", $"-Command \"{powerShellCommand.Replace("\"", "\\\"")}\"") + { + RedirectStandardError = true, + RedirectStandardOutput = true + }; + using var process = Process.Start(processStartInfo); + using var cancellationTokenSource = new CancellationTokenSource(); + if (session.ScriptTimeout.HasValue) + { + cancellationTokenSource.CancelAfter(session.ScriptTimeout.Value); + } + await process!.WaitForExitAsync(cancellationTokenSource.Token); + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync(); + return WebDriverResult.BadRequest(new ErrorResponse() + { + ErrorCode = "script error", + Message = $"Script failed with exit code {process.ExitCode}: {error}" + }); + } + var result = await process.StandardOutput.ReadToEndAsync(); + return WebDriverResult.Success(result); + } + + private Session GetSession(string sessionId) + { + var session = _sessionRepository.FindById(sessionId); + if (session == null) + { + throw WebDriverResponseException.SessionNotFound(sessionId); + } + return session; + } + } +} diff --git a/src/FlaUI.WebDriver/Controllers/FindElementsController.cs b/src/FlaUI.WebDriver/Controllers/FindElementsController.cs new file mode 100644 index 0000000..9c675bb --- /dev/null +++ b/src/FlaUI.WebDriver/Controllers/FindElementsController.cs @@ -0,0 +1,216 @@ +using FlaUI.WebDriver.Models; +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using System; +using Microsoft.Extensions.Logging; +using FlaUI.Core.Conditions; +using FlaUI.Core.Definitions; +using System.Linq; +using FlaUI.Core.AutomationElements; +using System.Text.RegularExpressions; + +namespace FlaUI.WebDriver.Controllers +{ + [Route("session/{sessionId}")] + [ApiController] + public class FindElementsController : ControllerBase + { + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepository; + + public FindElementsController(ILogger logger, ISessionRepository sessionRepository) + { + _logger = logger; + _sessionRepository = sessionRepository; + } + + [HttpPost("element")] + public async Task FindElement([FromRoute] string sessionId, [FromBody] FindElementRequest findElementRequest) + { + var session = GetActiveSession(sessionId); + return await FindElementFrom(() => session.CurrentWindow, findElementRequest, session); + } + + [HttpPost("element/{elementId}/element")] + public async Task FindElementFromElement([FromRoute] string sessionId, [FromRoute] string elementId, [FromBody] FindElementRequest findElementRequest) + { + var session = GetActiveSession(sessionId); + var element = GetElement(session, elementId); + return await FindElementFrom(() => element, findElementRequest, session); + } + + [HttpPost("elements")] + public async Task FindElements([FromRoute] string sessionId, [FromBody] FindElementRequest findElementRequest) + { + var session = GetActiveSession(sessionId); + return await FindElementsFrom(() => session.CurrentWindow, findElementRequest, session); + } + + [HttpPost("element/{elementId}/elements")] + public async Task FindElementsFromElement([FromRoute] string sessionId, [FromRoute] string elementId, [FromBody] FindElementRequest findElementRequest) + { + var session = GetActiveSession(sessionId); + var element = GetElement(session, elementId); + return await FindElementsFrom(() => element, findElementRequest, session); + } + + private static async Task FindElementFrom(Func startNode, FindElementRequest findElementRequest, Session session) + { + var condition = GetCondition(session.Automation.ConditionFactory, findElementRequest.Using, findElementRequest.Value); + AutomationElement? element = await Wait.Until(() => startNode().FindFirstDescendant(condition), element => element != null, session.ImplicitWaitTimeout); + + if (element == null) + { + return NoSuchElement(findElementRequest); + } + + var knownElement = session.GetOrAddKnownElement(element); + return await Task.FromResult(WebDriverResult.Success(new FindElementResponse + { + ElementReference = knownElement.ElementReference, + })); + } + + private static async Task FindElementsFrom(Func startNode, FindElementRequest findElementRequest, Session session) + { + var condition = GetCondition(session.Automation.ConditionFactory, findElementRequest.Using, findElementRequest.Value); + AutomationElement[] elements = await Wait.Until(() => startNode().FindAllDescendants(condition), elements => elements.Length > 0, session.ImplicitWaitTimeout); + + if (elements.Length == 0) + { + return NoSuchElement(findElementRequest); + } + + var knownElements = elements.Select(session.GetOrAddKnownElement); + return await Task.FromResult(WebDriverResult.Success( + + knownElements.Select(knownElement => new FindElementResponse() + { + ElementReference = knownElement.ElementReference + }).ToArray() + )); + } + + /// + /// 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() + { + ErrorCode = "no such element", + Message = $"No element found with selector '{findElementRequest.Using}' and value '{findElementRequest.Value}'" + }); + } + + private AutomationElement GetElement(Session session, string elementId) + { + var element = session.FindKnownElementById(elementId); + if (element == null) + { + throw WebDriverResponseException.ElementNotFound(elementId); + } + return element; + } + + private Session GetActiveSession(string sessionId) + { + var session = GetSession(sessionId); + if (session.App == null || session.App.HasExited) + { + throw WebDriverResponseException.NoWindowsOpenForSession(); + } + return session; + } + + private Session GetSession(string sessionId) + { + var session = _sessionRepository.FindById(sessionId); + if (session == null) + { + throw WebDriverResponseException.SessionNotFound(sessionId); + } + return session; + } + } +} diff --git a/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs b/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs new file mode 100644 index 0000000..9fd70a2 --- /dev/null +++ b/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System.IO; +using System.Threading.Tasks; +using System; +using FlaUI.Core.AutomationElements; +using System.Drawing; + +namespace FlaUI.WebDriver.Controllers +{ + [Route("session/{sessionId}")] + [ApiController] + public class ScreenshotController : ControllerBase + { + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepository; + + public ScreenshotController(ILogger logger, ISessionRepository sessionRepository) + { + _logger = logger; + _sessionRepository = sessionRepository; + } + + [HttpGet("screenshot")] + 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(); + return await Task.FromResult(WebDriverResult.Success(GetBase64Data(bitmap))); + } + + [HttpGet("element/{elementId}/screenshot")] + public async Task TakeElementScreenshot([FromRoute] string sessionId, [FromRoute] string elementId) + { + var session = GetActiveSession(sessionId); + var element = GetElement(session, elementId); + _logger.LogInformation("Taking screenshot of element with ID {ElementId} (session {SessionId})", elementId, session.SessionId); + using var bitmap = element.Capture(); + return await Task.FromResult(WebDriverResult.Success(GetBase64Data(bitmap))); + } + + private static string GetBase64Data(Bitmap bitmap) + { + using var memoryStream = new MemoryStream(); + bitmap.Save(memoryStream, System.Drawing.Imaging.ImageFormat.Png); + return Convert.ToBase64String(memoryStream.ToArray()); + } + + private AutomationElement GetElement(Session session, string elementId) + { + var element = session.FindKnownElementById(elementId); + if (element == null) + { + throw WebDriverResponseException.ElementNotFound(elementId); + } + return element; + } + + private Session GetActiveSession(string sessionId) + { + var session = GetSession(sessionId); + if (session.App == null || session.App.HasExited) + { + throw WebDriverResponseException.NoWindowsOpenForSession(); + } + return session; + } + + private Session GetSession(string sessionId) + { + var session = _sessionRepository.FindById(sessionId); + if (session == null) + { + throw WebDriverResponseException.SessionNotFound(sessionId); + } + return session; + } + } +} diff --git a/src/FlaUI.WebDriver/Controllers/SessionController.cs b/src/FlaUI.WebDriver/Controllers/SessionController.cs new file mode 100644 index 0000000..3645ab0 --- /dev/null +++ b/src/FlaUI.WebDriver/Controllers/SessionController.cs @@ -0,0 +1,182 @@ +using FlaUI.WebDriver.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace FlaUI.WebDriver.Controllers +{ + [ApiController] + [Route("[controller]")] + public class SessionController : ControllerBase + { + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepository; + + public SessionController(ILogger logger, ISessionRepository sessionRepository) + { + _logger = logger; + _sessionRepository = sessionRepository; + } + + [HttpPost] + public async Task CreateNewSession([FromBody] CreateSessionRequest request) + { + var possibleCapabilities = GetPossibleCapabilities(request); + var matchingCapabilities = possibleCapabilities.Where( + capabilities => capabilities.TryGetValue("platformName", out var platformName) && platformName.ToLowerInvariant() == "windows" + ); + + Core.Application? app; + var capabilities = matchingCapabilities.FirstOrDefault(); + if (capabilities == null) + { + return WebDriverResult.Error(new ErrorResponse + { + ErrorCode = "session not created", + Message = "Required capabilities did not match. Capability `platformName` with value `windows` is required" + }); + } + if (capabilities.TryGetValue("appium:app", out var appPath)) + { + if (appPath == "Root") + { + app = null; + } + else + { + capabilities.TryGetValue("appium:appArguments", out var appArguments); + try + { + var processStartInfo = new ProcessStartInfo(appPath, appArguments ?? ""); + app = Core.Application.Launch(processStartInfo); + } + catch(Exception e) + { + throw WebDriverResponseException.InvalidArgument($"Starting app '{appPath}' with arguments '{appArguments}' threw an exception: {e.Message}"); + } + } + } + else if(capabilities.TryGetValue("appium:appTopLevelWindow", out var appTopLevelWindowString)) + { + Process process = GetProcessByMainWindowHandle(appTopLevelWindowString); + app = Core.Application.Attach(process); + } + else if (capabilities.TryGetValue("appium:appTopLevelWindowTitleMatch", out var appTopLevelWindowTitleMatch)) + { + 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); + _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 + })); + } + + private static Process GetProcessByMainWindowTitle(string appTopLevelWindowTitleMatch) + { + Regex appMainWindowTitleRegex; + try + { + appMainWindowTitleRegex = new Regex(appTopLevelWindowTitleMatch); + } + catch(ArgumentException e) + { + throw WebDriverResponseException.InvalidArgument($"Capability appium:appTopLevelWindowTitleMatch '{appTopLevelWindowTitleMatch}' is not a valid regular expression: {e.Message}"); + } + var processes = Process.GetProcesses().Where(process => appMainWindowTitleRegex.IsMatch(process.MainWindowTitle)).ToArray(); + if (processes.Length == 0) + { + throw WebDriverResponseException.InvalidArgument($"Process with main window title matching '{appTopLevelWindowTitleMatch}' could not be found"); + } + else if (processes.Length > 1) + { + throw WebDriverResponseException.InvalidArgument($"Found multiple ({processes.Length}) processes with main window title matching '{appTopLevelWindowTitleMatch}'"); + } + return processes[0]; + } + + private static Process GetProcessByMainWindowHandle(string appTopLevelWindowString) + { + int appTopLevelWindow; + try + { + appTopLevelWindow = Convert.ToInt32(appTopLevelWindowString, 16); + } + catch (Exception) + { + throw WebDriverResponseException.InvalidArgument($"Capability appium:appTopLevelWindow '{appTopLevelWindowString}' is not a valid hexadecimal string"); + } + if (appTopLevelWindow == 0) + { + throw WebDriverResponseException.InvalidArgument($"Capability appium:appTopLevelWindow '{appTopLevelWindowString}' should not be zero"); + } + var process = Process.GetProcesses().SingleOrDefault(process => process.MainWindowHandle.ToInt32() == appTopLevelWindow); + if (process == null) + { + throw WebDriverResponseException.InvalidArgument($"Process with main window handle {appTopLevelWindowString} could not be found"); + } + return process; + } + + private static IEnumerable> GetPossibleCapabilities(CreateSessionRequest request) + { + var requiredCapabilities = request.Capabilities.AlwaysMatch ?? new Dictionary(); + var allFirstMatchCapabilities = request.Capabilities.FirstMatch ?? new List>(new[] { new Dictionary() }); + return allFirstMatchCapabilities.Select(firstMatchCapabilities => MergeCapabilities(firstMatchCapabilities, requiredCapabilities)); + } + + private static Dictionary MergeCapabilities(Dictionary firstMatchCapabilities, Dictionary requiredCapabilities) + { + var duplicateKeys = firstMatchCapabilities.Keys.Intersect(requiredCapabilities.Keys); + if (duplicateKeys.Any()) + { + throw WebDriverResponseException.InvalidArgument($"Capabilities cannot be merged because there are duplicate capabilities: {string.Join(", ", duplicateKeys)}"); + } + + return firstMatchCapabilities.Concat(requiredCapabilities) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + [HttpDelete("{sessionId}")] + public async Task DeleteSession([FromRoute] string sessionId) + { + var session = GetSession(sessionId); + _sessionRepository.Delete(session); + session.Dispose(); + _logger.LogInformation("Deleted session with ID {SessionId}", sessionId); + return await Task.FromResult(WebDriverResult.Success()); + } + + [HttpGet("{sessionId}/title")] + public async Task GetTitle([FromRoute] string sessionId) + { + var session = GetSession(sessionId); + var title = session.CurrentWindow.Title; + return await Task.FromResult(WebDriverResult.Success(title)); + } + + private Session GetSession(string sessionId) + { + var session = _sessionRepository.FindById(sessionId); + if (session == null) + { + throw WebDriverResponseException.SessionNotFound(sessionId); + } + return session; + } + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Controllers/StatusController.cs b/src/FlaUI.WebDriver/Controllers/StatusController.cs new file mode 100644 index 0000000..4b4b077 --- /dev/null +++ b/src/FlaUI.WebDriver/Controllers/StatusController.cs @@ -0,0 +1,20 @@ +using FlaUI.WebDriver.Models; +using Microsoft.AspNetCore.Mvc; + +namespace FlaUI.WebDriver.Controllers +{ + [Route("[controller]")] + [ApiController] + public class StatusController : ControllerBase + { + [HttpGet] + public ActionResult GetStatus() + { + return WebDriverResult.Success(new StatusResponse() + { + Ready = true, + Message = "Hello World!" + }); + } + } +} diff --git a/src/FlaUI.WebDriver/Controllers/TimeoutsController.cs b/src/FlaUI.WebDriver/Controllers/TimeoutsController.cs new file mode 100644 index 0000000..f189b78 --- /dev/null +++ b/src/FlaUI.WebDriver/Controllers/TimeoutsController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; + +namespace FlaUI.WebDriver.Controllers +{ + [ApiController] + [Route("session/{sessionId}/[controller]")] + public class TimeoutsController : ControllerBase + { + private readonly ISessionRepository _sessionRepository; + private readonly ILogger _logger; + + public TimeoutsController(ISessionRepository sessionRepository, ILogger logger) + { + _sessionRepository = sessionRepository; + _logger = logger; + } + + [HttpGet] + public async Task GetTimeouts([FromRoute] string sessionId) + { + var session = GetSession(sessionId); + return await Task.FromResult(WebDriverResult.Success(session.TimeoutsConfiguration)); + } + + [HttpPost] + public async Task SetTimeouts([FromRoute] string sessionId, [FromBody] TimeoutsConfiguration timeoutsConfiguration) + { + var session = GetSession(sessionId); + _logger.LogInformation("Setting timeouts to {Timeouts} (session {SessionId})", timeoutsConfiguration, session.SessionId); + + session.TimeoutsConfiguration = timeoutsConfiguration; + + return await Task.FromResult(WebDriverResult.Success()); + } + + private Session GetSession(string sessionId) + { + var session = _sessionRepository.FindById(sessionId); + if (session == null) + { + throw WebDriverResponseException.SessionNotFound(sessionId); + } + return session; + } + } +} diff --git a/src/FlaUI.WebDriver/Controllers/WindowController.cs b/src/FlaUI.WebDriver/Controllers/WindowController.cs new file mode 100644 index 0000000..6a9e66d --- /dev/null +++ b/src/FlaUI.WebDriver/Controllers/WindowController.cs @@ -0,0 +1,178 @@ +using FlaUI.Core.AutomationElements; +using FlaUI.WebDriver.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace FlaUI.WebDriver.Controllers +{ + [Route("session/{sessionId}/[controller]")] + [ApiController] + public class WindowController : ControllerBase + { + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepository; + + public WindowController(ILogger logger, ISessionRepository sessionRepository) + { + _logger = logger; + _sessionRepository = sessionRepository; + } + + [HttpDelete] + public async Task CloseWindow([FromRoute] string sessionId) + { + var session = GetSession(sessionId); + if (session.App == null || session.App.HasExited) + { + throw WebDriverResponseException.NoWindowsOpenForSession(); + } + + // When closing the last window of the application, the `GetAllTopLevelWindows` function times out with an exception + // Therefore retrieve windows before closing the current one + // https://github.com/FlaUI/FlaUI/issues/596 + var windowHandlesBeforeClose = GetWindowHandles(session).ToArray(); + + var currentWindow = session.CurrentWindow; + session.RemoveKnownWindow(currentWindow); + currentWindow.Close(); + + var remainingWindowHandles = windowHandlesBeforeClose.Except(new[] { session.CurrentWindowHandle } ); + if (!remainingWindowHandles.Any()) + { + _sessionRepository.Delete(session); + session.Dispose(); + _logger.LogInformation("Closed last window of session and therefore deleted session with ID {SessionId}", sessionId); + } + return await Task.FromResult(WebDriverResult.Success(remainingWindowHandles)); + } + + [HttpGet("handles")] + public async Task GetWindowHandles([FromRoute] string sessionId) + { + var session = GetSession(sessionId); + var windowHandles = GetWindowHandles(session); + return await Task.FromResult(WebDriverResult.Success(windowHandles)); + } + + [HttpGet] + public async Task GetWindowHandle([FromRoute] string sessionId) + { + var session = GetSession(sessionId); + + if(session.FindKnownWindowByWindowHandle(session.CurrentWindowHandle) == null) + { + throw WebDriverResponseException.WindowNotFoundByHandle(session.CurrentWindowHandle); + } + + return await Task.FromResult(WebDriverResult.Success(session.CurrentWindowHandle)); + } + + [HttpPost] + public async Task SwitchToWindow([FromRoute] string sessionId, [FromBody] SwitchWindowRequest switchWindowRequest) + { + var session = GetSession(sessionId); + if (session.App == null) + { + throw WebDriverResponseException.UnsupportedOperation("Close window not supported for Root app"); + } + var window = session.FindKnownWindowByWindowHandle(switchWindowRequest.Handle); + if (window == null) + { + throw WebDriverResponseException.WindowNotFoundByHandle(switchWindowRequest.Handle); + } + + session.CurrentWindow = window; + window.SetForeground(); + + _logger.LogInformation("Switched to window with title {WindowTitle} (handle {WindowHandle}) (session {SessionId})", window.Title, switchWindowRequest.Handle, session.SessionId); + return await Task.FromResult(WebDriverResult.Success()); + } + + [HttpGet("rect")] + public async Task GetWindowRect([FromRoute] string sessionId) + { + var session = GetSession(sessionId); + return await Task.FromResult(WebDriverResult.Success(GetWindowRect(session.CurrentWindow))); + } + + [HttpPost("rect")] + public async Task SetWindowRect([FromRoute] string sessionId, [FromBody] WindowRect windowRect) + { + var session = GetSession(sessionId); + + if(!session.CurrentWindow.Patterns.Transform.IsSupported) + { + throw WebDriverResponseException.UnsupportedOperation("Cannot transform the current window"); + } + + if (windowRect.Width != null && windowRect.Height != null) + { + if (!session.CurrentWindow.Patterns.Transform.Pattern.CanResize) + { + throw WebDriverResponseException.UnsupportedOperation("Cannot resize the current window"); + } + session.CurrentWindow.Patterns.Transform.Pattern.Resize(windowRect.Width.Value, windowRect.Height.Value); + } + + if (windowRect.X != null && windowRect.Y != null) + { + if (!session.CurrentWindow.Patterns.Transform.Pattern.CanMove) + { + throw WebDriverResponseException.UnsupportedOperation("Cannot move the current window"); + } + session.CurrentWindow.Move(windowRect.X.Value, windowRect.Y.Value); + } + + return await Task.FromResult(WebDriverResult.Success(GetWindowRect(session.CurrentWindow))); + } + + private IEnumerable GetWindowHandles(Session session) + { + if (session.App == null) + { + throw WebDriverResponseException.UnsupportedOperation("Window operations not supported for Root app"); + } + if (session.App.HasExited) + { + return Enumerable.Empty(); + } + var mainWindow = session.App.GetMainWindow(session.Automation, TimeSpan.Zero); + if (mainWindow == null) + { + return Enumerable.Empty(); + } + + // GetAllTopLevelWindows sometimes times out, so we return only the main window and modal windows + // https://github.com/FlaUI/FlaUI/issues/596 + var knownWindows = mainWindow.ModalWindows.Prepend(mainWindow) + .Select(session.GetOrAddKnownWindow); + return knownWindows.Select(knownWindows => knownWindows.WindowHandle); + } + + private WindowRect GetWindowRect(Window window) + { + var boundingRectangle = window.BoundingRectangle; + return new WindowRect + { + X = boundingRectangle.X, + Y = boundingRectangle.Y, + Width = boundingRectangle.Width, + Height = boundingRectangle.Height + }; + } + + private Session GetSession(string sessionId) + { + var session = _sessionRepository.FindById(sessionId); + if (session == null) + { + throw WebDriverResponseException.SessionNotFound(sessionId); + } + return session; + } + } +} diff --git a/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj b/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj new file mode 100644 index 0000000..64eaaf1 --- /dev/null +++ b/src/FlaUI.WebDriver/FlaUI.WebDriver.csproj @@ -0,0 +1,23 @@ + + + + net6.0-windows + enable + disable + preview + false + win-x64 + true + true + + + + FlaUI.WebDriver.Program + + + + + + + + diff --git a/src/FlaUI.WebDriver/ISessionRepository.cs b/src/FlaUI.WebDriver/ISessionRepository.cs new file mode 100644 index 0000000..32881e0 --- /dev/null +++ b/src/FlaUI.WebDriver/ISessionRepository.cs @@ -0,0 +1,9 @@ +namespace FlaUI.WebDriver +{ + public interface ISessionRepository + { + void Add(Session session); + void Delete(Session session); + Session? FindById(string sessionId); + } +} diff --git a/src/FlaUI.WebDriver/InputState.cs b/src/FlaUI.WebDriver/InputState.cs new file mode 100644 index 0000000..8c80238 --- /dev/null +++ b/src/FlaUI.WebDriver/InputState.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace FlaUI.WebDriver +{ + public class InputState + { + public List InputCancelList = new List(); + + public void Reset() + { + InputCancelList.Clear(); + } + } +} diff --git a/src/FlaUI.WebDriver/KnownElement.cs b/src/FlaUI.WebDriver/KnownElement.cs new file mode 100644 index 0000000..e34777e --- /dev/null +++ b/src/FlaUI.WebDriver/KnownElement.cs @@ -0,0 +1,17 @@ +using FlaUI.Core.AutomationElements; +using System; + +namespace FlaUI.WebDriver +{ + public class KnownElement + { + public KnownElement(AutomationElement element) + { + Element = element; + ElementReference = Guid.NewGuid().ToString(); + } + + public string ElementReference { get; set; } + public AutomationElement Element { get; } + } +} diff --git a/src/FlaUI.WebDriver/KnownWindow.cs b/src/FlaUI.WebDriver/KnownWindow.cs new file mode 100644 index 0000000..6d03a96 --- /dev/null +++ b/src/FlaUI.WebDriver/KnownWindow.cs @@ -0,0 +1,16 @@ +using FlaUI.Core.AutomationElements; +using System; + +namespace FlaUI.WebDriver +{ + public class KnownWindow + { + public KnownWindow(Window window) + { + Window = window; + WindowHandle = Guid.NewGuid().ToString(); + } + public string WindowHandle { get; set; } + public Window Window { get; set; } + } +} diff --git a/src/FlaUI.WebDriver/Models/ActionItem.cs b/src/FlaUI.WebDriver/Models/ActionItem.cs new file mode 100644 index 0000000..1f4aeba --- /dev/null +++ b/src/FlaUI.WebDriver/Models/ActionItem.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace FlaUI.WebDriver.Models +{ + public class ActionItem + { + public string Type { get; set; } = null!; + public int? Button { get; set; } + public int? Duration { get; set; } + public string? Origin { get; set; } + public int? X { get; set; } + public int? Y { get; set; } + public int? DeltaX { get; set; } + public int? DeltaY { get; set; } + public int? Width { get; set; } + public int? Height { get; set; } + public int? Pressure { get; set; } + public int? TangentialPressure { get; set; } + public int? TiltX { get; set; } + public int? TiltY { get; set; } + public int? Twist { get; set; } + public int? AltitudeAngle { get; set; } + public int? AzimuthAngle { get; set; } + public string? Value { get; set; } + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Models/ActionSequence.cs b/src/FlaUI.WebDriver/Models/ActionSequence.cs new file mode 100644 index 0000000..0dbe4fb --- /dev/null +++ b/src/FlaUI.WebDriver/Models/ActionSequence.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace FlaUI.WebDriver.Models +{ + public class ActionSequence + { + public string Id { get; set; } = null!; + public string Type { get; set; } = null!; + public Dictionary Parameters { get; set; } = new Dictionary(); + public List Actions { get; set; } = new List(); + } +} diff --git a/src/FlaUI.WebDriver/Models/ActionsRequest.cs b/src/FlaUI.WebDriver/Models/ActionsRequest.cs new file mode 100644 index 0000000..4c5a98a --- /dev/null +++ b/src/FlaUI.WebDriver/Models/ActionsRequest.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace FlaUI.WebDriver.Models +{ + public class ActionsRequest + { + public List Actions { get; set; } = new List(); + } +} diff --git a/src/FlaUI.WebDriver/Models/Capabilities.cs b/src/FlaUI.WebDriver/Models/Capabilities.cs new file mode 100644 index 0000000..b5c3954 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/Capabilities.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace FlaUI.WebDriver.Models +{ + public class Capabilities + { + public Dictionary? AlwaysMatch { get; set; } + public List>? FirstMatch { get; set; } + } +} diff --git a/src/FlaUI.WebDriver/Models/CreateSessionRequest.cs b/src/FlaUI.WebDriver/Models/CreateSessionRequest.cs new file mode 100644 index 0000000..2003987 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/CreateSessionRequest.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace FlaUI.WebDriver.Models +{ + public class CreateSessionRequest + { + public Capabilities Capabilities { get; set; } = new Capabilities(); + } +} diff --git a/src/FlaUI.WebDriver/Models/CreateSessionResponse.cs b/src/FlaUI.WebDriver/Models/CreateSessionResponse.cs new file mode 100644 index 0000000..ffe0eaf --- /dev/null +++ b/src/FlaUI.WebDriver/Models/CreateSessionResponse.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace FlaUI.WebDriver.Models +{ + public class CreateSessionResponse + { + public string SessionId { get; set; } = null!; + public Dictionary Capabilities { get; set; } = new Dictionary(); + } +} diff --git a/src/FlaUI.WebDriver/Models/ElementRect.cs b/src/FlaUI.WebDriver/Models/ElementRect.cs new file mode 100644 index 0000000..2172552 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/ElementRect.cs @@ -0,0 +1,10 @@ +namespace FlaUI.WebDriver.Models +{ + public class ElementRect + { + public int X { get; set; } + public int Y { get; set; } + public int Width { get; set; } + public int Height { get; set; } + } +} diff --git a/src/FlaUI.WebDriver/Models/ElementSendKeysRequest.cs b/src/FlaUI.WebDriver/Models/ElementSendKeysRequest.cs new file mode 100644 index 0000000..6d30011 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/ElementSendKeysRequest.cs @@ -0,0 +1,7 @@ +namespace FlaUI.WebDriver.Models +{ + public class ElementSendKeysRequest + { + public string Text { get; set; } = ""; + } +} diff --git a/src/FlaUI.WebDriver/Models/ErrorResponse.cs b/src/FlaUI.WebDriver/Models/ErrorResponse.cs new file mode 100644 index 0000000..b13cbe2 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/ErrorResponse.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace FlaUI.WebDriver.Models +{ + public class ErrorResponse + { + [JsonPropertyName("error")] + public string ErrorCode { get; set; } = "unknown error"; + public string Message { get; set; } = ""; + public string StackTrace { get; set; } = ""; + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs b/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs new file mode 100644 index 0000000..d1bc41c --- /dev/null +++ b/src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace FlaUI.WebDriver.Models +{ + public class ExecuteScriptRequest + { + public string Script { get; set; } = null!; + public List> Args { get; set; } = new List>(); + } +} diff --git a/src/FlaUI.WebDriver/Models/FindElementRequest.cs b/src/FlaUI.WebDriver/Models/FindElementRequest.cs new file mode 100644 index 0000000..de86426 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/FindElementRequest.cs @@ -0,0 +1,8 @@ +namespace FlaUI.WebDriver.Models +{ + public class FindElementRequest + { + public string Using { get; set; } = null!; + public string Value { get; set; } = null!; + } +} diff --git a/src/FlaUI.WebDriver/Models/FindElementResponse.cs b/src/FlaUI.WebDriver/Models/FindElementResponse.cs new file mode 100644 index 0000000..5511561 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/FindElementResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace FlaUI.WebDriver.Models +{ + public class FindElementResponse + { + /// + /// See https://www.w3.org/TR/webdriver2/#dfn-web-element-identifier + /// + [JsonPropertyName("element-6066-11e4-a52e-4f735466cecf")] + public string ElementReference { get; set; } = null!; + } +} diff --git a/src/FlaUI.WebDriver/Models/ResponseWithValue.cs b/src/FlaUI.WebDriver/Models/ResponseWithValue.cs new file mode 100644 index 0000000..01dfb8a --- /dev/null +++ b/src/FlaUI.WebDriver/Models/ResponseWithValue.cs @@ -0,0 +1,12 @@ +namespace FlaUI.WebDriver.Models +{ + public class ResponseWithValue + { + public T Value { get; set; } + + public ResponseWithValue(T value) + { + Value = value; + } + } +} diff --git a/src/FlaUI.WebDriver/Models/StatusResponse.cs b/src/FlaUI.WebDriver/Models/StatusResponse.cs new file mode 100644 index 0000000..622bda5 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/StatusResponse.cs @@ -0,0 +1,8 @@ +namespace FlaUI.WebDriver.Models +{ + internal class StatusResponse + { + public bool Ready { get; set; } + public string Message { get; set; } = ""; + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Models/SwitchWindowRequest.cs b/src/FlaUI.WebDriver/Models/SwitchWindowRequest.cs new file mode 100644 index 0000000..f649e1c --- /dev/null +++ b/src/FlaUI.WebDriver/Models/SwitchWindowRequest.cs @@ -0,0 +1,7 @@ +namespace FlaUI.WebDriver.Models +{ + public class SwitchWindowRequest + { + public string Handle { get; set; } = null!; + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Models/WindowRect.cs b/src/FlaUI.WebDriver/Models/WindowRect.cs new file mode 100644 index 0000000..721e961 --- /dev/null +++ b/src/FlaUI.WebDriver/Models/WindowRect.cs @@ -0,0 +1,10 @@ +namespace FlaUI.WebDriver.Models +{ + public class WindowRect + { + public int? X { get; set; } + public int? Y { get; set; } + public int? Width { get; set; } + public int? Height { get; set; } + } +} diff --git a/src/FlaUI.WebDriver/Program.cs b/src/FlaUI.WebDriver/Program.cs new file mode 100644 index 0000000..fc993b9 --- /dev/null +++ b/src/FlaUI.WebDriver/Program.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using System.IO; +using System.Reflection; + +namespace FlaUI.WebDriver +{ + public class Program + { + public static void Main(string[] args) + { + string assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; + Directory.SetCurrentDirectory(assemblyDir); + + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/src/FlaUI.WebDriver/Properties/launchSettings.json b/src/FlaUI.WebDriver/Properties/launchSettings.json new file mode 100644 index 0000000..4aeed20 --- /dev/null +++ b/src/FlaUI.WebDriver/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:26539", + "sslPort": 0 + } + }, + "profiles": { + "FlaUI.WebDriver": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:4723", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/FlaUI.WebDriver/Session.cs b/src/FlaUI.WebDriver/Session.cs new file mode 100644 index 0000000..c715a23 --- /dev/null +++ b/src/FlaUI.WebDriver/Session.cs @@ -0,0 +1,128 @@ +using FlaUI.Core; +using FlaUI.Core.AutomationElements; +using FlaUI.UIA3; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FlaUI.WebDriver +{ + public class Session : IDisposable + { + public Session(Application? app) + { + App = app; + SessionId = Guid.NewGuid().ToString(); + Automation = new UIA3Automation(); + InputState = new InputState(); + TimeoutsConfiguration = new TimeoutsConfiguration(); + + if (app != null) + { + // We have to capture the initial window handle to be able to keep it stable + CurrentWindowWithHandle = GetOrAddKnownWindow(app.GetMainWindow(Automation, PageLoadTimeout)); + } + } + + public string SessionId { get; } + public UIA3Automation Automation { get; } + public Application? App { get; } + public InputState InputState { get; } + private Dictionary KnownElementsByElementReference { get; } = new Dictionary(); + private Dictionary KnownWindowsByWindowHandle { get; } = new Dictionary(); + public TimeSpan ImplicitWaitTimeout => TimeSpan.FromMilliseconds(TimeoutsConfiguration.ImplicitWaitTimeoutMs); + public TimeSpan PageLoadTimeout => TimeSpan.FromMilliseconds(TimeoutsConfiguration.PageLoadTimeoutMs); + public TimeSpan? ScriptTimeout => TimeoutsConfiguration.ScriptTimeoutMs.HasValue ? TimeSpan.FromMilliseconds(TimeoutsConfiguration.ScriptTimeoutMs.Value) : null; + + public TimeoutsConfiguration TimeoutsConfiguration { get; set; } + + private KnownWindow? CurrentWindowWithHandle { get; set; } + + public Window CurrentWindow + { + get + { + if (App == null || CurrentWindowWithHandle == null) + { + throw WebDriverResponseException.UnsupportedOperation("This operation is not supported for Root app"); + } + return CurrentWindowWithHandle.Window; + } + set + { + CurrentWindowWithHandle = GetOrAddKnownWindow(value); + } + } + + public string CurrentWindowHandle + { + get + { + if (App == null || CurrentWindowWithHandle == null) + { + throw WebDriverResponseException.UnsupportedOperation("This operation is not supported for Root app"); + } + return CurrentWindowWithHandle.WindowHandle; + } + } + + public KnownElement GetOrAddKnownElement(AutomationElement element) + { + var result = KnownElementsByElementReference.Values.FirstOrDefault(knownElement => knownElement.Element.Equals(element)); + if (result == null) + { + result = new KnownElement(element); + KnownElementsByElementReference.Add(result.ElementReference, result); + } + return result; + } + + public AutomationElement? FindKnownElementById(string elementId) + { + if (!KnownElementsByElementReference.TryGetValue(elementId, out var knownElement)) + { + return null; + } + return knownElement.Element; + } + + public KnownWindow GetOrAddKnownWindow(Window window) + { + var result = KnownWindowsByWindowHandle.Values.FirstOrDefault(knownElement => knownElement.Window.Equals(window)); + if (result == null) + { + result = new KnownWindow(window); + KnownWindowsByWindowHandle.Add(result.WindowHandle, result); + } + return result; + } + + public Window? FindKnownWindowByWindowHandle(string windowHandle) + { + if (!KnownWindowsByWindowHandle.TryGetValue(windowHandle, out var knownWindow)) + { + return null; + } + return knownWindow.Window; + } + + public void RemoveKnownWindow(Window window) + { + var item = KnownWindowsByWindowHandle.Values.FirstOrDefault(knownElement => knownElement.Window.Equals(window)); + if (item != null) + { + KnownWindowsByWindowHandle.Remove(item.WindowHandle); + } + } + + public void Dispose() + { + if (App != null && !App.HasExited) + { + App.Close(); + } + Automation.Dispose(); + App?.Dispose(); + } + } +} diff --git a/src/FlaUI.WebDriver/SessionRepository.cs b/src/FlaUI.WebDriver/SessionRepository.cs new file mode 100644 index 0000000..fb0e146 --- /dev/null +++ b/src/FlaUI.WebDriver/SessionRepository.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Linq; + +namespace FlaUI.WebDriver +{ + public class SessionRepository : ISessionRepository + { + private List Sessions { get; } = new List(); + + public Session? FindById(string sessionId) + { + return Sessions.SingleOrDefault(session => session.SessionId == sessionId); + } + + public void Add(Session session) + { + Sessions.Add(session); + } + + public void Delete(Session session) + { + Sessions.Remove(session); + } + } +} diff --git a/src/FlaUI.WebDriver/Startup.cs b/src/FlaUI.WebDriver/Startup.cs new file mode 100644 index 0000000..4262641 --- /dev/null +++ b/src/FlaUI.WebDriver/Startup.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; +using System; + +namespace FlaUI.WebDriver +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + + services.Configure(options => options.LowercaseUrls = true); + services.AddControllers(options => + options.Filters.Add(new WebDriverResponseExceptionFilter())); + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "FlaUI.WebDriver", Version = "v1" }); + }); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "FlaUI.WebDriver v1")); + } + + app.Use(async (context, next) => + { + context.Response.GetTypedHeaders().CacheControl = + new Microsoft.Net.Http.Headers.CacheControlHeaderValue() + { + NoCache = true, + }; + await next(); + }); + + app.UseRouting(); + + app.UseAuthorization(); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + } +} diff --git a/src/FlaUI.WebDriver/TimeoutsConfiguration.cs b/src/FlaUI.WebDriver/TimeoutsConfiguration.cs new file mode 100644 index 0000000..47ff012 --- /dev/null +++ b/src/FlaUI.WebDriver/TimeoutsConfiguration.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace FlaUI.WebDriver +{ + public class TimeoutsConfiguration + { + [JsonPropertyName("script")] + public int? ScriptTimeoutMs { get; set; } = 30000; + [JsonPropertyName("pageLoad")] + public int PageLoadTimeoutMs { get; set; } = 300000; + [JsonPropertyName("implicit")] + public int ImplicitWaitTimeoutMs { get; set; } = 0; + } +} diff --git a/src/FlaUI.WebDriver/Wait.cs b/src/FlaUI.WebDriver/Wait.cs new file mode 100644 index 0000000..04980f4 --- /dev/null +++ b/src/FlaUI.WebDriver/Wait.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using System; + +namespace FlaUI.WebDriver +{ + public static class Wait + { + public static async Task Until(Func until, TimeSpan timeout) + { + return await Until(until, result => result, timeout); + } + + public static async Task Until(Func selector, Func until, TimeSpan timeout) + { + var timeSpent = TimeSpan.Zero; + T result; + while (!until(result = selector())) + { + if (timeSpent > timeout) + { + return result; + } + + var delay = TimeSpan.FromMilliseconds(100); + await Task.Delay(delay); + timeSpent += delay; + } + + return result; + } + } +} diff --git a/src/FlaUI.WebDriver/WebDriverExceptionFilter.cs b/src/FlaUI.WebDriver/WebDriverExceptionFilter.cs new file mode 100644 index 0000000..8a4134e --- /dev/null +++ b/src/FlaUI.WebDriver/WebDriverExceptionFilter.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc.Filters; +using FlaUI.WebDriver.Models; +using Microsoft.AspNetCore.Mvc; +using FlaUI.Core.Logging; +using Microsoft.Extensions.Logging; +using System.Windows.Forms.Design; +using Microsoft.Extensions.DependencyInjection; + +namespace FlaUI.WebDriver +{ + public class WebDriverResponseExceptionFilter : IActionFilter, IOrderedFilter + { + public int Order { get; } = int.MaxValue - 10; + + public void OnActionExecuting(ActionExecutingContext context) { } + + 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); + + context.Result = new ObjectResult(new ResponseWithValue(new ErrorResponse { ErrorCode = exception.ErrorCode, Message = exception.Message })) { + StatusCode = exception.StatusCode + }; + context.ExceptionHandled = true; + } + } + } +} diff --git a/src/FlaUI.WebDriver/WebDriverResponseException.cs b/src/FlaUI.WebDriver/WebDriverResponseException.cs new file mode 100644 index 0000000..52ccbcf --- /dev/null +++ b/src/FlaUI.WebDriver/WebDriverResponseException.cs @@ -0,0 +1,30 @@ +using System; + +namespace FlaUI.WebDriver +{ + public class WebDriverResponseException : Exception + { + private WebDriverResponseException(string message, string errorCode, int statusCode) : base(message) + { + ErrorCode = errorCode; + StatusCode = statusCode; + } + + public int StatusCode { get; set; } = 500; + public string ErrorCode { get; set; } = "unknown error"; + + public static WebDriverResponseException UnknownError(string message) => new WebDriverResponseException(message, "unknown error", 500); + + public static WebDriverResponseException UnsupportedOperation(string message) => new WebDriverResponseException(message, "unsupported operation", 500); + + public static WebDriverResponseException InvalidArgument(string message) => new WebDriverResponseException(message, "invalid argument", 400); + + public static WebDriverResponseException SessionNotFound(string sessionId) => new WebDriverResponseException($"No active session with ID '{sessionId}'", "invalid session id", 404); + + public static WebDriverResponseException ElementNotFound(string elementId) => new WebDriverResponseException($"No element found with ID '{elementId}'", "no such element", 404); + + 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); + } +} diff --git a/src/FlaUI.WebDriver/WebDriverResult.cs b/src/FlaUI.WebDriver/WebDriverResult.cs new file mode 100644 index 0000000..d39c39e --- /dev/null +++ b/src/FlaUI.WebDriver/WebDriverResult.cs @@ -0,0 +1,37 @@ +using FlaUI.WebDriver.Models; +using Microsoft.AspNetCore.Mvc; +using System; + +namespace FlaUI.WebDriver +{ + public static class WebDriverResult + { + public static ActionResult Success(T value) + { + return new OkObjectResult(new ResponseWithValue(value)); + } + + public static ActionResult Success() + { + return new OkObjectResult(new ResponseWithValue(null)); + } + + public static ActionResult BadRequest(ErrorResponse errorResponse) + { + return new BadRequestObjectResult(new ResponseWithValue(errorResponse)); + } + + public static ActionResult NotFound(ErrorResponse errorResponse) + { + return new NotFoundObjectResult(new ResponseWithValue(errorResponse)); + } + + public static ActionResult Error(ErrorResponse errorResponse) + { + return new ObjectResult(new ResponseWithValue(errorResponse)) + { + StatusCode = 500 + }; + } + } +} diff --git a/src/FlaUI.WebDriver/appsettings.Development.json b/src/FlaUI.WebDriver/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/FlaUI.WebDriver/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/FlaUI.WebDriver/appsettings.json b/src/FlaUI.WebDriver/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/FlaUI.WebDriver/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/TestApplications/WpfApplication/App.xaml b/src/TestApplications/WpfApplication/App.xaml new file mode 100644 index 0000000..b509a5c --- /dev/null +++ b/src/TestApplications/WpfApplication/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/src/TestApplications/WpfApplication/App.xaml.cs b/src/TestApplications/WpfApplication/App.xaml.cs new file mode 100644 index 0000000..4cd4cdb --- /dev/null +++ b/src/TestApplications/WpfApplication/App.xaml.cs @@ -0,0 +1,14 @@ +using System.Configuration; +using System.Data; +using System.Windows; + +namespace WpfApp1 +{ + /// + /// Interaction logic for App.xaml + /// + public partial class App : Application + { + } + +} diff --git a/src/TestApplications/WpfApplication/AssemblyInfo.cs b/src/TestApplications/WpfApplication/AssemblyInfo.cs new file mode 100644 index 0000000..b0ec827 --- /dev/null +++ b/src/TestApplications/WpfApplication/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/src/TestApplications/WpfApplication/DataGridItem.cs b/src/TestApplications/WpfApplication/DataGridItem.cs new file mode 100644 index 0000000..58a384d --- /dev/null +++ b/src/TestApplications/WpfApplication/DataGridItem.cs @@ -0,0 +1,25 @@ +using WpfApplication.Infrastructure; + +namespace WpfApplication +{ + public class DataGridItem : ObservableObject + { + public string Name + { + get => GetProperty(); + set => SetProperty(value); + } + + public int Number + { + get => GetProperty(); + set => SetProperty(value); + } + + public bool IsChecked + { + get => GetProperty(); + set => SetProperty(value); + } + } +} diff --git a/src/TestApplications/WpfApplication/Infrastructure/ObservableObject.cs b/src/TestApplications/WpfApplication/Infrastructure/ObservableObject.cs new file mode 100644 index 0000000..d29f3ab --- /dev/null +++ b/src/TestApplications/WpfApplication/Infrastructure/ObservableObject.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Runtime.CompilerServices; + +namespace WpfApplication.Infrastructure +{ + [SuppressMessage("ReSharper", "ExplicitCallerInfoArgument")] + public abstract class ObservableObject : INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + + private readonly Dictionary _backingFieldValues = new Dictionary(); + + /// + /// Gets a property value from the internal backing field + /// + protected T GetProperty([CallerMemberName] string propertyName = null) + { + if (propertyName == null) + { + throw new ArgumentNullException(nameof(propertyName)); + } + object value; + if (_backingFieldValues.TryGetValue(propertyName, out value)) + { + return (T)value; + } + return default(T); + } + + /// + /// Saves a property value to the internal backing field + /// + protected bool SetProperty(T newValue, [CallerMemberName] string propertyName = null) + { + if (propertyName == null) + { + throw new ArgumentNullException(nameof(propertyName)); + } + if (IsEqual(GetProperty(propertyName), newValue)) return false; + _backingFieldValues[propertyName] = newValue; + OnPropertyChanged(propertyName); + return true; + } + + /// + /// Sets a property value to the backing field + /// + protected bool SetProperty(ref T field, T newValue, [CallerMemberName] string propertyName = null) + { + if (IsEqual(field, newValue)) return false; + field = newValue; + OnPropertyChanged(propertyName); + return true; + } + + protected virtual void OnPropertyChanged(Expression> selectorExpression) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(GetNameFromExpression(selectorExpression))); + } + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private bool IsEqual(T field, T newValue) + { + // Alternative: EqualityComparer.Default.Equals(field, newValue); + return Equals(field, newValue); + } + + private string GetNameFromExpression(Expression> selectorExpression) + { + var body = (MemberExpression)selectorExpression.Body; + var propertyName = body.Member.Name; + return propertyName; + } + } +} diff --git a/src/TestApplications/WpfApplication/Infrastructure/RelayCommand.cs b/src/TestApplications/WpfApplication/Infrastructure/RelayCommand.cs new file mode 100644 index 0000000..3d897ef --- /dev/null +++ b/src/TestApplications/WpfApplication/Infrastructure/RelayCommand.cs @@ -0,0 +1,38 @@ +using System; +using System.Diagnostics; +using System.Windows.Input; + +namespace WpfApplication.Infrastructure +{ + public class RelayCommand : ICommand + { + private readonly Action _methodToExecute; + readonly Func _canExecuteEvaluator; + + public RelayCommand(Action methodToExecute) + : this(methodToExecute, null) { } + + public RelayCommand(Action methodToExecute, Func canExecuteEvaluator) + { + _methodToExecute = methodToExecute; + _canExecuteEvaluator = canExecuteEvaluator; + } + + [DebuggerStepThrough] + public bool CanExecute(object parameter) + { + return _canExecuteEvaluator == null || _canExecuteEvaluator.Invoke(parameter); + } + + public event EventHandler CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + + public void Execute(object parameter) + { + _methodToExecute.Invoke(parameter); + } + } +} diff --git a/src/TestApplications/WpfApplication/ListViewItem.cs b/src/TestApplications/WpfApplication/ListViewItem.cs new file mode 100644 index 0000000..6f88395 --- /dev/null +++ b/src/TestApplications/WpfApplication/ListViewItem.cs @@ -0,0 +1,19 @@ +using WpfApplication.Infrastructure; + +namespace WpfApplication +{ + public class ListViewItem : ObservableObject + { + public string Key + { + get => GetProperty(); + set => SetProperty(value); + } + + public string Value + { + get => GetProperty(); + set => SetProperty(value); + } + } +} diff --git a/src/TestApplications/WpfApplication/MainViewModel.cs b/src/TestApplications/WpfApplication/MainViewModel.cs new file mode 100644 index 0000000..bfbc58f --- /dev/null +++ b/src/TestApplications/WpfApplication/MainViewModel.cs @@ -0,0 +1,31 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; +using WpfApplication.Infrastructure; + +namespace WpfApplication +{ + public class MainViewModel : ObservableObject + { + public ObservableCollection DataGridItems { get; } + + public ICommand InvokeButtonCommand { get; } + + public string InvokeButtonText + { + get => GetProperty(); + set => SetProperty(value); + } + + public MainViewModel() + { + DataGridItems = new ObservableCollection + { + new DataGridItem { Name = "John", Number = 12, IsChecked = false }, + new DataGridItem { Name = "Doe", Number = 24, IsChecked = true }, + }; + + InvokeButtonText = "Invoke me!"; + InvokeButtonCommand = new RelayCommand(o => InvokeButtonText = "Invoked!"); + } + } +} diff --git a/src/TestApplications/WpfApplication/MainWindow.xaml b/src/TestApplications/WpfApplication/MainWindow.xaml new file mode 100644 index 0000000..c5f4be3 --- /dev/null +++ b/src/TestApplications/WpfApplication/MainWindow.xaml @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +