diff --git a/README.md b/README.md index 7b9bc80..e37aeb1 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,14 @@ There is an interpretation to use the WebDriver specification to drive native au | tag name | Control type in Windows | | attribute | [UI automation element property](https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-automation-element-propids) in Windows | +### Deviations from W3C WebDriver Spec + +https://www.w3.org/TR/webdriver2/#element-send-keys says: + +> Set the text insertion caret using set selection range using current text length for both the start and end parameters. + +This is impossible using UIA, as there is no API to set the caret position: text instead gets inserted at the beginning of a text box. This is also WinAppDriver's behavior. + ### Element Attributes Attributes are mapped to UI automation element properties. Attributes without a period (`.`) are mapped to [Automation Element Properties](https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-automation-element-propids). For example to read the `UIA_ClassNamePropertyId` using Selenium WebDriver: diff --git a/src/FlaUI.WebDriver.UITests/ElementTests.cs b/src/FlaUI.WebDriver.UITests/ElementTests.cs index aad7bc8..ff8d7fc 100644 --- a/src/FlaUI.WebDriver.UITests/ElementTests.cs +++ b/src/FlaUI.WebDriver.UITests/ElementTests.cs @@ -1,9 +1,9 @@ -using FlaUI.WebDriver.UITests.TestUtil; +using System.Threading; +using FlaUI.WebDriver.UITests.TestUtil; using NUnit.Framework; using OpenQA.Selenium; using OpenQA.Selenium.Interactions; using OpenQA.Selenium.Remote; -using System; namespace FlaUI.WebDriver.UITests { @@ -134,7 +134,51 @@ public void SendKeys_Default_IsSupported() element.SendKeys("Hello World!"); - Assert.That(element.Text, Is.EqualTo("Hello World!")); + Assert.That(element.Text, Is.EqualTo("Hello World!Test TextBox")); + } + + [Test] + public void SendKeys_ShiftedCharacter_ShiftIsReleased() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("TextBox")); + + element.SendKeys("!"); + element.SendKeys("1"); + + Assert.That(element.Text, Is.EqualTo("!1Test TextBox")); + } + + [Test] + public void SendKeys_DownArrow_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("NonEditableCombo")); + + element.SendKeys(Keys.Down); + + Assert.That(element.Text, Is.EqualTo("Item 2")); + } + + [Test, Ignore("Alt key combinations currently fail due to https://github.com/FlaUI/FlaUI/issues/320")] + public void SendKeys_AltDownArrowEscape_IsSupported() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + var element = driver.FindElement(ExtendedBy.AccessibilityId("NonEditableCombo")); + var expandCollapseState = element.GetDomAttribute("ExpandCollapse.ExpandCollapseState"); + + Assert.That(expandCollapseState, Is.EqualTo("Collapsed")); + + element.SendKeys(Keys.Alt + Keys.Down); + + Assert.That(expandCollapseState, Is.EqualTo("Expanded")); + + element.SendKeys(Keys.Escape); + + Assert.That(expandCollapseState, Is.EqualTo("Collapsed")); } [Test] diff --git a/src/FlaUI.WebDriver/Action.cs b/src/FlaUI.WebDriver/Action.cs index 4453af9..769d974 100644 --- a/src/FlaUI.WebDriver/Action.cs +++ b/src/FlaUI.WebDriver/Action.cs @@ -6,6 +6,7 @@ public class Action { public Action(ActionSequence actionSequence, ActionItem actionItem) { + Id = actionSequence.Id; Type = actionSequence.Type; SubType = actionItem.Type; Button = actionItem.Button; @@ -29,6 +30,7 @@ public Action(ActionSequence actionSequence, ActionItem actionItem) public Action(Action action) { + Id = action.Id; Type = action.Type; SubType = action.SubType; Button = action.Button; @@ -50,6 +52,7 @@ public Action(Action action) Value = action.Value; } + public string Id { get; set; } public string Type { get; set; } public string SubType { get; set; } public int? Button { get; set; } diff --git a/src/FlaUI.WebDriver/Controllers/ActionsController.cs b/src/FlaUI.WebDriver/Controllers/ActionsController.cs index 993c28b..edf1a30 100644 --- a/src/FlaUI.WebDriver/Controllers/ActionsController.cs +++ b/src/FlaUI.WebDriver/Controllers/ActionsController.cs @@ -1,7 +1,5 @@ -using System.Drawing; -using FlaUI.Core.Input; -using FlaUI.Core.WindowsAPI; -using FlaUI.WebDriver.Models; +using FlaUI.WebDriver.Models; +using FlaUI.WebDriver.Services; using Microsoft.AspNetCore.Mvc; namespace FlaUI.WebDriver.Controllers @@ -23,11 +21,11 @@ public ActionsController(ILogger logger, ISessionRepository s public async Task PerformActions([FromRoute] string sessionId, [FromBody] ActionsRequest actionsRequest) { var session = GetSession(sessionId); - var actionsByTick = ExtractActionSequence(actionsRequest); + var actionsByTick = ExtractActionSequence(session, actionsRequest); foreach (var tickActions in actionsByTick) { var tickDuration = tickActions.Max(tickAction => tickAction.Duration) ?? 0; - var dispatchTickActionTasks = tickActions.Select(tickAction => DispatchAction(session, tickAction)); + var dispatchTickActionTasks = tickActions.Select(tickAction => ActionsDispatcher.DispatchAction(session, tickAction)); if (tickDuration > 0) { dispatchTickActionTasks = dispatchTickActionTasks.Concat(new[] { Task.Delay(tickDuration) }); @@ -45,7 +43,7 @@ public async Task ReleaseActions([FromRoute] string sessionId) foreach (var cancelAction in session.InputState.InputCancelList) { - await DispatchAction(session, cancelAction); + await ActionsDispatcher.DispatchAction(session, cancelAction); } session.InputState.Reset(); @@ -56,13 +54,25 @@ public async Task ReleaseActions([FromRoute] string sessionId) /// See https://www.w3.org/TR/webdriver2/#dfn-extract-an-action-sequence. /// Returns all sequence actions synchronized by index. /// - /// + /// The session + /// The request /// - private static List> ExtractActionSequence(ActionsRequest actionsRequest) + private static List> ExtractActionSequence(Session session, ActionsRequest actionsRequest) { var actionsByTick = new List>(); foreach (var actionSequence in actionsRequest.Actions) { + // TODO: Implement other input source types. + if (actionSequence.Type == "key") + { + var source = session.InputState.GetOrCreateInputSource(actionSequence.Type, actionSequence.Id); + + // The spec says that input sources must be created for actions and they are later expected to be + // found in the input source map, but doesn't specify what should add them. Guessing that it should + // be done here. https://github.com/w3c/webdriver/issues/1810 + session.InputState.AddInputSource(actionSequence.Id, source); + } + for (var tickIndex = 0; tickIndex < actionSequence.Actions.Count; tickIndex++) { var actionItem = actionSequence.Actions[tickIndex]; @@ -77,292 +87,6 @@ private static List> ExtractActionSequence(ActionsRequest actionsRe return actionsByTick; } - private static async Task DispatchAction(Session session, Action action) - { - switch (action.Type) - { - case "pointer": - await DispatchPointerAction(session, action); - return; - case "key": - await DispatchKeyAction(session, action); - return; - case "wheel": - await DispatchWheelAction(session, action); - return; - case "none": - await DispatchNullAction(session, action); - return; - default: - throw WebDriverResponseException.UnsupportedOperation($"Action type {action.Type} not supported"); - } - } - - private static async Task DispatchNullAction(Session session, Action action) - { - switch (action.SubType) - { - case "pause": - await Task.Yield(); - return; - default: - throw WebDriverResponseException.InvalidArgument($"Null action subtype {action.SubType} unknown"); - } - } - - private static async Task DispatchKeyAction(Session session, Action action) - { - switch (action.SubType) - { - case "keyDown": - var keyToPress = GetKey(action.Value); - Keyboard.Press(keyToPress); - var cancelAction = action.Clone(); - cancelAction.SubType = "keyUp"; - session.InputState.InputCancelList.Add(cancelAction); - await Task.Yield(); - return; - case "keyUp": - var keyToRelease = GetKey(action.Value); - Keyboard.Release(keyToRelease); - await Task.Yield(); - return; - case "pause": - await Task.Yield(); - return; - default: - throw WebDriverResponseException.InvalidArgument($"Pointer action subtype {action.SubType} unknown"); - } - } - - private static async Task DispatchWheelAction(Session session, Action action) - { - switch (action.SubType) - { - case "scroll": - if (action.X == null || action.Y == null) - { - throw WebDriverResponseException.InvalidArgument("For wheel scroll, X and Y are required"); - } - Mouse.MoveTo(action.X.Value, action.Y.Value); - if (action.DeltaX == null || action.DeltaY == null) - { - throw WebDriverResponseException.InvalidArgument("For wheel scroll, delta X and delta Y are required"); - } - if (action.DeltaY != 0) - { - Mouse.Scroll(action.DeltaY.Value); - } - if (action.DeltaX != 0) - { - Mouse.HorizontalScroll(action.DeltaX.Value); - } - return; - case "pause": - await Task.Yield(); - return; - default: - throw WebDriverResponseException.InvalidArgument($"Wheel action subtype {action.SubType} unknown"); - } - } - - private static VirtualKeyShort GetKey(string? value) - { - if (value == null || value.Length != 1) - { - throw WebDriverResponseException.InvalidArgument($"Key action value argument should be exactly one character"); - } - switch (value[0]) - { - case '\uE001': return VirtualKeyShort.CANCEL; - case '\uE002': return VirtualKeyShort.HELP; - case '\uE003': return VirtualKeyShort.BACK; - case '\uE004': return VirtualKeyShort.TAB; - case '\uE005': return VirtualKeyShort.CLEAR; - case '\uE006': return VirtualKeyShort.RETURN; - case '\uE007': return VirtualKeyShort.ENTER; - case '\uE008': return VirtualKeyShort.LSHIFT; - case '\uE009': return VirtualKeyShort.LCONTROL; - case '\uE00A': return VirtualKeyShort.ALT; - case '\uE00B': return VirtualKeyShort.PAUSE; - case '\uE00C': return VirtualKeyShort.ESCAPE; - case '\uE00D': return VirtualKeyShort.SPACE; - case '\uE00E': return VirtualKeyShort.PRIOR; - case '\uE00F': return VirtualKeyShort.NEXT; - case '\uE010': return VirtualKeyShort.END; - case '\uE011': return VirtualKeyShort.HOME; - case '\uE012': return VirtualKeyShort.LEFT; - case '\uE013': return VirtualKeyShort.UP; - case '\uE014': return VirtualKeyShort.RIGHT; - case '\uE015': return VirtualKeyShort.DOWN; - case '\uE016': return VirtualKeyShort.INSERT; - case '\uE017': return VirtualKeyShort.DELETE; - // case '\uE018': ";" - // case '\uE019': "=" - case '\uE01A': return VirtualKeyShort.NUMPAD0; - case '\uE01B': return VirtualKeyShort.NUMPAD1; - case '\uE01C': return VirtualKeyShort.NUMPAD2; - case '\uE01D': return VirtualKeyShort.NUMPAD3; - case '\uE01E': return VirtualKeyShort.NUMPAD4; - case '\uE01F': return VirtualKeyShort.NUMPAD5; - case '\uE020': return VirtualKeyShort.NUMPAD6; - case '\uE021': return VirtualKeyShort.NUMPAD7; - case '\uE022': return VirtualKeyShort.NUMPAD8; - case '\uE023': return VirtualKeyShort.NUMPAD9; - case '\uE024': return VirtualKeyShort.ADD; - case '\uE025': return VirtualKeyShort.MULTIPLY; - case '\uE026': return VirtualKeyShort.SEPARATOR; - case '\uE027': return VirtualKeyShort.SUBTRACT; - case '\uE028': return VirtualKeyShort.DECIMAL; - case '\uE029': return VirtualKeyShort.DIVIDE; - case '\uE031': return VirtualKeyShort.F1; - case '\uE032': return VirtualKeyShort.F2; - case '\uE033': return VirtualKeyShort.F3; - case '\uE034': return VirtualKeyShort.F4; - case '\uE035': return VirtualKeyShort.F5; - case '\uE036': return VirtualKeyShort.F6; - case '\uE037': return VirtualKeyShort.F7; - case '\uE038': return VirtualKeyShort.F8; - case '\uE039': return VirtualKeyShort.F9; - case '\uE03A': return VirtualKeyShort.F10; - case '\uE03B': return VirtualKeyShort.F11; - case '\uE03C': return VirtualKeyShort.F12; - // case '\uE03D': "Meta" - // case '\uE040': "ZenkakuHankaku" - case '\uE050': return VirtualKeyShort.RSHIFT; - case '\uE051': return VirtualKeyShort.RCONTROL; - case '\uE052': return VirtualKeyShort.ALT; - // case '\uE053': "Meta" - case '\uE054': return VirtualKeyShort.PRIOR; - case '\uE055': return VirtualKeyShort.NEXT; - case '\uE056': return VirtualKeyShort.END; - case '\uE057': return VirtualKeyShort.HOME; - case '\uE058': return VirtualKeyShort.LEFT; - case '\uE059': return VirtualKeyShort.UP; - case '\uE05A': return VirtualKeyShort.RIGHT; - case '\uE05B': return VirtualKeyShort.DOWN; - case '\uE05C': return VirtualKeyShort.INSERT; - case '\uE05D': return VirtualKeyShort.DELETE; - case 'a': return VirtualKeyShort.KEY_A; - case 'b': return VirtualKeyShort.KEY_B; - case 'c': return VirtualKeyShort.KEY_C; - case 'd': return VirtualKeyShort.KEY_D; - case 'e': return VirtualKeyShort.KEY_E; - case 'f': return VirtualKeyShort.KEY_F; - case 'g': return VirtualKeyShort.KEY_G; - case 'h': return VirtualKeyShort.KEY_H; - case 'i': return VirtualKeyShort.KEY_I; - case 'j': return VirtualKeyShort.KEY_J; - case 'k': return VirtualKeyShort.KEY_K; - case 'l': return VirtualKeyShort.KEY_L; - case 'm': return VirtualKeyShort.KEY_M; - case 'n': return VirtualKeyShort.KEY_N; - case 'o': return VirtualKeyShort.KEY_O; - case 'p': return VirtualKeyShort.KEY_P; - case 'q': return VirtualKeyShort.KEY_Q; - case 'r': return VirtualKeyShort.KEY_R; - case 's': return VirtualKeyShort.KEY_S; - case 't': return VirtualKeyShort.KEY_T; - case 'u': return VirtualKeyShort.KEY_U; - case 'v': return VirtualKeyShort.KEY_V; - case 'w': return VirtualKeyShort.KEY_W; - case 'x': return VirtualKeyShort.KEY_X; - case 'y': return VirtualKeyShort.KEY_Y; - case 'z': return VirtualKeyShort.KEY_Z; - default: throw WebDriverResponseException.UnsupportedOperation($"Key {value} is not supported"); - } - } - - private static async Task DispatchPointerAction(Session session, Action action) - { - switch (action.SubType) - { - case "pointerMove": - var point = GetCoordinates(session, action); - Mouse.MoveTo(point); - await Task.Yield(); - return; - case "pointerDown": - Mouse.Down(GetMouseButton(action.Button)); - var cancelAction = action.Clone(); - cancelAction.SubType = "pointerUp"; - session.InputState.InputCancelList.Add(cancelAction); - await Task.Yield(); - return; - case "pointerUp": - Mouse.Up(GetMouseButton(action.Button)); - await Task.Yield(); - return; - case "pause": - await Task.Yield(); - return; - default: - throw WebDriverResponseException.UnsupportedOperation($"Pointer action subtype {action.Type} not supported"); - } - } - - private static Point GetCoordinates(Session session, Action action) - { - var origin = action.Origin ?? "viewport"; - - switch (origin) - { - case "viewport": - if (action.X == null || action.Y == null) - { - throw WebDriverResponseException.InvalidArgument("For pointer move, X and Y are required"); - } - - return new Point(action.X.Value, action.Y.Value); - case "pointer": - if (action.X == null || action.Y == null) - { - throw WebDriverResponseException.InvalidArgument("For pointer move, X and Y are required"); - } - - var current = Mouse.Position; - return new Point(current.X + action.X.Value, current.Y + action.Y.Value); - case Dictionary originMap: - if (originMap.TryGetValue("element-6066-11e4-a52e-4f735466cecf", out var elementId)) - { - if (session.FindKnownElementById(elementId) is { } element) - { - var bounds = element.BoundingRectangle; - var x = bounds.Left + (bounds.Width / 2) + (action.X ?? 0); - var y = bounds.Top + (bounds.Height / 2) + (action.Y ?? 0); - return new(x, y); - } - - throw WebDriverResponseException.InvalidArgument( - $"An unknown element ID '{elementId}' provided for action item '{action.Type}'."); - } - - throw WebDriverResponseException.InvalidArgument( - $"An unknown element '{origin}' provided for action item '{action.Type}'."); - default: - throw WebDriverResponseException.InvalidArgument( - $"Unknown origin type '{origin}' provided for action item '{action.Type}'."); - } - } - - private static MouseButton GetMouseButton(int? button) - { - if(button == null) - { - throw WebDriverResponseException.InvalidArgument($"Pointer action button argument missing"); - } - switch(button) - { - case 0: return MouseButton.Left; - case 1: return MouseButton.Middle; - case 2: return MouseButton.Right; - case 3: return MouseButton.XButton1; - case 4: return MouseButton.XButton2; - default: - throw WebDriverResponseException.UnsupportedOperation($"Pointer button {button} not supported"); - } - } - private Session GetSession(string sessionId) { var session = _sessionRepository.FindById(sessionId); diff --git a/src/FlaUI.WebDriver/Controllers/ElementController.cs b/src/FlaUI.WebDriver/Controllers/ElementController.cs index f13187c..66e6f88 100644 --- a/src/FlaUI.WebDriver/Controllers/ElementController.cs +++ b/src/FlaUI.WebDriver/Controllers/ElementController.cs @@ -1,6 +1,7 @@ using System.Text; using FlaUI.Core.AutomationElements; using FlaUI.WebDriver.Models; +using FlaUI.WebDriver.Services; using Microsoft.AspNetCore.Mvc; namespace FlaUI.WebDriver.Controllers @@ -178,7 +179,30 @@ public async Task ElementSendKeys([FromRoute] string sessionId, [F { return ElementNotInteractable(elementId); } - element.AsTextBox().Text = elementSendKeysRequest.Text; + + element.Focus(); + + // Warning: Deviation from the spec. https://www.w3.org/TR/webdriver2/#element-send-keys says: + // + // > Set the text insertion caret using set selection range using current text length for both the start and end parameters. + // + // In English: "the caret should be placed at the end of the text before sending keys". That doesn't seem to be possible + // with UIA, meaning that the text gets inserted at the beginning, which is also WinAppDriver's behavior. + + var inputState = session.InputState; + var inputId = Guid.NewGuid().ToString(); + var source = (KeyInputSource)inputState.CreateInputSource("key"); + + inputState.AddInputSource(inputId, source); + + try + { + await ActionsDispatcher.DispatchActionsForString(session, inputId, source, elementSendKeysRequest.Text); + } + finally + { + inputState.RemoveInputSource(inputId); + } return WebDriverResult.Success(); } diff --git a/src/FlaUI.WebDriver/InputSource.cs b/src/FlaUI.WebDriver/InputSource.cs new file mode 100644 index 0000000..271c5df --- /dev/null +++ b/src/FlaUI.WebDriver/InputSource.cs @@ -0,0 +1,12 @@ +namespace FlaUI.WebDriver; + +/// +/// An input source is a virtual device providing input events. +/// +/// +public class InputSource +{ + protected InputSource(string type) => Type = type; + + public string Type { get; } +} diff --git a/src/FlaUI.WebDriver/InputState.cs b/src/FlaUI.WebDriver/InputState.cs index 15b5136..1d2739f 100644 --- a/src/FlaUI.WebDriver/InputState.cs +++ b/src/FlaUI.WebDriver/InputState.cs @@ -1,12 +1,113 @@ -namespace FlaUI.WebDriver +using System.Diagnostics; + +namespace FlaUI.WebDriver { public class InputState { + private readonly Dictionary _inputStateMap = new(); + public List InputCancelList = new List(); public void Reset() { InputCancelList.Clear(); + _inputStateMap.Clear(); + } + + /// + /// Creates an input source of the given type. + /// + /// + /// Implements "create an input source" from https://www.w3.org/TR/webdriver2/#input-state + /// Note: The spec does not specify that a created input source should be added to the input state map. + /// + public InputSource CreateInputSource(string type) + { + return type switch + { + "none" => throw new NotImplementedException("Null input source is not implemented yet"), + "key" => new KeyInputSource(), + "pointer" => throw new NotImplementedException("Pointer input source is not implemented yet"), + "wheel" => throw new NotImplementedException("Wheel input source is not implemented yet"), + _ => throw new InvalidOperationException($"Unknown input source type: {type}") + }; + } + + /// + /// Tries to get an input source with the specified input ID. + /// + /// + /// Implements "get an input source" from https://www.w3.org/TR/webdriver2/#input-state + /// + public InputSource? GetInputSource(string inputId) + { + _inputStateMap.TryGetValue(inputId, out var result); + return result; + } + + /// + /// Tries to get an input source with the specified input ID. + /// + /// + /// Implements "get an input source" from https://www.w3.org/TR/webdriver2/#input-state + /// + public T? GetInputSource(string inputId) where T : InputSource + { + if (GetInputSource(inputId) is { } source) + { + if (source is T result) + { + return result; + } + else + { + throw WebDriverResponseException.InvalidArgument( + $"Input source with id '{inputId}' is not of the expected type: {typeof(T).Name}"); + } + } + + return null; + } + + /// + /// Gets an input source or creates a new one if it does not exist. + /// + /// + /// Implements "get or create an input source" from https://www.w3.org/TR/webdriver2/#input-state + /// Note: The spec does not specify that a created input source should be added to the input state map. + /// + public InputSource GetOrCreateInputSource(string type, string id) + { + var source = GetInputSource(id); + + if (source != null && source.Type != type) + { + throw WebDriverResponseException.InvalidArgument( + $"Input source with id '{id}' already exists and has a different type: {source.Type}"); + } + + return CreateInputSource(type); + } + + /// + /// Adds an input source. + /// + /// + /// Implements "add an input source" from https://www.w3.org/TR/webdriver2/#input-state + /// + public void AddInputSource(string inputId, InputSource inputSource) => _inputStateMap.Add(inputId, inputSource); + + /// + /// Removes an input source. + /// + /// + /// Implements "remove an input source" from https://www.w3.org/TR/webdriver2/#input-state + /// + public void RemoveInputSource(string inputId) + { + Debug.Assert(!InputCancelList.Any(x => x.Id == inputId)); + + _inputStateMap.Remove(inputId); } } } diff --git a/src/FlaUI.WebDriver/KeyInputSource.cs b/src/FlaUI.WebDriver/KeyInputSource.cs new file mode 100644 index 0000000..83f286e --- /dev/null +++ b/src/FlaUI.WebDriver/KeyInputSource.cs @@ -0,0 +1,24 @@ +namespace FlaUI.WebDriver; + +/// +/// A key input source is an input source that is associated with a keyboard-type device. +/// +/// +public class KeyInputSource() : InputSource("key") +{ + public HashSet Pressed = []; + + public bool Alt { get; set; } + public bool Ctrl{ get; set; } + public bool Meta { get; set; } + public bool Shift { get; set; } + + public void Reset() + { + Pressed.Clear(); + Alt = false; + Ctrl = false; + Meta = false; + Shift = false; + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Keys.cs b/src/FlaUI.WebDriver/Keys.cs new file mode 100644 index 0000000..cdc006a --- /dev/null +++ b/src/FlaUI.WebDriver/Keys.cs @@ -0,0 +1,505 @@ +using FlaUI.Core.WindowsAPI; + +namespace FlaUI.WebDriver; + +internal class Keys +{ + /// + /// Normalized key mapping from https://www.w3.org/TR/webdriver2/#keyboard-actions + /// + private static readonly Dictionary s_normalizedKeys = new Dictionary() + { + { '\uE000', "Unidentified" }, + { '\uE001', "Cancel" }, + { '\uE002', "Help" }, + { '\uE003', "Backspace" }, + { '\uE004', "Tab" }, + { '\uE005', "Clear" }, + { '\uE006', "Return" }, + { '\uE007', "Enter" }, + { '\uE008', "Shift" }, + { '\uE009', "Control" }, + { '\uE00A', "Alt" }, + { '\uE00B', "Pause" }, + { '\uE00C', "Escape" }, + { '\uE00D', " " }, + { '\uE00E', "PageUp" }, + { '\uE00F', "PageDown" }, + { '\uE010', "End" }, + { '\uE011', "Home" }, + { '\uE012', "ArrowLeft" }, + { '\uE013', "ArrowUp" }, + { '\uE014', "ArrowRight" }, + { '\uE015', "ArrowDown" }, + { '\uE016', "Insert" }, + { '\uE017', "Delete" }, + { '\uE018', ";" }, + { '\uE019', "=" }, + { '\uE01A', "0" }, + { '\uE01B', "1" }, + { '\uE01C', "2" }, + { '\uE01D', "3" }, + { '\uE01E', "4" }, + { '\uE01F', "5" }, + { '\uE020', "6" }, + { '\uE021', "7" }, + { '\uE022', "8" }, + { '\uE023', "9" }, + { '\uE024', "*" }, + { '\uE025', "+" }, + { '\uE026', "," }, + { '\uE027', "-" }, + { '\uE028', "." }, + { '\uE029', "/" }, + { '\uE031', "F1" }, + { '\uE032', "F2" }, + { '\uE033', "F3" }, + { '\uE034', "F4" }, + { '\uE035', "F5" }, + { '\uE036', "F6" }, + { '\uE037', "F7" }, + { '\uE038', "F8" }, + { '\uE039', "F9" }, + { '\uE03A', "F10" }, + { '\uE03B', "F11" }, + { '\uE03C', "F12" }, + { '\uE03D', "Meta" }, + { '\uE03E', "Command" }, + { '\uE040', "ZenkakuHankaku" }, + { '\uE050', "Shift" }, + { '\uE051', "Control" }, + { '\uE052', "Alt" }, + { '\uE053', "Meta" }, + { '\uE054', "PageUp" }, + { '\uE055', "PageDown" }, + { '\uE056', "End" }, + { '\uE057', "Home" }, + { '\uE058', "ArrowLeft" }, + { '\uE059', "ArrowUp" }, + { '\uE05A', "ArrowRight" }, + { '\uE05B', "ArrowDown" }, + { '\uE05C', "Insert" }, + { '\uE05D', "Delete" }, + }; + + private static readonly Dictionary s_keyToCode = new() + { + { '`', "Backquote" }, + { '\\', "Backslash" }, + { '\uE003', "Backspace" }, + { '[', "BracketLeft" }, + { ']', "BracketRight" }, + { ',', "Comma" }, + { '0', "Digit0" }, + { '1', "Digit1" }, + { '2', "Digit2" }, + { '3', "Digit3" }, + { '4', "Digit4" }, + { '5', "Digit5" }, + { '6', "Digit6" }, + { '7', "Digit7" }, + { '8', "Digit8" }, + { '9', "Digit9" }, + { '=', "Equal" }, + { '<', "IntlBackslash" }, + { 'a', "KeyA" }, + { 'b', "KeyB" }, + { 'c', "KeyC" }, + { 'd', "KeyD" }, + { 'e', "KeyE" }, + { 'f', "KeyF" }, + { 'g', "KeyG" }, + { 'h', "KeyH" }, + { 'i', "KeyI" }, + { 'j', "KeyJ" }, + { 'k', "KeyK" }, + { 'l', "KeyL" }, + { 'm', "KeyM" }, + { 'n', "KeyN" }, + { 'o', "KeyO" }, + { 'p', "KeyP" }, + { 'q', "KeyQ" }, + { 'r', "KeyR" }, + { 's', "KeyS" }, + { 't', "KeyT" }, + { 'u', "KeyU" }, + { 'v', "KeyV" }, + { 'w', "KeyW" }, + { 'x', "KeyX" }, + { 'y', "KeyY" }, + { 'z', "KeyZ" }, + { '-', "Minus" }, + { '.', "Period" }, + { '\'', "Quote" }, + { ';', "Semicolon" }, + { '/', "Slash" }, + { '\uE00A', "AltLeft" }, + { '\uE052', "AltRight" }, + { '\uE009', "ControlLeft" }, + { '\uE051', "ControlRight" }, + { '\uE006', "Enter" }, + { '\uE00B', "Pause" }, + { '\uE03D', "MetaLeft" }, + { '\uE053', "MetaRight" }, + { '\uE008', "ShiftLeft" }, + { '\uE050', "ShiftRight" }, + { ' ', "Space" }, + { '\uE004', "Tab" }, + { '\uE017', "Delete" }, + { '\uE010', "End" }, + { '\uE002', "Help" }, + { '\uE011', "Home" }, + { '\uE016', "Insert" }, + { '\uE00F', "PageDown" }, + { '\uE00E', "PageUp" }, + { '\uE015', "ArrowDown" }, + { '\uE012', "ArrowLeft" }, + { '\uE014', "ArrowRight" }, + { '\uE013', "ArrowUp" }, + { '\uE00C', "Escape" }, + { '\uE031', "F1" }, + { '\uE032', "F2" }, + { '\uE033', "F3" }, + { '\uE034', "F4" }, + { '\uE035', "F5" }, + { '\uE036', "F6" }, + { '\uE037', "F7" }, + { '\uE038', "F8" }, + { '\uE039', "F9" }, + { '\uE03A', "F10" }, + { '\uE03B', "F11" }, + { '\uE03C', "F12" }, + { '\uE019', "NumpadEqual" }, + { '\uE01A', "Numpad0" }, + { '\uE01B', "Numpad1" }, + { '\uE01C', "Numpad2" }, + { '\uE01D', "Numpad3" }, + { '\uE01E', "Numpad4" }, + { '\uE01F', "Numpad5" }, + { '\uE020', "Numpad6" }, + { '\uE021', "Numpad7" }, + { '\uE022', "Numpad8" }, + { '\uE023', "Numpad9" }, + { '\uE025', "NumpadAdd" }, + { '\uE026', "NumpadComma" }, + { '\uE028', "NumpadDecimal" }, + { '\uE029', "NumpadDivide" }, + { '\uE007', "NumpadEnter" }, + { '\uE024', "NumpadMultiply" }, + { '\uE027', "NumpadSubtract" }, + }; + + private static readonly Dictionary s_shiftedKeyToCode = new() + { + { '~', "Backquote" }, + { '|', "Backslash" }, + { '{', "BracketLeft" }, + { '}', "BracketRight" }, + { '<', "Comma" }, + { ')', "Digit0" }, + { '!', "Digit1" }, + { '@', "Digit2" }, + { '#', "Digit3" }, + { '$', "Digit4" }, + { '%', "Digit5" }, + { '^', "Digit6" }, + { '&', "Digit7" }, + { '*', "Digit8" }, + { '(', "Digit9" }, + { '+', "Equal" }, + { '>', "IntlBackslash" }, + { 'A', "KeyA" }, + { 'B', "KeyB" }, + { 'C', "KeyC" }, + { 'D', "KeyD" }, + { 'E', "KeyE" }, + { 'F', "KeyF" }, + { 'G', "KeyG" }, + { 'H', "KeyH" }, + { 'I', "KeyI" }, + { 'J', "KeyJ" }, + { 'K', "KeyK" }, + { 'L', "KeyL" }, + { 'M', "KeyM" }, + { 'N', "KeyN" }, + { 'O', "KeyO" }, + { 'P', "KeyP" }, + { 'Q', "KeyQ" }, + { 'R', "KeyR" }, + { 'S', "KeyS" }, + { 'T', "KeyT" }, + { 'U', "KeyU" }, + { 'V', "KeyV" }, + { 'W', "KeyW" }, + { 'X', "KeyX" }, + { 'Y', "KeyY" }, + { 'Z', "KeyZ" }, + { '_', "Minus" }, + { '.', "Period" }, + { '"', "Quote" }, + { ':', "Semicolon" }, + { '?', "Slash" }, + { '\uE00D', "Space" }, + { '\uE05C', "Numpad0" }, + { '\uE056', "Numpad1" }, + { '\uE05B', "Numpad2" }, + { '\uE055', "Numpad3" }, + { '\uE058', "Numpad4" }, + { '\uE05A', "Numpad6" }, + { '\uE057', "Numpad7" }, + { '\uE059', "Numpad8" }, + { '\uE054', "Numpad9" }, + { '\uE05D', "NumpadDecimal" }, + }; + + public const char Null = '\uE000'; + public const char Cancel = '\uE001'; + public const char Help = '\uE002'; + public const char Backspace = '\uE003'; + public const char Tab = '\uE004'; + public const char Clear = '\uE005'; + public const char Return = '\uE006'; + public const char Enter = '\uE007'; + public const char Shift = '\uE008'; + public const char LeftShift = '\uE008'; + public const char Control = '\uE009'; + public const char LeftControl = '\uE009'; + public const char Alt = '\uE00A'; + public const char LeftAlt = '\uE00A'; + public const char Pause = '\uE00B'; + public const char Escape = '\uE00C'; + public const char Space = '\uE00D'; + public const char PageUp = '\uE00E'; + public const char PageDown = '\uE00F'; + public const char End = '\uE010'; + public const char Home = '\uE011'; + public const char Left = '\uE012'; + public const char ArrowLeft = '\uE012'; + public const char Up = '\uE013'; + public const char ArrowUp = '\uE013'; + public const char Right = '\uE014'; + public const char ArrowRight = '\uE014'; + public const char Down = '\uE015'; + public const char ArrowDown = '\uE015'; + public const char Insert = '\uE016'; + public const char Delete = '\uE017'; + public const char Semicolon = '\uE018'; + public const char Equal = '\uE019'; + public const char NumberPad0 = '\uE01A'; + public const char NumberPad1 = '\uE01B'; + public const char NumberPad2 = '\uE01C'; + public const char NumberPad3 = '\uE01D'; + public const char NumberPad4 = '\uE01E'; + public const char NumberPad5 = '\uE01F'; + public const char NumberPad6 = '\uE020'; + public const char NumberPad7 = '\uE021'; + public const char NumberPad8 = '\uE022'; + public const char NumberPad9 = '\uE023'; + public const char Multiply = '\uE024'; + public const char Add = '\uE025'; + public const char Separator = '\uE026'; + public const char Subtract = '\uE027'; + public const char Decimal = '\uE028'; + public const char Divide = '\uE029'; + public const char F1 = '\uE031'; + public const char F2 = '\uE032'; + public const char F3 = '\uE033'; + public const char F4 = '\uE034'; + public const char F5 = '\uE035'; + public const char F6 = '\uE036'; + public const char F7 = '\uE037'; + public const char F8 = '\uE038'; + public const char F9 = '\uE039'; + public const char F10 = '\uE03A'; + public const char F11 = '\uE03B'; + public const char F12 = '\uE03C'; + public const char Meta = '\uE03D'; + public const char Command = '\uE03D'; + public const char ZenkakuHankaku = '\uE040'; + + /// + /// Gets a value indicating whether a key attribute value represents a modifier key. + /// + /// The key attribute value. + /// + /// Defined in https://www.w3.org/TR/uievents-key/#keys-modifier + /// + public static bool IsModifier(string key) + { + return key is "Alt" or "AltGraph" or "CapsLock" or "Control" or "Fn" or "FnLock" or + "Meta" or "NumLock" or "ScrollLock" or "Shift" or "Symbol" or "SymbolLock"; + } + + /// + /// Gets a value indicating whether a character is shifted. + /// + /// The character. + /// + /// Defined in https://www.w3.org/TR/webdriver2/#keyboard-actions + /// + internal static bool IsShiftedChar(char c) => s_shiftedKeyToCode.ContainsKey(c); + + /// + /// Gets a value indicating whether a graphene cluster is typeable. + /// + /// The graphene cluster + /// + /// Defined in https://www.w3.org/TR/webdriver2/#element-send-keys + /// + public static bool IsTypeable(string c) + { + return c.Length == 1 && (s_keyToCode.ContainsKey(c[0]) || s_shiftedKeyToCode.ContainsKey(c[0])); + } + + /// + /// Gets the code for a raw key. + /// + /// The raw key. + /// + /// Defined in https://www.w3.org/TR/webdriver2/#keyboard-actions + /// + public static string? GetCode(string key) + { + if (key.Length == 1) + { + var c = key[0]; + + if (s_keyToCode.TryGetValue(c, out var code)) + { + return code; + } + else if (s_shiftedKeyToCode.TryGetValue(c, out code)) + { + return code; + } + } + + return null; + } + + /// + /// Gets a normalized key value. + /// + /// The raw key. + /// + /// Defined in https://www.w3.org/TR/webdriver2/#keyboard-actions + /// + public static string GetNormalizedKeyValue(string key) + { + return key.Length == 1 && s_normalizedKeys.TryGetValue(key[0], out var value) ? value : key; + } + + /// + /// Gets the win32 virtual key code for a key code returned by . + /// + public static VirtualKeyShort GetVirtualKey(string? code) + { + return code switch + { + "Backquote" => VirtualKeyShort.OEM_3, + "Backslash" => VirtualKeyShort.OEM_5, + "Backspace" => VirtualKeyShort.BACK, + "BracketLeft" => VirtualKeyShort.OEM_4, + "BracketRight" => VirtualKeyShort.OEM_6, + "Comma" => VirtualKeyShort.OEM_COMMA, + "Digit0" => VirtualKeyShort.KEY_0, + "Digit1" => VirtualKeyShort.KEY_1, + "Digit2" => VirtualKeyShort.KEY_2, + "Digit3" => VirtualKeyShort.KEY_3, + "Digit4" => VirtualKeyShort.KEY_4, + "Digit5" => VirtualKeyShort.KEY_5, + "Digit6" => VirtualKeyShort.KEY_6, + "Digit7" => VirtualKeyShort.KEY_7, + "Digit8" => VirtualKeyShort.KEY_8, + "Digit9" => VirtualKeyShort.KEY_9, + "Equal" => VirtualKeyShort.OEM_PLUS, + "IntlBackslash" => VirtualKeyShort.OEM_102, + "KeyA" => VirtualKeyShort.KEY_A, + "KeyB" => VirtualKeyShort.KEY_B, + "KeyC" => VirtualKeyShort.KEY_C, + "KeyD" => VirtualKeyShort.KEY_D, + "KeyE" => VirtualKeyShort.KEY_E, + "KeyF" => VirtualKeyShort.KEY_F, + "KeyG" => VirtualKeyShort.KEY_G, + "KeyH" => VirtualKeyShort.KEY_H, + "KeyI" => VirtualKeyShort.KEY_I, + "KeyJ" => VirtualKeyShort.KEY_J, + "KeyK" => VirtualKeyShort.KEY_K, + "KeyL" => VirtualKeyShort.KEY_L, + "KeyM" => VirtualKeyShort.KEY_M, + "KeyN" => VirtualKeyShort.KEY_N, + "KeyO" => VirtualKeyShort.KEY_O, + "KeyP" => VirtualKeyShort.KEY_P, + "KeyQ" => VirtualKeyShort.KEY_Q, + "KeyR" => VirtualKeyShort.KEY_R, + "KeyS" => VirtualKeyShort.KEY_S, + "KeyT" => VirtualKeyShort.KEY_T, + "KeyU" => VirtualKeyShort.KEY_U, + "KeyV" => VirtualKeyShort.KEY_V, + "KeyW" => VirtualKeyShort.KEY_W, + "KeyX" => VirtualKeyShort.KEY_X, + "KeyY" => VirtualKeyShort.KEY_Y, + "KeyZ" => VirtualKeyShort.KEY_Z, + "Minus" => VirtualKeyShort.OEM_MINUS, + "Period" => VirtualKeyShort.OEM_PERIOD, + "Quote" => VirtualKeyShort.OEM_7, + "Semicolon" => VirtualKeyShort.OEM_1, + "Slash" => VirtualKeyShort.OEM_2, + "AltLeft" => VirtualKeyShort.ALT, + "AltRight" => VirtualKeyShort.ALT, + "ControlLeft" => VirtualKeyShort.CONTROL, + "ControlRight" => VirtualKeyShort.CONTROL, + "Enter" => VirtualKeyShort.ENTER, + "Pause" => VirtualKeyShort.PAUSE, + "MetaLeft" => VirtualKeyShort.LWIN, + "MetaRight" => VirtualKeyShort.RWIN, + "ShiftLeft" => VirtualKeyShort.LSHIFT, + "ShiftRight" => VirtualKeyShort.RSHIFT, + "Space" => VirtualKeyShort.SPACE, + "Tab" => VirtualKeyShort.TAB, + "Delete" => VirtualKeyShort.DELETE, + "End" => VirtualKeyShort.END, + "Help" => VirtualKeyShort.HELP, + "Home" => VirtualKeyShort.HOME, + "Insert" => VirtualKeyShort.INSERT, + "PageDown" => VirtualKeyShort.NEXT, + "PageUp" => VirtualKeyShort.PRIOR, + "ArrowDown" => VirtualKeyShort.DOWN, + "ArrowLeft" => VirtualKeyShort.LEFT, + "ArrowRight" => VirtualKeyShort.RIGHT, + "ArrowUp" => VirtualKeyShort.UP, + "Escape" => VirtualKeyShort.ESCAPE, + "F1" => VirtualKeyShort.F1, + "F2" => VirtualKeyShort.F2, + "F3" => VirtualKeyShort.F3, + "F4" => VirtualKeyShort.F4, + "F5" => VirtualKeyShort.F5, + "F6" => VirtualKeyShort.F6, + "F7" => VirtualKeyShort.F7, + "F8" => VirtualKeyShort.F8, + "F9" => VirtualKeyShort.F9, + "F10" => VirtualKeyShort.F10, + "F11" => VirtualKeyShort.F11, + "F12" => VirtualKeyShort.F12, + "NumpadEqual" => VirtualKeyShort.SEPARATOR, + "Numpad0" => VirtualKeyShort.NUMPAD0, + "Numpad1" => VirtualKeyShort.NUMPAD1, + "Numpad2" => VirtualKeyShort.NUMPAD2, + "Numpad3" => VirtualKeyShort.NUMPAD3, + "Numpad4" => VirtualKeyShort.NUMPAD4, + "Numpad5" => VirtualKeyShort.NUMPAD5, + "Numpad6" => VirtualKeyShort.NUMPAD6, + "Numpad7" => VirtualKeyShort.NUMPAD7, + "Numpad8" => VirtualKeyShort.NUMPAD8, + "Numpad9" => VirtualKeyShort.NUMPAD9, + "NumpadAdd" => VirtualKeyShort.ADD, + "NumpadComma" => VirtualKeyShort.OEM_COMMA, + "NumpadDecimal" => VirtualKeyShort.DECIMAL, + "NumpadDivide" => VirtualKeyShort.DIVIDE, + "NumpadEnter" => VirtualKeyShort.ENTER, + "NumpadMultiply" => VirtualKeyShort.MULTIPLY, + "NumpadSubtract" => VirtualKeyShort.SUBTRACT, + _ => throw WebDriverResponseException.UnsupportedOperation($"Key '{code}' is not supported"), + }; + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Services/ActionsDispatcher.cs b/src/FlaUI.WebDriver/Services/ActionsDispatcher.cs new file mode 100644 index 0000000..948dc3b --- /dev/null +++ b/src/FlaUI.WebDriver/Services/ActionsDispatcher.cs @@ -0,0 +1,409 @@ +using System.Drawing; +using System.Globalization; +using System.Text; +using FlaUI.Core.Input; +using FlaUI.WebDriver.Models; + +namespace FlaUI.WebDriver.Services +{ + public static class ActionsDispatcher + { + public static async Task DispatchAction(Session session, Action action) + { + switch (action.Type) + { + case "pointer": + await DispatchPointerAction(session, action); + return; + case "key": + await DispatchKeyAction(session, action); + return; + case "wheel": + await DispatchWheelAction(session, action); + return; + case "none": + await DispatchNullAction(session, action); + return; + default: + throw WebDriverResponseException.UnsupportedOperation($"Action type {action.Type} not supported"); + } + } + + /// + /// Implements "dispatch actions for a string" from https://www.w3.org/TR/webdriver2/#element-send-keys + /// + public static async Task DispatchActionsForString( + Session session, + string inputId, + KeyInputSource source, + string text) + { + var clusters = StringInfo.GetTextElementEnumerator(text); + var currentTypeableText = new StringBuilder(); + + while (clusters.MoveNext()) + { + var cluster = clusters.GetTextElement(); + + if (cluster == Keys.Null.ToString()) + { + await DispatchTypeableString(session, inputId, source, currentTypeableText.ToString()); + currentTypeableText.Clear(); + await ClearModifierKeyState(session, inputId); + } + else if (Keys.IsModifier(Keys.GetNormalizedKeyValue(cluster))) + { + await DispatchTypeableString(session, inputId, source, currentTypeableText.ToString()); + currentTypeableText.Clear(); + + var keyDownAction = new Action( + new ActionSequence + { + Id = inputId, + Type = "key" + }, + new ActionItem + { + Type = "keyDown", + Value = cluster + }); + + await DispatchAction(session, keyDownAction); + + var undo = keyDownAction.Clone(); + undo.SubType = "keyUp"; + + // NOTE: According to the spec, the undo action should be added to an "undo actions" list, + // but that may be an oversight in the spec: we already have such a thing in the input cancel + // list which won't get cleared correctly if we're using a separate "undo actions" list. See + // https://github.com/w3c/webdriver/issues/1809. + session.InputState.InputCancelList.Add(undo); + } + else if (Keys.IsTypeable(cluster)) + { + currentTypeableText.Append(cluster); + } + else + { + await DispatchTypeableString(session, inputId, source, currentTypeableText.ToString()); + currentTypeableText.Clear(); + // TODO: Dispatch composition events. + } + } + + if (currentTypeableText.Length > 0) + { + await DispatchTypeableString(session, inputId, source, currentTypeableText.ToString()); + } + + await ClearModifierKeyState(session, inputId); + } + + /// + /// Dispatches the release actions for the given input ID. + /// + /// + /// The only part of the spec that mentions this is https://www.w3.org/TR/webdriver2/#release-actions, but the spec + /// mentions that the input cancel list must be empty before removing an input source in + /// https://www.w3.org/TR/webdriver2/#input-state so I can only assume that there was an oversight in the spec. + /// + public static async Task DispatchReleaseActions(Session session, string inputId) + { + for (var i = session.InputState.InputCancelList.Count - 1; i >= 0; i--) + { + var cancelAction = session.InputState.InputCancelList[i]; + + if (cancelAction.Id == inputId) + { + await DispatchAction(session, cancelAction); + session.InputState.InputCancelList.RemoveAt(i); + } + } + } + + /// + /// Implements a variation on "clear the modifier key state" from + /// https://www.w3.org/TR/webdriver2/#element-send-keys. + /// + /// + /// https://github.com/w3c/webdriver/issues/1809 + /// + private static Task ClearModifierKeyState(Session session, string inputId) => DispatchReleaseActions(session, inputId); + + /// + /// Implements "dispatch the events for a typeable string" from https://www.w3.org/TR/webdriver2/#element-send-keys + /// + private static async Task DispatchTypeableString( + Session session, + string inputId, + KeyInputSource source, + string text) + { + foreach (var c in text) + { + var isShifted = Keys.IsShiftedChar(c); + + if (isShifted != source.Shift) + { + var action = new Action( + new ActionSequence + { + Id = inputId, + Type = "key" + }, + new ActionItem + { + Type = source.Shift ? "keyUp" : "keyDown", + Value = Keys.LeftShift.ToString(), + }); + await DispatchAction(session, action); + } + + var keyDownAction = new Action( + new ActionSequence + { + Id = inputId, + Type = "key" + }, + new ActionItem + { + Type = "keyDown", + Value = c.ToString(), + }); + + var keyUpAction = keyDownAction.Clone(); + keyUpAction.SubType = "keyUp"; + + await DispatchAction(session, keyDownAction); + await DispatchAction(session, keyUpAction); + } + } + + private static async Task DispatchNullAction(Session session, Action action) + { + switch (action.SubType) + { + case "pause": + await Task.Yield(); + return; + default: + throw WebDriverResponseException.InvalidArgument($"Null action subtype {action.SubType} unknown"); + } + } + + /// + /// Dispatches a keyDown, keyUp or pause action from https://www.w3.org/TR/webdriver2/#keyboard-actions + /// + private static async Task DispatchKeyAction(Session session, Action action) + { + if (action.Value == null) + { + return; + } + + var source = session.InputState.GetInputSource(action.Id) ?? + throw WebDriverResponseException.UnknownError($"Input source for key action '{action.Id}' not found."); + + switch (action.SubType) + { + case "keyDown": + { + var key = Keys.GetNormalizedKeyValue(action.Value); + var code = Keys.GetCode(action.Value); + var virtualKey = Keys.GetVirtualKey(code); + + if (key == "Alt") + { + source.Alt = true; + } + else if (key == "Shift") + { + source.Shift = true; + } + else if (key == "Control") + { + source.Ctrl = true; + } + else if (key == "Meta") + { + source.Meta = true; + } + + source.Pressed.Add(action.Value); + + Keyboard.Press(virtualKey); + + var cancelAction = action.Clone(); + cancelAction.SubType = "keyUp"; + session.InputState.InputCancelList.Add(cancelAction); + + // HACK: Adding a small delay after each key press because otherwise the key press + // seems to sometimes appear after the key action completes. + await Task.Delay(10); + await Task.Yield(); + return; + } + case "keyUp": + { + var key = Keys.GetNormalizedKeyValue(action.Value); + var code = Keys.GetCode(action.Value); + var virtualKey = Keys.GetVirtualKey(code); + + if (key == "Alt") + { + source.Alt = false; + } + else if (key == "Shift") + { + source.Shift = false; + } + else if (key == "Control") + { + source.Ctrl = false; + } + else if (key == "Meta") + { + source.Meta = false; + } + + source.Pressed.Remove(action.Value); + + Keyboard.Release(virtualKey); + + // HACK: Adding a small delay after each key press because otherwise the key press + // seems to sometimes appear after the key action completes. + await Task.Delay(10); + + await Task.Yield(); + return; + } + case "pause": + await Task.Yield(); + return; + default: + throw WebDriverResponseException.InvalidArgument($"Pointer action subtype {action.SubType} unknown"); + } + } + + private static async Task DispatchWheelAction(Session session, Action action) + { + switch (action.SubType) + { + case "scroll": + if (action.X == null || action.Y == null) + { + throw WebDriverResponseException.InvalidArgument("For wheel scroll, X and Y are required"); + } + Mouse.MoveTo(action.X.Value, action.Y.Value); + if (action.DeltaX == null || action.DeltaY == null) + { + throw WebDriverResponseException.InvalidArgument("For wheel scroll, delta X and delta Y are required"); + } + if (action.DeltaY != 0) + { + Mouse.Scroll(action.DeltaY.Value); + } + if (action.DeltaX != 0) + { + Mouse.HorizontalScroll(action.DeltaX.Value); + } + return; + case "pause": + await Task.Yield(); + return; + default: + throw WebDriverResponseException.InvalidArgument($"Wheel action subtype {action.SubType} unknown"); + } + } + + private static async Task DispatchPointerAction(Session session, Action action) + { + switch (action.SubType) + { + case "pointerMove": + var point = GetCoordinates(session, action); + Mouse.MoveTo(point); + await Task.Yield(); + return; + case "pointerDown": + Mouse.Down(GetMouseButton(action.Button)); + var cancelAction = action.Clone(); + cancelAction.SubType = "pointerUp"; + session.InputState.InputCancelList.Add(cancelAction); + await Task.Yield(); + return; + case "pointerUp": + Mouse.Up(GetMouseButton(action.Button)); + await Task.Yield(); + return; + case "pause": + await Task.Yield(); + return; + default: + throw WebDriverResponseException.UnsupportedOperation($"Pointer action subtype {action.Type} not supported"); + } + } + + private static Point GetCoordinates(Session session, Action action) + { + var origin = action.Origin ?? "viewport"; + + switch (origin) + { + case "viewport": + if (action.X == null || action.Y == null) + { + throw WebDriverResponseException.InvalidArgument("For pointer move, X and Y are required"); + } + + return new Point(action.X.Value, action.Y.Value); + case "pointer": + if (action.X == null || action.Y == null) + { + throw WebDriverResponseException.InvalidArgument("For pointer move, X and Y are required"); + } + + var current = Mouse.Position; + return new Point(current.X + action.X.Value, current.Y + action.Y.Value); + case Dictionary originMap: + if (originMap.TryGetValue("element-6066-11e4-a52e-4f735466cecf", out var elementId)) + { + if (session.FindKnownElementById(elementId) is { } element) + { + var bounds = element.BoundingRectangle; + var x = bounds.Left + (bounds.Width / 2) + (action.X ?? 0); + var y = bounds.Top + (bounds.Height / 2) + (action.Y ?? 0); + return new(x, y); + } + + throw WebDriverResponseException.InvalidArgument( + $"An unknown element ID '{elementId}' provided for action item '{action.Type}'."); + } + + throw WebDriverResponseException.InvalidArgument( + $"An unknown element '{origin}' provided for action item '{action.Type}'."); + default: + throw WebDriverResponseException.InvalidArgument( + $"Unknown origin type '{origin}' provided for action item '{action.Type}'."); + } + } + + private static MouseButton GetMouseButton(int? button) + { + if (button == null) + { + throw WebDriverResponseException.InvalidArgument($"Pointer action button argument missing"); + } + switch (button) + { + case 0: return MouseButton.Left; + case 1: return MouseButton.Middle; + case 2: return MouseButton.Right; + case 3: return MouseButton.XButton1; + case 4: return MouseButton.XButton2; + default: + throw WebDriverResponseException.UnsupportedOperation($"Pointer button {button} not supported"); + } + } + } +}