diff --git a/.bazelignore b/.bazelignore index 37c69cdeb57ea..cf30cbf771c39 100644 --- a/.bazelignore +++ b/.bazelignore @@ -23,6 +23,7 @@ java/build/production java/client/build java/server/build javascript/atoms/node_modules +javascript/atoms-ts/test/node_modules javascript/grid-ui/node_modules javascript/private/node_modules javascript/selenium-webdriver/node_modules diff --git a/javascript/atoms-ts/BUILD.bazel b/javascript/atoms-ts/BUILD.bazel new file mode 100644 index 0000000000000..8ff1151e403d0 --- /dev/null +++ b/javascript/atoms-ts/BUILD.bazel @@ -0,0 +1,6 @@ +load("@aspect_rules_ts//ts:defs.bzl", "ts_project") + +exports_files( + ["tsconfig.json"], + visibility = ["//javascript/atoms-ts:__subpackages__"], +) diff --git a/javascript/atoms-ts/src/BUILD.bazel b/javascript/atoms-ts/src/BUILD.bazel new file mode 100644 index 0000000000000..699127330f1cd --- /dev/null +++ b/javascript/atoms-ts/src/BUILD.bazel @@ -0,0 +1,277 @@ +load("@aspect_rules_ts//ts:defs.bzl", "ts_project") + +ts_project( + name = "error", + srcs = ["error.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "bot", + srcs = ["bot.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "domcore", + srcs = ["domcore.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + deps = [":error"], + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "color", + srcs = ["color.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "json", + srcs = ["json.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "response", + srcs = ["response.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + deps = [":error"], + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "userAgent", + srcs = ["userAgent.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "frame", + srcs = ["frame.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + deps = [ + ":bot", + ":domcore", + ":error", + ], + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "inject", + srcs = ["inject.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + deps = [ + ":bot", + ":error", + ":json", + ":response", + ], + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "dom", + srcs = ["dom.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + deps = [ + ":bot", + ":color", + ":domcore", + ":error", + ":userAgent", + ], + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "events", + srcs = ["events.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + deps = [ + ":bot", + ":error", + ":userAgent", + ], + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "device", + srcs = ["device.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + deps = [ + ":bot", + ":dom", + ":error", + ":events", + ":userAgent", + ], + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "keyboard", + srcs = ["keyboard.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + deps = [ + ":device", + ":dom", + ":error", + ":events", + ":userAgent", + ], + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "mouse", + srcs = ["mouse.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + deps = [ + ":bot", + ":device", + ":dom", + ":error", + ":events", + ":userAgent", + ], + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "touchscreen", + srcs = ["touchscreen.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + deps = [ + ":bot", + ":device", + ":dom", + ":error", + ":events", + ":userAgent", + ], + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "window", + srcs = ["window.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + deps = [ + ":bot", + ":error", + ":events", + ":userAgent", + ], + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) + +ts_project( + name = "action", + srcs = ["action.ts"], + declaration = True, + declaration_map = True, + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", + deps = [ + ":bot", + ":device", + ":dom", + ":error", + ":events", + ":keyboard", + ":mouse", + ":touchscreen", + ":userAgent", + ], + visibility = ["//javascript/atoms-ts:__subpackages__", + "//javascript/webdriver-atoms-ts/src:__pkg__"], +) diff --git a/javascript/atoms-ts/src/action.ts b/javascript/atoms-ts/src/action.ts new file mode 100644 index 0000000000000..d7b415d868a93 --- /dev/null +++ b/javascript/atoms-ts/src/action.ts @@ -0,0 +1,627 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Atoms for simulating user actions against the DOM. + * The action namespace is required since these atoms would otherwise form a + * circular dependency between dom and events. + */ + +import * as bot from './bot'; +import * as dom from './dom'; +import * as events from './events'; +import { WebDriverError, ErrorCode } from './error'; +import { Device } from './device'; +import { Keyboard } from './keyboard'; +import { Mouse } from './mouse'; +import { Touchscreen } from './touchscreen'; +import * as userAgent from './userAgent'; + +/** + * Throws an exception if an element is not shown to the user, ignoring its opacity. + */ +function checkShown_(element: Element): void { + if (!dom.isShown(element, /* ignoreOpacity */ true)) { + throw new WebDriverError(ErrorCode.ELEMENT_NOT_VISIBLE, + 'Element is not currently visible and may not be manipulated'); + } +} + +/** + * Throws an exception if the given element cannot be interacted with. + */ +function checkInteractable_(element: Element): void { + if (!dom.isInteractable(element)) { + throw new WebDriverError(ErrorCode.INVALID_ELEMENT_STATE, + 'Element is not currently interactable and may not be manipulated'); + } +} + +/** + * Clears the given `element` if it is an editable text field. + */ +export function clear(element: Element): void { + checkInteractable_(element); + if (!dom.isEditable(element)) { + throw new WebDriverError(ErrorCode.INVALID_ELEMENT_STATE, + 'Element must be user-editable in order to clear it.'); + } + + if ((element as any).value) { + LegacyDevice_.focusOnElement(element); + if (userAgent.IS_IE && dom.isInputType(element as HTMLInputElement, 'range')) { + const input = element as HTMLInputElement; + const min = input.min ? parseInt(input.min, 10) : 0; + const max = input.max ? parseInt(input.max, 10) : 100; + input.value = String((max < min) ? min : min + (max - min) / 2); + } else { + (element as any).value = ''; + } + events.fire(element, events.EventType.CHANGE); + if (userAgent.IS_IE) { + events.fire(element, events.EventType.BLUR); + } + const body = document.body; + if (body) { + LegacyDevice_.focusOnElement(body); + } else { + throw new WebDriverError(ErrorCode.UNKNOWN_ERROR, + 'Cannot unfocus element after clearing.'); + } + } else if (element instanceof HTMLInputElement && element.type === 'number') { + // number input fields that have invalid inputs report their value as empty + // string with no way to tell if there is a current value or not + LegacyDevice_.focusOnElement(element); + element.value = ''; + } else if (dom.isContentEditable(element)) { + // A single space is required; an empty string won't allow interaction + // with the element in Firefox. + LegacyDevice_.focusOnElement(element); + if (userAgent.IS_FIREFOX) { + element.textContent = ' '; + } else { + element.textContent = ''; + } + const body = document.body; + if (body) { + LegacyDevice_.focusOnElement(body); + } else { + throw new WebDriverError(ErrorCode.UNKNOWN_ERROR, + 'Cannot unfocus element after clearing.'); + } + } +} + +/** + * Focuses on the given element if it is not already the active element. + */ +export function focusOnElement(element: Element): void { + checkInteractable_(element); + LegacyDevice_.focusOnElement(element); +} + +/** + * Types keys on the given `element` with a virtual keyboard. + */ +export function type( + element: Element, + values: string | any | (string | any)[], + opt_keyboard?: Keyboard, + opt_persistModifiers?: boolean +): void { + // If the element has already been brought into focus somehow, typing is + // always allowed to proceed. Otherwise, we require the element be in an + // "interactable" state. + if (element !== dom.getActiveElement(element)) { + checkInteractable_(element); + scrollIntoView(element); + } + + const keyboard = opt_keyboard || new Keyboard(); + keyboard.moveCursor(element); + + function typeValue(value: string | any): void { + if (typeof value === 'string') { + value.split('').forEach((ch: string) => { + const keyShiftPair = (Keyboard as any).Key.fromChar(ch); + if (keyShiftPair.shift && !(keyboard as any).isPressed((Keyboard as any).Keys.SHIFT)) { + keyboard.pressKey((Keyboard as any).Keys.SHIFT); + } + keyboard.pressKey(keyShiftPair.key); + keyboard.releaseKey(keyShiftPair.key); + if (keyShiftPair.shift && !(keyboard as any).isPressed((Keyboard as any).Keys.SHIFT)) { + keyboard.releaseKey((Keyboard as any).Keys.SHIFT); + } + }); + } else if ((Keyboard as any).MODIFIERS && (Keyboard as any).MODIFIERS.includes(value)) { + if ((keyboard as any).isPressed(value)) { + keyboard.releaseKey(value); + } else { + keyboard.pressKey(value); + } + } else { + keyboard.pressKey(value); + keyboard.releaseKey(value); + } + } + + // mobile safari (iPhone / iPad) - one cannot 'type' in a date field + if ((!(userAgent.IS_SAFARI && !userAgent.IS_MOBILE)) && + userAgent.IS_WEBKIT && (element as any).type === 'date') { + const val = Array.isArray(values) ? values.join('') : values; + const datePattern = /\d{4}-\d{2}-\d{2}/; + const match = val.match(datePattern); + if (match) { + // The following events get fired on iOS first + if (userAgent.IS_MOBILE && userAgent.IS_SAFARI) { + events.fire(element, events.EventType.TOUCHSTART); + events.fire(element, events.EventType.TOUCHEND); + } + events.fire(element, events.EventType.FOCUS); + (element as any).value = match[0]; + events.fire(element, events.EventType.CHANGE); + events.fire(element, events.EventType.BLUR); + return; + } + } + + if (Array.isArray(values)) { + values.forEach(typeValue); + } else { + typeValue(values); + } + + if (!opt_persistModifiers) { + // Release all the modifier keys. + if ((keyboard as any).isPressed((Keyboard as any).Keys?.SHIFT)) { + keyboard.releaseKey((Keyboard as any).Keys.SHIFT); + } + if ((keyboard as any).isPressed((Keyboard as any).Keys?.CONTROL)) { + keyboard.releaseKey((Keyboard as any).Keys.CONTROL); + } + if ((keyboard as any).isPressed((Keyboard as any).Keys?.ALT)) { + keyboard.releaseKey((Keyboard as any).Keys.ALT); + } + if ((keyboard as any).isPressed((Keyboard as any).Keys?.META)) { + keyboard.releaseKey((Keyboard as any).Keys.META); + } + } +} + +/** + * Submits the form containing the given `element`. + * + * @deprecated Click on a submit button or type ENTER in a text box instead. + */ +export function submit(element: Element): void { + const form = LegacyDevice_.findAncestorForm(element); + if (!form) { + throw new WebDriverError(ErrorCode.NO_SUCH_ELEMENT, + 'Element was not in a form, so could not submit.'); + } + LegacyDevice_.submitForm(element, form); +} + +/** + * Moves the mouse over the given `element` with a virtual mouse. + */ +export function moveMouse( + element: Element, + opt_coords?: { x: number; y: number }, + opt_mouse?: Mouse +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); +} + +/** + * Clicks on the given `element` with a virtual mouse. + */ +export function click( + element: Element, + opt_coords?: { x: number; y: number }, + opt_mouse?: Mouse, + opt_force?: boolean +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); + mouse.pressButton((Mouse as any).Button.LEFT); + mouse.releaseButton(opt_force); +} + +/** + * Right-clicks on the given `element` with a virtual mouse. + */ +export function rightClick( + element: Element, + opt_coords?: { x: number; y: number }, + opt_mouse?: Mouse +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); + mouse.pressButton((Mouse as any).Button.RIGHT); + mouse.releaseButton(); +} + +/** + * Double-clicks on the given `element` with a virtual mouse. + */ +export function doubleClick( + element: Element, + opt_coords?: { x: number; y: number }, + opt_mouse?: Mouse +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); + mouse.pressButton((Mouse as any).Button.LEFT); + mouse.releaseButton(); + mouse.pressButton((Mouse as any).Button.LEFT); + mouse.releaseButton(); +} + +/** + * Scrolls the mouse wheel on the given `element` with a virtual mouse. + */ +export function scrollMouse( + element: Element, + ticks: number, + opt_coords?: { x: number; y: number }, + opt_mouse?: Mouse +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); + (mouse as any).scroll(ticks); +} + +/** + * Drags the given `element` by (dx, dy) with a virtual mouse. + */ +export function drag( + element: Element, + dx: number, + dy: number, + opt_steps?: number, + opt_coords?: { x: number; y: number }, + opt_mouse?: Mouse +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const initRect = dom.getClientRect(element); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); + mouse.pressButton((Mouse as any).Button.LEFT); + const steps = opt_steps !== undefined ? opt_steps : 2; + if (steps < 1) { + throw new WebDriverError(ErrorCode.UNKNOWN_ERROR, + 'There must be at least one step as part of a drag.'); + } + for (let i = 1; i <= steps; i++) { + moveTo(Math.floor(i * dx / steps), Math.floor(i * dy / steps)); + } + mouse.releaseButton(); + + function moveTo(x: number, y: number): void { + const currRect = dom.getClientRect(element); + const newPos = { + x: coords.x + initRect.left + x - currRect.left, + y: coords.y + initRect.top + y - currRect.top + }; + mouse.move(element, newPos); + } +} + +/** + * Taps on the given `element` with a virtual touch screen. + */ +export function tap( + element: Element, + opt_coords?: { x: number; y: number }, + opt_touchscreen?: Touchscreen +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const touchscreen = opt_touchscreen || new Touchscreen(); + touchscreen.move(element, coords); + touchscreen.press(); + touchscreen.release(); +} + +/** + * Swipes the given `element` by (dx, dy) with a virtual touch screen. + */ +export function swipe( + element: Element, + dx: number, + dy: number, + opt_steps?: number, + opt_coords?: { x: number; y: number }, + opt_touchscreen?: Touchscreen +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const touchscreen = opt_touchscreen || new Touchscreen(); + const initRect = dom.getClientRect(element); + touchscreen.move(element, coords); + touchscreen.press(); + const steps = opt_steps !== undefined ? opt_steps : 2; + if (steps < 1) { + throw new WebDriverError(ErrorCode.UNKNOWN_ERROR, + 'There must be at least one step as part of a swipe.'); + } + for (let i = 1; i <= steps; i++) { + moveTo(Math.floor(i * dx / steps), Math.floor(i * dy / steps)); + } + touchscreen.release(); + + function moveTo(x: number, y: number): void { + const currRect = dom.getClientRect(element); + const newPos = { + x: coords.x + initRect.left + x - currRect.left, + y: coords.y + initRect.top + y - currRect.top + }; + touchscreen.move(element, newPos); + } +} + +/** + * Pinches the given `element` by the given distance with a virtual touch screen. + */ +export function pinch( + element: Element, + distance: number, + opt_coords?: { x: number; y: number }, + opt_touchscreen?: Touchscreen +): void { + if (distance === 0) { + throw new WebDriverError(ErrorCode.UNKNOWN_ERROR, + 'Cannot pinch by a distance of zero.'); + } + function startSoThatEndsAtMax(offsetVec: Vec2): void { + if (distance < 0) { + const magnitude = offsetVec.magnitude(); + offsetVec.scale(magnitude ? (magnitude + distance) / magnitude : 0); + } + } + const halfDistance = distance / 2; + function scaleByHalfDistance(offsetVec: Vec2): void { + const magnitude = offsetVec.magnitude(); + offsetVec.scale(magnitude ? (magnitude - halfDistance) / magnitude : 0); + } + multiTouchAction_(element, startSoThatEndsAtMax, scaleByHalfDistance, + opt_coords, opt_touchscreen); +} + +/** + * Rotates the given `element` by the given angle with a virtual touch screen. + */ +export function rotate( + element: Element, + angle: number, + opt_coords?: { x: number; y: number }, + opt_touchscreen?: Touchscreen +): void { + if (angle === 0) { + throw new WebDriverError(ErrorCode.UNKNOWN_ERROR, + 'Cannot rotate by an angle of zero.'); + } + function startHalfwayToMax(offsetVec: Vec2): void { + offsetVec.scale(0.5); + } + const halfRadians = Math.PI * (angle / 180) / 2; + function rotateByHalfAngle(offsetVec: Vec2): void { + offsetVec.rotate(halfRadians); + } + multiTouchAction_(element, startHalfwayToMax, rotateByHalfAngle, + opt_coords, opt_touchscreen); +} + +/** + * Simple 2D Vector implementation. + */ +class Vec2 { + constructor(public x: number, public y: number) { } + + magnitude(): number { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + scale(factor: number): void { + this.x *= factor; + this.y *= factor; + } + + rotate(angle: number): void { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const newX = this.x * cos - this.y * sin; + const newY = this.x * sin + this.y * cos; + this.x = newX; + this.y = newY; + } + + static sum(v1: Vec2, v2: Vec2): Vec2 { + return new Vec2(v1.x + v2.x, v1.y + v2.y); + } + + static difference(v1: Vec2, v2: Vec2): Vec2 { + return new Vec2(v1.x - v2.x, v1.y - v2.y); + } + + subtract(v: Vec2): Vec2 { + return new Vec2(this.x - v.x, this.y - v.y); + } + + static fromCoordinate(coord: { x: number; y: number }): Vec2 { + return new Vec2(coord.x, coord.y); + } +} + +/** + * Performs a multi-touch action with two fingers on the given element. + */ +function multiTouchAction_( + element: Element, + transformStart: (vec: Vec2) => void, + transformHalf: (vec: Vec2) => void, + opt_coords?: { x: number; y: number }, + opt_touchscreen?: Touchscreen +): void { + const center = prepareToInteractWith_(element, opt_coords); + const size = getInteractableSize(element); + const offsetVec = new Vec2( + Math.min(center.x, size.width - center.x), + Math.min(center.y, size.height - center.y)); + + const touchScreen = opt_touchscreen || new Touchscreen(); + transformStart(offsetVec); + const start1 = Vec2.sum(center, offsetVec); + const start2 = Vec2.difference(center, offsetVec); + touchScreen.move(element, start1, start2); + touchScreen.press(/* Two Finger Press */ true); + + const initRect = dom.getClientRect(element); + transformHalf(offsetVec); + const mid1 = Vec2.sum(center, offsetVec); + const mid2 = Vec2.difference(center, offsetVec); + touchScreen.move(element, mid1, mid2); + + const midRect = dom.getClientRect(element); + const movedVec = Vec2.difference( + new Vec2(midRect.left, midRect.top), + new Vec2(initRect.left, initRect.top)); + transformHalf(offsetVec); + const end1 = Vec2.sum(center, offsetVec).subtract(movedVec); + const end2 = Vec2.difference(center, offsetVec).subtract(movedVec); + touchScreen.move(element, end1, end2); + touchScreen.release(); +} + +/** + * Prepares to interact with the given `element`. + */ +function prepareToInteractWith_( + element: Element, + opt_coords?: { x: number; y: number } +): Vec2 { + checkShown_(element); + scrollIntoView(element, opt_coords); + + if (opt_coords) { + return new Vec2(opt_coords.x, opt_coords.y); + } else { + const size = getInteractableSize(element); + return new Vec2(size.width / 2, size.height / 2); + } +} + +/** + * Returns the interactable size of an element. + */ +function getInteractableSize(elem: Element): { width: number; height: number } { + const style = window.getComputedStyle(elem); + const width = parseFloat(style.width) || 0; + const height = parseFloat(style.height) || 0; + + if ((width > 0 && height > 0) || !(elem as HTMLElement).offsetParent) { + return { width, height }; + } + return getInteractableSize((elem as HTMLElement).offsetParent!); +} + +/** + * A Device that allows access to protected members of the Device superclass. + */ +class LegacyDevice extends Device { + private static instance_: LegacyDevice; + + static getInstance(): LegacyDevice { + if (!LegacyDevice.instance_) { + LegacyDevice.instance_ = new LegacyDevice(); + } + return LegacyDevice.instance_; + } + + static focusOnElement(element: Element): boolean { + const instance = LegacyDevice.getInstance(); + instance.setElement(element); + return instance.focusOnElement(); + } + + static submitForm(element: Element, form: HTMLFormElement): void { + const instance = LegacyDevice.getInstance(); + instance.setElement(element); + instance.submitForm(form); + } + + static findAncestorForm(element: Element): HTMLFormElement | null { + return (Device.findAncestorForm(element) as any as HTMLFormElement | null); + } +} + +const LegacyDevice_ = LegacyDevice; + +/** + * Scrolls the given `element` into the current viewport. + */ +export function scrollIntoView( + element: Element, + opt_region?: { x: number; y: number } | any +): boolean { + // If the element is already in view, return true; if hidden, return false. + const overflow = dom.getOverflowState(element, opt_region); + if (overflow !== dom.OverflowState.SCROLL) { + return overflow === dom.OverflowState.NONE; + } + + // Some elements may not have a scrollIntoView function. + if ((element as any).scrollIntoView) { + (element as any).scrollIntoView(); + if (dom.OverflowState.NONE === dom.getOverflowState(element, opt_region)) { + return true; + } + } + + // Scroll manually if needed. + const region = dom.getClientRegion(element, opt_region); + for (let container = dom.getParentElement(element); + container; + container = dom.getParentElement(container)) { + scrollClientRegionIntoContainerView(container); + } + return dom.OverflowState.NONE === dom.getOverflowState(element, opt_region); + + function scrollClientRegionIntoContainerView(container: Element): void { + const containerRect = dom.getClientRect(container); + const style = window.getComputedStyle(container); + const borderLeft = parseFloat(style.borderLeftWidth) || 0; + const borderTop = parseFloat(style.borderTopWidth) || 0; + + // Relative position of the region to the container's content box. + const relX = region.left - containerRect.left - borderLeft; + const relY = region.top - containerRect.top - borderTop; + + // How much the region can move in the container. + const spaceX = (container as HTMLElement).clientWidth + region.left - region.right; + const spaceY = (container as HTMLElement).clientHeight + region.top - region.bottom; + + // Scroll the element into view of the container. + (container as HTMLElement).scrollLeft += Math.min(relX, Math.max(relX - spaceX, 0)); + (container as HTMLElement).scrollTop += Math.min(relY, Math.max(relY - spaceY, 0)); + } +} diff --git a/javascript/atoms-ts/src/bot.ts b/javascript/atoms-ts/src/bot.ts new file mode 100644 index 0000000000000..e7c4826baab21 --- /dev/null +++ b/javascript/atoms-ts/src/bot.ts @@ -0,0 +1,57 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Window and document management for the Selenium Atoms. + * Frameworks using the atoms keep track of which window or frame is currently + * being used for command execution. + */ + +/** + * The window currently being used for command execution. + * Defaults to the global window object in browser environments. + */ +let currentWindow: Window = window; + +/** + * Returns the window currently being used for command execution. + * This allows frameworks to manage execution context across multiple windows and frames. + * + * @returns The window for command execution + */ +export const getWindow = (): Window => { + return currentWindow; +}; + +/** + * Sets the window to be used for command execution. + * Frameworks use this to switch the execution context between windows or frames. + * + * @param win The window for command execution + */ +export const setWindow = (win: Window): void => { + currentWindow = win; +}; + +/** + * Returns the document of the window currently being used for command execution. + * + * @returns The current window's document + */ +export const getDocument = (): Document => { + return currentWindow.document; +}; diff --git a/javascript/atoms-ts/src/color.ts b/javascript/atoms-ts/src/color.ts new file mode 100644 index 0000000000000..879f2a9c02770 --- /dev/null +++ b/javascript/atoms-ts/src/color.ts @@ -0,0 +1,348 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Utilities for color parsing and standardization. + * Converts various color formats (hex, rgb, rgba, named colors) to a standard rgba format. + */ + +/** + * RGBA color represented as a tuple of [red, green, blue, alpha] + * where r, g, b are integers in [0, 255] and a is a float in [0, 1]. + */ +type RgbaColor = [number, number, number, number]; + +/** + * Standard CSS color names mapped to hex values. + * This is a subset of the most commonly used CSS named colors. + * For a complete list, use a dedicated color names package. + */ +const COLOR_NAMES: Record = { + aliceblue: '#f0f8ff', + antiquewhite: '#faebd7', + aqua: '#00ffff', + aquamarine: '#7fffd4', + azure: '#f0ffff', + beige: '#f5f5dc', + bisque: '#ffe4c4', + black: '#000000', + blanchedalmond: '#ffebcd', + blue: '#0000ff', + blueviolet: '#8a2be2', + brown: '#a52a2a', + burlywood: '#deb887', + cadetblue: '#5f9ea0', + chartreuse: '#7fff00', + chocolate: '#d2691e', + coral: '#ff7f50', + cornflowerblue: '#6495ed', + cornsilk: '#fff8dc', + crimson: '#dc143c', + cyan: '#00ffff', + darkblue: '#00008b', + darkcyan: '#008b8b', + darkgoldenrod: '#b8860b', + darkgray: '#a9a9a9', + darkgrey: '#a9a9a9', + darkgreen: '#006400', + darkkhaki: '#bdb76b', + darkmagenta: '#8b008b', + darkolivegreen: '#556b2f', + darkorange: '#ff8c00', + darkorchid: '#9932cc', + darkred: '#8b0000', + darksalmon: '#e9967a', + darkseagreen: '#8fbc8f', + darkslateblue: '#483d8b', + darkslategray: '#2f4f4f', + darkslategrey: '#2f4f4f', + darkturquoise: '#00ced1', + darkviolet: '#9400d3', + deeppink: '#ff1493', + deepskyblue: '#00bfff', + dimgray: '#696969', + dimgrey: '#696969', + dodgerblue: '#1e90ff', + firebrick: '#b22222', + floralwhite: '#fffaf0', + forestgreen: '#228b22', + fuchsia: '#ff00ff', + gainsboro: '#dcdcdc', + ghostwhite: '#f8f8ff', + gold: '#ffd700', + goldenrod: '#daa520', + gray: '#808080', + grey: '#808080', + green: '#008000', + greenyellow: '#adff2f', + honeydew: '#f0fff0', + hotpink: '#ff69b4', + indianred: '#cd5c5c', + indigo: '#4b0082', + ivory: '#fffff0', + khaki: '#f0e68c', + lavender: '#e6e6fa', + lavenderblush: '#fff0f5', + lawngreen: '#7cfc00', + lemonchiffon: '#fffacd', + lightblue: '#add8e6', + lightcoral: '#f08080', + lightcyan: '#e0ffff', + lightgoldenrodyellow: '#fafad2', + lightgray: '#d3d3d3', + lightgrey: '#d3d3d3', + lightgreen: '#90ee90', + lightpink: '#ffb6c1', + lightsalmon: '#ffa07a', + lightseagreen: '#20b2aa', + lightskyblue: '#87cefa', + lightslategray: '#778899', + lightslategrey: '#778899', + lightsteelblue: '#b0c4de', + lightyellow: '#ffffe0', + lime: '#00ff00', + limegreen: '#32cd32', + linen: '#faf0e6', + magenta: '#ff00ff', + maroon: '#800000', + mediumaquamarine: '#66cdaa', + mediumblue: '#0000cd', + mediumorchid: '#ba55d3', + mediumpurple: '#9370db', + mediumseagreen: '#3cb371', + mediumslateblue: '#7b68ee', + mediumspringgreen: '#00fa9a', + mediumturquoise: '#48d1cc', + mediumvioletred: '#c71585', + midnightblue: '#191970', + mintcream: '#f5fffa', + mistyrose: '#ffe4e1', + moccasin: '#ffe4b5', + navajowhite: '#ffdead', + navy: '#000080', + oldlace: '#fdf5e6', + olive: '#808000', + olivedrab: '#6b8e23', + orange: '#ffa500', + orangered: '#ff4500', + orchid: '#da70d6', + palegoldenrod: '#eee8aa', + palegreen: '#98fb98', + paleturquoise: '#afeeee', + palevioletred: '#db7093', + papayawhip: '#ffefd5', + peachpuff: '#ffdab9', + peru: '#cd853f', + pink: '#ffc0cb', + plum: '#dda0dd', + powderblue: '#b0e0e6', + purple: '#800080', + rebeccapurple: '#663399', + red: '#ff0000', + rosybrown: '#bc8f8f', + royalblue: '#4169e1', + saddlebrown: '#8b4513', + salmon: '#fa8072', + sandybrown: '#f4a460', + seagreen: '#2e8b57', + seashell: '#fff5ee', + sienna: '#a0522d', + silver: '#c0c0c0', + skyblue: '#87ceeb', + slateblue: '#6a5acd', + slategray: '#708090', + slategrey: '#708090', + snow: '#fffafa', + springgreen: '#00ff7f', + steelblue: '#4682b4', + tan: '#d2b48c', + teal: '#008080', + thistle: '#d8bfd8', + tomato: '#ff6347', + turquoise: '#40e0d0', + violet: '#ee82ee', + wheat: '#f5deb3', + white: '#ffffff', + whitesmoke: '#f5f5f5', + yellow: '#ffff00', + yellowgreen: '#9acd32', +}; + +/** + * CSS properties that contain color values and should be standardized. + * Extracted from the W3C CSS specification. + */ +const COLOR_PROPERTIES = new Set([ + 'backgroundColor', + 'borderTopColor', + 'borderRightColor', + 'borderBottomColor', + 'borderLeftColor', + 'color', + 'outlineColor', +]); + +/** + * Regular expression for matching hex color triplets (short form). + * Matches patterns like #RGB and expands to #RRGGBB. + */ +const HEX_TRIPLET_RE = /#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])/; + +/** + * Regular expression for validating hex color format. + * Matches #RGB or #RRGGBB formats. + */ +const VALID_HEX_COLOR_RE = /^#(?:[0-9a-f]{3}){1,2}$/i; + +/** + * Regular expression for parsing rgba() color strings. + * Matches: rgba(r, g, b, a) or (r, g, b, a) format + * where r, g, b are integers [0-255] and a is float [0-1]. + */ +const RGBA_COLOR_RE = /^(?:rgba)?\((\d{1,3}),\s?(\d{1,3}),\s?(\d{1,3}),\s?(0|1|0\.\d*)\)$/i; + +/** + * Regular expression for parsing rgb() color strings. + * Matches: rgb(r, g, b) or (r, g, b) format + * where r, g, b are integers [0-255]. + */ +const RGB_COLOR_RE = /^(?:rgb)?\((0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2})\)$/i; + +/** + * Attempts to parse a string as an rgba color. + * Expects format: 'rgba(r, g, b, a)' or '(r, g, b, a)' + * where r, g, b are integers in [0, 255] and a is a float in [0, 1]. + * + * @param str String to parse + * @returns RGBA tuple [r, g, b, a] or null if invalid + */ +function maybeParseRgbaColor(str: string): RgbaColor | null { + const match = str.match(RGBA_COLOR_RE); + if (!match) { + return null; + } + + const r = Number(match[1]); + const g = Number(match[2]); + const b = Number(match[3]); + const a = Number(match[4]); + + if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255 && a >= 0 && a <= 1) { + return [r, g, b, a]; + } + + return null; +} + +/** + * Attempts to parse a string as an rgb color. + * Expects format: 'rgb(r, g, b)' or '(r, g, b)' + * where r, g, b are integers in [0, 255]. + * + * @param str String to parse + * @returns RGBA tuple [r, g, b, 1] or null if invalid + */ +function maybeParseRgbColor(str: string): RgbaColor | null { + const match = str.match(RGB_COLOR_RE); + if (!match) { + return null; + } + + const r = Number(match[1]); + const g = Number(match[2]); + const b = Number(match[3]); + + if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) { + return [r, g, b, 1]; + } + + return null; +} + +/** + * Converts a hex color or CSS color name to RGBA format. + * Handles both short (#RGB) and long (#RRGGBB) hex formats, + * as well as standard CSS color names. + * + * @param hexOrColorName Hex color (with or without #) or CSS color name + * @returns RGBA tuple [r, g, b, a] or null if invalid + */ +function maybeConvertHexOrColorName(hexOrColorName: string): RgbaColor | null { + const normalized = hexOrColorName.toLowerCase(); + + // Try to look up as a color name + let hex = COLOR_NAMES[normalized]; + + if (!hex) { + // Treat as hex color + hex = normalized.startsWith('#') ? normalized : `#${normalized}`; + + // Expand short form (#RGB -> #RRGGBB) + if (hex.length === 4) { + hex = hex.replace(HEX_TRIPLET_RE, '#$1$1$2$2$3$3'); + } + + // Validate hex format + if (!VALID_HEX_COLOR_RE.test(hex)) { + return null; + } + } + + // Parse hex to RGB + const r = parseInt(hex.substring(1, 3), 16); + const g = parseInt(hex.substring(3, 5), 16); + const b = parseInt(hex.substring(5, 7), 16); + + return [r, g, b, 1]; +} + +/** + * Standardizes a CSS color property value to rgba format. + * Converts hex, rgb, rgba, and named colors to a consistent rgba() format. + * If the property is not a color property or the value cannot be parsed, + * returns the original value unchanged. + * + * @param propertyName CSS property name in camelCase + * @param propertyValue The CSS property value + * @returns Standardized color value in rgba format, or original value if not a color + * + * @example + * standardizeColor('color', '#f00') // 'rgba(255, 0, 0, 1)' + * standardizeColor('color', 'rgb(255, 0, 0)') // 'rgba(255, 0, 0, 1)' + * standardizeColor('color', 'red') // 'rgba(255, 0, 0, 1)' + * standardizeColor('margin', '10px') // '10px' (not a color property) + */ +export function standardizeColor(propertyName: string, propertyValue: string): string { + // Only process known color properties + if (!COLOR_PROPERTIES.has(propertyName)) { + return propertyValue; + } + + // Try parsing in order of specificity + const rgba = + maybeParseRgbaColor(propertyValue) || + maybeParseRgbColor(propertyValue) || + maybeConvertHexOrColorName(propertyValue); + + // If parsing succeeded, return standardized rgba format + if (rgba) { + return `rgba(${rgba.join(', ')})`; + } + + // If parsing failed, return original value + return propertyValue; +} diff --git a/javascript/atoms-ts/src/device.ts b/javascript/atoms-ts/src/device.ts new file mode 100644 index 0000000000000..1b68c6c1d1717 --- /dev/null +++ b/javascript/atoms-ts/src/device.ts @@ -0,0 +1,784 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * The base class for input devices such as the keyboard, mouse, and touchscreen. + */ + +import { WebDriverError, ErrorCode } from './error'; +import * as dom from './dom'; +import * as events from './events'; +import * as userAgent from './userAgent'; +import * as bot from './bot'; + +/** + * Simple coordinate type + */ +export class Coordinate { + constructor(public x: number, public y: number) { } +} + +/** + * An enum for the various modifier keys (keycode-independent). + */ +export enum Modifier { + SHIFT = 0x1, + CONTROL = 0x2, + ALT = 0x4, + META = 0x8 +} + +/** + * Stores the state of modifier keys + */ +export class ModifiersState { + private pressedModifiers: number = 0; + + /** + * Checks whether a specific modifier is pressed + */ + isPressed(modifier: Modifier): boolean { + return (this.pressedModifiers & modifier) !== 0; + } + + /** + * Sets the state of a given modifier. + */ + setPressed(modifier: Modifier, isPressed: boolean): void { + if (isPressed) { + this.pressedModifiers = this.pressedModifiers | modifier; + } else { + this.pressedModifiers = this.pressedModifiers & ~modifier; + } + } + + /** + * @return State of the Shift key. + */ + isShiftPressed(): boolean { + return this.isPressed(Modifier.SHIFT); + } + + /** + * @return State of the Control key. + */ + isControlPressed(): boolean { + return this.isPressed(Modifier.CONTROL); + } + + /** + * @return State of the Alt key. + */ + isAltPressed(): boolean { + return this.isPressed(Modifier.ALT); + } + + /** + * @return State of the Meta key. + */ + isMetaPressed(): boolean { + return this.isPressed(Modifier.META); + } +} + +/** + * Fires events, a driver can replace it with a custom implementation + */ +export class EventEmitter { + /** + * Fires an HTML event given the state of the device. + */ + fireHtmlEvent(target: Element, type: any): boolean { + return events.fire(target, type); + } + + /** + * Fires a keyboard event given the state of the device and the given arguments. + */ + fireKeyboardEvent(target: Element, type: any, args: events.KeyboardArgs): boolean { + return events.fire(target, type, args); + } + + /** + * Fires a mouse event given the state of the device and the given arguments. + */ + fireMouseEvent(target: Element, type: any, args: events.MouseArgs): boolean { + return events.fire(target, type, args); + } + + /** + * Fires a touch event given the state of the device and the given arguments. + */ + fireTouchEvent(target: Element, type: any, args: events.TouchArgs): boolean { + return events.fire(target, type, args); + } + + /** + * Fires an MSPointer event given the state of the device and the given arguments. + */ + fireMSPointerEvent(target: Element, type: any, args: events.MSPointerArgs): boolean { + return events.fire(target, type, args); + } +} + +/** + * A Device class that provides common functionality for input devices. + */ +export class Device { + private element: Element; + private select: Element | null = null; + protected modifiersState: ModifiersState; + protected eventEmitter: EventEmitter; + + constructor(opt_modifiersState?: ModifiersState, opt_eventEmitter?: EventEmitter) { + this.element = bot.getDocument().documentElement; + + // If there is an active element, make that the current element instead. + const activeElement = dom.getActiveElement(this.element); + if (activeElement) { + this.setElement(activeElement); + } + + this.modifiersState = opt_modifiersState || new ModifiersState(); + this.eventEmitter = opt_eventEmitter || new EventEmitter(); + } + + /** + * Returns the element with which the device is interacting. + */ + getElement(): Element { + return this.element; + } + + /** + * Sets the element with which the device is interacting. + */ + setElement(element: Element): void { + this.element = element; + if (element.tagName?.toLowerCase() === 'option') { + let ancestor: Element | null = element; + while (ancestor !== null) { + if (ancestor.tagName?.toLowerCase() === 'select') { + this.select = ancestor; + return; + } + ancestor = ancestor.parentElement; + } + this.select = null; + } else { + this.select = null; + } + } + + /** + * Fires an HTML event given the state of the device. + */ + protected fireHtmlEvent(type: any): boolean { + return this.eventEmitter.fireHtmlEvent(this.element, type); + } + + /** + * Fires a keyboard event given the state of the device and the given arguments. + */ + protected fireKeyboardEvent(type: any, args: events.KeyboardArgs): boolean { + return this.eventEmitter.fireKeyboardEvent(this.element, type, args); + } + + /** + * Fires a mouse event given the state of the device and the given arguments. + */ + protected fireMouseEvent( + type: any, + coord: Coordinate, + button: number, + opt_related?: Element, + opt_wheelDelta?: number, + opt_force?: boolean, + opt_pointerId?: number, + opt_count?: number + ): boolean { + if (!opt_force && !dom.isInteractable(this.element)) { + return false; + } + + if (opt_related && + !(type === events.EventType.MOUSEOVER || + type === events.EventType.MOUSEOUT)) { + throw new WebDriverError(ErrorCode.INVALID_ELEMENT_STATE, + 'Event type does not allow related target: ' + type); + } + + const args: events.MouseArgs = { + clientX: coord.x, + clientY: coord.y, + button, + altKey: this.modifiersState.isAltPressed(), + ctrlKey: this.modifiersState.isControlPressed(), + shiftKey: this.modifiersState.isShiftPressed(), + metaKey: this.modifiersState.isMetaPressed(), + wheelDelta: opt_wheelDelta || 0, + relatedTarget: opt_related + }; + + const pointerId = opt_pointerId || MOUSE_MS_POINTER_ID; + let target = this.element; + + // On click and mousedown events, captured pointers are ignored and the + // event always fires on the original element. + if (type !== events.EventType.CLICK && + type !== events.EventType.MOUSEDOWN && + pointerId in pointerElementMap) { + target = pointerElementMap[pointerId]; + } else if (this.select) { + const optionTarget = this.getTargetOfOptionMouseEvent_(type); + if (optionTarget) { + target = optionTarget; + } + } + + return target ? this.eventEmitter.fireMouseEvent(target, type, args) : true; + } + + /** + * Fires a touch event given the state of the device and the given arguments. + */ + protected fireTouchEvent( + type: any, + id: number, + coord: Coordinate, + opt_id2?: number, + opt_coord2?: Coordinate + ): boolean { + const args: events.TouchArgs = { + touches: [], + targetTouches: [], + changedTouches: [], + altKey: this.modifiersState.isAltPressed(), + ctrlKey: this.modifiersState.isControlPressed(), + shiftKey: this.modifiersState.isShiftPressed(), + metaKey: this.modifiersState.isMetaPressed(), + scale: 0, + rotation: 0 + }; + + const pageOffset = this.getDocumentScroll(); + + const addTouch = (identifier: number, coords: Coordinate) => { + const touch: events.Touch = { + identifier, + screenX: coords.x, + screenY: coords.y, + clientX: coords.x, + clientY: coords.y, + pageX: coords.x + pageOffset.x, + pageY: coords.y + pageOffset.y + }; + + args.changedTouches.push(touch); + if (type === events.EventType.TOUCHSTART || type === events.EventType.TOUCHMOVE) { + args.touches.push(touch); + args.targetTouches.push(touch); + } + }; + + addTouch(id, coord); + if (opt_id2 !== undefined && opt_coord2) { + addTouch(opt_id2, opt_coord2); + } + + return this.eventEmitter.fireTouchEvent(this.element, type, args); + } + + /** + * Fires a MSPointer event given the state of the device and the given arguments. + */ + protected fireMSPointerEvent( + type: any, + coord: Coordinate, + button: number, + pointerId: number, + device: number, + isPrimary: boolean, + opt_related?: Element, + opt_force?: boolean + ): boolean { + if (!opt_force && !dom.isInteractable(this.element)) { + return false; + } + + if (opt_related && + !(type === events.EventType.MSPOINTEROVER || + type === events.EventType.MSPOINTEROUT)) { + throw new WebDriverError(ErrorCode.INVALID_ELEMENT_STATE, + 'Event type does not allow related target: ' + type); + } + + const args: events.MSPointerArgs = { + clientX: coord.x, + clientY: coord.y, + button, + altKey: false, + ctrlKey: false, + shiftKey: false, + metaKey: false, + relatedTarget: opt_related, + width: 0, + height: 0, + pressure: 0, + rotation: 0, + pointerId, + tiltX: 0, + tiltY: 0, + pointerType: device, + isPrimary + }; + + let target = this.select ? this.getTargetOfOptionMouseEvent_(type) : this.element; + if (!target) { + target = this.element; + } + if (pointerElementMap[pointerId]) { + target = pointerElementMap[pointerId]; + } + + const owner = this.element.ownerDocument?.defaultView; + let originalMsSetPointerCapture: any; + + if (owner && type === events.EventType.MSPOINTERDOWN) { + originalMsSetPointerCapture = (owner as any).Element.prototype.msSetPointerCapture; + (owner as any).Element.prototype.msSetPointerCapture = function (id: number) { + pointerElementMap[id] = this; + }; + } + + const result = target ? this.eventEmitter.fireMSPointerEvent(target, type, args) : true; + + if (originalMsSetPointerCapture) { + (owner as any).Element.prototype.msSetPointerCapture = originalMsSetPointerCapture; + } + + return result; + } + + /** + * A mouse event fired "on" an option element, doesn't always fire on the + * option element itself. + */ + private getTargetOfOptionMouseEvent_(type: any): Element | null { + if (!this.select) { + return null; + } + + // IE either fires the event on the parent select or not at all + if (userAgent.IS_IE) { + const typeStr = String(type); + if (typeStr.includes('mouseover')) { + return null; + } + if (typeStr.includes('contextmenu') || typeStr.includes('mousemove')) { + return (this.select as HTMLSelectElement).multiple ? this.select : null; + } + return this.select; + } + + // WebKit always fires on the option element of multi-selects + if ((userAgent as any).IS_WEBKIT) { + const typeStr = String(type); + if (typeStr.includes('click') || typeStr.includes('mouseup')) { + return (this.select as HTMLSelectElement).multiple ? this.element : this.select; + } + return (this.select as HTMLSelectElement).multiple ? this.element : null; + } + + // Firefox fires every event on the option element + return this.element; + } + + /** + * A helper function to fire click events. + */ + protected clickElement( + coord: Coordinate, + button: number, + opt_force?: boolean, + opt_pointerId?: number + ): void { + if (!opt_force && !dom.isInteractable(this.element)) { + return; + } + + let targetLink: Element | null = null; + let targetButton: Element | null = null; + + if (!ALWAYS_FOLLOWS_LINKS_ON_CLICK) { + let e: Element | null = this.element; + while (e !== null) { + if (e.tagName?.toLowerCase() === 'a') { + targetLink = e; + break; + } else if (Device.isFormSubmitElement(e)) { + targetButton = e; + break; + } + e = e.parentElement; + } + } + + const isRadioOrCheckbox = !this.select && dom.isSelectable(this.element); + const wasChecked = isRadioOrCheckbox && dom.isSelected(this.element); + + // When clicking a form submit button in IE, we need to call Element.click() explicitly + if (userAgent.IS_IE && targetButton) { + (targetButton as any).click(); + return; + } + + const performDefault = this.fireMouseEvent( + events.EventType.CLICK, coord, button, undefined, 0, opt_force, opt_pointerId + ); + + if (!performDefault) { + return; + } + + if (targetLink && Device.shouldFollowHref_(targetLink as HTMLAnchorElement)) { + Device.followHref_(targetLink as HTMLAnchorElement); + } else if (isRadioOrCheckbox) { + this.toggleRadioButtonOrCheckbox_(wasChecked); + } + } + + /** + * Focuses on the given element and returns true if it supports being focused + * and does not already have focus; otherwise, returns false. + */ + protected focusOnElement(): boolean { + let elementToFocus: Element | null = this.element; + let current: Element | null = this.element; + + while (current) { + if (dom.isElement(current) && dom.isFocusable(current)) { + elementToFocus = current; + break; + } + current = current.parentElement; + } + + const activeElement = dom.getActiveElement(elementToFocus); + if (elementToFocus === activeElement) { + return false; + } + + // If there is a currently active element, try to blur it. + if (activeElement && typeof (activeElement as any).blur === 'function') { + if (!dom.isElement(activeElement, 'body')) { + try { + (activeElement as any).blur(); + } catch (e) { + if (!(userAgent.IS_IE && (e as any).message === 'Unspecified error.')) { + throw e; + } + } + } + + // Sometimes IE6 and IE7 will not fire an onblur event after blur() is called + if (userAgent.IS_IE && !userAgent.isEngineVersion(8)) { + const owner = elementToFocus.ownerDocument?.defaultView; + if (owner) { + owner.focus(); + } + } + } + + // Try to focus on the element. + if (typeof (elementToFocus as any).focus === 'function') { + (elementToFocus as any).focus(); + return true; + } + + return false; + } + + /** + * Toggles the selected state of the current element if it is an option. + */ + protected maybeToggleOption(): void { + // If this is not an