├── src ├── TestApplications │ └── WpfApplication │ │ ├── WPFApp.ico │ │ ├── App.config │ │ ├── Properties │ │ ├── Settings.settings │ │ ├── Settings.Designer.cs │ │ ├── Resources.Designer.cs │ │ └── Resources.resx │ │ ├── App.xaml.cs │ │ ├── App.xaml │ │ ├── ListViewItem.cs │ │ ├── WpfApplication.csproj │ │ ├── app.manifest │ │ ├── DataGridItem.cs │ │ ├── AssemblyInfo.cs │ │ ├── Window1.xaml.cs │ │ ├── Window1.xaml │ │ ├── MainViewModel.cs │ │ ├── Infrastructure │ │ ├── RelayCommand.cs │ │ └── ObservableObject.cs │ │ ├── MainWindow.xaml.cs │ │ └── MainWindow.xaml ├── FlaUI.WebDriver │ ├── Models │ │ ├── SwitchWindowRequest.cs │ │ ├── ElementSendKeysRequest.cs │ │ ├── WindowsGetClipboardScript.cs │ │ ├── ActionsRequest.cs │ │ ├── CreateSessionRequest.cs │ │ ├── StatusResponse.cs │ │ ├── FindElementRequest.cs │ │ ├── WindowsSetClipboardScript.cs │ │ ├── ElementRect.cs │ │ ├── ResponseWithValue.cs │ │ ├── WindowRect.cs │ │ ├── ExecuteScriptRequest.cs │ │ ├── WindowsKeyScript.cs │ │ ├── Capabilities.cs │ │ ├── CreateSessionResponse.cs │ │ ├── ErrorResponse.cs │ │ ├── WindowsScrollScript.cs │ │ ├── ActionSequence.cs │ │ ├── FindElementResponse.cs │ │ ├── WindowsClickScript.cs │ │ ├── WindowsHoverScript.cs │ │ ├── StringOrDictionaryConverter.cs │ │ └── ActionItem.cs │ ├── appsettings.Development.json │ ├── .config │ │ └── dotnet-tools.json │ ├── SessionCleanupOptions.cs │ ├── appsettings.json │ ├── Services │ │ ├── IConditionParser.cs │ │ ├── IActionsDispatcher.cs │ │ ├── IWindowsExtensionService.cs │ │ ├── ConditionParser.cs │ │ └── WindowsExtensionService.cs │ ├── ISessionRepository.cs │ ├── InputSource.cs │ ├── TimeoutsConfiguration.cs │ ├── app.manifest │ ├── Controllers │ │ ├── StatusController.cs │ │ ├── ErrorController.cs │ │ ├── TimeoutsController.cs │ │ ├── ScreenshotController.cs │ │ ├── ActionsController.cs │ │ ├── FindElementsController.cs │ │ ├── WindowController.cs │ │ └── ExecuteController.cs │ ├── KeyInputSource.cs │ ├── Wait.cs │ ├── SessionRepository.cs │ ├── Properties │ │ └── launchSettings.json │ ├── KnownWindow.cs │ ├── KnownElement.cs │ ├── WebDriverResult.cs │ ├── WebDriverExceptionFilter.cs │ ├── FlaUI.WebDriver.csproj │ ├── Program.cs │ ├── WebDriverResponseException.cs │ ├── NotFoundMiddleware.cs │ ├── AutomationElementExtensions.cs │ ├── Action.cs │ ├── SessionCleanupService.cs │ ├── MergedCapabilities.cs │ ├── InputState.cs │ └── Session.cs ├── FlaUI.WebDriver.UITests │ ├── TestUtil │ │ ├── ExtendedBy.cs │ │ ├── TestAppProcess.cs │ │ ├── TestApplication.cs │ │ ├── FlaUIAppiumDriverOptions.cs │ │ └── FlaUIDriverOptions.cs │ ├── FlaUI.WebDriver.UITests.csproj │ ├── ScreenshotTests.cs │ ├── TimeoutsTests.cs │ ├── WebDriverFixture.cs │ ├── ActionsTests.cs │ ├── ExecuteTests.cs │ ├── WindowTests.cs │ └── FindElementsTests.cs ├── FlaUI.WebDriver.UnitTests │ ├── Services │ │ └── ConditionParserTests.cs │ └── FlaUI.WebDriver.UnitTests.csproj └── FlaUI.WebDriver.sln ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── codeql.yml ├── LICENSE ├── CONTRIBUTING.md └── .gitignore /src/TestApplications/WpfApplication/WPFApp.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlaUI/FlaUI.WebDriver/HEAD/src/TestApplications/WpfApplication/WPFApp.ico -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "nuget" 4 | directory: "/src" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/SwitchWindowRequest.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class SwitchWindowRequest 4 | { 5 | public string Handle { get; set; } = null!; 6 | } 7 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/ElementSendKeysRequest.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class ElementSendKeysRequest 4 | { 5 | public string Text { get; set; } = ""; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/WindowsGetClipboardScript.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class WindowsGetClipboardScript 4 | { 5 | public string? ContentType { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/ActionsRequest.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class ActionsRequest 4 | { 5 | public List Actions { get; set; } = new List(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/CreateSessionRequest.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class CreateSessionRequest 4 | { 5 | public Capabilities Capabilities { get; set; } = new Capabilities(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/StatusResponse.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | internal class StatusResponse 4 | { 5 | public bool Ready { get; set; } 6 | public string Message { get; set; } = ""; 7 | } 8 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "FlaUI.WebDriver": "Debug" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/App.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-ef": { 6 | "version": "8.0.3", 7 | "commands": [ 8 | "dotnet-ef" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/FindElementRequest.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class FindElementRequest 4 | { 5 | public string Using { get; set; } = null!; 6 | public string Value { get; set; } = null!; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/WindowsSetClipboardScript.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class WindowsSetClipboardScript 4 | { 5 | public string B64Content { get; set; } = ""; 6 | public string? ContentType { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/Properties/Settings.settings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/SessionCleanupOptions.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver 2 | { 3 | public class SessionCleanupOptions 4 | { 5 | public const string OptionsSectionName = "SessionCleanup"; 6 | 7 | public double SchedulingIntervalSeconds { get; set; } = 60; 8 | } 9 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "AllowedHosts": "*", 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Information", 6 | "Microsoft.AspNetCore": "Warning" 7 | } 8 | }, 9 | "SessionCleanup": { 10 | "ScheduledIntervalSeconds": 60 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/ElementRect.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class ElementRect 4 | { 5 | public int X { get; set; } 6 | public int Y { get; set; } 7 | public int Width { get; set; } 8 | public int Height { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/ResponseWithValue.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class ResponseWithValue 4 | { 5 | public T Value { get; set; } 6 | 7 | public ResponseWithValue(T value) 8 | { 9 | Value = value; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/WindowRect.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class WindowRect 4 | { 5 | public int? X { get; set; } 6 | public int? Y { get; set; } 7 | public int? Width { get; set; } 8 | public int? Height { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Services/IConditionParser.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.Core.Conditions; 2 | 3 | namespace FlaUI.WebDriver.Services 4 | { 5 | public interface IConditionParser 6 | { 7 | PropertyCondition ParseCondition(ConditionFactory conditionFactory, string @using, string value); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/ExecuteScriptRequest.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace FlaUI.WebDriver.Models 4 | { 5 | public class ExecuteScriptRequest 6 | { 7 | public string Script { get; set; } = null!; 8 | public List Args { get; set; } = new List(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/WindowsKeyScript.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class WindowsKeyScript 4 | { 5 | public int? Pause { get; set; } 6 | public string? Text { get; set; } 7 | public ushort? VirtualKeyCode { get; set; } 8 | public bool? Down { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/Capabilities.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace FlaUI.WebDriver.Models 4 | { 5 | public class Capabilities 6 | { 7 | public Dictionary? AlwaysMatch { get; set; } 8 | public List>? FirstMatch { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/App.xaml.cs: -------------------------------------------------------------------------------- 1 | using System.Configuration; 2 | using System.Data; 3 | using System.Windows; 4 | 5 | namespace WpfApplication 6 | { 7 | /// 8 | /// Interaction logic for App.xaml 9 | /// 10 | public partial class App : Application 11 | { 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Services/IActionsDispatcher.cs: -------------------------------------------------------------------------------- 1 | 2 | namespace FlaUI.WebDriver.Services 3 | { 4 | public interface IActionsDispatcher 5 | { 6 | Task DispatchAction(Session session, Action action); 7 | Task DispatchActionsForString(Session session, string inputId, KeyInputSource source, string text); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/ISessionRepository.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver 2 | { 3 | public interface ISessionRepository 4 | { 5 | void Add(Session session); 6 | void Delete(Session session); 7 | List FindAll(); 8 | Session? FindById(string sessionId); 9 | List FindTimedOut(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/CreateSessionResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace FlaUI.WebDriver.Models 4 | { 5 | public class CreateSessionResponse 6 | { 7 | public string SessionId { get; set; } = null!; 8 | public IDictionary Capabilities { get; set; } = new Dictionary(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/InputSource.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver; 2 | 3 | /// 4 | /// An input source is a virtual device providing input events. 5 | /// 6 | /// 7 | public class InputSource 8 | { 9 | protected InputSource(string type) => Type = type; 10 | 11 | public string Type { get; } 12 | } 13 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/ErrorResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace FlaUI.WebDriver.Models 4 | { 5 | public class ErrorResponse 6 | { 7 | [JsonPropertyName("error")] 8 | public string ErrorCode { get; set; } = "unknown error"; 9 | public string Message { get; set; } = ""; 10 | public string StackTrace { get; set; } = ""; 11 | } 12 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/WindowsScrollScript.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class WindowsScrollScript 4 | { 5 | public string? ElementId { get; set; } 6 | public int? X { get; set; } 7 | public int? Y { get; set; } 8 | public int? DeltaX { get; set; } 9 | public int? DeltaY { get; set; } 10 | public string[]? ModifierKeys { get; set; } 11 | } 12 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/ActionSequence.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class ActionSequence 4 | { 5 | public string Id { get; set; } = null!; 6 | public string Type { get; set; } = null!; 7 | public Dictionary Parameters { get; set; } = new Dictionary(); 8 | public List Actions { get; set; } = new List(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/App.xaml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/FindElementResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace FlaUI.WebDriver.Models 4 | { 5 | public class FindElementResponse 6 | { 7 | /// 8 | /// See https://www.w3.org/TR/webdriver2/#dfn-web-element-identifier 9 | /// 10 | [JsonPropertyName("element-6066-11e4-a52e-4f735466cecf")] 11 | public string ElementReference { get; set; } = null!; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/ListViewItem.cs: -------------------------------------------------------------------------------- 1 | using WpfApplication.Infrastructure; 2 | 3 | namespace WpfApplication 4 | { 5 | public class ListViewItem : ObservableObject 6 | { 7 | public string Key 8 | { 9 | get => GetProperty(); 10 | set => SetProperty(value); 11 | } 12 | 13 | public string Value 14 | { 15 | get => GetProperty(); 16 | set => SetProperty(value); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/TimeoutsConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace FlaUI.WebDriver 4 | { 5 | public class TimeoutsConfiguration 6 | { 7 | [JsonPropertyName("script")] 8 | public double? ScriptTimeoutMs { get; set; } = 30000; 9 | [JsonPropertyName("pageLoad")] 10 | public double PageLoadTimeoutMs { get; set; } = 300000; 11 | [JsonPropertyName("implicit")] 12 | public double ImplicitWaitTimeoutMs { get; set; } = 0; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/WpfApplication.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WinExe 5 | net8.0-windows 6 | disable 7 | enable 8 | true 9 | false 10 | app.manifest 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/WindowsClickScript.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class WindowsClickScript 4 | { 5 | public string? ElementId { get; set; } 6 | public int? X { get; set; } 7 | public int? Y { get; set; } 8 | public string? Button { get; set; } 9 | public string[]? ModifierKeys { get; set; } 10 | public int? DurationMs { get; set; } 11 | public int? Times { get; set; } 12 | public int? InterClickDelayMs { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/WindowsHoverScript.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver.Models 2 | { 3 | public class WindowsHoverScript 4 | { 5 | public string? StartElementId { get; set; } 6 | public int? StartX { get; set; } 7 | public int? StartY { get; set; } 8 | public int? EndX { get; set; } 9 | public int? EndY { get; set; } 10 | public string? EndElementId { get; set; } 11 | public int? DurationMs { get; set; } 12 | public string[]? ModifierKeys { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/app.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | PerMonitorV2 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/app.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | PerMonitorV2 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Controllers/StatusController.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.Models; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace FlaUI.WebDriver.Controllers 5 | { 6 | [Route("[controller]")] 7 | [ApiController] 8 | public class StatusController : ControllerBase 9 | { 10 | [HttpGet] 11 | public ActionResult GetStatus() 12 | { 13 | return WebDriverResult.Success(new StatusResponse() 14 | { 15 | Ready = true, 16 | Message = "Hello World!" 17 | }); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/TestUtil/ExtendedBy.cs: -------------------------------------------------------------------------------- 1 | using OpenQA.Selenium; 2 | 3 | namespace FlaUI.WebDriver.UITests.TestUtil 4 | { 5 | internal class ExtendedBy : By 6 | { 7 | public ExtendedBy(string mechanism, string criteria) : base(mechanism, criteria) 8 | { 9 | } 10 | 11 | public static ExtendedBy AccessibilityId(string accessibilityId) => new ExtendedBy("accessibility id", accessibilityId); 12 | 13 | public static ExtendedBy NonCssName(string name) => new ExtendedBy("name", name); 14 | 15 | public static ExtendedBy NonCssClassName(string name) => new ExtendedBy("class name", name); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/DataGridItem.cs: -------------------------------------------------------------------------------- 1 | using WpfApplication.Infrastructure; 2 | 3 | namespace WpfApplication 4 | { 5 | public class DataGridItem : ObservableObject 6 | { 7 | public string Name 8 | { 9 | get => GetProperty(); 10 | set => SetProperty(value); 11 | } 12 | 13 | public int Number 14 | { 15 | get => GetProperty(); 16 | set => SetProperty(value); 17 | } 18 | 19 | public bool IsChecked 20 | { 21 | get => GetProperty(); 22 | set => SetProperty(value); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Windows; 2 | 3 | [assembly: ThemeInfo( 4 | ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located 5 | //(used if a resource is not found in the page, 6 | // or application resource dictionaries) 7 | ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located 8 | //(used if a resource is not found in the page, 9 | // app, or any theme specific resource dictionaries) 10 | )] 11 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/KeyInputSource.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver; 2 | 3 | /// 4 | /// A key input source is an input source that is associated with a keyboard-type device. 5 | /// 6 | /// 7 | public class KeyInputSource() : InputSource("key") 8 | { 9 | public HashSet Pressed = []; 10 | 11 | public bool Alt { get; set; } 12 | public bool Ctrl{ get; set; } 13 | public bool Meta { get; set; } 14 | public bool Shift { get; set; } 15 | 16 | public void Reset() 17 | { 18 | Pressed.Clear(); 19 | Alt = false; 20 | Ctrl = false; 21 | Meta = false; 22 | Shift = false; 23 | } 24 | } -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/Window1.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.Windows; 7 | using System.Windows.Controls; 8 | using System.Windows.Data; 9 | using System.Windows.Documents; 10 | using System.Windows.Input; 11 | using System.Windows.Media; 12 | using System.Windows.Media.Imaging; 13 | using System.Windows.Shapes; 14 | 15 | namespace WpfApplication 16 | { 17 | /// 18 | /// Interaction logic for Window1.xaml 19 | /// 20 | public partial class Window1 : Window 21 | { 22 | public Window1() 23 | { 24 | InitializeComponent(); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/TestUtil/TestAppProcess.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | 4 | namespace FlaUI.WebDriver.UITests.TestUtil 5 | { 6 | public class TestAppProcess : IDisposable 7 | { 8 | private readonly Process _process; 9 | 10 | public TestAppProcess() 11 | { 12 | _process = Process.Start(TestApplication.FullPath); 13 | while (_process.MainWindowHandle == IntPtr.Zero) 14 | { 15 | System.Threading.Thread.Sleep(100); 16 | } 17 | } 18 | 19 | public Process Process => _process; 20 | 21 | public void Dispose() 22 | { 23 | _process.Kill(); 24 | _process.Dispose(); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Services/IWindowsExtensionService.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.Models; 2 | 3 | namespace FlaUI.WebDriver.Services 4 | { 5 | public interface IWindowsExtensionService 6 | { 7 | Task ExecuteClickScript(Session session, WindowsClickScript action); 8 | Task ExecuteScrollScript(Session session, WindowsScrollScript action); 9 | Task ExecuteHoverScript(Session session, WindowsHoverScript action); 10 | Task ExecuteKeyScript(Session session, WindowsKeyScript action); 11 | Task ExecuteGetClipboardScript(Session session, WindowsGetClipboardScript action); 12 | Task ExecuteSetClipboardScript(Session session, WindowsSetClipboardScript action); 13 | Task ExecuteClearClipboardScript(Session session); 14 | } 15 | } -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/Window1.xaml: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UnitTests/Services/ConditionParserTests.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.Services; 2 | using NUnit.Framework; 3 | 4 | namespace FlaUI.WebDriver.UnitTests.Services 5 | { 6 | public class ConditionParserTests 7 | { 8 | [TestCase("[name=\"2\"]")] 9 | [TestCase("*[name=\"2\"]")] 10 | [TestCase("*[name = \"2\"]")] 11 | public void ParseCondition_ByCssAttributeName_ReturnsCondition(string selector) 12 | { 13 | var parser = new ConditionParser(); 14 | var uia3 = new UIA3.UIA3Automation(); 15 | 16 | var result = parser.ParseCondition(uia3.ConditionFactory, "css selector", selector); 17 | 18 | Assert.That(result.Property, Is.EqualTo(uia3.PropertyLibrary.Element.Name)); 19 | Assert.That(result.Value, Is.EqualTo("2")); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Wait.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver 2 | { 3 | public static class Wait 4 | { 5 | public static async Task Until(Func until, TimeSpan timeout) 6 | { 7 | return await Until(until, result => result, timeout); 8 | } 9 | 10 | public static async Task Until(Func selector, Func until, TimeSpan timeout) 11 | { 12 | var timeSpent = TimeSpan.Zero; 13 | T result; 14 | while (!until(result = selector())) 15 | { 16 | if (timeSpent > timeout) 17 | { 18 | return result; 19 | } 20 | 21 | var delay = TimeSpan.FromMilliseconds(100); 22 | await Task.Delay(delay); 23 | timeSpent += delay; 24 | } 25 | 26 | return result; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UnitTests/FlaUI.WebDriver.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0-windows 5 | 6 | false 7 | preview 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | all 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/SessionRepository.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver 2 | { 3 | public class SessionRepository : ISessionRepository 4 | { 5 | private List Sessions { get; } = new List(); 6 | 7 | public Session? FindById(string sessionId) 8 | { 9 | return Sessions.SingleOrDefault(session => session.SessionId == sessionId); 10 | } 11 | 12 | public void Add(Session session) 13 | { 14 | Sessions.Add(session); 15 | } 16 | 17 | public void Delete(Session session) 18 | { 19 | Sessions.Remove(session); 20 | } 21 | 22 | public List FindTimedOut() 23 | { 24 | return Sessions.Where(session => session.IsTimedOut).ToList(); 25 | } 26 | 27 | public List FindAll() 28 | { 29 | return new List(Sessions); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "FlaUI.WebDriver": { 4 | "commandName": "Project", 5 | "commandLineArgs": "--urls=http://localhost:4723/", 6 | "launchUrl": "swagger", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | }, 10 | "dotnetRunMessages": true, 11 | "applicationUrl": "http://localhost:4723" 12 | }, 13 | "IIS Express": { 14 | "commandName": "IISExpress", 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | } 21 | }, 22 | "$schema": "https://json.schemastore.org/launchsettings.json", 23 | "iisSettings": { 24 | "windowsAuthentication": false, 25 | "anonymousAuthentication": true, 26 | "iisExpress": { 27 | "applicationUrl": "http://localhost:26539", 28 | "sslPort": 0 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/StringOrDictionaryConverter.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using System.Text.Json; 3 | 4 | namespace FlaUI.WebDriver.Models; 5 | 6 | internal class StringOrDictionaryConverter : JsonConverter 7 | { 8 | public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 9 | { 10 | if (reader.TokenType == JsonTokenType.String) 11 | { 12 | return reader.GetString(); 13 | } 14 | else if (reader.TokenType == JsonTokenType.StartObject) 15 | { 16 | var dictionary = JsonSerializer.Deserialize>(ref reader, options); 17 | return dictionary; 18 | } 19 | else 20 | { 21 | throw new JsonException("Unexpected JSON token type."); 22 | } 23 | } 24 | 25 | public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) 26 | { 27 | throw new NotImplementedException(); 28 | } 29 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Models/ActionItem.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace FlaUI.WebDriver.Models 4 | { 5 | public class ActionItem 6 | { 7 | public string Type { get; set; } = null!; 8 | public int? Button { get; set; } 9 | public int? Duration { get; set; } 10 | [JsonConverter(typeof(StringOrDictionaryConverter))] 11 | public object? Origin { get; set; } 12 | public int? X { get; set; } 13 | public int? Y { get; set; } 14 | public int? DeltaX { get; set; } 15 | public int? DeltaY { get; set; } 16 | public int? Width { get; set; } 17 | public int? Height { get; set; } 18 | public int? Pressure { get; set; } 19 | public int? TangentialPressure { get; set; } 20 | public int? TiltX { get; set; } 21 | public int? TiltY { get; set; } 22 | public int? Twist { get; set; } 23 | public int? AltitudeAngle { get; set; } 24 | public int? AzimuthAngle { get; set; } 25 | public string? Value { get; set; } 26 | } 27 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/KnownWindow.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.Core.AutomationElements; 2 | 3 | namespace FlaUI.WebDriver 4 | { 5 | public class KnownWindow 6 | { 7 | public KnownWindow(Window window, string? windowRuntimeId, string windowHandle) 8 | { 9 | Window = window; 10 | WindowRuntimeId = windowRuntimeId; 11 | WindowHandle = windowHandle; 12 | } 13 | 14 | public string WindowHandle { get; } 15 | 16 | /// 17 | /// A temporarily unique ID, so cannot be used for identity over time, but can be used for improving performance of equality tests. 18 | /// "The identifier is only guaranteed to be unique to the UI of the desktop on which it was generated. Identifiers can be reused over time." 19 | /// 20 | /// 21 | public string? WindowRuntimeId { get; } 22 | 23 | public Window Window { get; } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/MainViewModel.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | using System.Windows.Input; 3 | using WpfApplication.Infrastructure; 4 | 5 | namespace WpfApplication 6 | { 7 | public class MainViewModel : ObservableObject 8 | { 9 | public ObservableCollection DataGridItems { get; } 10 | 11 | public ICommand InvokeButtonCommand { get; } 12 | 13 | public string InvokeButtonText 14 | { 15 | get => GetProperty(); 16 | set => SetProperty(value); 17 | } 18 | 19 | public MainViewModel() 20 | { 21 | DataGridItems = new ObservableCollection 22 | { 23 | new DataGridItem { Name = "John", Number = 12, IsChecked = false }, 24 | new DataGridItem { Name = "Doe", Number = 24, IsChecked = true }, 25 | }; 26 | 27 | InvokeButtonText = "Invoke me!"; 28 | InvokeButtonCommand = new RelayCommand(o => InvokeButtonText = "Invoked!"); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/KnownElement.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.Core.AutomationElements; 2 | 3 | namespace FlaUI.WebDriver 4 | { 5 | public class KnownElement 6 | { 7 | public KnownElement(AutomationElement element, string? elementRuntimeId, string elementReference) 8 | { 9 | Element = element; 10 | ElementRuntimeId = elementRuntimeId; 11 | ElementReference = elementReference; 12 | } 13 | 14 | public string ElementReference { get; } 15 | 16 | /// 17 | /// A temporarily unique ID, so cannot be used for identity over time, but can be used for improving performance of equality tests. 18 | /// "The identifier is only guaranteed to be unique to the UI of the desktop on which it was generated. Identifiers can be reused over time." 19 | /// 20 | /// 21 | public string? ElementRuntimeId { get; } 22 | 23 | public AutomationElement Element { get; } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 FlaUI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/FlaUI.WebDriver.UITests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0-windows 5 | 6 | false 7 | preview 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/TestUtil/TestApplication.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Linq; 3 | using OpenQA.Selenium.Remote; 4 | 5 | namespace FlaUI.WebDriver.UITests.TestUtil 6 | { 7 | public static class TestApplication 8 | { 9 | 10 | private static readonly string s_currentDirectory = Directory.GetCurrentDirectory(); 11 | private static readonly string s_solutionDirectory = FindSolutionDirectory(s_currentDirectory); 12 | 13 | public static double GetScaling(RemoteWebDriver driver) 14 | { 15 | return double.Parse(driver.FindElement(ExtendedBy.AccessibilityId("DpiScaling")).Text.ToString()); 16 | } 17 | 18 | private static string FindSolutionDirectory(string currentDirectory) 19 | { 20 | while (!Directory.GetFiles(currentDirectory, "*.sln").Any()) 21 | { 22 | currentDirectory = Directory.GetParent(currentDirectory).FullName; 23 | } 24 | return currentDirectory; 25 | } 26 | 27 | public static string FullPath => Path.Combine(s_solutionDirectory, "TestApplications", "WpfApplication", "bin", "Release", "WpfApplication.exe"); 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/Properties/Settings.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace WpfApplication.Properties { 12 | 13 | 14 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 15 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.0.3.0")] 16 | internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { 17 | 18 | private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); 19 | 20 | public static Settings Default { 21 | get { 22 | return defaultInstance; 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/WebDriverResult.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.Models; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace FlaUI.WebDriver 5 | { 6 | public static class WebDriverResult 7 | { 8 | public static ActionResult Success(T value) 9 | { 10 | return new OkObjectResult(new ResponseWithValue(value)); 11 | } 12 | 13 | public static ActionResult Success() 14 | { 15 | return new OkObjectResult(new ResponseWithValue(null)); 16 | } 17 | 18 | public static ActionResult BadRequest(ErrorResponse errorResponse) 19 | { 20 | return new BadRequestObjectResult(new ResponseWithValue(errorResponse)); 21 | } 22 | 23 | public static ActionResult NotFound(ErrorResponse errorResponse) 24 | { 25 | return new NotFoundObjectResult(new ResponseWithValue(errorResponse)); 26 | } 27 | 28 | public static ActionResult Error(ErrorResponse errorResponse) 29 | { 30 | return new ObjectResult(new ResponseWithValue(errorResponse)) 31 | { 32 | StatusCode = 500 33 | }; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/WebDriverExceptionFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Filters; 2 | using FlaUI.WebDriver.Models; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace FlaUI.WebDriver 6 | { 7 | public class WebDriverResponseExceptionFilter : IActionFilter, IOrderedFilter 8 | { 9 | public int Order { get; } = int.MaxValue - 10; 10 | 11 | public void OnActionExecuting(ActionExecutingContext context) { } 12 | 13 | public void OnActionExecuted(ActionExecutedContext context) 14 | { 15 | if (context.Exception is WebDriverResponseException exception) 16 | { 17 | var logger = context.HttpContext.RequestServices.GetRequiredService>(); 18 | logger.LogError(exception, "Returning WebDriver error response with error code '{ErrorCode}'", exception.ErrorCode); 19 | 20 | context.Result = new ObjectResult(new ResponseWithValue(new ErrorResponse { ErrorCode = exception.ErrorCode, Message = exception.Message })) 21 | { 22 | StatusCode = exception.StatusCode 23 | }; 24 | context.ExceptionHandled = true; 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Controllers/ErrorController.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.Models; 2 | using Microsoft.AspNetCore.Diagnostics; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace FlaUI.WebDriver.Controllers 6 | { 7 | [ApiController] 8 | [ApiExplorerSettings(IgnoreApi = true)] 9 | public class ErrorController : ControllerBase 10 | { 11 | private readonly ILogger _logger; 12 | 13 | 14 | public ErrorController(ILogger logger) { 15 | _logger = logger; 16 | } 17 | 18 | [Route("/error")] 19 | public IActionResult HandleError() { 20 | var exceptionHandlerFeature = HttpContext.Features.Get()!; 21 | 22 | _logger.LogError(exceptionHandlerFeature.Error, "Returning WebDriver error response with error code 'unknown error'"); 23 | 24 | return new ObjectResult(new ResponseWithValue(new ErrorResponse { 25 | ErrorCode = "unknown error", 26 | Message = exceptionHandlerFeature.Error.Message, 27 | StackTrace = exceptionHandlerFeature.Error.StackTrace ?? "" })) 28 | { 29 | StatusCode = 500 30 | }; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/FlaUI.WebDriver.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0-windows 5 | enable 6 | enable 7 | preview 8 | false 9 | win-x64 10 | true 11 | true 12 | app.manifest 13 | net8.0 14 | 15 | 16 | 17 | 18 | 19 | all 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/Infrastructure/RelayCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Windows.Input; 4 | 5 | namespace WpfApplication.Infrastructure 6 | { 7 | public class RelayCommand : ICommand 8 | { 9 | private readonly Action _methodToExecute; 10 | readonly Func _canExecuteEvaluator; 11 | 12 | public RelayCommand(Action methodToExecute) 13 | : this(methodToExecute, null) { } 14 | 15 | public RelayCommand(Action methodToExecute, Func canExecuteEvaluator) 16 | { 17 | _methodToExecute = methodToExecute; 18 | _canExecuteEvaluator = canExecuteEvaluator; 19 | } 20 | 21 | [DebuggerStepThrough] 22 | public bool CanExecute(object parameter) 23 | { 24 | return _canExecuteEvaluator == null || _canExecuteEvaluator.Invoke(parameter); 25 | } 26 | 27 | public event EventHandler CanExecuteChanged 28 | { 29 | add => CommandManager.RequerySuggested += value; 30 | remove => CommandManager.RequerySuggested -= value; 31 | } 32 | 33 | public void Execute(object parameter) 34 | { 35 | _methodToExecute.Invoke(parameter); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/ScreenshotTests.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.UITests.TestUtil; 2 | using NUnit.Framework; 3 | using OpenQA.Selenium.Remote; 4 | using System.Drawing; 5 | using System.IO; 6 | 7 | namespace FlaUI.WebDriver.UITests 8 | { 9 | public class ScreenshotTests 10 | { 11 | [Test] 12 | public void GetScreenshot_FromDesktop_ReturnsScreenshot() 13 | { 14 | var driverOptions = FlaUIDriverOptions.RootApp(); 15 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 16 | 17 | var screenshot = driver.GetScreenshot(); 18 | 19 | Assert.That(screenshot.AsByteArray.Length, Is.Not.Zero); 20 | } 21 | 22 | [Test] 23 | public void GetScreenshot_FromApplication_ReturnsScreenshotOfCurrentWindow() 24 | { 25 | var driverOptions = FlaUIDriverOptions.TestApp(); 26 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 27 | 28 | var screenshot = driver.GetScreenshot(); 29 | 30 | Assert.That(screenshot.AsByteArray.Length, Is.Not.Zero); 31 | using var stream = new MemoryStream(screenshot.AsByteArray); 32 | using var image = new Bitmap(stream); 33 | Assert.That(image.Size, Is.EqualTo(driver.Manage().Window.Size)); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/TimeoutsTests.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.UITests.TestUtil; 2 | using NUnit.Framework; 3 | using OpenQA.Selenium.Remote; 4 | using System; 5 | 6 | namespace FlaUI.WebDriver.UITests 7 | { 8 | [TestFixture] 9 | public class TimeoutsTests 10 | { 11 | [Test] 12 | public void SetTimeouts_Default_IsSupported() 13 | { 14 | var driverOptions = FlaUIDriverOptions.RootApp(); 15 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 16 | 17 | driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(3); 18 | 19 | Assert.That(driver.Manage().Timeouts().ImplicitWait, Is.EqualTo(TimeSpan.FromSeconds(3))); 20 | } 21 | 22 | [Test] 23 | public void GetTimeouts_Default_ReturnsDefaultTimeouts() 24 | { 25 | var driverOptions = FlaUIDriverOptions.RootApp(); 26 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 27 | 28 | var timeouts = driver.Manage().Timeouts(); 29 | 30 | Assert.That(timeouts.ImplicitWait, Is.EqualTo(TimeSpan.Zero)); 31 | Assert.That(timeouts.PageLoad, Is.EqualTo(TimeSpan.FromMilliseconds(300000))); 32 | Assert.That(timeouts.AsynchronousJavaScript, Is.EqualTo(TimeSpan.FromMilliseconds(30000))); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Program.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver; 2 | using FlaUI.WebDriver.Services; 3 | using Microsoft.OpenApi.Models; 4 | 5 | var builder = WebApplication.CreateBuilder(args); 6 | 7 | builder.Services.AddSingleton(); 8 | builder.Services.AddScoped(); 9 | builder.Services.AddScoped(); 10 | builder.Services.AddScoped(); 11 | 12 | builder.Services.Configure(options => options.LowercaseUrls = true); 13 | builder.Services.AddControllers(options => 14 | options.Filters.Add(new WebDriverResponseExceptionFilter())); 15 | 16 | builder.Services.AddEndpointsApiExplorer(); 17 | builder.Services.AddSwaggerGen(c => 18 | { 19 | c.SwaggerDoc("v1", new OpenApiInfo { Title = "FlaUI.WebDriver", Version = "v1" }); 20 | }); 21 | 22 | builder.Services.Configure( 23 | builder.Configuration.GetSection(SessionCleanupOptions.OptionsSectionName)); 24 | builder.Services.AddHostedService(); 25 | 26 | var app = builder.Build(); 27 | 28 | app.UseMiddleware(); 29 | 30 | app.UseExceptionHandler("/error"); 31 | 32 | // Configure the HTTP request pipeline. 33 | if (app.Environment.IsDevelopment()) 34 | { 35 | app.UseSwagger(); 36 | app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "FlaUI.WebDriver v1")); 37 | } 38 | 39 | app.UseAuthorization(); 40 | 41 | app.MapControllers(); 42 | 43 | app.Run(); 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | ## Building 4 | 5 | Use `dotnet build` to build. 6 | 7 | ## Testing 8 | 9 | Use `dotnet test` to run tests. At the moment the tests are end-to-end UI tests that use [Selenium.WebDriver](https://www.nuget.org/packages/Selenium.WebDriver) to operate a test application, running FlaUI.WebDriver.exe in the background, so they should be run on Windows. 10 | 11 | Add UI tests for every feature added and every bug fixed, and feel free to improve existing test coverage. 12 | 13 | Follow the [naming convention `UnitOfWork_StateUnderTest_ExpectedBehavior`](https://osherove.com/blog/2005/4/3/naming-standards-for-unit-tests.html) for test names. 14 | Separate arrange/act/assert parts of the test by newlines. 15 | 16 | ## Submitting changes 17 | 18 | Please send a [GitHub Pull Request](https://github.com/FlaUI/FlaUI.WebDriver/pulls) with a clear list of what you've done (read more about [pull requests](http://help.github.com/pull-requests/)). Please follow our coding conventions (below) and make sure all of your commits are atomic (one feature per commit). 19 | 20 | Always write a clear log message for your commits. One-line messages are fine for small changes, but bigger changes should look like this: 21 | 22 | $ git commit -m "A brief summary of the commit 23 | > 24 | > A paragraph describing what changed and its impact." 25 | 26 | ## Coding conventions 27 | 28 | Follow the [.NET Runtime Coding Style](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md). 29 | 30 | ## Releasing 31 | 32 | To release, simply create a tag and push it, which will trigger the release automatically: 33 | 34 | git tag -a v0.1.0-alpha -m "Your tag message" 35 | git push origin v0.1.0-alpha 36 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/TestUtil/FlaUIAppiumDriverOptions.cs: -------------------------------------------------------------------------------- 1 | using OpenQA.Selenium.Appium; 2 | 3 | namespace FlaUI.WebDriver.UITests.TestUtil 4 | { 5 | internal class FlaUIAppiumDriverOptions : AppiumOptions 6 | { 7 | public static FlaUIAppiumDriverOptions TestApp() => ForApp(TestApplication.FullPath); 8 | 9 | public static FlaUIAppiumDriverOptions RootApp() => ForApp("Root"); 10 | 11 | public static FlaUIAppiumDriverOptions ForApp(string path) 12 | { 13 | return new FlaUIAppiumDriverOptions() 14 | { 15 | AutomationName = "FlaUI", 16 | PlatformName = "Windows", 17 | App = path 18 | }; 19 | } 20 | 21 | public static FlaUIAppiumDriverOptions ForAppTopLevelWindow(string windowHandle) 22 | { 23 | var options = new FlaUIAppiumDriverOptions() 24 | { 25 | AutomationName = "FlaUI", 26 | PlatformName = "Windows" 27 | }; 28 | options.AddAdditionalAppiumOption("appium:appTopLevelWindow", windowHandle); 29 | return options; 30 | } 31 | 32 | public static FlaUIAppiumDriverOptions ForAppTopLevelWindowTitleMatch(string match) 33 | { 34 | var options = new FlaUIAppiumDriverOptions() 35 | { 36 | AutomationName = "FlaUI", 37 | PlatformName = "Windows" 38 | }; 39 | options.AddAdditionalAppiumOption("appium:appTopLevelWindowTitleMatch", match); 40 | return options; 41 | } 42 | 43 | public static FlaUIAppiumDriverOptions Empty() 44 | { 45 | return new FlaUIAppiumDriverOptions(); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | branches: 8 | - "main" 9 | pull_request: 10 | 11 | jobs: 12 | build: 13 | runs-on: windows-latest 14 | 15 | env: 16 | Configuration: Release 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Setup .NET 25 | uses: actions/setup-dotnet@v4 26 | with: 27 | dotnet-version: 8.0.x 28 | 29 | - name: Restore dependencies 30 | run: dotnet restore 31 | working-directory: ./src 32 | 33 | - name: Build 34 | run: dotnet build --no-restore --configuration $env:Configuration 35 | working-directory: ./src 36 | 37 | - name: Test 38 | run: dotnet test --no-build --configuration $env:Configuration --verbosity normal 39 | working-directory: ./src 40 | 41 | # Unfortunately, --no-build does not seem to work when we publish a specific project, so we use --no-restore instead 42 | # Skip adding a web.config for IIS, as we will always use Kestrel (IsTransformWebConfigDisabled=true) 43 | - name: Publish 44 | run: dotnet publish FlaUI.WebDriver/FlaUI.WebDriver.csproj --no-restore --configuration $env:Configuration --self-contained /p:IsTransformWebConfigDisabled=true 45 | working-directory: ./src 46 | 47 | - name: Upload build artifacts 48 | uses: actions/upload-artifact@v4 49 | with: 50 | name: FlaUI.WebDriver 51 | path: ./src/FlaUI.WebDriver/bin/Release/win-x64/publish 52 | 53 | - name: Release 54 | uses: softprops/action-gh-release@v2 55 | if: startsWith(github.ref, 'refs/tags/') 56 | with: 57 | files: ./src/FlaUI.WebDriver/bin/Release/win-x64/publish/*.* 58 | generate_release_notes: true 59 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Controllers/TimeoutsController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace FlaUI.WebDriver.Controllers 4 | { 5 | [ApiController] 6 | [Route("session/{sessionId}/[controller]")] 7 | public class TimeoutsController : ControllerBase 8 | { 9 | private readonly ISessionRepository _sessionRepository; 10 | private readonly ILogger _logger; 11 | 12 | public TimeoutsController(ISessionRepository sessionRepository, ILogger logger) 13 | { 14 | _sessionRepository = sessionRepository; 15 | _logger = logger; 16 | } 17 | 18 | [HttpGet] 19 | public async Task GetTimeouts([FromRoute] string sessionId) 20 | { 21 | var session = GetSession(sessionId); 22 | return await Task.FromResult(WebDriverResult.Success(session.TimeoutsConfiguration)); 23 | } 24 | 25 | [HttpPost] 26 | public async Task SetTimeouts([FromRoute] string sessionId, [FromBody] TimeoutsConfiguration timeoutsConfiguration) 27 | { 28 | var session = GetSession(sessionId); 29 | _logger.LogInformation("Setting timeouts to {Timeouts} (session {SessionId})", timeoutsConfiguration, session.SessionId); 30 | 31 | session.TimeoutsConfiguration = timeoutsConfiguration; 32 | 33 | return await Task.FromResult(WebDriverResult.Success()); 34 | } 35 | 36 | private Session GetSession(string sessionId) 37 | { 38 | var session = _sessionRepository.FindById(sessionId); 39 | if (session == null) 40 | { 41 | throw WebDriverResponseException.SessionNotFound(sessionId); 42 | } 43 | session.SetLastCommandTimeToNow(); 44 | return session; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/WebDriverResponseException.cs: -------------------------------------------------------------------------------- 1 | namespace FlaUI.WebDriver 2 | { 3 | public class WebDriverResponseException : Exception 4 | { 5 | private WebDriverResponseException(string message, string errorCode, int statusCode) : base(message) 6 | { 7 | ErrorCode = errorCode; 8 | StatusCode = statusCode; 9 | } 10 | 11 | public int StatusCode { get; set; } = 500; 12 | public string ErrorCode { get; set; } = "unknown error"; 13 | 14 | public static WebDriverResponseException UnknownError(string message) => new WebDriverResponseException(message, "unknown error", 500); 15 | 16 | public static WebDriverResponseException UnsupportedOperation(string message) => new WebDriverResponseException(message, "unsupported operation", 500); 17 | 18 | public static WebDriverResponseException InvalidArgument(string message) => new WebDriverResponseException(message, "invalid argument", 400); 19 | 20 | public static WebDriverResponseException SessionNotFound(string sessionId) => new WebDriverResponseException($"No active session with ID '{sessionId}'", "invalid session id", 404); 21 | 22 | public static WebDriverResponseException ElementNotFound(string elementId) => new WebDriverResponseException($"No element found with ID '{elementId}'", "no such element", 404); 23 | 24 | public static WebDriverResponseException WindowNotFoundByHandle(string windowHandle) => new WebDriverResponseException($"No window found with handle '{windowHandle}'", "no such window", 404); 25 | 26 | public static WebDriverResponseException NoWindowsOpenForSession() => new WebDriverResponseException($"No windows are open for the current session", "no such window", 404); 27 | 28 | public static WebDriverResponseException Timeout(string message) => new WebDriverResponseException($"Timeout: ${message}", "timeout", 500); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/TestUtil/FlaUIDriverOptions.cs: -------------------------------------------------------------------------------- 1 | using OpenQA.Selenium; 2 | 3 | namespace FlaUI.WebDriver.UITests.TestUtil 4 | { 5 | internal class FlaUIDriverOptions : DriverOptions 6 | { 7 | public override ICapabilities ToCapabilities() 8 | { 9 | return GenerateDesiredCapabilities(true); 10 | } 11 | 12 | public static FlaUIDriverOptions TestApp() => App(TestApplication.FullPath); 13 | 14 | public static DriverOptions RootApp() => App("Root"); 15 | 16 | public static FlaUIDriverOptions App(string path) 17 | { 18 | var options = new FlaUIDriverOptions() 19 | { 20 | PlatformName = "Windows" 21 | }; 22 | options.AddAdditionalOption("appium:automationName", "FlaUI"); 23 | options.AddAdditionalOption("appium:app", path); 24 | return options; 25 | } 26 | 27 | public static DriverOptions AppTopLevelWindow(string windowHandle) 28 | { 29 | var options = new FlaUIDriverOptions() 30 | { 31 | PlatformName = "Windows" 32 | }; 33 | options.AddAdditionalOption("appium:automationName", "FlaUI"); 34 | options.AddAdditionalOption("appium:appTopLevelWindow", windowHandle); 35 | return options; 36 | } 37 | 38 | public static DriverOptions AppTopLevelWindowTitleMatch(string match) 39 | { 40 | var options = new FlaUIDriverOptions() 41 | { 42 | PlatformName = "Windows" 43 | }; 44 | options.AddAdditionalOption("appium:automationName", "FlaUI"); 45 | options.AddAdditionalOption("appium:appTopLevelWindowTitleMatch", match); 46 | return options; 47 | } 48 | 49 | public static DriverOptions Empty() 50 | { 51 | return new FlaUIDriverOptions(); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/NotFoundMiddleware.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.Models; 2 | 3 | namespace FlaUI.WebDriver 4 | { 5 | public class NotFoundMiddleware 6 | { 7 | private readonly RequestDelegate _next; 8 | private readonly ILogger _logger; 9 | 10 | public NotFoundMiddleware(RequestDelegate next, ILogger logger) 11 | { 12 | _next = next; 13 | _logger = logger; 14 | } 15 | 16 | public async Task Invoke(HttpContext httpContext) 17 | { 18 | await _next(httpContext); 19 | if (!httpContext.Response.HasStarted) 20 | { 21 | if (httpContext.Response.StatusCode == StatusCodes.Status404NotFound) 22 | { 23 | _logger.LogError("Unknown endpoint {Path}", SanitizeForLog(httpContext.Request.Path.ToString())); 24 | await httpContext.Response.WriteAsJsonAsync(new ResponseWithValue(new ErrorResponse 25 | { 26 | ErrorCode = "unknown command", 27 | Message = "Unknown command" 28 | })); 29 | } 30 | else if (httpContext.Response.StatusCode == StatusCodes.Status405MethodNotAllowed) 31 | { 32 | _logger.LogError("Unknown method {Method} for endpoint {Path}", SanitizeForLog(httpContext.Request.Method), SanitizeForLog(httpContext.Request.Path.ToString())); 33 | await httpContext.Response.WriteAsJsonAsync(new ResponseWithValue(new ErrorResponse 34 | { 35 | ErrorCode = "unknown method", 36 | Message = "Unknown method for this endpoint" 37 | })); 38 | } 39 | } 40 | } 41 | 42 | private static string SanitizeForLog(string str) 43 | { 44 | return str.Replace("\r", "").Replace("\n", ""); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/AutomationElementExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using FlaUI.Core.AutomationElements; 3 | using FlaUI.Core.Identifiers; 4 | using FlaUI.Core.Patterns.Infrastructure; 5 | using static FlaUI.Core.FrameworkAutomationElementBase; 6 | 7 | namespace FlaUI.WebDriver; 8 | 9 | /// 10 | /// Gets properties and patterns from an . 11 | /// 12 | /// 13 | /// Not so crazy about using reflection here, but it seems it's the only way to query these objects by string? 14 | /// 15 | public static class AutomationElementExtensions 16 | { 17 | public static bool TryGetPattern(this AutomationElement element, string patternName, [NotNullWhen(true)] out IPattern? pattern) 18 | { 19 | if (typeof(IFrameworkPatterns).GetProperty(patternName) is { } propertyInfo && 20 | propertyInfo.GetValue(element.Patterns) is { } patterns && 21 | patterns.GetType().GetProperty("PatternOrDefault") is { } patternPropertyInfo && 22 | patternPropertyInfo.GetValue(patterns) is IPattern patternValue) 23 | { 24 | pattern = patternValue; 25 | return true; 26 | } 27 | 28 | pattern = null; 29 | return false; 30 | } 31 | 32 | public static bool TryGetProperty(this AutomationElement element, string propertyName, out object? value) 33 | { 34 | var library = element.FrameworkAutomationElement.PropertyIdLibrary; 35 | 36 | if (library.GetType().GetProperty(propertyName) is { } propertyInfo && 37 | propertyInfo.GetValue(library) is PropertyId propertyId) 38 | { 39 | element.FrameworkAutomationElement.TryGetPropertyValue(propertyId, out value); 40 | return true; 41 | } 42 | 43 | value = null; 44 | return false; 45 | } 46 | 47 | public static bool TryGetProperty(this IPattern pattern, string propertyName, out object? value) 48 | { 49 | if (pattern.GetType().GetProperty(propertyName) is { } propertyInfo) 50 | { 51 | value = propertyInfo.GetValue(pattern); 52 | return true; 53 | } 54 | 55 | value = null; 56 | return false; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/WebDriverFixture.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using System; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Reflection; 6 | 7 | namespace FlaUI.WebDriver.UITests 8 | { 9 | [SetUpFixture] 10 | public class WebDriverFixture 11 | { 12 | public static readonly Uri WebDriverUrl = new Uri("http://localhost:4723/"); 13 | public static readonly TimeSpan SessionCleanupInterval = TimeSpan.FromSeconds(1); 14 | 15 | private Process _webDriverProcess; 16 | 17 | [OneTimeSetUp] 18 | public void Setup() 19 | { 20 | string assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); 21 | Directory.SetCurrentDirectory(assemblyDir); 22 | 23 | string webDriverPath = Path.Combine(Directory.GetCurrentDirectory(), "FlaUI.WebDriver.exe"); 24 | var webDriverArguments = $"--urls={WebDriverUrl} --SessionCleanup:SchedulingIntervalSeconds={SessionCleanupInterval.TotalSeconds}"; 25 | var webDriverProcessStartInfo = new ProcessStartInfo(webDriverPath, webDriverArguments) 26 | { 27 | RedirectStandardError = true, 28 | RedirectStandardOutput = true 29 | }; 30 | _webDriverProcess = new Process() 31 | { 32 | StartInfo = webDriverProcessStartInfo 33 | }; 34 | TestContext.Progress.WriteLine($"Attempting to start web driver with command {webDriverPath} {webDriverArguments}"); 35 | _webDriverProcess.Start(); 36 | 37 | System.Threading.Thread.Sleep(5000); 38 | if (_webDriverProcess.HasExited) 39 | { 40 | var error = _webDriverProcess.StandardError.ReadToEnd(); 41 | if (error.Contains("address already in use")) 42 | { 43 | // For manual debugging of FlaUI.WebDriver it is nice to be able to start it separately 44 | TestContext.Progress.WriteLine("Using already running web driver instead"); 45 | return; 46 | } 47 | throw new Exception($"Could not start WebDriver: {error}"); 48 | } 49 | } 50 | 51 | [OneTimeTearDown] 52 | public void Dispose() 53 | { 54 | if (_webDriverProcess.HasExited) 55 | { 56 | var error = _webDriverProcess.StandardError.ReadToEnd(); 57 | Console.Error.WriteLine($"WebDriver has exited before end of the test: {error}"); 58 | } 59 | else 60 | { 61 | TestContext.Progress.WriteLine("Killing web driver"); 62 | _webDriverProcess.Kill(true); 63 | } 64 | TestContext.Progress.WriteLine("Disposing web driver"); 65 | _webDriverProcess.Dispose(); 66 | TestContext.Progress.WriteLine("Finished disposing web driver"); 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Action.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.Models; 2 | 3 | namespace FlaUI.WebDriver 4 | { 5 | public class Action 6 | { 7 | public Action(ActionSequence actionSequence, ActionItem actionItem) 8 | { 9 | Id = actionSequence.Id; 10 | Type = actionSequence.Type; 11 | SubType = actionItem.Type; 12 | Button = actionItem.Button; 13 | Duration = actionItem.Duration; 14 | Origin = actionItem.Origin; 15 | X = actionItem.X; 16 | Y = actionItem.Y; 17 | DeltaX = actionItem.DeltaX; 18 | DeltaY = actionItem.DeltaY; 19 | Width = actionItem.Width; 20 | Height = actionItem.Height; 21 | Pressure = actionItem.Pressure; 22 | TangentialPressure = actionItem.TangentialPressure; 23 | TiltX = actionItem.TiltX; 24 | TiltY = actionItem.TiltY; 25 | Twist = actionItem.Twist; 26 | AltitudeAngle = actionItem.AltitudeAngle; 27 | AzimuthAngle = actionItem.AzimuthAngle; 28 | Value = actionItem.Value; 29 | } 30 | 31 | public Action(Action action) 32 | { 33 | Id = action.Id; 34 | Type = action.Type; 35 | SubType = action.SubType; 36 | Button = action.Button; 37 | Duration = action.Duration; 38 | Origin = action.Origin; 39 | X = action.X; 40 | Y = action.Y; 41 | DeltaX = action.DeltaX; 42 | DeltaY = action.DeltaY; 43 | Width = action.Width; 44 | Height = action.Height; 45 | Pressure = action.Pressure; 46 | TangentialPressure = action.TangentialPressure; 47 | TiltX = action.TiltX; 48 | TiltY = action.TiltY; 49 | Twist = action.Twist; 50 | AltitudeAngle = action.AltitudeAngle; 51 | AzimuthAngle = action.AzimuthAngle; 52 | Value = action.Value; 53 | } 54 | 55 | public string Id { get; set; } 56 | public string Type { get; set; } 57 | public string SubType { get; set; } 58 | public int? Button { get; set; } 59 | public int? Duration { get; set; } 60 | public object? Origin { get; set; } 61 | public int? X { get; set; } 62 | public int? Y { get; set; } 63 | public int? DeltaX { get; set; } 64 | public int? DeltaY { get; set; } 65 | public int? Width { get; set; } 66 | public int? Height { get; set; } 67 | public int? Pressure { get; set; } 68 | public int? TangentialPressure { get; set; } 69 | public int? TiltX { get; set; } 70 | public int? TiltY { get; set; } 71 | public int? Twist { get; set; } 72 | public int? AltitudeAngle { get; set; } 73 | public int? AzimuthAngle { get; set; } 74 | public string? Value { get; set; } 75 | 76 | public Action Clone() 77 | { 78 | return new Action(this); 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/Properties/Resources.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace WpfApplication.Properties { 12 | using System; 13 | 14 | 15 | /// 16 | /// A strongly-typed resource class, for looking up localized strings, etc. 17 | /// 18 | // This class was auto-generated by the StronglyTypedResourceBuilder 19 | // class via a tool like ResGen or Visual Studio. 20 | // To add or remove a member, edit your .ResX file then rerun ResGen 21 | // with the /str option, or rebuild your VS project. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | internal class Resources { 26 | 27 | private static global::System.Resources.ResourceManager resourceMan; 28 | 29 | private static global::System.Globalization.CultureInfo resourceCulture; 30 | 31 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 32 | internal Resources() { 33 | } 34 | 35 | /// 36 | /// Returns the cached ResourceManager instance used by this class. 37 | /// 38 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 39 | internal static global::System.Resources.ResourceManager ResourceManager { 40 | get { 41 | if (object.ReferenceEquals(resourceMan, null)) { 42 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("WpfApplication.Properties.Resources", typeof(Resources).Assembly); 43 | resourceMan = temp; 44 | } 45 | return resourceMan; 46 | } 47 | } 48 | 49 | /// 50 | /// Overrides the current thread's CurrentUICulture property for all 51 | /// resource lookups using this strongly typed resource class. 52 | /// 53 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 54 | internal static global::System.Globalization.CultureInfo Culture { 55 | get { 56 | return resourceCulture; 57 | } 58 | set { 59 | resourceCulture = value; 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/MainWindow.xaml.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Windows; 3 | using System.Windows.Controls; 4 | using System.Windows.Media; 5 | 6 | namespace WpfApplication 7 | { 8 | /// 9 | /// Interaction logic for MainWindow.xaml 10 | /// 11 | public partial class MainWindow 12 | { 13 | private Window1 _subWindow; 14 | 15 | public MainWindow() 16 | { 17 | InitializeComponent(); 18 | var vm = new MainViewModel(); 19 | DataContext = vm; 20 | } 21 | 22 | private void Selector_OnSelectionChanged(object sender, SelectionChangedEventArgs e) 23 | { 24 | foreach (var item in e.AddedItems) 25 | { 26 | var textBlock = (TextBlock)item; 27 | if (textBlock.Text == "Item 4") 28 | { 29 | MessageBox.Show("Do you really want to do it?"); 30 | } 31 | } 32 | } 33 | 34 | protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo) 35 | { 36 | DpiScaling.Text = VisualTreeHelper.GetDpi(this).DpiScaleX.ToString(); 37 | base.OnRenderSizeChanged(sizeInfo); 38 | } 39 | 40 | protected override void OnClosing(CancelEventArgs e) 41 | { 42 | _subWindow?.Close(); 43 | base.OnClosing(e); 44 | } 45 | 46 | private void OnShowLabel(object sender, RoutedEventArgs e) 47 | { 48 | MenuItem menuitem = sender as MenuItem; 49 | if (menuitem == null) { return; } 50 | 51 | if (menuitem.IsChecked == true) 52 | { 53 | lblMenuChk.Visibility = Visibility.Visible; 54 | } 55 | else 56 | { 57 | lblMenuChk.Visibility = Visibility.Hidden; 58 | } 59 | } 60 | 61 | private void OnDisableForm(object sender, RoutedEventArgs e) 62 | { 63 | textBox.IsEnabled = false; 64 | passwordBox.IsEnabled = false; 65 | editableCombo.IsEnabled = false; 66 | nonEditableCombo.IsEnabled = false; 67 | listBox.IsEnabled = false; 68 | checkBox.IsEnabled = false; 69 | threeStateCheckbox.IsEnabled = false; 70 | radioButton1.IsEnabled = false; 71 | radioButton2.IsEnabled = false; 72 | slider.IsEnabled = false; 73 | invokableButton.IsEnabled = false; 74 | PopupToggleButton1.IsEnabled = false; 75 | lblMenuChk.IsEnabled = false; 76 | } 77 | 78 | private void MenuItem_Click(object sender, RoutedEventArgs e) 79 | { 80 | _subWindow = new Window1(); 81 | _subWindow.Show(); 82 | } 83 | 84 | private void LabelWithHover_MouseEnter(object sender, System.Windows.Input.MouseEventArgs e) 85 | { 86 | lblHover.Content = "Hovered!"; 87 | } 88 | 89 | private void LabelWithHover_MouseLeave(object sender, System.Windows.Input.MouseEventArgs e) 90 | { 91 | lblHover.Content = "Please hover over me"; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/SessionCleanupService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | 3 | namespace FlaUI.WebDriver 4 | { 5 | public class SessionCleanupService : IHostedService, IDisposable 6 | { 7 | private readonly ILogger _logger; 8 | private Timer? _timer = null; 9 | 10 | public IServiceProvider Services { get; } 11 | public IOptions Options { get; } 12 | 13 | public SessionCleanupService(IServiceProvider services, IOptions options, ILogger logger) 14 | { 15 | Services = services; 16 | Options = options; 17 | _logger = logger; 18 | } 19 | 20 | public Task StartAsync(CancellationToken stoppingToken) 21 | { 22 | _logger.LogInformation("Session cleanup service running every {SchedulingIntervalSeconds} seconds", Options.Value.SchedulingIntervalSeconds); 23 | 24 | _timer = new Timer(DoWork, null, TimeSpan.FromSeconds(Options.Value.SchedulingIntervalSeconds), TimeSpan.FromSeconds(Options.Value.SchedulingIntervalSeconds)); 25 | 26 | return Task.CompletedTask; 27 | } 28 | 29 | private void DoWork(object? state) 30 | { 31 | using (var scope = Services.CreateScope()) 32 | { 33 | var sessionRepository = 34 | scope.ServiceProvider 35 | .GetRequiredService(); 36 | 37 | RemoveTimedOutSessions(sessionRepository); 38 | 39 | EvictUnavailableElements(sessionRepository); 40 | } 41 | } 42 | 43 | private void EvictUnavailableElements(ISessionRepository sessionRepository) 44 | { 45 | foreach(var session in sessionRepository.FindAll()) 46 | { 47 | session.EvictUnavailableElements(); 48 | session.EvictUnavailableWindows(); 49 | } 50 | } 51 | 52 | private void RemoveTimedOutSessions(ISessionRepository sessionRepository) 53 | { 54 | var timedOutSessions = sessionRepository.FindTimedOut(); 55 | if (timedOutSessions.Count > 0) 56 | { 57 | _logger.LogInformation("Session cleanup service cleaning up {Count} sessions that did not receive commands in their specified new command timeout interval", timedOutSessions.Count); 58 | 59 | foreach (Session session in timedOutSessions) 60 | { 61 | sessionRepository.Delete(session); 62 | session.Dispose(); 63 | } 64 | } 65 | else 66 | { 67 | _logger.LogInformation("Session cleanup service did not find sessions to cleanup"); 68 | } 69 | } 70 | 71 | public Task StopAsync(CancellationToken stoppingToken) 72 | { 73 | _logger.LogInformation("Session cleanup service is stopping"); 74 | 75 | _timer?.Change(Timeout.Infinite, 0); 76 | 77 | return Task.CompletedTask; 78 | } 79 | 80 | public void Dispose() 81 | { 82 | _timer?.Dispose(); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/Infrastructure/ObservableObject.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Diagnostics.CodeAnalysis; 5 | using System.Linq.Expressions; 6 | using System.Runtime.CompilerServices; 7 | 8 | namespace WpfApplication.Infrastructure 9 | { 10 | [SuppressMessage("ReSharper", "ExplicitCallerInfoArgument")] 11 | public abstract class ObservableObject : INotifyPropertyChanged 12 | { 13 | public event PropertyChangedEventHandler PropertyChanged; 14 | 15 | private readonly Dictionary _backingFieldValues = new Dictionary(); 16 | 17 | /// 18 | /// Gets a property value from the internal backing field 19 | /// 20 | protected T GetProperty([CallerMemberName] string propertyName = null) 21 | { 22 | if (propertyName == null) 23 | { 24 | throw new ArgumentNullException(nameof(propertyName)); 25 | } 26 | object value; 27 | if (_backingFieldValues.TryGetValue(propertyName, out value)) 28 | { 29 | return (T)value; 30 | } 31 | return default(T); 32 | } 33 | 34 | /// 35 | /// Saves a property value to the internal backing field 36 | /// 37 | protected bool SetProperty(T newValue, [CallerMemberName] string propertyName = null) 38 | { 39 | if (propertyName == null) 40 | { 41 | throw new ArgumentNullException(nameof(propertyName)); 42 | } 43 | if (IsEqual(GetProperty(propertyName), newValue)) return false; 44 | _backingFieldValues[propertyName] = newValue; 45 | OnPropertyChanged(propertyName); 46 | return true; 47 | } 48 | 49 | /// 50 | /// Sets a property value to the backing field 51 | /// 52 | protected bool SetProperty(ref T field, T newValue, [CallerMemberName] string propertyName = null) 53 | { 54 | if (IsEqual(field, newValue)) return false; 55 | field = newValue; 56 | OnPropertyChanged(propertyName); 57 | return true; 58 | } 59 | 60 | protected virtual void OnPropertyChanged(Expression> selectorExpression) 61 | { 62 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(GetNameFromExpression(selectorExpression))); 63 | } 64 | 65 | protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) 66 | { 67 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); 68 | } 69 | 70 | private bool IsEqual(T field, T newValue) 71 | { 72 | // Alternative: EqualityComparer.Default.Equals(field, newValue); 73 | return Equals(field, newValue); 74 | } 75 | 76 | private string GetNameFromExpression(Expression> selectorExpression) 77 | { 78 | var body = (MemberExpression)selectorExpression.Body; 79 | var propertyName = body.Member.Name; 80 | return propertyName; 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/MergedCapabilities.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Text.Json; 3 | 4 | namespace FlaUI.WebDriver 5 | { 6 | public class MergedCapabilities 7 | { 8 | public Dictionary Capabilities { get; } 9 | 10 | public MergedCapabilities(Dictionary firstMatchCapabilities, Dictionary requiredCapabilities) 11 | : this(MergeCapabilities(firstMatchCapabilities, requiredCapabilities)) 12 | { 13 | 14 | } 15 | 16 | public MergedCapabilities(Dictionary capabilities) 17 | { 18 | Capabilities = capabilities; 19 | } 20 | 21 | private static Dictionary MergeCapabilities(Dictionary firstMatchCapabilities, Dictionary requiredCapabilities) 22 | { 23 | var duplicateKeys = firstMatchCapabilities.Keys.Intersect(requiredCapabilities.Keys); 24 | if (duplicateKeys.Any()) 25 | { 26 | throw WebDriverResponseException.InvalidArgument($"Capabilities cannot be merged because there are duplicate capabilities: {string.Join(", ", duplicateKeys)}"); 27 | } 28 | 29 | return firstMatchCapabilities.Concat(requiredCapabilities) 30 | .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); 31 | } 32 | 33 | public bool TryGetStringCapability(string key, [MaybeNullWhen(false)] out string value) 34 | { 35 | if (Capabilities.TryGetValue(key, out var valueJson)) 36 | { 37 | if (valueJson.ValueKind != JsonValueKind.String) 38 | { 39 | throw WebDriverResponseException.InvalidArgument($"Capability {key} must be a string"); 40 | } 41 | 42 | value = valueJson.GetString(); 43 | return value != null; 44 | } 45 | 46 | value = null; 47 | return false; 48 | } 49 | 50 | public bool TryGetNumberCapability(string key, out double value) 51 | { 52 | if (Capabilities.TryGetValue(key, out var valueJson)) 53 | { 54 | if (valueJson.ValueKind != JsonValueKind.Number) 55 | { 56 | throw WebDriverResponseException.InvalidArgument($"Capability {key} must be a number"); 57 | } 58 | 59 | value = valueJson.GetDouble(); 60 | return true; 61 | } 62 | 63 | value = default; 64 | return false; 65 | } 66 | 67 | public void Copy(string key, MergedCapabilities fromCapabilities) 68 | { 69 | Capabilities.Add(key, fromCapabilities.Capabilities[key]); 70 | } 71 | 72 | public bool Contains(string key) 73 | { 74 | return Capabilities.ContainsKey(key); 75 | } 76 | 77 | public bool TryGetCapability(string key, [MaybeNullWhen(false)] out T? value) 78 | { 79 | if (!Capabilities.TryGetValue(key, out var valueJson)) 80 | { 81 | value = default; 82 | return false; 83 | } 84 | 85 | var deserializedValue = JsonSerializer.Deserialize(valueJson); 86 | if (deserializedValue == null) 87 | { 88 | throw WebDriverResponseException.InvalidArgument($"Could not deserialize {key} capability"); 89 | } 90 | value = deserializedValue; 91 | return true; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Controllers/ScreenshotController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using FlaUI.Core.AutomationElements; 3 | using System.Drawing; 4 | 5 | namespace FlaUI.WebDriver.Controllers 6 | { 7 | [Route("session/{sessionId}")] 8 | [ApiController] 9 | public class ScreenshotController : ControllerBase 10 | { 11 | private readonly ILogger _logger; 12 | private readonly ISessionRepository _sessionRepository; 13 | 14 | public ScreenshotController(ILogger logger, ISessionRepository sessionRepository) 15 | { 16 | _logger = logger; 17 | _sessionRepository = sessionRepository; 18 | } 19 | 20 | [HttpGet("screenshot")] 21 | public async Task TakeScreenshot([FromRoute] string sessionId) 22 | { 23 | var session = GetActiveSession(sessionId); 24 | AutomationElement screenshotElement; 25 | if (session.App == null) 26 | { 27 | _logger.LogInformation("Taking screenshot of desktop (session {SessionId})", session.SessionId); 28 | screenshotElement = session.Automation.GetDesktop(); 29 | } 30 | else 31 | { 32 | var currentWindow = session.CurrentWindow; 33 | _logger.LogInformation("Taking screenshot of window with title {WindowTitle} (session {SessionId})", currentWindow.Title, session.SessionId); 34 | screenshotElement = currentWindow; 35 | } 36 | using var bitmap = screenshotElement.Capture(); 37 | return await Task.FromResult(WebDriverResult.Success(GetBase64Data(bitmap))); 38 | } 39 | 40 | [HttpGet("element/{elementId}/screenshot")] 41 | public async Task TakeElementScreenshot([FromRoute] string sessionId, [FromRoute] string elementId) 42 | { 43 | var session = GetActiveSession(sessionId); 44 | var element = GetElement(session, elementId); 45 | _logger.LogInformation("Taking screenshot of element with ID {ElementId} (session {SessionId})", elementId, session.SessionId); 46 | using var bitmap = element.Capture(); 47 | return await Task.FromResult(WebDriverResult.Success(GetBase64Data(bitmap))); 48 | } 49 | 50 | private static string GetBase64Data(Bitmap bitmap) 51 | { 52 | using var memoryStream = new MemoryStream(); 53 | bitmap.Save(memoryStream, System.Drawing.Imaging.ImageFormat.Png); 54 | return Convert.ToBase64String(memoryStream.ToArray()); 55 | } 56 | 57 | private AutomationElement GetElement(Session session, string elementId) 58 | { 59 | var element = session.FindKnownElementById(elementId); 60 | if (element == null) 61 | { 62 | throw WebDriverResponseException.ElementNotFound(elementId); 63 | } 64 | return element; 65 | } 66 | 67 | private Session GetActiveSession(string sessionId) 68 | { 69 | var session = GetSession(sessionId); 70 | if (session.App != null && session.App.HasExited) 71 | { 72 | throw WebDriverResponseException.NoWindowsOpenForSession(); 73 | } 74 | return session; 75 | } 76 | 77 | private Session GetSession(string sessionId) 78 | { 79 | var session = _sessionRepository.FindById(sessionId); 80 | if (session == null) 81 | { 82 | throw WebDriverResponseException.SessionNotFound(sessionId); 83 | } 84 | session.SetLastCommandTimeToNow(); 85 | return session; 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/ActionsTests.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.UITests.TestUtil; 2 | using NUnit.Framework; 3 | using OpenQA.Selenium; 4 | using OpenQA.Selenium.Interactions; 5 | using OpenQA.Selenium.Remote; 6 | 7 | namespace FlaUI.WebDriver.UITests 8 | { 9 | [TestFixture] 10 | public class ActionsTests 11 | { 12 | private RemoteWebDriver _driver; 13 | 14 | [SetUp] 15 | public void Setup() 16 | { 17 | var driverOptions = FlaUIDriverOptions.TestApp(); 18 | _driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 19 | } 20 | 21 | [TearDown] 22 | public void Teardown() 23 | { 24 | _driver?.Dispose(); 25 | } 26 | 27 | [Test] 28 | public void PerformActions_KeyDownKeyUp_IsSupported() 29 | { 30 | var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 31 | element.Click(); 32 | 33 | new Actions(_driver).KeyDown(Keys.Control).KeyDown(Keys.Backspace).KeyUp(Keys.Backspace).KeyUp(Keys.Control).Perform(); 34 | 35 | string activeElementText = _driver.SwitchTo().ActiveElement().Text; 36 | Assert.That(activeElementText, Is.EqualTo("Test ")); 37 | } 38 | 39 | [Test] 40 | public void SendKeys_Default_IsSupported() 41 | { 42 | var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 43 | element.Clear(); 44 | 45 | element.SendKeys("abc123"); 46 | 47 | Assert.That(element.Text, Is.EqualTo("abc123")); 48 | } 49 | 50 | [Test] 51 | public void SendKeys_ShiftedCharacter_IsSupported() 52 | { 53 | var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 54 | element.Clear(); 55 | 56 | element.SendKeys("@TEST"); 57 | 58 | Assert.That(element.Text, Is.EqualTo("@TEST")); 59 | } 60 | 61 | [Test] 62 | public void ReleaseActions_Default_ReleasesKeys() 63 | { 64 | var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 65 | element.Click(); 66 | new Actions(_driver).KeyDown(Keys.Control).Perform(); 67 | 68 | _driver.ResetInputState(); 69 | 70 | new Actions(_driver).KeyDown(Keys.Backspace).KeyUp(Keys.Backspace).Perform(); 71 | string activeElmentText = _driver.SwitchTo().ActiveElement().Text; 72 | Assert.That(activeElmentText, Is.EqualTo("Test TextBo")); 73 | } 74 | 75 | [Test] 76 | public void PerformActions_MoveToElementAndClick_SelectsElement() 77 | { 78 | var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 79 | 80 | new Actions(_driver).MoveToElement(element).Click().Perform(); 81 | 82 | string activeElementText = _driver.SwitchTo().ActiveElement().Text; 83 | Assert.That(activeElementText, Is.EqualTo("Test TextBox")); 84 | } 85 | 86 | [Test] 87 | public void PerformActions_MoveToElement_IsSupported() 88 | { 89 | var element = _driver.FindElement(ExtendedBy.AccessibilityId("LabelWithHover")); 90 | 91 | new Actions(_driver).MoveToElement(element).Perform(); 92 | 93 | Assert.That(element.Text, Is.EqualTo("Hovered!")); 94 | } 95 | 96 | [Test] 97 | public void PerformActions_MoveToElementMoveByOffsetAndClick_SelectsElement() 98 | { 99 | var element = _driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 100 | 101 | new Actions(_driver).MoveToElement(element).MoveByOffset(5, 0).Click().Perform(); 102 | 103 | string activeElementText = _driver.SwitchTo().ActiveElement().Text; 104 | Assert.That(activeElementText, Is.EqualTo("Test TextBox")); 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Controllers/ActionsController.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.Models; 2 | using FlaUI.WebDriver.Services; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace FlaUI.WebDriver.Controllers 6 | { 7 | [Route("session/{sessionId}/[controller]")] 8 | [ApiController] 9 | public class ActionsController : ControllerBase 10 | { 11 | private readonly ILogger _logger; 12 | private readonly ISessionRepository _sessionRepository; 13 | private readonly IActionsDispatcher _actionsDispatcher; 14 | 15 | public ActionsController(ILogger logger, ISessionRepository sessionRepository, IActionsDispatcher actionsDispatcher) 16 | { 17 | _logger = logger; 18 | _sessionRepository = sessionRepository; 19 | _actionsDispatcher = actionsDispatcher; 20 | } 21 | 22 | [HttpPost] 23 | public async Task PerformActions([FromRoute] string sessionId, [FromBody] ActionsRequest actionsRequest) 24 | { 25 | _logger.LogDebug("Performing actions for session {SessionId}", sessionId); 26 | 27 | var session = GetSession(sessionId); 28 | var actionsByTick = ExtractActionSequence(session, actionsRequest); 29 | foreach (var tickActions in actionsByTick) 30 | { 31 | var tickDuration = tickActions.Max(tickAction => tickAction.Duration) ?? 0; 32 | var dispatchTickActionTasks = tickActions.Select(tickAction => _actionsDispatcher.DispatchAction(session, tickAction)); 33 | if (tickDuration > 0) 34 | { 35 | dispatchTickActionTasks = dispatchTickActionTasks.Concat(new[] { Task.Delay(tickDuration) }); 36 | } 37 | await Task.WhenAll(dispatchTickActionTasks); 38 | } 39 | 40 | return WebDriverResult.Success(); 41 | } 42 | 43 | [HttpDelete] 44 | public async Task ReleaseActions([FromRoute] string sessionId) 45 | { 46 | _logger.LogDebug("Releasing actions for session {SessionId}", sessionId); 47 | 48 | var session = GetSession(sessionId); 49 | 50 | foreach (var cancelAction in session.InputState.InputCancelList) 51 | { 52 | await _actionsDispatcher.DispatchAction(session, cancelAction); 53 | } 54 | session.InputState.Reset(); 55 | 56 | return WebDriverResult.Success(); 57 | } 58 | 59 | /// 60 | /// See https://www.w3.org/TR/webdriver2/#dfn-extract-an-action-sequence. 61 | /// Returns all sequence actions synchronized by index. 62 | /// 63 | /// The session 64 | /// The request 65 | /// 66 | private static List> ExtractActionSequence(Session session, ActionsRequest actionsRequest) 67 | { 68 | var actionsByTick = new List>(); 69 | foreach (var actionSequence in actionsRequest.Actions) 70 | { 71 | // TODO: Implement other input source types. 72 | if (actionSequence.Type == "key") 73 | { 74 | session.InputState.GetOrCreateInputSource(actionSequence.Type, actionSequence.Id); 75 | } 76 | 77 | for (var tickIndex = 0; tickIndex < actionSequence.Actions.Count; tickIndex++) 78 | { 79 | var actionItem = actionSequence.Actions[tickIndex]; 80 | var action = new Action(actionSequence, actionItem); 81 | if (actionsByTick.Count < tickIndex + 1) 82 | { 83 | actionsByTick.Add(new List()); 84 | } 85 | actionsByTick[tickIndex].Add(action); 86 | } 87 | } 88 | return actionsByTick; 89 | } 90 | 91 | private Session GetSession(string sessionId) 92 | { 93 | var session = _sessionRepository.FindById(sessionId); 94 | if (session == null) 95 | { 96 | throw WebDriverResponseException.SessionNotFound(sessionId); 97 | } 98 | session.SetLastCommandTimeToNow(); 99 | return session; 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '18 13 * * 2' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze (${{ matrix.language }}) 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners (GitHub.com only) 29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'windows-latest' }} 31 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 32 | permissions: 33 | # required for all workflows 34 | security-events: write 35 | 36 | # only required for workflows in private repositories 37 | actions: read 38 | contents: read 39 | 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | include: 44 | - language: csharp 45 | build-mode: autobuild 46 | # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 47 | # Use `c-cpp` to analyze code written in C, C++ or both 48 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 49 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 50 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 51 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 52 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 53 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 54 | steps: 55 | - name: Checkout repository 56 | uses: actions/checkout@v4 57 | with: 58 | fetch-depth: 0 59 | 60 | # Initializes the CodeQL tools for scanning. 61 | - name: Initialize CodeQL 62 | uses: github/codeql-action/init@v3 63 | with: 64 | languages: ${{ matrix.language }} 65 | build-mode: ${{ matrix.build-mode }} 66 | # If you wish to specify custom queries, you can do so here or in a config file. 67 | # By default, queries listed here will override any specified in a config file. 68 | # Prefix the list here with "+" to use these queries and those in the config file. 69 | 70 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 71 | # queries: security-extended,security-and-quality 72 | 73 | # If the analyze step fails for one of the languages you are analyzing with 74 | # "We were unable to automatically build your code", modify the matrix above 75 | # to set the build mode to "manual" for that language. Then modify this step 76 | # to build your code. 77 | # ℹ️ Command-line programs to run using the OS shell. 78 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 79 | - if: matrix.build-mode == 'manual' 80 | run: | 81 | echo 'If you are using a "manual" build mode for one or more of the' \ 82 | 'languages you are analyzing, replace this with the commands to build' \ 83 | 'your code, for example:' 84 | echo ' make bootstrap' 85 | echo ' make release' 86 | exit 1 87 | 88 | - name: Perform CodeQL Analysis 89 | uses: github/codeql-action/analyze@v3 90 | with: 91 | category: "/language:${{matrix.language}}" 92 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/InputState.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace FlaUI.WebDriver 4 | { 5 | public class InputState 6 | { 7 | private readonly Dictionary _inputStateMap = new(); 8 | 9 | public List InputCancelList = new List(); 10 | 11 | public void Reset() 12 | { 13 | InputCancelList.Clear(); 14 | _inputStateMap.Clear(); 15 | } 16 | 17 | /// 18 | /// Creates an input source of the given type. 19 | /// 20 | /// 21 | /// Implements "create an input source" from https://www.w3.org/TR/webdriver2/#input-state 22 | /// Note: The spec does not specify that a created input source should be added to the input state map. 23 | /// 24 | public InputSource CreateInputSource(string type) 25 | { 26 | return type switch 27 | { 28 | "none" => throw new NotImplementedException("Null input source is not implemented yet"), 29 | "key" => new KeyInputSource(), 30 | "pointer" => throw new NotImplementedException("Pointer input source is not implemented yet"), 31 | "wheel" => throw new NotImplementedException("Wheel input source is not implemented yet"), 32 | _ => throw new InvalidOperationException($"Unknown input source type: {type}") 33 | }; 34 | } 35 | 36 | /// 37 | /// Tries to get an input source with the specified input ID. 38 | /// 39 | /// 40 | /// Implements "get an input source" from https://www.w3.org/TR/webdriver2/#input-state 41 | /// 42 | public InputSource? GetInputSource(string inputId) 43 | { 44 | _inputStateMap.TryGetValue(inputId, out var result); 45 | return result; 46 | } 47 | 48 | /// 49 | /// Tries to get an input source with the specified input ID. 50 | /// 51 | /// 52 | /// Implements "get an input source" from https://www.w3.org/TR/webdriver2/#input-state 53 | /// 54 | public T? GetInputSource(string inputId) where T : InputSource 55 | { 56 | if (GetInputSource(inputId) is { } source) 57 | { 58 | if (source is T result) 59 | { 60 | return result; 61 | } 62 | else 63 | { 64 | throw WebDriverResponseException.InvalidArgument( 65 | $"Input source with id '{inputId}' is not of the expected type: {typeof(T).Name}"); 66 | } 67 | } 68 | 69 | return null; 70 | } 71 | 72 | /// 73 | /// Gets an input source or creates a new one if it does not exist. 74 | /// 75 | /// 76 | /// Implements "get or create an input source" from https://www.w3.org/TR/webdriver2/#input-state 77 | /// Note: The spec does not specify that a created input source should be added to the input state map 78 | /// but this implementation does. 79 | /// 80 | public InputSource GetOrCreateInputSource(string type, string id) 81 | { 82 | var source = GetInputSource(id); 83 | 84 | if (source != null && source.Type != type) 85 | { 86 | throw WebDriverResponseException.InvalidArgument( 87 | $"Input source with id '{id}' already exists and has a different type: {source.Type}"); 88 | } 89 | 90 | // Note: The spec does not specify that a created input source should be added to the input state map, 91 | // however it needs to be added somewhere. The caller can't do it because it doesn't know if the source 92 | // was created or already existed. See https://github.com/w3c/webdriver/issues/1810 93 | if (source == null) 94 | { 95 | source = CreateInputSource(type); 96 | AddInputSource(id, source); 97 | } 98 | 99 | return source; 100 | } 101 | 102 | /// 103 | /// Adds an input source. 104 | /// 105 | /// 106 | /// Implements "add an input source" from https://www.w3.org/TR/webdriver2/#input-state 107 | /// 108 | public void AddInputSource(string inputId, InputSource inputSource) => _inputStateMap.Add(inputId, inputSource); 109 | 110 | /// 111 | /// Removes an input source. 112 | /// 113 | /// 114 | /// Implements "remove an input source" from https://www.w3.org/TR/webdriver2/#input-state 115 | /// 116 | public void RemoveInputSource(string inputId) 117 | { 118 | Debug.Assert(!InputCancelList.Any(x => x.Id == inputId)); 119 | 120 | _inputStateMap.Remove(inputId); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Services/ConditionParser.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.Core.Conditions; 2 | using FlaUI.Core.Definitions; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace FlaUI.WebDriver.Services 6 | { 7 | public class ConditionParser : IConditionParser 8 | { 9 | /// 10 | /// Based on https://www.w3.org/TR/CSS21/grammar.html (see also https://www.w3.org/TR/CSS22/grammar.html) 11 | /// Limitations: 12 | /// - Unicode escape characters are not supported. 13 | /// - Multiple selectors are not supported. 14 | /// 15 | private static Regex SimpleCssIdSelectorRegex = new Regex(@"^#(?(?[_a-z0-9-]|[\240-\377]|(?\\[^\r\n\f0-9a-f]))+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); 16 | 17 | /// 18 | /// Based on https://www.w3.org/TR/CSS21/grammar.html (see also https://www.w3.org/TR/CSS22/grammar.html) 19 | /// Limitations: 20 | /// - Unicode escape characters are not supported. 21 | /// - Multiple selectors are not supported. 22 | /// 23 | private static Regex SimpleCssClassSelectorRegex = new Regex(@"^\.(?-?(?[_a-z]|[\240-\377])(?[_a-z0-9-]|[\240-\377]|(?\\[^\r\n\f0-9a-f]))*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); 24 | 25 | /// 26 | /// Based on https://www.w3.org/TR/CSS21/grammar.html (see also https://www.w3.org/TR/CSS22/grammar.html) 27 | /// Limitations: 28 | /// - Unicode escape characters or escape characters in the attribute name are not supported. 29 | /// - Multiple selectors are not supported. 30 | /// - Attribute presence selector (e.g. `[name]`) not supported. 31 | /// - Attribute equals attribute (e.g. `[name=value]`) not supported. 32 | /// - ~= or |= not supported. 33 | /// 34 | 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); 35 | 36 | /// 37 | /// Based on https://www.w3.org/TR/CSS21/grammar.html (see also https://www.w3.org/TR/CSS22/grammar.html) 38 | /// Limitations: 39 | /// - Unicode escape characters are not supported. 40 | /// 41 | private static Regex SimpleCssEscapeCharacterRegex = new Regex(@"\\[^\r\n\f0-9a-f]", RegexOptions.Compiled | RegexOptions.IgnoreCase); 42 | 43 | public PropertyCondition ParseCondition(ConditionFactory conditionFactory, string @using, string value) 44 | { 45 | switch (@using) 46 | { 47 | case "accessibility id": 48 | return conditionFactory.ByAutomationId(value); 49 | case "name": 50 | return conditionFactory.ByName(value); 51 | case "class name": 52 | return conditionFactory.ByClassName(value); 53 | case "link text": 54 | return conditionFactory.ByText(value); 55 | case "partial link text": 56 | return conditionFactory.ByText(value, PropertyConditionFlags.MatchSubstring); 57 | case "tag name": 58 | return conditionFactory.ByControlType(Enum.Parse(value)); 59 | case "css selector": 60 | var cssIdSelectorMatch = SimpleCssIdSelectorRegex.Match(value); 61 | if (cssIdSelectorMatch.Success) 62 | { 63 | return conditionFactory.ByAutomationId(ReplaceCssEscapedCharacters(value.Substring(1))); 64 | } 65 | var cssClassSelectorMatch = SimpleCssClassSelectorRegex.Match(value); 66 | if (cssClassSelectorMatch.Success) 67 | { 68 | return conditionFactory.ByClassName(ReplaceCssEscapedCharacters(value.Substring(1))); 69 | } 70 | var cssAttributeSelectorMatch = SimpleCssAttributeSelectorRegex.Match(value); 71 | if (cssAttributeSelectorMatch.Success) 72 | { 73 | var attributeValue = ReplaceCssEscapedCharacters(cssAttributeSelectorMatch.Groups["string1value"].Success ? 74 | cssAttributeSelectorMatch.Groups["string1value"].Value : 75 | cssAttributeSelectorMatch.Groups["string2value"].Value); 76 | if (cssAttributeSelectorMatch.Groups["ident"].Value == "name") 77 | { 78 | return conditionFactory.ByName(attributeValue); 79 | } 80 | } 81 | throw WebDriverResponseException.UnsupportedOperation($"Selector strategy 'css selector' with value '{value}' is not supported"); 82 | default: 83 | throw WebDriverResponseException.UnsupportedOperation($"Selector strategy '{@using}' is not supported"); 84 | } 85 | } 86 | 87 | private static string ReplaceCssEscapedCharacters(string value) 88 | { 89 | return SimpleCssEscapeCharacterRegex.Replace(value, match => match.Value.Substring(1)); 90 | } 91 | 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/ExecuteTests.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.UITests.TestUtil; 2 | using NUnit.Framework; 3 | using OpenQA.Selenium; 4 | using OpenQA.Selenium.Interactions; 5 | using OpenQA.Selenium.Remote; 6 | using System.Collections.Generic; 7 | 8 | namespace FlaUI.WebDriver.UITests 9 | { 10 | [TestFixture] 11 | public class ExecuteTests 12 | { 13 | [Test] 14 | public void ExecuteScript_PowerShellCommand_ReturnsResult() 15 | { 16 | var driverOptions = FlaUIDriverOptions.RootApp(); 17 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 18 | 19 | var executeScriptResult = driver.ExecuteScript("powerShell", new Dictionary { ["command"] = "1+1" }); 20 | 21 | Assert.That(executeScriptResult, Is.EqualTo("2\r\n")); 22 | } 23 | 24 | [Test] 25 | public void ExecuteScript_WindowsClickXY_IsSupported() 26 | { 27 | var driverOptions = FlaUIDriverOptions.TestApp(); 28 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 29 | var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 30 | 31 | driver.ExecuteScript("windows: click", new Dictionary { ["x"] = element.Location.X + element.Size.Width / 2, ["y"] = element.Location.Y + element.Size.Height / 2}); 32 | 33 | string activeElementText = driver.SwitchTo().ActiveElement().Text; 34 | Assert.That(activeElementText, Is.EqualTo("Test TextBox")); 35 | } 36 | 37 | [Test] 38 | public void ExecuteScript_WindowsGetClipboard_IsSupported() 39 | { 40 | var driverOptions = FlaUIDriverOptions.TestApp(); 41 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 42 | var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 43 | element.Click(); 44 | new Actions(driver).KeyDown(Keys.Control).SendKeys("a").KeyUp(Keys.Control).Perform(); 45 | new Actions(driver).KeyDown(Keys.Control).SendKeys("c").KeyUp(Keys.Control).Perform(); 46 | 47 | var result = driver.ExecuteScript("windows: getClipboard", new Dictionary {}); 48 | 49 | Assert.That(result, Is.EqualTo("Test TextBox")); 50 | } 51 | 52 | [Test] 53 | public void ExecuteScript_WindowsSetClipboard_IsSupported() 54 | { 55 | var driverOptions = FlaUIDriverOptions.TestApp(); 56 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 57 | 58 | var result = driver.ExecuteScript("windows: setClipboard", new Dictionary { 59 | ["b64Content"] = "Pasted!"}); 60 | 61 | var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 62 | element.Click(); 63 | new Actions(driver).KeyDown(Keys.Control).SendKeys("v").KeyUp(Keys.Control).Perform(); 64 | Assert.That(element.Text, Is.EqualTo("Test TextBoxPasted!")); 65 | } 66 | 67 | [Test] 68 | public void ExecuteScript_WindowsClearClipboard_IsSupported() 69 | { 70 | var driverOptions = FlaUIDriverOptions.TestApp(); 71 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 72 | driver.ExecuteScript("windows: setClipboard", new Dictionary { 73 | ["b64Content"] = "Pasted!"}); 74 | driver.ExecuteScript("windows: clearClipboard"); 75 | var result = driver.ExecuteScript("windows: getClipboard", new Dictionary {}); 76 | Assert.That(result, Is.EqualTo("")); 77 | } 78 | 79 | [Test] 80 | public void ExecuteScript_WindowsHoverXY_IsSupported() 81 | { 82 | var driverOptions = FlaUIDriverOptions.TestApp(); 83 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 84 | var element = driver.FindElement(ExtendedBy.AccessibilityId("LabelWithHover")); 85 | 86 | driver.ExecuteScript("windows: hover", new Dictionary { 87 | ["startX"] = element.Location.X + element.Size.Width / 2, 88 | ["startY"] = element.Location.Y + element.Size.Height / 2, 89 | ["endX"] = element.Location.X + element.Size.Width / 2, 90 | ["endY"] = element.Location.Y + element.Size.Height / 2 91 | }); 92 | 93 | Assert.That(element.Text, Is.EqualTo("Hovered!")); 94 | } 95 | 96 | [Test] 97 | public void ExecuteScript_WindowsKeys_IsSupported() 98 | { 99 | var driverOptions = FlaUIDriverOptions.TestApp(); 100 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 101 | var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 102 | element.Click(); 103 | 104 | driver.ExecuteScript("windows: keys", new Dictionary { ["actions"] = new[] { 105 | new Dictionary { ["virtualKeyCode"] = 0x11, ["down"]=true }, // CTRL 106 | new Dictionary { ["virtualKeyCode"] = 0x08, ["down"]=true }, // BACKSPACE 107 | new Dictionary { ["virtualKeyCode"] = 0x08, ["down"]=false }, 108 | new Dictionary { ["virtualKeyCode"] = 0x11, ["down"]=false } 109 | } }); 110 | 111 | string activeElementText = driver.SwitchTo().ActiveElement().Text; 112 | Assert.That(activeElementText, Is.EqualTo("Test ")); 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/Properties/Resources.resx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | text/microsoft-resx 107 | 108 | 109 | 2.0 110 | 111 | 112 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 113 | 114 | 115 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Controllers/FindElementsController.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.Models; 2 | using Microsoft.AspNetCore.Mvc; 3 | using FlaUI.Core.Conditions; 4 | using FlaUI.Core.Definitions; 5 | using FlaUI.Core.AutomationElements; 6 | using System.Text.RegularExpressions; 7 | using FlaUI.WebDriver.Services; 8 | 9 | namespace FlaUI.WebDriver.Controllers 10 | { 11 | [Route("session/{sessionId}")] 12 | [ApiController] 13 | public class FindElementsController : ControllerBase 14 | { 15 | private readonly ILogger _logger; 16 | private readonly ISessionRepository _sessionRepository; 17 | private readonly IConditionParser _conditionParser; 18 | 19 | public FindElementsController(ILogger logger, ISessionRepository sessionRepository, IConditionParser conditionParser) 20 | { 21 | _logger = logger; 22 | _sessionRepository = sessionRepository; 23 | _conditionParser = conditionParser; 24 | } 25 | 26 | [HttpPost("element")] 27 | public async Task FindElement([FromRoute] string sessionId, [FromBody] FindElementRequest findElementRequest) 28 | { 29 | var session = GetActiveSession(sessionId); 30 | return await FindElementFrom(() => session.App == null ? session.Automation.GetDesktop() : session.CurrentWindow, findElementRequest, session); 31 | } 32 | 33 | [HttpPost("element/{elementId}/element")] 34 | public async Task FindElementFromElement([FromRoute] string sessionId, [FromRoute] string elementId, [FromBody] FindElementRequest findElementRequest) 35 | { 36 | var session = GetActiveSession(sessionId); 37 | var element = GetElement(session, elementId); 38 | return await FindElementFrom(() => element, findElementRequest, session); 39 | } 40 | 41 | [HttpPost("elements")] 42 | public async Task FindElements([FromRoute] string sessionId, [FromBody] FindElementRequest findElementRequest) 43 | { 44 | var session = GetActiveSession(sessionId); 45 | return await FindElementsFrom(() => session.App == null ? session.Automation.GetDesktop() : session.CurrentWindow, findElementRequest, session); 46 | } 47 | 48 | [HttpPost("element/{elementId}/elements")] 49 | public async Task FindElementsFromElement([FromRoute] string sessionId, [FromRoute] string elementId, [FromBody] FindElementRequest findElementRequest) 50 | { 51 | var session = GetActiveSession(sessionId); 52 | var element = GetElement(session, elementId); 53 | return await FindElementsFrom(() => element, findElementRequest, session); 54 | } 55 | 56 | private async Task FindElementFrom(Func startNode, FindElementRequest findElementRequest, Session session) 57 | { 58 | AutomationElement? element; 59 | if (findElementRequest.Using == "xpath") 60 | { 61 | element = await Wait.Until(() => startNode().FindFirstByXPath(findElementRequest.Value), element => element != null, session.ImplicitWaitTimeout); 62 | } 63 | else 64 | { 65 | var condition = _conditionParser.ParseCondition(session.Automation.ConditionFactory, findElementRequest.Using, findElementRequest.Value); 66 | element = await Wait.Until(() => startNode().FindFirstDescendant(condition), element => element != null, session.ImplicitWaitTimeout); 67 | } 68 | 69 | if (element == null) 70 | { 71 | return NoSuchElement(findElementRequest); 72 | } 73 | 74 | var knownElement = session.GetOrAddKnownElement(element); 75 | return await Task.FromResult(WebDriverResult.Success(new FindElementResponse 76 | { 77 | ElementReference = knownElement.ElementReference, 78 | })); 79 | } 80 | 81 | private async Task FindElementsFrom(Func startNode, FindElementRequest findElementRequest, Session session) 82 | { 83 | AutomationElement[] elements; 84 | if (findElementRequest.Using == "xpath") 85 | { 86 | elements = await Wait.Until(() => startNode().FindAllByXPath(findElementRequest.Value), elements => elements.Length > 0, session.ImplicitWaitTimeout); 87 | } 88 | else 89 | { 90 | var condition = _conditionParser.ParseCondition(session.Automation.ConditionFactory, findElementRequest.Using, findElementRequest.Value); 91 | elements = await Wait.Until(() => startNode().FindAllDescendants(condition), elements => elements.Length > 0, session.ImplicitWaitTimeout); 92 | } 93 | 94 | var knownElements = elements.Select(session.GetOrAddKnownElement); 95 | return await Task.FromResult(WebDriverResult.Success( 96 | 97 | knownElements.Select(knownElement => new FindElementResponse() 98 | { 99 | ElementReference = knownElement.ElementReference 100 | }).ToArray() 101 | )); 102 | } 103 | 104 | private static ActionResult NoSuchElement(FindElementRequest findElementRequest) 105 | { 106 | return WebDriverResult.NotFound(new ErrorResponse() 107 | { 108 | ErrorCode = "no such element", 109 | Message = $"No element found with selector '{findElementRequest.Using}' and value '{findElementRequest.Value}'" 110 | }); 111 | } 112 | 113 | private AutomationElement GetElement(Session session, string elementId) 114 | { 115 | var element = session.FindKnownElementById(elementId); 116 | if (element == null) 117 | { 118 | throw WebDriverResponseException.ElementNotFound(elementId); 119 | } 120 | return element; 121 | } 122 | 123 | private Session GetActiveSession(string sessionId) 124 | { 125 | var session = GetSession(sessionId); 126 | if (session.App != null && session.App.HasExited) 127 | { 128 | throw WebDriverResponseException.NoWindowsOpenForSession(); 129 | } 130 | return session; 131 | } 132 | 133 | private Session GetSession(string sessionId) 134 | { 135 | var session = _sessionRepository.FindById(sessionId); 136 | if (session == null) 137 | { 138 | throw WebDriverResponseException.SessionNotFound(sessionId); 139 | } 140 | session.SetLastCommandTimeToNow(); 141 | return session; 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/WindowTests.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.UITests.TestUtil; 2 | using NUnit.Framework; 3 | using OpenQA.Selenium; 4 | using OpenQA.Selenium.Remote; 5 | using System.Linq; 6 | 7 | namespace FlaUI.WebDriver.UITests 8 | { 9 | [TestFixture] 10 | public class WindowTests 11 | { 12 | [Test] 13 | public void GetWindowRect_Default_IsSupported() 14 | { 15 | var driverOptions = FlaUIDriverOptions.TestApp(); 16 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 17 | var scaling = TestApplication.GetScaling(driver); 18 | 19 | var position = driver.Manage().Window.Position; 20 | var size = driver.Manage().Window.Size; 21 | 22 | Assert.That(position.X, Is.GreaterThanOrEqualTo(0)); 23 | Assert.That(position.Y, Is.GreaterThanOrEqualTo(0)); 24 | Assert.That(size.Width, Is.InRange(629 * scaling, 630 * scaling)); 25 | Assert.That(size.Height, Is.InRange(550 * scaling, 551 * scaling)); 26 | } 27 | 28 | [Test] 29 | public void SetWindowRect_Position_IsSupported() 30 | { 31 | var driverOptions = FlaUIDriverOptions.TestApp(); 32 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 33 | 34 | driver.Manage().Window.Position = new System.Drawing.Point(100, 100); 35 | 36 | var newPosition = driver.Manage().Window.Position; 37 | Assert.That(newPosition.X, Is.EqualTo(100)); 38 | Assert.That(newPosition.Y, Is.EqualTo(100)); 39 | } 40 | 41 | [Test] 42 | public void SetWindowRect_Size_IsSupported() 43 | { 44 | var driverOptions = FlaUIDriverOptions.TestApp(); 45 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 46 | 47 | driver.Manage().Window.Size = new System.Drawing.Size(650, 650); 48 | 49 | var newSize = driver.Manage().Window.Size; 50 | Assert.That(newSize.Width, Is.EqualTo(650)); 51 | Assert.That(newSize.Height, Is.EqualTo(650)); 52 | } 53 | 54 | [Test] 55 | public void GetWindowHandle_AppOpensNewWindow_DoesNotSwitchToNewWindow() 56 | { 57 | var driverOptions = FlaUIDriverOptions.TestApp(); 58 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 59 | var initialWindowHandle = driver.CurrentWindowHandle; 60 | OpenAnotherWindow(driver); 61 | 62 | var windowHandleAfterOpenCloseOtherWindow = driver.CurrentWindowHandle; 63 | 64 | Assert.That(windowHandleAfterOpenCloseOtherWindow, Is.EqualTo(initialWindowHandle)); 65 | } 66 | 67 | [Test] 68 | public void GetWindowHandle_WindowClosed_ReturnsNoSuchWindow() 69 | { 70 | var driverOptions = FlaUIDriverOptions.TestApp(); 71 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 72 | var newWindowHandle = OpenAndSwitchToNewWindow(driver); 73 | driver.Close(); 74 | 75 | var getWindowHandle = () => driver.CurrentWindowHandle; 76 | 77 | Assert.That(getWindowHandle, Throws.TypeOf().With.Message.EqualTo($"No window found with handle '{newWindowHandle}'")); 78 | } 79 | 80 | [Test] 81 | public void GetWindowHandles_Default_ReturnsUniqueHandlePerWindow() 82 | { 83 | var driverOptions = FlaUIDriverOptions.TestApp(); 84 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 85 | var initialWindowHandle = driver.CurrentWindowHandle; 86 | OpenAnotherWindow(driver); 87 | 88 | var windowHandles = driver.WindowHandles; 89 | 90 | Assert.That(windowHandles, Has.Count.EqualTo(2)); 91 | Assert.That(windowHandles[1], Is.Not.EqualTo(windowHandles[0])); 92 | } 93 | 94 | [Test] 95 | public void Close_Default_DoesNotChangeWindowHandle() 96 | { 97 | var driverOptions = FlaUIDriverOptions.TestApp(); 98 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 99 | var initialWindowHandle = driver.CurrentWindowHandle; 100 | OpenAnotherWindow(driver); 101 | 102 | driver.Close(); 103 | 104 | // See https://www.w3.org/TR/webdriver2/#get-window-handle: Should throw if window does not exist 105 | Assert.Throws(() => _ = driver.CurrentWindowHandle); 106 | } 107 | 108 | [Test] 109 | public void Close_LastWindow_EndsSession() 110 | { 111 | var driverOptions = FlaUIDriverOptions.TestApp(); 112 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 113 | 114 | driver.Close(); 115 | 116 | var currentWindowHandle = () => driver.CurrentWindowHandle; 117 | Assert.That(currentWindowHandle, Throws.TypeOf().With.Message.StartsWith("No active session")); 118 | } 119 | 120 | [Test] 121 | public void SwitchWindow_Default_SwitchesToWindow() 122 | { 123 | var driverOptions = FlaUIDriverOptions.TestApp(); 124 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 125 | var initialWindowHandle = driver.CurrentWindowHandle; 126 | OpenAnotherWindow(driver); 127 | var newWindowHandle = driver.WindowHandles.Except(new[] { initialWindowHandle }).Single(); 128 | 129 | driver.SwitchTo().Window(newWindowHandle); 130 | 131 | Assert.That(driver.CurrentWindowHandle, Is.EqualTo(newWindowHandle)); 132 | } 133 | 134 | [Test] 135 | public void SwitchWindow_Default_MovesWindowToForeground() 136 | { 137 | var driverOptions = FlaUIDriverOptions.TestApp(); 138 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 139 | var initialWindowHandle = driver.CurrentWindowHandle; 140 | OpenAnotherWindow(driver); 141 | 142 | driver.SwitchTo().Window(initialWindowHandle); 143 | 144 | // We assert that it is in the foreground by checking if a button can be clicked without an error 145 | var element = driver.FindElement(ExtendedBy.AccessibilityId("InvokableButton")); 146 | element.Click(); 147 | Assert.That(element.Text, Is.EqualTo("Invoked!")); 148 | } 149 | 150 | private static string OpenAndSwitchToNewWindow(RemoteWebDriver driver) 151 | { 152 | var initialWindowHandle = driver.CurrentWindowHandle; 153 | OpenAnotherWindow(driver); 154 | var newWindowHandle = driver.WindowHandles.Except(new[] { initialWindowHandle }).Single(); 155 | driver.SwitchTo().Window(newWindowHandle); 156 | return newWindowHandle; 157 | } 158 | 159 | private static void OpenAnotherWindow(RemoteWebDriver driver) 160 | { 161 | driver.FindElement(ExtendedBy.NonCssName("_File")).Click(); 162 | driver.FindElement(ExtendedBy.NonCssName("Open Window 1")).Click(); 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Controllers/WindowController.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.Core.AutomationElements; 2 | using FlaUI.WebDriver.Models; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace FlaUI.WebDriver.Controllers 6 | { 7 | [Route("session/{sessionId}/[controller]")] 8 | [ApiController] 9 | public class WindowController : ControllerBase 10 | { 11 | private readonly ILogger _logger; 12 | private readonly ISessionRepository _sessionRepository; 13 | 14 | public WindowController(ILogger logger, ISessionRepository sessionRepository) 15 | { 16 | _logger = logger; 17 | _sessionRepository = sessionRepository; 18 | } 19 | 20 | [HttpDelete] 21 | public async Task CloseWindow([FromRoute] string sessionId) 22 | { 23 | var session = GetSession(sessionId); 24 | if (session.App == null || session.App.HasExited) 25 | { 26 | throw WebDriverResponseException.NoWindowsOpenForSession(); 27 | } 28 | 29 | // When closing the last window of the application, the `GetAllTopLevelWindows` function times out with an exception 30 | // Therefore retrieve windows before closing the current one 31 | // https://github.com/FlaUI/FlaUI/issues/596 32 | var windowHandlesBeforeClose = GetWindowHandles(session).ToArray(); 33 | 34 | var currentWindow = session.CurrentWindow; 35 | session.RemoveKnownWindow(currentWindow); 36 | currentWindow.Close(); 37 | 38 | var remainingWindowHandles = windowHandlesBeforeClose.Except(new[] { session.CurrentWindowHandle } ); 39 | if (!remainingWindowHandles.Any()) 40 | { 41 | _sessionRepository.Delete(session); 42 | session.Dispose(); 43 | _logger.LogInformation("Closed last window of session and therefore deleted session with ID {SessionId}", sessionId); 44 | } 45 | return await Task.FromResult(WebDriverResult.Success(remainingWindowHandles)); 46 | } 47 | 48 | [HttpGet("handles")] 49 | public async Task GetWindowHandles([FromRoute] string sessionId) 50 | { 51 | var session = GetSession(sessionId); 52 | var windowHandles = GetWindowHandles(session); 53 | return await Task.FromResult(WebDriverResult.Success(windowHandles)); 54 | } 55 | 56 | [HttpGet] 57 | public async Task GetWindowHandle([FromRoute] string sessionId) 58 | { 59 | var session = GetSession(sessionId); 60 | 61 | if(session.FindKnownWindowByWindowHandle(session.CurrentWindowHandle) == null) 62 | { 63 | throw WebDriverResponseException.WindowNotFoundByHandle(session.CurrentWindowHandle); 64 | } 65 | 66 | return await Task.FromResult(WebDriverResult.Success(session.CurrentWindowHandle)); 67 | } 68 | 69 | [HttpPost] 70 | public async Task SwitchToWindow([FromRoute] string sessionId, [FromBody] SwitchWindowRequest switchWindowRequest) 71 | { 72 | var session = GetSession(sessionId); 73 | if (session.App == null) 74 | { 75 | throw WebDriverResponseException.UnsupportedOperation("Close window not supported for Root app"); 76 | } 77 | var window = session.FindKnownWindowByWindowHandle(switchWindowRequest.Handle); 78 | if (window == null) 79 | { 80 | throw WebDriverResponseException.WindowNotFoundByHandle(switchWindowRequest.Handle); 81 | } 82 | 83 | session.CurrentWindow = window; 84 | window.SetForeground(); 85 | 86 | _logger.LogInformation("Switched to window with title {WindowTitle} (handle {WindowHandle}) (session {SessionId})", window.Title, switchWindowRequest.Handle, session.SessionId); 87 | return await Task.FromResult(WebDriverResult.Success()); 88 | } 89 | 90 | [HttpGet("rect")] 91 | public async Task GetWindowRect([FromRoute] string sessionId) 92 | { 93 | var session = GetSession(sessionId); 94 | return await Task.FromResult(WebDriverResult.Success(GetWindowRect(session.CurrentWindow))); 95 | } 96 | 97 | [HttpPost("rect")] 98 | public async Task SetWindowRect([FromRoute] string sessionId, [FromBody] WindowRect windowRect) 99 | { 100 | var session = GetSession(sessionId); 101 | 102 | if(!session.CurrentWindow.Patterns.Transform.IsSupported) 103 | { 104 | throw WebDriverResponseException.UnsupportedOperation("Cannot transform the current window"); 105 | } 106 | 107 | if (windowRect.Width != null && windowRect.Height != null) 108 | { 109 | if (!session.CurrentWindow.Patterns.Transform.Pattern.CanResize) 110 | { 111 | throw WebDriverResponseException.UnsupportedOperation("Cannot resize the current window"); 112 | } 113 | session.CurrentWindow.Patterns.Transform.Pattern.Resize(windowRect.Width.Value, windowRect.Height.Value); 114 | } 115 | 116 | if (windowRect.X != null && windowRect.Y != null) 117 | { 118 | if (!session.CurrentWindow.Patterns.Transform.Pattern.CanMove) 119 | { 120 | throw WebDriverResponseException.UnsupportedOperation("Cannot move the current window"); 121 | } 122 | session.CurrentWindow.Move(windowRect.X.Value, windowRect.Y.Value); 123 | } 124 | 125 | return await Task.FromResult(WebDriverResult.Success(GetWindowRect(session.CurrentWindow))); 126 | } 127 | 128 | private IEnumerable GetWindowHandles(Session session) 129 | { 130 | if (session.App == null) 131 | { 132 | throw WebDriverResponseException.UnsupportedOperation("Window operations not supported for Root app"); 133 | } 134 | if (session.App.HasExited) 135 | { 136 | return Enumerable.Empty(); 137 | } 138 | 139 | var knownWindows = session.App.GetAllTopLevelWindows(session.Automation) 140 | .Select(session.GetOrAddKnownWindow); 141 | return knownWindows.Select(knownWindows => knownWindows.WindowHandle); 142 | } 143 | 144 | private WindowRect GetWindowRect(Window window) 145 | { 146 | var boundingRectangle = window.BoundingRectangle; 147 | return new WindowRect 148 | { 149 | X = boundingRectangle.X, 150 | Y = boundingRectangle.Y, 151 | Width = boundingRectangle.Width, 152 | Height = boundingRectangle.Height 153 | }; 154 | } 155 | 156 | private Session GetSession(string sessionId) 157 | { 158 | var session = _sessionRepository.FindById(sessionId); 159 | if (session == null) 160 | { 161 | throw WebDriverResponseException.SessionNotFound(sessionId); 162 | } 163 | session.SetLastCommandTimeToNow(); 164 | return session; 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Session.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.Core; 2 | using FlaUI.Core.AutomationElements; 3 | using FlaUI.UIA3; 4 | using System.Collections.Concurrent; 5 | using System.Runtime.InteropServices; 6 | 7 | namespace FlaUI.WebDriver 8 | { 9 | public class Session : IDisposable 10 | { 11 | public Session(Application? app, bool isAppOwnedBySession, TimeoutsConfiguration timeoutsConfiguration, TimeSpan newCommandTimeout) 12 | { 13 | App = app; 14 | SessionId = Guid.NewGuid().ToString(); 15 | Automation = new UIA3Automation(); 16 | InputState = new InputState(); 17 | TimeoutsConfiguration = timeoutsConfiguration; 18 | IsAppOwnedBySession = isAppOwnedBySession; 19 | NewCommandTimeout = newCommandTimeout; 20 | 21 | if (app != null) 22 | { 23 | // We have to capture the initial window handle to be able to keep it stable 24 | var mainWindow = app.GetMainWindow(Automation, PageLoadTimeout); 25 | if (mainWindow == null) 26 | { 27 | throw WebDriverResponseException.Timeout($"Could not get the main window of the app within the page load timeout (${PageLoadTimeout.TotalMilliseconds}ms)"); 28 | } 29 | CurrentWindowWithHandle = GetOrAddKnownWindow(mainWindow); 30 | } 31 | } 32 | 33 | public string SessionId { get; } 34 | public UIA3Automation Automation { get; } 35 | public Application? App { get; } 36 | public InputState InputState { get; } 37 | private ConcurrentDictionary KnownElementsByElementReference { get; } = new ConcurrentDictionary(); 38 | private ConcurrentDictionary KnownWindowsByWindowHandle { get; } = new ConcurrentDictionary(); 39 | public TimeSpan ImplicitWaitTimeout => TimeSpan.FromMilliseconds(TimeoutsConfiguration.ImplicitWaitTimeoutMs); 40 | public TimeSpan PageLoadTimeout => TimeSpan.FromMilliseconds(TimeoutsConfiguration.PageLoadTimeoutMs); 41 | public TimeSpan? ScriptTimeout => TimeoutsConfiguration.ScriptTimeoutMs.HasValue ? TimeSpan.FromMilliseconds(TimeoutsConfiguration.ScriptTimeoutMs.Value) : null; 42 | public bool IsAppOwnedBySession { get; } 43 | 44 | public TimeoutsConfiguration TimeoutsConfiguration { get; set; } 45 | 46 | private KnownWindow? CurrentWindowWithHandle { get; set; } 47 | 48 | public Window CurrentWindow 49 | { 50 | get 51 | { 52 | if (App == null || CurrentWindowWithHandle == null) 53 | { 54 | throw WebDriverResponseException.UnsupportedOperation("This operation is not supported for Root app"); 55 | } 56 | return CurrentWindowWithHandle.Window; 57 | } 58 | set 59 | { 60 | CurrentWindowWithHandle = GetOrAddKnownWindow(value); 61 | } 62 | } 63 | 64 | public string CurrentWindowHandle 65 | { 66 | get 67 | { 68 | if (App == null || CurrentWindowWithHandle == null) 69 | { 70 | throw WebDriverResponseException.UnsupportedOperation("This operation is not supported for Root app"); 71 | } 72 | return CurrentWindowWithHandle.WindowHandle; 73 | } 74 | } 75 | 76 | public bool IsTimedOut => (DateTime.UtcNow - LastNewCommandTimeUtc) > NewCommandTimeout; 77 | 78 | public TimeSpan NewCommandTimeout { get; internal set; } = TimeSpan.FromSeconds(60); 79 | public DateTime LastNewCommandTimeUtc { get; internal set; } = DateTime.UtcNow; 80 | 81 | public void SetLastCommandTimeToNow() 82 | { 83 | LastNewCommandTimeUtc = DateTime.UtcNow; 84 | } 85 | 86 | public KnownElement GetOrAddKnownElement(AutomationElement element) 87 | { 88 | var elementRuntimeId = GetRuntimeId(element); 89 | var result = KnownElementsByElementReference.Values.FirstOrDefault(knownElement => knownElement.ElementRuntimeId == elementRuntimeId && SafeElementEquals(knownElement.Element, element)); 90 | if (result == null) 91 | { 92 | do 93 | { 94 | result = new KnownElement(element, elementRuntimeId, Guid.NewGuid().ToString()); 95 | } 96 | while (!KnownElementsByElementReference.TryAdd(result.ElementReference, result)); 97 | } 98 | return result; 99 | } 100 | 101 | public AutomationElement? FindKnownElementById(string elementId) 102 | { 103 | if (!KnownElementsByElementReference.TryGetValue(elementId, out var knownElement)) 104 | { 105 | return null; 106 | } 107 | return knownElement.Element; 108 | } 109 | 110 | public KnownWindow GetOrAddKnownWindow(Window window) 111 | { 112 | var windowRuntimeId = GetRuntimeId(window); 113 | var result = KnownWindowsByWindowHandle.Values.FirstOrDefault(knownWindow => knownWindow.WindowRuntimeId == windowRuntimeId && SafeElementEquals(knownWindow.Window, window)); 114 | if (result == null) 115 | { 116 | do 117 | { 118 | result = new KnownWindow(window, windowRuntimeId, Guid.NewGuid().ToString()); 119 | } 120 | while (!KnownWindowsByWindowHandle.TryAdd(result.WindowHandle, result)); 121 | } 122 | return result; 123 | } 124 | 125 | public Window? FindKnownWindowByWindowHandle(string windowHandle) 126 | { 127 | if (!KnownWindowsByWindowHandle.TryGetValue(windowHandle, out var knownWindow)) 128 | { 129 | return null; 130 | } 131 | return knownWindow.Window; 132 | } 133 | 134 | public void RemoveKnownWindow(Window window) 135 | { 136 | var item = KnownWindowsByWindowHandle.Values.FirstOrDefault(knownElement => knownElement.Window.Equals(window)); 137 | if (item != null) 138 | { 139 | KnownWindowsByWindowHandle.TryRemove(item.WindowHandle, out _); 140 | } 141 | } 142 | 143 | public void EvictUnavailableElements() 144 | { 145 | // Evict unavailable elements to prevent slowing down 146 | // (use ToArray to prevent concurrency issues while enumerating) 147 | var unavailableElements = KnownElementsByElementReference.ToArray().Where(item => !item.Value.Element.IsAvailable).Select(item => item.Key); 148 | foreach (var unavailableElementKey in unavailableElements) 149 | { 150 | KnownElementsByElementReference.TryRemove(unavailableElementKey, out _); 151 | } 152 | } 153 | 154 | public void EvictUnavailableWindows() 155 | { 156 | // Evict unavailable windows to prevent slowing down 157 | // (use ToArray to prevent concurrency issues while enumerating) 158 | var unavailableWindows = KnownWindowsByWindowHandle.ToArray().Where(item => !item.Value.Window.IsAvailable).Select(item => item.Key).ToArray(); 159 | foreach (var unavailableWindowKey in unavailableWindows) 160 | { 161 | KnownWindowsByWindowHandle.TryRemove(unavailableWindowKey, out _); 162 | } 163 | } 164 | 165 | public void Dispose() 166 | { 167 | if (IsAppOwnedBySession && App != null && !App.HasExited) 168 | { 169 | App.Close(); 170 | } 171 | Automation.Dispose(); 172 | App?.Dispose(); 173 | } 174 | 175 | private string? GetRuntimeId(AutomationElement element) 176 | { 177 | if (!element.Properties.RuntimeId.IsSupported) 178 | { 179 | return null; 180 | } 181 | 182 | return string.Join(",", element.Properties.RuntimeId.Value.Select(item => Convert.ToBase64String(BitConverter.GetBytes(item)))); 183 | } 184 | 185 | private bool SafeElementEquals(AutomationElement element1, AutomationElement element2) 186 | { 187 | try 188 | { 189 | return element1.Equals(element2); 190 | } 191 | catch (COMException) 192 | { 193 | // May occur if the element is suddenly no longer available 194 | return false; 195 | } 196 | } 197 | 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.tlog 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 298 | *.vbp 299 | 300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 301 | *.dsw 302 | *.dsp 303 | 304 | # Visual Studio 6 technical files 305 | *.ncb 306 | *.aps 307 | 308 | # Visual Studio LightSwitch build output 309 | **/*.HTMLClient/GeneratedArtifacts 310 | **/*.DesktopClient/GeneratedArtifacts 311 | **/*.DesktopClient/ModelManifest.xml 312 | **/*.Server/GeneratedArtifacts 313 | **/*.Server/ModelManifest.xml 314 | _Pvt_Extensions 315 | 316 | # Paket dependency manager 317 | .paket/paket.exe 318 | paket-files/ 319 | 320 | # FAKE - F# Make 321 | .fake/ 322 | 323 | # CodeRush personal settings 324 | .cr/personal 325 | 326 | # Python Tools for Visual Studio (PTVS) 327 | __pycache__/ 328 | *.pyc 329 | 330 | # Cake - Uncomment if you are using it 331 | # tools/** 332 | # !tools/packages.config 333 | 334 | # Tabs Studio 335 | *.tss 336 | 337 | # Telerik's JustMock configuration file 338 | *.jmconfig 339 | 340 | # BizTalk build output 341 | *.btp.cs 342 | *.btm.cs 343 | *.odx.cs 344 | *.xsd.cs 345 | 346 | # OpenCover UI analysis results 347 | OpenCover/ 348 | 349 | # Azure Stream Analytics local run output 350 | ASALocalRun/ 351 | 352 | # MSBuild Binary and Structured Log 353 | *.binlog 354 | 355 | # NVidia Nsight GPU debugger configuration file 356 | *.nvuser 357 | 358 | # MFractors (Xamarin productivity tool) working folder 359 | .mfractor/ 360 | 361 | # Local History for Visual Studio 362 | .localhistory/ 363 | 364 | # Visual Studio History (VSHistory) files 365 | .vshistory/ 366 | 367 | # BeatPulse healthcheck temp database 368 | healthchecksdb 369 | 370 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 371 | MigrationBackup/ 372 | 373 | # Ionide (cross platform F# VS Code tools) working folder 374 | .ionide/ 375 | 376 | # Fody - auto-generated XML schema 377 | FodyWeavers.xsd 378 | 379 | # VS Code files for those working on multiple tools 380 | .vscode/* 381 | !.vscode/settings.json 382 | !.vscode/tasks.json 383 | !.vscode/launch.json 384 | !.vscode/extensions.json 385 | *.code-workspace 386 | 387 | # Local History for Visual Studio Code 388 | .history/ 389 | 390 | # Windows Installer files from build outputs 391 | *.cab 392 | *.msi 393 | *.msix 394 | *.msm 395 | *.msp 396 | 397 | # JetBrains Rider 398 | *.sln.iml -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.3.32901.215 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3DFE78D4-89EB-4CEE-A5D1-F5FDDED10959}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TestApplications", "TestApplications", "{00BCF82A-388A-4DC9-A1E2-6D6D983BAEE3}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FlaUI.WebDriver", "FlaUI.WebDriver\FlaUI.WebDriver.csproj", "{07FE5EE9-0104-42CE-A79D-88FD7D79B542}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FlaUI.WebDriver.UITests", "FlaUI.WebDriver.UITests\FlaUI.WebDriver.UITests.csproj", "{5315D9CF-DDA4-49AE-BA92-AB5814E61901}" 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WpfApplication", "TestApplications\WpfApplication\WpfApplication.csproj", "{23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}" 15 | EndProject 16 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4F6F2546-27D9-468C-AD80-1629B54139DA}" 17 | ProjectSection(SolutionItems) = preProject 18 | ..\CONTRIBUTING.md = ..\CONTRIBUTING.md 19 | ..\README.md = ..\README.md 20 | EndProjectSection 21 | EndProject 22 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FlaUI.WebDriver.UnitTests", "FlaUI.WebDriver.UnitTests\FlaUI.WebDriver.UnitTests.csproj", "{22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}" 23 | EndProject 24 | Global 25 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 26 | Debug|Any CPU = Debug|Any CPU 27 | Debug|ARM = Debug|ARM 28 | Debug|ARM64 = Debug|ARM64 29 | Debug|x64 = Debug|x64 30 | Debug|x86 = Debug|x86 31 | Release|Any CPU = Release|Any CPU 32 | Release|ARM = Release|ARM 33 | Release|ARM64 = Release|ARM64 34 | Release|x64 = Release|x64 35 | Release|x86 = Release|x86 36 | EndGlobalSection 37 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 38 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|ARM.ActiveCfg = Debug|Any CPU 41 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|ARM.Build.0 = Debug|Any CPU 42 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|ARM64.ActiveCfg = Debug|Any CPU 43 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|ARM64.Build.0 = Debug|Any CPU 44 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|x64.ActiveCfg = Debug|Any CPU 45 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|x64.Build.0 = Debug|Any CPU 46 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|x86.ActiveCfg = Debug|Any CPU 47 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Debug|x86.Build.0 = Debug|Any CPU 48 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|Any CPU.ActiveCfg = Release|Any CPU 49 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|Any CPU.Build.0 = Release|Any CPU 50 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|ARM.ActiveCfg = Release|Any CPU 51 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|ARM.Build.0 = Release|Any CPU 52 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|ARM64.ActiveCfg = Release|Any CPU 53 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|ARM64.Build.0 = Release|Any CPU 54 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|x64.ActiveCfg = Release|Any CPU 55 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|x64.Build.0 = Release|Any CPU 56 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|x86.ActiveCfg = Release|Any CPU 57 | {07FE5EE9-0104-42CE-A79D-88FD7D79B542}.Release|x86.Build.0 = Release|Any CPU 58 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 59 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|Any CPU.Build.0 = Debug|Any CPU 60 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|ARM.ActiveCfg = Debug|Any CPU 61 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|ARM.Build.0 = Debug|Any CPU 62 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|ARM64.ActiveCfg = Debug|Any CPU 63 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|ARM64.Build.0 = Debug|Any CPU 64 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|x64.ActiveCfg = Debug|Any CPU 65 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|x64.Build.0 = Debug|Any CPU 66 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|x86.ActiveCfg = Debug|Any CPU 67 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Debug|x86.Build.0 = Debug|Any CPU 68 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|Any CPU.ActiveCfg = Release|Any CPU 69 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|Any CPU.Build.0 = Release|Any CPU 70 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|ARM.ActiveCfg = Release|Any CPU 71 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|ARM.Build.0 = Release|Any CPU 72 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|ARM64.ActiveCfg = Release|Any CPU 73 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|ARM64.Build.0 = Release|Any CPU 74 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|x64.ActiveCfg = Release|Any CPU 75 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|x64.Build.0 = Release|Any CPU 76 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|x86.ActiveCfg = Release|Any CPU 77 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901}.Release|x86.Build.0 = Release|Any CPU 78 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 79 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|Any CPU.Build.0 = Debug|Any CPU 80 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|ARM.ActiveCfg = Debug|Any CPU 81 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|ARM.Build.0 = Debug|Any CPU 82 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|ARM64.ActiveCfg = Debug|Any CPU 83 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|ARM64.Build.0 = Debug|Any CPU 84 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|x64.ActiveCfg = Debug|Any CPU 85 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|x64.Build.0 = Debug|Any CPU 86 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|x86.ActiveCfg = Debug|Any CPU 87 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Debug|x86.Build.0 = Debug|Any CPU 88 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|Any CPU.ActiveCfg = Release|Any CPU 89 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|Any CPU.Build.0 = Release|Any CPU 90 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|ARM.ActiveCfg = Release|Any CPU 91 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|ARM.Build.0 = Release|Any CPU 92 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|ARM64.ActiveCfg = Release|Any CPU 93 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|ARM64.Build.0 = Release|Any CPU 94 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|x64.ActiveCfg = Release|Any CPU 95 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|x64.Build.0 = Release|Any CPU 96 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|x86.ActiveCfg = Release|Any CPU 97 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0}.Release|x86.Build.0 = Release|Any CPU 98 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 99 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|Any CPU.Build.0 = Debug|Any CPU 100 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|ARM.ActiveCfg = Debug|Any CPU 101 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|ARM.Build.0 = Debug|Any CPU 102 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|ARM64.ActiveCfg = Debug|Any CPU 103 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|ARM64.Build.0 = Debug|Any CPU 104 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|x64.ActiveCfg = Debug|Any CPU 105 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|x64.Build.0 = Debug|Any CPU 106 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|x86.ActiveCfg = Debug|Any CPU 107 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Debug|x86.Build.0 = Debug|Any CPU 108 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|Any CPU.ActiveCfg = Release|Any CPU 109 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|Any CPU.Build.0 = Release|Any CPU 110 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|ARM.ActiveCfg = Release|Any CPU 111 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|ARM.Build.0 = Release|Any CPU 112 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|ARM64.ActiveCfg = Release|Any CPU 113 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|ARM64.Build.0 = Release|Any CPU 114 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|x64.ActiveCfg = Release|Any CPU 115 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|x64.Build.0 = Release|Any CPU 116 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|x86.ActiveCfg = Release|Any CPU 117 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3}.Release|x86.Build.0 = Release|Any CPU 118 | EndGlobalSection 119 | GlobalSection(SolutionProperties) = preSolution 120 | HideSolutionNode = FALSE 121 | EndGlobalSection 122 | GlobalSection(NestedProjects) = preSolution 123 | {5315D9CF-DDA4-49AE-BA92-AB5814E61901} = {3DFE78D4-89EB-4CEE-A5D1-F5FDDED10959} 124 | {23F0E331-C5AE-4D3D-B4E2-534D52E65CA0} = {00BCF82A-388A-4DC9-A1E2-6D6D983BAEE3} 125 | {22701FF4-ADE3-4C05-9E5E-0616A5ED26F3} = {3DFE78D4-89EB-4CEE-A5D1-F5FDDED10959} 126 | EndGlobalSection 127 | GlobalSection(ExtensibilityGlobals) = postSolution 128 | SolutionGuid = {F2B64231-45B2-4129-960A-9F26AFFD16AE} 129 | EndGlobalSection 130 | EndGlobal 131 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Services/WindowsExtensionService.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.Core.Input; 2 | using FlaUI.Core.Tools; 3 | using FlaUI.Core.WindowsAPI; 4 | using FlaUI.WebDriver.Models; 5 | using System.Drawing; 6 | 7 | namespace FlaUI.WebDriver.Services 8 | { 9 | public class WindowsExtensionService : IWindowsExtensionService 10 | { 11 | private readonly ILogger _logger; 12 | 13 | public WindowsExtensionService(ILogger logger) 14 | { 15 | _logger = logger; 16 | } 17 | 18 | public Task ExecuteGetClipboardScript(Session session, WindowsGetClipboardScript action) 19 | { 20 | switch (action.ContentType) 21 | { 22 | default: 23 | case "plaintext": 24 | return Task.FromResult(ExecuteOnClipboardThread( 25 | () => System.Windows.Forms.Clipboard.GetText(System.Windows.Forms.TextDataFormat.UnicodeText) 26 | )); 27 | case "image": 28 | return Task.FromResult(ExecuteOnClipboardThread(() => 29 | { 30 | using var image = System.Windows.Forms.Clipboard.GetImage(); 31 | if (image == null) 32 | { 33 | return ""; 34 | } 35 | using var stream = new MemoryStream(); 36 | image.Save(stream, System.Drawing.Imaging.ImageFormat.Png); 37 | return Convert.ToBase64String(stream.ToArray()); 38 | })); 39 | } 40 | } 41 | 42 | public Task ExecuteSetClipboardScript(Session session, WindowsSetClipboardScript action) 43 | { 44 | switch (action.ContentType) 45 | { 46 | default: 47 | case "plaintext": 48 | ExecuteOnClipboardThread(() => System.Windows.Forms.Clipboard.SetText(action.B64Content)); 49 | break; 50 | case "image": 51 | ExecuteOnClipboardThread(() => 52 | { 53 | using var stream = new MemoryStream(Convert.FromBase64String(action.B64Content)); 54 | using var image = Image.FromStream(stream); 55 | System.Windows.Forms.Clipboard.SetImage(image); 56 | }); 57 | break; 58 | } 59 | return Task.CompletedTask; 60 | } 61 | 62 | public Task ExecuteClearClipboardScript(Session session) 63 | { 64 | ExecuteOnClipboardThread(() => System.Windows.Forms.Clipboard.Clear()); 65 | return Task.CompletedTask; 66 | } 67 | 68 | private void ExecuteOnClipboardThread(System.Action action) 69 | { 70 | // See https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.clipboard?view=windowsdesktop-8.0#remarks 71 | var thread = new Thread(() => action()); 72 | thread.SetApartmentState(ApartmentState.STA); 73 | thread.Start(); 74 | thread.Join(); 75 | } 76 | 77 | private string ExecuteOnClipboardThread(Func method) 78 | { 79 | // See https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.clipboard?view=windowsdesktop-8.0#remarks 80 | string result = ""; 81 | var thread = new Thread(() => { result = method(); }); 82 | thread.SetApartmentState(ApartmentState.STA); 83 | thread.Start(); 84 | thread.Join(); 85 | return result; 86 | } 87 | 88 | public async Task ExecuteClickScript(Session session, WindowsClickScript action) 89 | { 90 | if (action.DurationMs.HasValue) 91 | { 92 | throw WebDriverResponseException.UnsupportedOperation("Duration is not yet supported"); 93 | } 94 | if (action.Times.HasValue) 95 | { 96 | throw WebDriverResponseException.UnsupportedOperation("Times is not yet supported"); 97 | } 98 | if (action.ModifierKeys != null) 99 | { 100 | throw WebDriverResponseException.UnsupportedOperation("Modifier keys are not yet supported"); 101 | } 102 | var point = GetPoint(action.ElementId, action.X, action.Y, session); 103 | var mouseButton = action.Button != null ? Enum.Parse(action.Button, true) : MouseButton.Left; 104 | _logger.LogDebug("Clicking point ({X}, {Y}) with mouse button {MouseButton}", point.X, point.Y, mouseButton); 105 | Mouse.Click(point, mouseButton); 106 | await Task.Yield(); 107 | } 108 | 109 | public async Task ExecuteScrollScript(Session session, WindowsScrollScript action) 110 | { 111 | if (action.ModifierKeys != null) 112 | { 113 | throw WebDriverResponseException.UnsupportedOperation("Modifier keys are not yet supported"); 114 | } 115 | var point = GetPoint(action.ElementId, action.X, action.Y, session); 116 | _logger.LogDebug("Scrolling at point ({X}, {Y})", point.X, point.Y); 117 | Mouse.Position = point; 118 | if (action.DeltaY.HasValue && action.DeltaY.Value != 0) 119 | { 120 | Mouse.Scroll(action.DeltaY.Value); 121 | } 122 | if (action.DeltaX.HasValue && action.DeltaX.Value != 0) 123 | { 124 | Mouse.HorizontalScroll(-action.DeltaX.Value); 125 | } 126 | await Task.Yield(); 127 | } 128 | 129 | private Point GetPoint(string? elementId, int? x, int? y, Session session) 130 | { 131 | if (elementId != null) 132 | { 133 | var element = session.FindKnownElementById(elementId); 134 | if (element == null) 135 | { 136 | throw WebDriverResponseException.ElementNotFound(elementId); 137 | } 138 | 139 | if (x.HasValue && y.HasValue) 140 | { 141 | return new Point 142 | { 143 | X = element.BoundingRectangle.Left + x.Value, 144 | Y = element.BoundingRectangle.Top + y.Value 145 | }; 146 | } 147 | 148 | return element.BoundingRectangle.Center(); 149 | } 150 | 151 | if (x.HasValue && y.HasValue) 152 | { 153 | return new Point { X = x.Value, Y = y.Value }; 154 | } 155 | 156 | throw WebDriverResponseException.InvalidArgument("Either element ID or x and y must be provided"); 157 | } 158 | 159 | public async Task ExecuteHoverScript(Session session, WindowsHoverScript action) 160 | { 161 | if (action.ModifierKeys != null) 162 | { 163 | throw WebDriverResponseException.UnsupportedOperation("Modifier keys are not yet supported"); 164 | } 165 | var startingPoint = GetPoint(action.StartElementId, action.StartX, action.StartY, session); 166 | var endPoint = GetPoint(action.EndElementId, action.EndX, action.EndY, session); 167 | 168 | _logger.LogDebug("Moving mouse to starting point ({X}, {Y})", startingPoint.X, startingPoint.Y); 169 | Mouse.Position = startingPoint; 170 | 171 | if (endPoint == startingPoint) 172 | { 173 | // Hover for specified time 174 | await Task.Delay(action.DurationMs ?? 100); 175 | return; 176 | } 177 | 178 | _logger.LogDebug("Moving mouse to end point ({X}, {Y})", endPoint.X, endPoint.Y); 179 | if (action.DurationMs.HasValue) 180 | { 181 | if (action.DurationMs.Value <= 0) 182 | { 183 | throw WebDriverResponseException.UnsupportedOperation("Duration less than or equal to zero is not supported"); 184 | } 185 | Mouse.MovePixelsPerMillisecond = endPoint.Distance(startingPoint) / action.DurationMs.Value; 186 | } 187 | Mouse.MoveTo(endPoint); 188 | } 189 | 190 | public async Task ExecuteKeyScript(Session session, WindowsKeyScript action) 191 | { 192 | if (action.VirtualKeyCode.HasValue) 193 | { 194 | if (action.Down.HasValue) 195 | { 196 | if (action.Down.Value == true) 197 | { 198 | _logger.LogDebug("Pressing key {VirtualKeyCode}", action.VirtualKeyCode.Value); 199 | Keyboard.Press((VirtualKeyShort)action.VirtualKeyCode.Value); 200 | await Task.Delay(10); 201 | } 202 | else 203 | { 204 | _logger.LogDebug("Releasing key {VirtualKeyCode}", action.VirtualKeyCode.Value); 205 | Keyboard.Release((VirtualKeyShort)action.VirtualKeyCode.Value); 206 | await Task.Delay(10); 207 | } 208 | } 209 | else 210 | { 211 | _logger.LogDebug("Pressing and releasing key {VirtualKeyCode}", action.VirtualKeyCode.Value); 212 | Keyboard.Press((VirtualKeyShort)action.VirtualKeyCode.Value); 213 | await Task.Delay(10); 214 | Keyboard.Release((VirtualKeyShort)action.VirtualKeyCode.Value); 215 | await Task.Delay(10); 216 | } 217 | } 218 | else if (action.Text != null) 219 | { 220 | _logger.LogDebug("Typing {Text}", action.Text); 221 | Keyboard.Type(action.Text); 222 | } 223 | else if (action.Pause.HasValue) 224 | { 225 | _logger.LogDebug("Pausing for {Pause} milliseconds", action.Pause.Value); 226 | await Task.Delay(action.Pause.Value); 227 | } 228 | else 229 | { 230 | throw WebDriverResponseException.InvalidArgument("Action must have either \"text\", \"virtualKeyCode\" or \"pause\""); 231 | } 232 | } 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver.UITests/FindElementsTests.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.UITests.TestUtil; 2 | using NUnit.Framework; 3 | using OpenQA.Selenium; 4 | using OpenQA.Selenium.Remote; 5 | using System.Linq; 6 | 7 | namespace FlaUI.WebDriver.UITests 8 | { 9 | public class FindElementsTests 10 | { 11 | [Test] 12 | public void FindElement_FromDesktop_ReturnsElement() 13 | { 14 | var driverOptions = FlaUIDriverOptions.RootApp(); 15 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 16 | 17 | var element = driver.FindElement(By.Name("Taskbar")); 18 | 19 | Assert.That(element, Is.Not.Null); 20 | } 21 | 22 | [Test] 23 | public void FindElement_ByXPath_ReturnsElement() 24 | { 25 | var driverOptions = FlaUIDriverOptions.TestApp(); 26 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 27 | 28 | var element = driver.FindElement(By.XPath("//Text")); 29 | 30 | Assert.That(element, Is.Not.Null); 31 | } 32 | 33 | [Test] 34 | public void FindElement_ByAccessibilityId_ReturnsElement() 35 | { 36 | var driverOptions = FlaUIDriverOptions.TestApp(); 37 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 38 | 39 | var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 40 | 41 | Assert.That(element, Is.Not.Null); 42 | } 43 | 44 | [Test] 45 | public void FindElement_ById_ReturnsElement() 46 | { 47 | var driverOptions = FlaUIDriverOptions.TestApp(); 48 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 49 | 50 | var element = driver.FindElement(By.Id("TextBox")); 51 | 52 | Assert.That(element, Is.Not.Null); 53 | } 54 | 55 | [Test] 56 | public void FindElement_ByName_ReturnsElement() 57 | { 58 | var driverOptions = FlaUIDriverOptions.TestApp(); 59 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 60 | 61 | var element = driver.FindElement(By.Name("Test Label")); 62 | 63 | Assert.That(element, Is.Not.Null); 64 | } 65 | 66 | [Test] 67 | public void FindElement_ByNativeName_ReturnsElement() 68 | { 69 | var driverOptions = FlaUIDriverOptions.TestApp(); 70 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 71 | 72 | var element = driver.FindElement(ExtendedBy.NonCssName("Test Label")); 73 | 74 | Assert.That(element, Is.Not.Null); 75 | } 76 | 77 | [Test] 78 | public void FindElement_ByNativeClassName_ReturnsElement() 79 | { 80 | var driverOptions = FlaUIDriverOptions.TestApp(); 81 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 82 | 83 | var element = driver.FindElement(ExtendedBy.NonCssClassName("TextBlock")); 84 | 85 | Assert.That(element, Is.Not.Null); 86 | } 87 | 88 | [Test] 89 | public void FindElement_ByClassName_ReturnsElement() 90 | { 91 | var driverOptions = FlaUIDriverOptions.TestApp(); 92 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 93 | 94 | var element = driver.FindElement(By.ClassName("TextBlock")); 95 | 96 | Assert.That(element, Is.Not.Null); 97 | } 98 | 99 | [Test] 100 | public void FindElement_ByLinkText_ReturnsElement() 101 | { 102 | var driverOptions = FlaUIDriverOptions.TestApp(); 103 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 104 | 105 | var element = driver.FindElement(By.LinkText("Invoke me!")); 106 | 107 | Assert.That(element, Is.Not.Null); 108 | } 109 | 110 | [Test] 111 | public void FindElement_ByPartialLinkText_ReturnsElement() 112 | { 113 | var driverOptions = FlaUIDriverOptions.TestApp(); 114 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 115 | 116 | var element = driver.FindElement(By.PartialLinkText("Invoke")); 117 | 118 | Assert.That(element, Is.Not.Null); 119 | } 120 | 121 | [Test] 122 | public void FindElement_ByTagName_ReturnsElement() 123 | { 124 | var driverOptions = FlaUIDriverOptions.TestApp(); 125 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 126 | 127 | var element = driver.FindElement(By.TagName("Text")); 128 | 129 | Assert.That(element, Is.Not.Null); 130 | } 131 | 132 | [Test] 133 | public void FindElement_NotExisting_ThrowsNoSuchElementException() 134 | { 135 | var driverOptions = FlaUIDriverOptions.TestApp(); 136 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 137 | 138 | var findElement = () => driver.FindElement(ExtendedBy.AccessibilityId("NotExisting")); 139 | 140 | Assert.That(findElement, Throws.TypeOf()); 141 | } 142 | 143 | [Test] 144 | public void FindElementFromElement_InsideElement_ReturnsElement() 145 | { 146 | var driverOptions = FlaUIDriverOptions.TestApp(); 147 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 148 | var fromElement = driver.FindElement(By.TagName("Tab")); 149 | 150 | var foundElement = fromElement.FindElement(ExtendedBy.AccessibilityId("TextBox")); 151 | 152 | Assert.That(foundElement, Is.Not.Null); 153 | } 154 | 155 | [Test] 156 | public void FindElementFromElement_OutsideElement_ThrowsNoSuchElementException() 157 | { 158 | var driverOptions = FlaUIDriverOptions.TestApp(); 159 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 160 | var fromElement = driver.FindElement(ExtendedBy.AccessibilityId("ListBox")); 161 | 162 | var findElement = () => fromElement.FindElement(ExtendedBy.AccessibilityId("TextBox")); 163 | 164 | Assert.That(findElement, Throws.TypeOf()); 165 | } 166 | 167 | [Test] 168 | public void FindElementsFromElement_InsideElement_ReturnsElement() 169 | { 170 | var driverOptions = FlaUIDriverOptions.TestApp(); 171 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 172 | var fromElement = driver.FindElement(By.TagName("Tab")); 173 | 174 | var foundElements = fromElement.FindElements(By.TagName("RadioButton")); 175 | 176 | Assert.That(foundElements, Has.Count.EqualTo(2)); 177 | } 178 | 179 | [Test] 180 | public void FindElementsFromElement_OutsideElement_ReturnsEmptyList() 181 | { 182 | var driverOptions = FlaUIDriverOptions.TestApp(); 183 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 184 | var fromElement = driver.FindElement(ExtendedBy.AccessibilityId("ListBox")); 185 | 186 | var foundElements = fromElement.FindElements(ExtendedBy.AccessibilityId("TextBox")); 187 | 188 | Assert.That(foundElements, Is.Empty); 189 | } 190 | 191 | [Test] 192 | public void FindElements_Default_ReturnsElements() 193 | { 194 | var driverOptions = FlaUIDriverOptions.TestApp(); 195 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 196 | 197 | var elements = driver.FindElements(By.TagName("RadioButton")); 198 | 199 | Assert.That(elements, Has.Count.EqualTo(2)); 200 | } 201 | 202 | [Test] 203 | public void FindElements_NotExisting_ReturnsEmptyList() 204 | { 205 | var driverOptions = FlaUIDriverOptions.TestApp(); 206 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 207 | 208 | var foundElements = driver.FindElements(ExtendedBy.AccessibilityId("NotExisting")); 209 | 210 | Assert.That(foundElements, Is.Empty); 211 | } 212 | 213 | [Test] 214 | public void FindElement_InOtherWindow_ThrowsNoSuchElementException() 215 | { 216 | var driverOptions = FlaUIDriverOptions.TestApp(); 217 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 218 | OpenAndSwitchToAnotherWindow(driver); 219 | 220 | var findElement = () => driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); 221 | 222 | Assert.That(findElement, Throws.TypeOf()); 223 | var elementInNewWindow = driver.FindElement(ExtendedBy.AccessibilityId("Window1TextBox")); 224 | Assert.That(elementInNewWindow, Is.Not.Null); 225 | } 226 | 227 | [Test] 228 | public void FindElements_InOtherWindow_ReturnsEmptyList() 229 | { 230 | var driverOptions = FlaUIDriverOptions.TestApp(); 231 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 232 | OpenAndSwitchToAnotherWindow(driver); 233 | 234 | var foundElements = driver.FindElements(ExtendedBy.AccessibilityId("TextBox")); 235 | 236 | Assert.That(foundElements, Is.Empty); 237 | var elementsInNewWindow = driver.FindElements(ExtendedBy.AccessibilityId("Window1TextBox")); 238 | Assert.That(elementsInNewWindow, Has.Count.EqualTo(1)); 239 | } 240 | 241 | [Test] 242 | public void FindElements_AfterPreviousKnownElementUnavailable_DoesNotThrow() 243 | { 244 | var driverOptions = FlaUIDriverOptions.TestApp(); 245 | using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); 246 | var initialWindowHandle = driver.CurrentWindowHandle; 247 | OpenAndSwitchToAnotherWindow(driver); 248 | var elementInNewWindow = driver.FindElement(ExtendedBy.AccessibilityId("Window1TextBox")); 249 | // close to make the elementInNewWindow unavailable 250 | driver.Close(); 251 | driver.SwitchTo().Window(initialWindowHandle); 252 | 253 | var foundElements = driver.FindElements(ExtendedBy.AccessibilityId("TextBox")); 254 | 255 | Assert.That(foundElements, Has.Count.EqualTo(1)); 256 | } 257 | 258 | private static void OpenAndSwitchToAnotherWindow(RemoteWebDriver driver) 259 | { 260 | var initialWindowHandles = new[] { driver.CurrentWindowHandle }; 261 | OpenAnotherWindow(driver); 262 | var windowHandlesAfterOpen = driver.WindowHandles; 263 | var newWindowHandle = windowHandlesAfterOpen.Except(initialWindowHandles).Single(); 264 | driver.SwitchTo().Window(newWindowHandle); 265 | } 266 | 267 | private static void OpenAnotherWindow(RemoteWebDriver driver) 268 | { 269 | driver.FindElement(ExtendedBy.NonCssName("_File")).Click(); 270 | driver.FindElement(ExtendedBy.NonCssName("Open Window 1")).Click(); 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/FlaUI.WebDriver/Controllers/ExecuteController.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.WebDriver.Models; 2 | using FlaUI.WebDriver.Services; 3 | using Microsoft.AspNetCore.Mvc; 4 | using System.Diagnostics; 5 | using System.Text.Json; 6 | 7 | namespace FlaUI.WebDriver.Controllers 8 | { 9 | [Route("session/{sessionId}/[controller]")] 10 | [ApiController] 11 | public class ExecuteController : ControllerBase 12 | { 13 | private readonly ILogger _logger; 14 | private readonly ISessionRepository _sessionRepository; 15 | private readonly IWindowsExtensionService _windowsExtensionService; 16 | 17 | public ExecuteController(ISessionRepository sessionRepository, IWindowsExtensionService windowsExtensionService, ILogger logger) 18 | { 19 | _sessionRepository = sessionRepository; 20 | _windowsExtensionService = windowsExtensionService; 21 | _logger = logger; 22 | } 23 | 24 | [HttpPost("sync")] 25 | public async Task ExecuteScript([FromRoute] string sessionId, [FromBody] ExecuteScriptRequest executeScriptRequest) 26 | { 27 | var session = GetSession(sessionId); 28 | switch (executeScriptRequest.Script) 29 | { 30 | case "powerShell": 31 | return await ExecutePowerShellScript(session, executeScriptRequest); 32 | case "windows: keys": 33 | return await ExecuteWindowsKeysScript(session, executeScriptRequest); 34 | case "windows: click": 35 | return await ExecuteWindowsClickScript(session, executeScriptRequest); 36 | case "windows: hover": 37 | return await ExecuteWindowsHoverScript(session, executeScriptRequest); 38 | case "windows: scroll": 39 | return await ExecuteWindowsScrollScript(session, executeScriptRequest); 40 | case "windows: setClipboard": 41 | return await ExecuteWindowsSetClipboardScript(session, executeScriptRequest); 42 | case "windows: getClipboard": 43 | return await ExecuteWindowsGetClipboardScript(session, executeScriptRequest); 44 | case "windows: clearClipboard": 45 | return await ExecuteWindowsClearClipboardScript(session, executeScriptRequest); 46 | default: 47 | throw WebDriverResponseException.UnsupportedOperation("Only 'powerShell', 'windows: keys', 'windows: click', 'windows: hover', 'windows: scroll', " + 48 | "'windows: setClipboard', 'windows: getClipboard', 'windows: clearClipboard' scripts are supported"); 49 | } 50 | } 51 | 52 | private async Task ExecutePowerShellScript(Session session, ExecuteScriptRequest executeScriptRequest) 53 | { 54 | if (executeScriptRequest.Args.Count != 1) 55 | { 56 | throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the PowerShell script, but got {executeScriptRequest.Args.Count} arguments"); 57 | } 58 | var powerShellArgs = executeScriptRequest.Args[0]; 59 | if (!powerShellArgs.TryGetProperty("command", out var powerShellCommandJson)) 60 | { 61 | throw WebDriverResponseException.InvalidArgument("Expected a \"command\" property of the first argument for the PowerShell script"); 62 | } 63 | if (powerShellCommandJson.ValueKind != JsonValueKind.String) 64 | { 65 | throw WebDriverResponseException.InvalidArgument($"Powershell \"command\" property must be a string"); 66 | } 67 | string? powerShellCommand = powerShellCommandJson.GetString(); 68 | if (string.IsNullOrEmpty(powerShellCommand)) 69 | { 70 | throw WebDriverResponseException.InvalidArgument($"Powershell \"command\" property must be non-empty"); 71 | } 72 | 73 | _logger.LogInformation("Executing PowerShell command {Command} (session {SessionId})", powerShellCommand, session.SessionId); 74 | 75 | var processStartInfo = new ProcessStartInfo("powershell.exe", $"-Command \"{powerShellCommand.Replace("\"", "\\\"")}\"") 76 | { 77 | RedirectStandardError = true, 78 | RedirectStandardOutput = true 79 | }; 80 | using var process = Process.Start(processStartInfo); 81 | using var cancellationTokenSource = new CancellationTokenSource(); 82 | if (session.ScriptTimeout.HasValue) 83 | { 84 | cancellationTokenSource.CancelAfter(session.ScriptTimeout.Value); 85 | } 86 | await process!.WaitForExitAsync(cancellationTokenSource.Token); 87 | if (process.ExitCode != 0) 88 | { 89 | var error = await process.StandardError.ReadToEndAsync(); 90 | return WebDriverResult.BadRequest(new ErrorResponse() 91 | { 92 | ErrorCode = "script error", 93 | Message = $"Script failed with exit code {process.ExitCode}: {error}" 94 | }); 95 | } 96 | var result = await process.StandardOutput.ReadToEndAsync(); 97 | return WebDriverResult.Success(result); 98 | } 99 | 100 | private async Task ExecuteWindowsSetClipboardScript(Session session, ExecuteScriptRequest executeScriptRequest) 101 | { 102 | if (executeScriptRequest.Args.Count != 1) 103 | { 104 | throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: setClipboard script, but got {executeScriptRequest.Args.Count} arguments"); 105 | } 106 | var action = JsonSerializer.Deserialize(executeScriptRequest.Args[0], new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); 107 | if (action == null) 108 | { 109 | throw WebDriverResponseException.InvalidArgument("Action cannot be null"); 110 | } 111 | await _windowsExtensionService.ExecuteSetClipboardScript(session, action); 112 | return WebDriverResult.Success(); 113 | } 114 | 115 | private async Task ExecuteWindowsGetClipboardScript(Session session, ExecuteScriptRequest executeScriptRequest) 116 | { 117 | if (executeScriptRequest.Args.Count != 1) 118 | { 119 | throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: getClipboard script, but got {executeScriptRequest.Args.Count} arguments"); 120 | } 121 | var action = JsonSerializer.Deserialize(executeScriptRequest.Args[0], new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); 122 | if (action == null) 123 | { 124 | throw WebDriverResponseException.InvalidArgument("Action cannot be null"); 125 | } 126 | var result = await _windowsExtensionService.ExecuteGetClipboardScript(session, action); 127 | return WebDriverResult.Success(result); 128 | } 129 | 130 | private async Task ExecuteWindowsClearClipboardScript(Session session, ExecuteScriptRequest executeScriptRequest) 131 | { 132 | if (executeScriptRequest.Args.Count != 0) 133 | { 134 | throw WebDriverResponseException.InvalidArgument($"No arguments expected for the windows: getClipboard script, but got {executeScriptRequest.Args.Count} arguments"); 135 | } 136 | 137 | await _windowsExtensionService.ExecuteClearClipboardScript(session); 138 | return WebDriverResult.Success(); 139 | } 140 | 141 | private async Task ExecuteWindowsClickScript(Session session, ExecuteScriptRequest executeScriptRequest) 142 | { 143 | if (executeScriptRequest.Args.Count != 1) 144 | { 145 | throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: click script, but got {executeScriptRequest.Args.Count} arguments"); 146 | } 147 | var action = JsonSerializer.Deserialize(executeScriptRequest.Args[0], new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); 148 | if (action == null) 149 | { 150 | throw WebDriverResponseException.InvalidArgument("Action cannot be null"); 151 | } 152 | await _windowsExtensionService.ExecuteClickScript(session, action); 153 | return WebDriverResult.Success(); 154 | } 155 | 156 | private async Task ExecuteWindowsScrollScript(Session session, ExecuteScriptRequest executeScriptRequest) 157 | { 158 | if (executeScriptRequest.Args.Count != 1) 159 | { 160 | throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: click script, but got {executeScriptRequest.Args.Count} arguments"); 161 | } 162 | var action = JsonSerializer.Deserialize(executeScriptRequest.Args[0], new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); 163 | if (action == null) 164 | { 165 | throw WebDriverResponseException.InvalidArgument("Action cannot be null"); 166 | } 167 | await _windowsExtensionService.ExecuteScrollScript(session, action); 168 | return WebDriverResult.Success(); 169 | } 170 | 171 | private async Task ExecuteWindowsHoverScript(Session session, ExecuteScriptRequest executeScriptRequest) 172 | { 173 | if (executeScriptRequest.Args.Count != 1) 174 | { 175 | throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: hover script, but got {executeScriptRequest.Args.Count} arguments"); 176 | } 177 | var action = JsonSerializer.Deserialize(executeScriptRequest.Args[0], new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); 178 | if (action == null) 179 | { 180 | throw WebDriverResponseException.InvalidArgument("Action cannot be null"); 181 | } 182 | await _windowsExtensionService.ExecuteHoverScript(session, action); 183 | return WebDriverResult.Success(); 184 | } 185 | 186 | private async Task ExecuteWindowsKeysScript(Session session, ExecuteScriptRequest executeScriptRequest) 187 | { 188 | if (executeScriptRequest.Args.Count != 1) 189 | { 190 | throw WebDriverResponseException.InvalidArgument($"Expected an array of exactly 1 arguments for the windows: keys script, but got {executeScriptRequest.Args.Count} arguments"); 191 | } 192 | var windowsKeysArgs = executeScriptRequest.Args[0]; 193 | if (!windowsKeysArgs.TryGetProperty("actions", out var actionsJson)) 194 | { 195 | throw WebDriverResponseException.InvalidArgument("Expected a \"actions\" property of the first argument for the windows: keys script"); 196 | } 197 | session.CurrentWindow.FocusNative(); 198 | if (actionsJson.ValueKind == JsonValueKind.Array) 199 | { 200 | var actions = JsonSerializer.Deserialize>(actionsJson, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); 201 | if (actions == null) 202 | { 203 | throw WebDriverResponseException.InvalidArgument("Argument \"actions\" cannot be null"); 204 | } 205 | foreach (var action in actions) 206 | { 207 | await _windowsExtensionService.ExecuteKeyScript(session, action); 208 | } 209 | } 210 | else 211 | { 212 | var action = JsonSerializer.Deserialize(actionsJson, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); 213 | if (action == null) 214 | { 215 | throw WebDriverResponseException.InvalidArgument("Action cannot be null"); 216 | } 217 | await _windowsExtensionService.ExecuteKeyScript(session, action); 218 | } 219 | return WebDriverResult.Success(); 220 | } 221 | 222 | private Session GetSession(string sessionId) 223 | { 224 | var session = _sessionRepository.FindById(sessionId); 225 | if (session == null) 226 | { 227 | throw WebDriverResponseException.SessionNotFound(sessionId); 228 | } 229 | session.SetLastCommandTimeToNow(); 230 | return session; 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/TestApplications/WpfApplication/MainWindow.xaml: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |