blob: 1985a5841e6b91b42b9a3d2235d1083a70ed9b74 [file] [log] [blame] [edit]
// Copyright 2010 WebDriver committers
// Copyright 2010 Google Inc.
//
// Licensed 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.
/**
* @fileoverview Atoms for simulating user actions against the DOM.
* The bot.action namespace is required since these atoms would otherwise form a
* circular dependency between bot.dom and bot.events.
*
*/
goog.provide('bot.action');
goog.require('bot');
goog.require('bot.Device');
goog.require('bot.Error');
goog.require('bot.ErrorCode');
goog.require('bot.Keyboard');
goog.require('bot.Mouse');
goog.require('bot.Touchscreen');
goog.require('bot.dom');
goog.require('bot.events');
goog.require('bot.events.EventType');
goog.require('bot.userAgent');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.math.Coordinate');
goog.require('goog.math.Rect');
goog.require('goog.math.Vec2');
goog.require('goog.style');
goog.require('goog.userAgent');
/**
* Throws an exception if an element is not shown to the user, ignoring its
* opacity.
*
* @param {!Element} element The element to check.
* @see bot.dom.isShown.
* @private
*/
bot.action.checkShown_ = function(element) {
if (!bot.dom.isShown(element, /*ignoreOpacity=*/true)) {
throw new bot.Error(bot.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.
*
* @param {!Element} element The element to check.
* @throws {bot.Error} If the element cannot be interacted with.
* @see bot.dom.isInteractable.
* @private
*/
bot.action.checkInteractable_ = function(element) {
if (!bot.dom.isInteractable(element)) {
throw new bot.Error(bot.ErrorCode.INVALID_ELEMENT_STATE,
'Element is not currently interactable and may not be manipulated');
}
};
/**
* Clears the given {@code element} if it is a editable text field.
*
* @param {!Element} element The element to clear.
* @throws {bot.Error} If the element is not an editable text field.
*/
bot.action.clear = function(element) {
bot.action.checkInteractable_(element);
if (!bot.dom.isEditable(element)) {
throw new bot.Error(bot.ErrorCode.INVALID_ELEMENT_STATE,
'Element must be user-editable in order to clear it.');
}
bot.action.LegacyDevice_.focusOnElement(element);
if (element.value) {
element.value = '';
bot.events.fire(element, bot.events.EventType.CHANGE);
}
if (bot.dom.isContentEditable(element)) {
// A single space is required, if you put empty string here you'll not be
// able to interact with this element anymore in Firefox.
element.innerHTML = ' ';
// contentEditable does not generate onchange event.
}
};
/**
* Focuses on the given element if it is not already the active element.
*
* @param {!Element} element The element to focus on.
*/
bot.action.focusOnElement = function(element) {
bot.action.checkInteractable_(element);
bot.action.LegacyDevice_.focusOnElement(element);
};
/**
* Types keys on the given {@code element} with a virtual keyboard.
*
* <p>Callers can pass in a string, a key in bot.Keyboard.Key, or an array
* of strings or keys. If a modifier key is provided, it is pressed but not
* released, until it is either is listed again or the function ends.
*
* <p>Example:
* bot.keys.type(element, ['ab', bot.Keyboard.Key.LEFT,
* bot.Keyboard.Key.SHIFT, 'cd']);
*
* @param {!Element} element The element receiving the event.
* @param {(string|!bot.Keyboard.Key|!Array.<(string|!bot.Keyboard.Key)>)}
* values Value or values to type on the element.
* @param {bot.Keyboard=} opt_keyboard Keyboard to use; if not provided,
* constructs one.
* @throws {bot.Error} If the element cannot be interacted with.
*/
bot.action.type = function(element, values, opt_keyboard) {
bot.action.checkShown_(element);
bot.action.checkInteractable_(element);
var keyboard = opt_keyboard || new bot.Keyboard();
keyboard.moveCursor(element);
function typeValue(value) {
if (goog.isString(value)) {
goog.array.forEach(value.split(''), function(ch) {
var keyShiftPair = bot.Keyboard.Key.fromChar(ch);
var shiftIsPressed = keyboard.isPressed(bot.Keyboard.Keys.SHIFT);
if (keyShiftPair.shift && !shiftIsPressed) {
keyboard.pressKey(bot.Keyboard.Keys.SHIFT);
}
keyboard.pressKey(keyShiftPair.key);
keyboard.releaseKey(keyShiftPair.key);
if (keyShiftPair.shift && !shiftIsPressed) {
keyboard.releaseKey(bot.Keyboard.Keys.SHIFT);
}
});
} else if (goog.array.contains(bot.Keyboard.MODIFIERS, value)) {
if (keyboard.isPressed(value)) {
keyboard.releaseKey(value);
} else {
keyboard.pressKey(value);
}
} else {
keyboard.pressKey(value);
keyboard.releaseKey(value);
}
}
if (goog.isArray(values)) {
goog.array.forEach(values, typeValue);
} else {
typeValue(values);
}
// Release all the modifier keys.
goog.array.forEach(bot.Keyboard.MODIFIERS, function(key) {
if (keyboard.isPressed(key)) {
keyboard.releaseKey(key);
}
});
};
/**
* Submits the form containing the given {@code element}.
*
* <p>Note this function submits the form, but does not simulate user input
* (a click or key press).
*
* @param {!Element} element The element to submit.
* @deprecated Click on a submit button or type ENTER in a text box instead.
*/
bot.action.submit = function(element) {
var form = bot.action.LegacyDevice_.findAncestorForm(element);
if (!form) {
throw new bot.Error(bot.ErrorCode.INVALID_ELEMENT_STATE,
'Element was not in a form, so could not submit.');
}
bot.action.LegacyDevice_.submitForm(element, form);
};
/**
* Moves the mouse over the given {@code element} with a virtual mouse.
*
* @param {!Element} element The element to click.
* @param {goog.math.Coordinate=} opt_coords Mouse position relative to the
* element.
* @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one.
* @throws {bot.Error} If the element cannot be interacted with.
*/
bot.action.moveMouse = function(element, opt_coords, opt_mouse) {
var coords = bot.action.prepareToInteractWith_(element, opt_coords);
var mouse = opt_mouse || new bot.Mouse();
mouse.move(element, coords);
};
/**
* Clicks on the given {@code element} with a virtual mouse.
*
* @param {!Element} element The element to click.
* @param {goog.math.Coordinate=} opt_coords Mouse position relative to the
* element.
* @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one.
* @throws {bot.Error} If the element cannot be interacted with.
*/
bot.action.click = function(element, opt_coords, opt_mouse) {
var coords = bot.action.prepareToInteractWith_(element, opt_coords);
var mouse = opt_mouse || new bot.Mouse();
mouse.move(element, coords);
mouse.pressButton(bot.Mouse.Button.LEFT);
mouse.releaseButton();
};
/**
* Right-clicks on the given {@code element} with a virtual mouse.
*
* @param {!Element} element The element to click.
* @param {goog.math.Coordinate=} opt_coords Mouse position relative to the
* element.
* @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one.
* @throws {bot.Error} If the element cannot be interacted with.
*/
bot.action.rightClick = function(element, opt_coords, opt_mouse) {
var coords = bot.action.prepareToInteractWith_(element, opt_coords);
var mouse = opt_mouse || new bot.Mouse();
mouse.move(element, coords);
mouse.pressButton(bot.Mouse.Button.RIGHT);
mouse.releaseButton();
};
/**
* Double-clicks on the given {@code element} with a virtual mouse.
*
* @param {!Element} element The element to click.
* @param {goog.math.Coordinate=} opt_coords Mouse position relative to the
* element.
* @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one.
* @throws {bot.Error} If the element cannot be interacted with.
*/
bot.action.doubleClick = function(element, opt_coords, opt_mouse) {
var coords = bot.action.prepareToInteractWith_(element, opt_coords);
var mouse = opt_mouse || new bot.Mouse();
mouse.move(element, coords);
mouse.pressButton(bot.Mouse.Button.LEFT);
mouse.releaseButton();
mouse.pressButton(bot.Mouse.Button.LEFT);
mouse.releaseButton();
};
/**
* Scrolls the mouse wheel on the given {@code element} with a virtual mouse.
*
* @param {!Element} element The element to scroll the mouse wheel on.
* @param {number} ticks Number of ticks to scroll the mouse wheel; a positive
* number scrolls down and a negative scrolls up.
* @param {goog.math.Coordinate=} opt_coords Mouse position relative to the
* element.
* @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one.
* @throws {bot.Error} If the element cannot be interacted with.
*/
bot.action.scrollMouse = function(element, ticks, opt_coords, opt_mouse) {
var coords = bot.action.prepareToInteractWith_(element, opt_coords);
var mouse = opt_mouse || new bot.Mouse();
mouse.move(element, coords);
mouse.scroll(ticks);
};
/**
* Drags the given {@code element} by (dx, dy) with a virtual mouse.
*
* @param {!Element} element The element to drag.
* @param {number} dx Increment in x coordinate.
* @param {number} dy Increment in y coordinate.
* @param {goog.math.Coordinate=} opt_coords Drag start position relative to the
* element.
* @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one.
* @throws {bot.Error} If the element cannot be interacted with.
*/
bot.action.drag = function(element, dx, dy, opt_coords, opt_mouse) {
var coords = bot.action.prepareToInteractWith_(element, opt_coords);
var mouse = opt_mouse || new bot.Mouse();
mouse.move(element, coords);
mouse.pressButton(bot.Mouse.Button.LEFT);
// Fire two mousemoves (middle and destination) to trigger a drag action.
var initPos = goog.style.getClientPosition(element);
var midXY = new goog.math.Coordinate(coords.x + Math.floor(dx / 2),
coords.y + Math.floor(dy / 2));
mouse.move(element, midXY);
var midPos = goog.style.getClientPosition(element);
var finalXY = new goog.math.Coordinate(initPos.x + coords.x + dx - midPos.x,
initPos.y + coords.y + dy - midPos.y);
mouse.move(element, finalXY);
mouse.releaseButton();
};
/**
* Taps on the given {@code element} with a virtual touch screen.
*
* @param {!Element} element The element to tap.
* @param {goog.math.Coordinate=} opt_coords Finger position relative to the
* target.
* @param {bot.Touchscreen=} opt_touchscreen Touchscreen to use; if not
* provided, constructs one.
* @throws {bot.Error} If the element cannot be interacted with.
*/
bot.action.tap = function(element, opt_coords, opt_touchscreen) {
var coords = bot.action.prepareToInteractWith_(element, opt_coords);
var touchscreen = opt_touchscreen || new bot.Touchscreen();
touchscreen.move(element, coords);
touchscreen.press();
touchscreen.release();
};
/**
* Swipes the given {@code element} by (dx, dy) with a virtual touch screen.
*
* @param {!Element} element The element to swipe.
* @param {number} dx Increment in x coordinate.
* @param {number} dy Increment in y coordinate.
* @param {goog.math.Coordinate=} opt_coords Swipe start position relative to
* the element.
* @param {bot.Touchscreen=} opt_touchscreen Touchscreen to use; if not
* provided, constructs one.
* @throws {bot.Error} If the element cannot be interacted with.
*/
bot.action.swipe = function(element, dx, dy, opt_coords, opt_touchscreen) {
var coords = bot.action.prepareToInteractWith_(element, opt_coords);
var touchscreen = opt_touchscreen || new bot.Touchscreen();
touchscreen.move(element, coords);
touchscreen.press();
// Fire two touchmoves (middle and destination) to trigger a drag action.
var initPos = goog.style.getClientPosition(element);
var midXY = new goog.math.Coordinate(coords.x + Math.floor(dx / 2),
coords.y + Math.floor(dy / 2));
touchscreen.move(element, midXY);
var midPos = goog.style.getClientPosition(element);
var finalXY = new goog.math.Coordinate(initPos.x + coords.x + dx - midPos.x,
initPos.y + coords.y + dy - midPos.y);
touchscreen.move(element, finalXY);
touchscreen.release();
};
/**
* Pinches the given {@code element} by the given distance with a virtual touch
* screen. A positive distance moves two fingers inward toward each and a
* negative distances spreds them outward. The optional coordinate is the point
* the fingers move towards (for positive distances) or away from (for negative
* distances); and if not provided, defaults to the center of the element.
*
* @param {!Element} element The element to pinch.
* @param {number} distance The distance by which to pinch the element.
* @param {goog.math.Coordinate=} opt_coords Position relative to the element
* at the center of the pinch.
* @param {bot.Touchscreen=} opt_touchscreen Touchscreen to use; if not
* provided, constructs one.
* @throws {bot.Error} If the element cannot be interacted with.
*/
bot.action.pinch = function(element, distance, opt_coords, opt_touchscreen) {
if (distance == 0) {
throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR,
'Cannot pinch by a distance of zero.');
}
function startSoThatEndsAtMax(offsetVec) {
if (distance < 0) {
var magnitude = offsetVec.magnitude();
offsetVec.scale(magnitude ? (magnitude + distance) / magnitude : 0);
}
}
var halfDistance = distance / 2;
function scaleByHalfDistance(offsetVec) {
var magnitude = offsetVec.magnitude();
offsetVec.scale(magnitude ? (magnitude - halfDistance) / magnitude : 0);
}
bot.action.multiTouchAction_(element,
startSoThatEndsAtMax,
scaleByHalfDistance,
opt_coords,
opt_touchscreen);
};
/**
* Rotates the given {@code element} by the given angle with a virtual touch
* screen. A positive angle moves two fingers clockwise and a negative angle
* moves them counter-clockwise. The optional coordinate is the point to
* rotate around; and if not provided, defaults to the center of the element.
*
* @param {!Element} element The element to rotate.
* @param {number} angle The angle by which to rotate the element.
* @param {goog.math.Coordinate=} opt_coords Position relative to the element
* at the center of the rotation.
* @param {bot.Touchscreen=} opt_touchscreen Touchscreen to use; if not
* provided, constructs one.
* @throws {bot.Error} If the element cannot be interacted with.
*/
bot.action.rotate = function(element, angle, opt_coords, opt_touchscreen) {
if (angle == 0) {
throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR,
'Cannot rotate by an angle of zero.');
}
function startHalfwayToMax(offsetVec) {
offsetVec.scale(0.5);
}
var halfRadians = Math.PI * (angle / 180) / 2;
function rotateByHalfAngle(offsetVec) {
offsetVec.rotate(halfRadians);
}
bot.action.multiTouchAction_(element,
startHalfwayToMax,
rotateByHalfAngle,
opt_coords,
opt_touchscreen);
};
/**
* Performs a multi-touch action with two fingers on the given element. This
* helper function works by manipulating an "offsetVector", which is the vector
* away from the center of the interaction at which the fingers are positioned.
* It computes the maximum offset vector and passes it to transformStart to
* find the starting position of the fingers; it then passes it to transformHalf
* twice to find the midpoint and final position of the fingers.
*
* @param {!Element} element Element to interact with.
* @param {function(goog.math.Vec2)} transformStart Function to transform the
* maximum offset vector to the starting offset vector.
* @param {function(goog.math.Vec2)} transformHalf Function to transform the
* offset vector halfway to its destination.
* @param {goog.math.Coordinate=} opt_coords Position relative to the element
* at the center of the pinch.
* @param {bot.Touchscreen=} opt_touchscreen Touchscreen to use; if not
* provided, constructs one.
* @private
*/
bot.action.multiTouchAction_ = function(element, transformStart, transformHalf,
opt_coords, opt_touchscreen) {
var center = bot.action.prepareToInteractWith_(element, opt_coords);
var size = bot.action.getInteractableSize(element);
var offsetVec = new goog.math.Vec2(
Math.min(center.x, size.width - center.x),
Math.min(center.y, size.height - center.y));
var touchScreen = opt_touchscreen || new bot.Touchscreen();
transformStart(offsetVec);
var start1 = goog.math.Vec2.sum(center, offsetVec);
var start2 = goog.math.Vec2.difference(center, offsetVec);
touchScreen.move(element, start1, start2);
touchScreen.press(/*Two Finger Press*/ true);
var initPos = goog.style.getClientPosition(element);
transformHalf(offsetVec);
var mid1 = goog.math.Vec2.sum(center, offsetVec);
var mid2 = goog.math.Vec2.difference(center, offsetVec);
touchScreen.move(element, mid1, mid2);
var movedVec = goog.math.Vec2.difference(
goog.style.getClientPosition(element), initPos);
transformHalf(offsetVec);
var end1 = goog.math.Vec2.sum(center, offsetVec).subtract(movedVec);
var end2 = goog.math.Vec2.difference(center, offsetVec).subtract(movedVec);
touchScreen.move(element, end1, end2);
touchScreen.release();
};
/**
* Prepares to interact with the given {@code element}. It checks if the the
* element is shown, scrolls the element into view, and returns the coordinates
* of the interaction, which if not provided, is the center of the element.
*
* @param {!Element} element The element to be interacted with.
* @param {goog.math.Coordinate=} opt_coords Position relative to the target.
* @return {!goog.math.Vec2} Coordinates at the center of the interaction.
* @throws {bot.Error} If the element cannot be interacted with.
* @private
*/
bot.action.prepareToInteractWith_ = function(element, opt_coords) {
bot.action.checkShown_(element);
// Unlike element.scrollIntoView(), this scrolls the minimal amount
// necessary, not scrolling at all if the element is already in view.
var doc = goog.dom.getOwnerDocument(element);
goog.style.scrollIntoContainerView(element,
goog.userAgent.WEBKIT ? doc.body : doc.documentElement);
// NOTE(user): Ideally, we would check that any provided coordinates fall
// within the bounds of the element, but this has proven difficult, because:
// (1) Browsers sometimes lie about the true size of elements, e.g. when text
// overflows the bounding box of an element, browsers report the size of the
// box even though the true area that can be interacted with is larger; and
// (2) Elements with children styled as position:absolute will often not have
// a bounding box that surrounds all of their children, but it is useful for
// the user to be able to interact with this parent element as if it does.
if (opt_coords) {
return goog.math.Vec2.fromCoordinate(opt_coords);
} else {
var size = bot.action.getInteractableSize(element);
return new goog.math.Vec2(size.width / 2, size.height / 2);
}
};
/**
* Returns the interactable size of an element.
*
* @param {!Element} elem Element.
* @return {!goog.math.Size} size Size of the element.
*/
bot.action.getInteractableSize = function(elem) {
var size = goog.style.getSize(elem);
return ((size.width > 0 && size.height > 0) || !elem.offsetParent) ? size :
bot.action.getInteractableSize(elem.offsetParent);
};
/**
* A Device that is intended to allows access to protected members of the
* Device superclass. A singleton.
*
* @constructor
* @extends {bot.Device}
* @private
*/
bot.action.LegacyDevice_ = function() {
goog.base(this);
};
goog.inherits(bot.action.LegacyDevice_, bot.Device);
goog.addSingletonGetter(bot.action.LegacyDevice_);
/**
* Focuses on the given element. See {@link bot.device.focusOnElement}.
* @param {!Element} element The element to focus on.
* @return {boolean} True if element.focus() was called on the element.
*/
bot.action.LegacyDevice_.focusOnElement = function(element) {
var instance = bot.action.LegacyDevice_.getInstance();
instance.setElement(element);
return instance.focusOnElement();
};
/**
* Submit the form for the element. See {@link bot.device.submit}.
* @param {!Element} element The element to submit a form on.
* @param {!Element} form The form to submit.
*/
bot.action.LegacyDevice_.submitForm = function(element, form) {
var instance = bot.action.LegacyDevice_.getInstance();
instance.setElement(element);
instance.submitForm(form);
};
/**
* Find FORM element that is an ancestor of the passed in element. See
* {@link bot.device.findAncestorForm}.
* @param {!Element} element The element to find an ancestor form.
* @return {Element} form The ancestor form, or null if none.
*/
bot.action.LegacyDevice_.findAncestorForm = function(element) {
return bot.Device.findAncestorForm(element);
};
/**
* Scrolls the given {@code element} in to the current viewport. Aims to do the
* minimum scrolling necessary, but prefers too much scrolling to too little.
*
* @param {!Element} element The element to scroll in to view.
* @param {!goog.math.Coordinate=} opt_coords Offset relative to the top-left
* corner of the element, to ensure is scrolled in to view.
* @return {boolean} Whether the element is in view after scrolling.
*/
bot.action.scrollIntoView = function(element, opt_coords) {
if (!bot.dom.isScrolledIntoView(element, opt_coords)) {
element.scrollIntoView();
// In Opera 10, scrollIntoView only scrolls the element into the viewport of
// its immediate parent window, so we explicitly scroll the ancestor frames
// into view of their respective windows. Note that scrolling the top frame
// first --- and so on down to the element itself --- does not work, because
// Opera 10 apparently treats element.scrollIntoView() as a noop when it
// immediately follows a scrollIntoView() call on its parent frame.
if (goog.userAgent.OPERA && !bot.userAgent.isEngineVersion(11)) {
var win = goog.dom.getWindow(goog.dom.getOwnerDocument(element));
for (var frame = win.frameElement; frame; frame = win.frameElement) {
frame.scrollIntoView();
win = goog.dom.getWindow(goog.dom.getOwnerDocument(frame));
}
}
}
if (opt_coords) {
var rect = new goog.math.Rect(opt_coords.x, opt_coords.y, 1, 1);
bot.dom.scrollElementRegionIntoClientView(element, rect);
}
var isInView = bot.dom.isScrolledIntoView(element, opt_coords);
if (!isInView && opt_coords) {
// It's possible that the element has been scrolled in to view, but the
// coords passed aren't in view; if this is the case, scroll those
// coordinates into view.
var elementCoordsInViewport = goog.style.getClientPosition(element);
var desiredPointInViewport =
goog.math.Coordinate.sum(elementCoordsInViewport, opt_coords);
try {
bot.dom.getInViewLocation(
desiredPointInViewport,
goog.dom.getWindow(goog.dom.getOwnerDocument(element)));
isInView = true;
} catch (ex) {
// Point couldn't be scrolled into view.
isInView = false;
}
}
return isInView;
};