/*
gpuBenchmarking finishes a gesture by sending a completion callback to the
renderer after the final input event. When the renderer receives the callback
it requests a new frame. This should flush all input through the system - and
DOM events should synchronously run here - and produce a compositor frame.
The callback is resolved when the frame is presented to the screen.
For methods in this file, the callback is the resolve method of the Promise
returned.
Example:
await mouseMoveTo(10,10);
The await returns after the mousemove event fired.
Note:
Given the event handler runs synchronous code, the await returns after
the event handler finished running.
*/
function waitForCompositorCommit() {
return new Promise((resolve) => {
if (window.testRunner) {
// After doing the composite, we also allow any async tasks to run before
// resolving, via setTimeout().
testRunner.updateAllLifecyclePhasesAndCompositeThen(() => {
window.setTimeout(resolve, 0);
});
} else {
// Fall back to just rAF twice.
window.requestAnimationFrame(() => {
window.requestAnimationFrame(resolve);
});
}
});
}
async function waitForCompositorReady() {
const animation =
document.body.animate({ opacity: [ 0, 1 ] }, {duration: 1 });
return animation.finished;
}
// Returns a promise that resolves when the given condition is met or rejects
// after 200 animation frames.
function waitFor(condition, error_message = 'Reaches the maximum frames.') {
const MAX_FRAME = 200;
return new Promise((resolve, reject) => {
function tick(frames) {
// We requestAnimationFrame either for 200 frames or until condition is
// met.
if (frames >= MAX_FRAME)
reject(error_message);
else if (condition())
resolve();
else
requestAnimationFrame(tick.bind(this, frames + 1));
}
tick(0);
});
}
// Returns a promise that only gets resolved when the condition is met.
function waitUntil(condition) {
return new Promise((resolve, reject) => {
function tick() {
if (condition())
resolve();
else
requestAnimationFrame(tick.bind(this));
}
tick();
});
}
// Returns a promise that resolves when the given condition holds for 10
// animation frames or rejects if the condition changes to false within 10
// animation frames.
function conditionHolds(condition, error_message = 'Condition is not true anymore.') {
const MAX_FRAME = 10;
return new Promise((resolve, reject) => {
function tick(frames) {
// We requestAnimationFrame either for 10 frames or until condition is
// violated.
if (frames >= MAX_FRAME)
resolve();
else if (!condition())
reject(error_message);
else
requestAnimationFrame(tick.bind(this, frames + 1));
}
tick(0);
});
}
// Waits until scrolling has stopped for a period of time.
// @deprecated:
// If a scroll is expected then use waitForScrollendEvent.
// TODO(kevers): Not possible in all cases, e.g. an input field does not
// fire a scrollend event when scrolled. This is the exception rather than
// the rule. Once each remaining call has been reviewed, we can see if
// there are enough special cases to warrant a waitForScrollend with
// "polyfilled" behavior in cases where a scrollend event is not expected.
// If asserting that no scroll takes place then:
// Add a scroll listener with assert_unreached(message)
// If possible, wait on a sentinel event that indicates when handling of
// the gesture is complete. Otherwise, wait a few animation frames.
// Pointerup is an example of a suitable sentinel event if the test
// is driven by pointer events since pointerup is replace by
// pointercancel if scrolling occurs.
function waitForAnimationEndTimeBased(getValue) {
// Give up if the animation still isn't done after this many milliseconds.
const TIMEOUT_MS = 1000;
// If the value is unchanged for this many milliseconds, we consider the
// animation ended and return.
const END_THRESHOLD_MS = 200;
const START_TIME = performance.now();
let last_changed_time = START_TIME;
let last_value = getValue();
return new Promise((resolve, reject) => {
function tick() {
let cur_time = performance.now();
if (cur_time - last_changed_time > END_THRESHOLD_MS) {
resolve();
return;
}
if (cur_time - START_TIME > TIMEOUT_MS) {
reject(new Error("Timeout waiting for animation to end"));
return;
}
let current_value = getValue();
if (last_value != current_value) {
last_changed_time = cur_time;
last_value = current_value;
}
requestAnimationFrame(tick);
}
tick();
})
}
function waitForEvent(eventTarget, eventName, timeoutMs = 2000) {
return new Promise((resolve, reject) => {
const eventListener = (evt) => {
clearTimeout(timeout);
eventTarget.removeEventListener(eventName, eventListener);
resolve(evt);
};
let timeout = setTimeout(() => {
eventTarget.removeEventListener(eventName, eventListener);
reject(`Timeout waiting for ${eventName} event`);
}, timeoutMs);
eventTarget.addEventListener(eventName, eventListener);
});
}
function waitForScrollEvent(eventTarget, timeoutMs = 2000) {
return waitForEvent(eventTarget, 'scroll', timeoutMs);
}
function scrollendEventTarget(scroller) {
const isRootScroller =
scroller == scroller.ownerDocument.scrollingElement;
return isRootScroller ? scroller.ownerDocument : scroller;
}
function waitForScrollendEvent(eventTarget, timeoutMs = 2000) {
return waitForEvent(eventTarget, 'scrollend', timeoutMs);
}
// Event driven scroll promise. This method has the advantage over timing
// methods, as it is more forgiving to delays in event dispatch or between
// chained smooth scrolls. It has an additional advantage of completing sooner
// once the end condition is reached.
// The promise is resolved when the result of calling getValue matches the
// target value. The timeout timer starts once the first event has been
// received.
function waitForScrollEnd(eventTarget, getValue, targetValue, errorMessage) {
// Give up if the animation still isn't done after this many milliseconds from
// the time of the first scroll event.
const TIMEOUT_MS = 1000;
return new Promise((resolve, reject) => {
let timeout = undefined;
const scrollListener = () => {
if (!timeout)
timeout = setTimeout(() => {
reject(errorMessage || 'Timeout waiting for scroll end');
}, TIMEOUT_MS);
if (getValue() == targetValue) {
clearTimeout(timeout);
eventTarget.removeEventListener('scroll', scrollListener);
// Wait for a commit to allow the scroll to propagate through the
// compositor before resolving.
return waitForCompositorCommit().then(() => { resolve(); });
}
};
if (getValue() == targetValue)
resolve();
else
eventTarget.addEventListener('scroll', scrollListener);
});
}
// Enums for gesture_source_type parameters in gpuBenchmarking synthetic
// gesture methods. Must match C++ side enums in synthetic_gesture_params.h
const GestureSourceType = (function() {
var isDefined = (window.chrome && chrome.gpuBenchmarking);
return {
DEFAULT_INPUT: isDefined && chrome.gpuBenchmarking.DEFAULT_INPUT,
TOUCH_INPUT: isDefined && chrome.gpuBenchmarking.TOUCH_INPUT,
MOUSE_INPUT: isDefined && chrome.gpuBenchmarking.MOUSE_INPUT,
TOUCHPAD_INPUT: isDefined && chrome.gpuBenchmarking.TOUCHPAD_INPUT,
PEN_INPUT: isDefined && chrome.gpuBenchmarking.PEN_INPUT,
ToString: function(value) {
if (!isDefined)
return 'Synthetic gestures unavailable';
switch (value) {
case chrome.gpuBenchmarking.DEFAULT_INPUT:
return 'DefaultInput';
case chrome.gpuBenchmarking.TOUCH_INPUT:
return 'Touchscreen';
case chrome.gpuBenchmarking.MOUSE_INPUT:
return 'MouseWheel/Touchpad';
case chrome.gpuBenchmarking.PEN_INPUT:
return 'Pen';
default:
return 'Invalid';
}
}
}
})();
// Enums used as input to the |modifier_keys| parameters of methods in this
// file like smoothScrollWithXY and wheelTick.
const Modifiers = (function() {
return {
ALT: "Alt",
CONTROL: "Control",
META: "Meta",
SHIFT: "Shift",
CAPSLOCK: "CapsLock",
NUMLOCK: "NumLock",
ALTGRAPH: "AltGraph",
}
})();
// Enums used as input to the |modifier_buttons| parameters of methods in this
// file like smoothScrollWithXY and wheelTick.
const Buttons = (function() {
return {
LEFT: "Left",
MIDDLE: "Middle",
RIGHT: "Right",
BACK: "Back",
FORWARD: "Forward",
}
})();
// Takes a value from the Buttons enum (above) and returns an integer suitable
// for the "button" property in the gpuBenchmarking.pointerActionSequence API.
// Keep in sync with ToSyntheticMouseButton in actions_parser.cc.
function pointerActionButtonId(button_str) {
if (button_str === undefined)
return undefined;
switch (button_str) {
case Buttons.LEFT:
return 0;
case Buttons.MIDDLE:
return 1;
case Buttons.RIGHT:
return 2;
case Buttons.BACK:
return 3;
case Buttons.FORWARD:
return 4;
}
throw new Error("invalid button");
}
// Use this for speed to make gestures (effectively) instant. That is, finish
// entirely within one Begin|Update|End triplet. This is in physical
// pixels/second.
// TODO(bokan): This isn't really instant but high enough that it works for
// current purposes. This should be replaced with the Infinity value and
// the synthetic gesture code modified to guarantee the single update behavior.
// https://crbug.com/893608
const SPEED_INSTANT = 400000;
// Constant wheel delta value when percent based scrolling is enabled
const WHEEL_DELTA = 100;
// kMinFractionToStepWhenPaging constant from cc/input/scroll_utils.h
const MIN_FRACTION_TO_STEP_WHEN_PAGING = 0.875;
// This will be replaced by smoothScrollWithXY.
function smoothScroll(pixels_to_scroll, start_x, start_y, gesture_source_type,
direction, speed_in_pixels_s, precise_scrolling_deltas,
scroll_by_page, cursor_visible, scroll_by_percentage,
modifier_keys) {
let pixels_to_scroll_x = 0;
let pixels_to_scroll_y = 0;
if (direction == "down") {
pixels_to_scroll_y = pixels_to_scroll;
} else if (direction == "up") {
pixels_to_scroll_y = -pixels_to_scroll;
} else if (direction == "right") {
pixels_to_scroll_x = pixels_to_scroll;
} else if (direction == "left") {
pixels_to_scroll_x = -pixels_to_scroll;
} else if (direction == "upleft") {
pixels_to_scroll_x = -pixels_to_scroll;
pixels_to_scroll_y = -pixels_to_scroll;
} else if (direction == "upright") {
pixels_to_scroll_x = pixels_to_scroll;
pixels_to_scroll_y = -pixels_to_scroll;
} else if (direction == "downleft") {
pixels_to_scroll_x = -pixels_to_scroll;
pixels_to_scroll_y = pixels_to_scroll;
} else if (direction == "downright") {
pixels_to_scroll_x = pixels_to_scroll;
pixels_to_scroll_y = pixels_to_scroll;
}
return smoothScrollWithXY(pixels_to_scroll_x, pixels_to_scroll_y, start_x,
start_y, gesture_source_type, speed_in_pixels_s,
precise_scrolling_deltas, scroll_by_page,
cursor_visible, scroll_by_percentage, modifier_keys);
}
// Perform a percent based scroll using smoothScrollWithXY
function percentScroll(percent_to_scroll_x, percent_to_scroll_y, start_x, start_y, gesture_source_type) {
return smoothScrollWithXY(percent_to_scroll_x, percent_to_scroll_y, start_x, start_y,
gesture_source_type,
undefined /* speed_in_pixels_s - not defined for percent based scrolls */,
false /* precise_scrolling_deltas */,
false /* scroll_by_page */,
true /* cursor_visible */,
true /* scroll_by_percentage */);
}
// modifier_keys means the keys pressed while doing the mouse wheel scroll, it
// should be one of the values in the |Modifiers| or a comma separated string
// to specify multiple values.
// modifier_buttons means the mouse buttons pressed while doing the mouse wheel
// scroll, it should be one of the values in the |Buttons| or a comma separated
// string to specify multiple values.
function smoothScrollWithXY(pixels_to_scroll_x, pixels_to_scroll_y, start_x,
start_y, gesture_source_type, speed_in_pixels_s,
precise_scrolling_deltas, scroll_by_page,
cursor_visible, scroll_by_percentage, modifier_keys,
modifier_buttons) {
return new Promise((resolve, reject) => {
if (window.chrome && chrome.gpuBenchmarking) {
chrome.gpuBenchmarking.smoothScrollByXY(pixels_to_scroll_x,
pixels_to_scroll_y,
resolve,
start_x,
start_y,
gesture_source_type,
speed_in_pixels_s,
precise_scrolling_deltas,
scroll_by_page,
cursor_visible,
scroll_by_percentage,
modifier_keys,
modifier_buttons);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
// modifier_keys means the keys pressed while doing the mouse wheel scroll, it
// should be one of the values in the |Modifiers| or a comma separated string
// to specify multiple values.
// modifier_buttons means the mouse buttons pressed while doing the mouse wheel
// scroll, it should be one of the values in the |Buttons| or a comma separated
// string to specify multiple values.
function wheelTick(scroll_tick_x, scroll_tick_y, center, speed_in_pixels_s,
modifier_keys, modifier_buttons) {
if (typeof(speed_in_pixels_s) == "undefined")
speed_in_pixels_s = SPEED_INSTANT;
// Do not allow precise scrolling deltas for tick wheel scroll.
return smoothScrollWithXY(scroll_tick_x * pixelsPerTick(),
scroll_tick_y * pixelsPerTick(),
center.x, center.y, GestureSourceType.MOUSE_INPUT,
speed_in_pixels_s, false /* precise_scrolling_deltas */,
false /* scroll_by_page */, true /* cursor_visible */,
false /* scroll_by_percentage */, modifier_keys,
modifier_buttons);
}
const LEGACY_MOUSE_WHEEL_TICK_MULTIPLIER = 120;
// The number of pixels keyboard arrows will scroll when the device scale factor
// equals to 1. Defined in cc/input/scroll_utils.h.
// Matches SCROLLBAR_SCROLL_PIXELS from scrollbar-util.js.
const KEYBOARD_SCROLL_PIXELS = 40;
// Returns the number of pixels per wheel tick which is a platform specific value.
function pixelsPerTick() {
// Comes from ui/events/event.cc
if (navigator.platform.indexOf("Win") != -1 || navigator.platform.indexOf("Linux") != -1)
return 120;
if (navigator.platform.indexOf("Mac") != -1 || navigator.platform.indexOf("iPhone") != -1 ||
navigator.platform.indexOf("iPod") != -1 || navigator.platform.indexOf("iPad") != -1) {
return 40;
}
// Some android devices return android while others return Android.
if (navigator.platform.toLowerCase().indexOf("android") != -1)
return 64;
// Legacy, comes from ui/events/event.cc
return 53;
}
// Note: unlike other functions in this file, the |direction| parameter here is
// the "finger direction". This means |y| pixels "up" causes the finger to move
// up so the page scrolls down (i.e. scrollTop increases).
function swipe(pixels_to_scroll, start_x, start_y, direction, speed_in_pixels_s, fling_velocity, gesture_source_type) {
return new Promise((resolve, reject) => {
if (window.chrome && chrome.gpuBenchmarking) {
chrome.gpuBenchmarking.swipe(direction,
pixels_to_scroll,
resolve,
start_x,
start_y,
speed_in_pixels_s,
fling_velocity,
gesture_source_type);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
function pinchBy(scale, centerX, centerY, speed_in_pixels_s, gesture_source_type) {
return new Promise((resolve, reject) => {
if (window.chrome && chrome.gpuBenchmarking) {
chrome.gpuBenchmarking.pinchBy(scale,
centerX,
centerY,
resolve,
speed_in_pixels_s,
gesture_source_type);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
function mouseMoveTo(xPosition, yPosition, withButtonPressed) {
return new Promise(function(resolve, reject) {
if (window.chrome && chrome.gpuBenchmarking) {
chrome.gpuBenchmarking.pointerActionSequence([
{source: 'mouse',
actions: [
{ name: 'pointerMove', x: xPosition, y: yPosition,
button: pointerActionButtonId(withButtonPressed) },
]}], resolve);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
function mouseDownAt(xPosition, yPosition) {
return new Promise(function(resolve, reject) {
if (window.chrome && chrome.gpuBenchmarking) {
chrome.gpuBenchmarking.pointerActionSequence([
{source: 'mouse',
actions: [
{ name: 'pointerDown', x: xPosition, y: yPosition },
]}], resolve);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
function mouseUpAt(xPosition, yPosition) {
return new Promise(function(resolve, reject) {
if (window.chrome && chrome.gpuBenchmarking) {
chrome.gpuBenchmarking.pointerActionSequence([
{source: 'mouse',
actions: [
{ name: 'pointerMove', x: xPosition, y: yPosition },
{ name: 'pointerUp' },
]}], resolve);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
// Improves test readability by accepting a struct.
function mouseClickHelper(point) {
return mouseClickOn(point.x, point.y, point.left_click, point.input_modifier);
}
// Simulate a mouse click on point.
function mouseClickOn(x, y, button = 0 /* left */, keys = '') {
return new Promise((resolve, reject) => {
if (window.chrome && chrome.gpuBenchmarking) {
let pointerActions = [{
source: 'mouse',
actions: [
{ 'name': 'pointerMove', 'x': x, 'y': y },
{ 'name': 'pointerDown', 'x': x, 'y': y, 'button': button, 'keys': keys },
{ 'name': 'pointerUp', 'button': button },
]
}];
chrome.gpuBenchmarking.pointerActionSequence(pointerActions, resolve);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
// Simulate a mouse double click on point.
function mouseDoubleClickOn(x, y, button = 0 /* left */, keys = '') {
return new Promise((resolve, reject) => {
if (window.chrome && chrome.gpuBenchmarking) {
let pointerActions = [{
source: 'mouse',
actions: [
{ 'name': 'pointerMove', 'x': x, 'y': y },
{ 'name': 'pointerDown', 'x': x, 'y': y, 'button': button, 'keys': keys },
{ 'name': 'pointerUp', 'button': button },
{ 'name': 'pointerDown', 'x': x, 'y': y, 'button': button, 'keys': keys },
{ 'name': 'pointerUp', 'button': button },
]
}];
chrome.gpuBenchmarking.pointerActionSequence(pointerActions, resolve);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
// Simulate a mouse press on point for a certain time.
function mousePressOn(x, y, t) {
return new Promise((resolve, reject) => {
if (window.chrome && chrome.gpuBenchmarking) {
let pointerActions = [{
source: 'mouse',
actions: [
{ 'name': 'pointerMove', 'x': x, 'y': y },
{ 'name': 'pointerDown', 'x': x, 'y': y },
{ 'name': 'pause', duration: t},
{ 'name': 'pointerUp' },
]
}];
chrome.gpuBenchmarking.pointerActionSequence(pointerActions, resolve);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
// Simulate a mouse drag and drop. mouse down at {start_x, start_y}, move to
// {end_x, end_y} and release.
function mouseDragAndDrop(start_x, start_y, end_x, end_y, button = 0 /* left */, t = 0) {
return new Promise((resolve, reject) => {
if (window.chrome && chrome.gpuBenchmarking) {
let pointerActions = [{
source: 'mouse',
actions: [
{ 'name': 'pointerMove', 'x': start_x, 'y': start_y },
{ 'name': 'pointerDown', 'x': start_x, 'y': start_y, 'button': button },
{ 'name': 'pause', 'duration': t},
{ 'name': 'pointerMove', 'x': end_x, 'y': end_y },
{ 'name': 'pause', 'duration': t},
{ 'name': 'pointerUp', 'button': button },
]
}];
chrome.gpuBenchmarking.pointerActionSequence(pointerActions, resolve);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
// Helper functions used in some of the gesture scroll layouttests.
function recordScroll() {
scrollEventsOccurred++;
}
function notScrolled() {
return scrolledElement.scrollTop == 0 && scrolledElement.scrollLeft == 0;
}
function checkScrollOffset() {
// To avoid flakiness up to two pixels off per gesture is allowed.
var pixels = 2 * (gesturesOccurred + 1);
var result = approx_equals(scrolledElement.scrollTop, scrollAmountY[gesturesOccurred], pixels) &&
approx_equals(scrolledElement.scrollLeft, scrollAmountX[gesturesOccurred], pixels);
if (result)
gesturesOccurred++;
return result;
}
// This promise gets resolved in iframe onload.
var iframeLoadResolve;
iframeOnLoadPromise = new Promise(function(resolve) {
iframeLoadResolve = resolve;
});
// Include run-after-layout-and-paint.js to use this promise.
function WaitForlayoutAndPaint() {
return new Promise((resolve, reject) => {
if (typeof runAfterLayoutAndPaint !== 'undefined')
runAfterLayoutAndPaint(resolve);
else
reject('This test requires run-after-layout-and-paint.js');
});
}
function touchTapOn(xPosition, yPosition) {
return new Promise(function(resolve, reject) {
if (window.chrome && chrome.gpuBenchmarking) {
chrome.gpuBenchmarking.pointerActionSequence( [
{source: 'touch',
actions: [
{ name: 'pointerDown', x: xPosition, y: yPosition },
{ name: 'pointerUp' }
]}], resolve);
} else {
reject();
}
});
}
function touchPull(pull) {
const PREVENT_FLING_PAUSE = 40;
return new Promise(function(resolve, reject) {
if (window.chrome && chrome.gpuBenchmarking) {
chrome.gpuBenchmarking.pointerActionSequence( [
{source: 'touch',
actions: [
{ name: 'pointerDown', x: pull.start_x, y: pull.start_y },
{ name: 'pause', duration: PREVENT_FLING_PAUSE },
{ name: 'pointerMove', x: pull.end_x, y: pull.end_y},
{ name: 'pause', duration: PREVENT_FLING_PAUSE },
]}], resolve);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
// @deprecated: Use touchDrag, which uses test-driver.
function touchDragTo(drag) {
const PREVENT_FLING_PAUSE = 40;
return new Promise(function(resolve, reject) {
if (window.chrome && chrome.gpuBenchmarking) {
chrome.gpuBenchmarking.pointerActionSequence( [
{source: 'touch',
actions: [
{ name: 'pointerDown', x: drag.start_x, y: drag.start_y },
{ name: 'pause', duration: PREVENT_FLING_PAUSE },
{ name: 'pointerMove', x: drag.end_x, y: drag.end_y},
{ name: 'pause', duration: PREVENT_FLING_PAUSE },
{ name: 'pointerUp', x: drag.end_x, y: drag.end_y }
]}], resolve);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
// Trigger fling by doing pointerUp right after pointerMoves.
function touchFling(drag) {
return new Promise(function(resolve, reject) {
if (window.chrome && chrome.gpuBenchmarking) {
chrome.gpuBenchmarking.pointerActionSequence( [
{source: 'touch',
actions: [
{ name: 'pointerDown', x: drag.start_x, y: drag.start_y },
{ name: 'pointerMove', x: drag.end_x, y: drag.end_y},
{ name: 'pointerUp', x: drag.end_x, y: drag.end_y }
]}], resolve);
} else {
reject('This test requires chrome.gpuBenchmarking');
}
});
}
function doubleTapAt(xPosition, yPosition) {
// This comes from config constants in gesture_detector.cc.
const DOUBLE_TAP_MINIMUM_DURATION_MS = 40;
return new Promise(function(resolve, reject) {
if (!window.chrome || !chrome.gpuBenchmarking) {
reject("chrome.gpuBenchmarking not found.");
return;
}
chrome.gpuBenchmarking.pointerActionSequence( [{
source: 'touch',
actions: [
{ name: 'pointerDown', x: xPosition, y: yPosition },
{ name: 'pointerUp' },
{ name: 'pause', duration: DOUBLE_TAP_MINIMUM_DURATION_MS },
{ name: 'pointerDown', x: xPosition, y: yPosition },
{ name: 'pointerUp' }
]
}], resolve);
});
}
function approx_equals(actual, expected, epsilon) {
return actual >= expected - epsilon && actual <= expected + epsilon;
}
function clientToViewport(client_point) {
const viewport_point = {
x: (client_point.x - visualViewport.offsetLeft) * visualViewport.scale,
y: (client_point.y - visualViewport.offsetTop) * visualViewport.scale
};
return viewport_point;
}
// Convenience enums for elementPosition function.
ElementAlignment = {
LEFT: 0,
TOP: 0,
CENTER: 0.5,
BOTTOM: 1,
RIGHT: 1
};
// Returns a point within an element's rect in visual viewport
// coordinates. The relative offsets are between 0 (left or top) and 1
// (right or bottom). Returned object is a point with |x| and |y| properties.
function elementPosition(element,
relativeHorizontalOffset,
relativeVerticalOffset,
insets = { x: 0, y: 0 } ) {
const rect = element.getBoundingClientRect();
rect.x += insets.x;
rect.y += insets.y;
rect.width -= 2 * insets.x;
rect.height -= 2 * insets.y;
const center_point = {
x: rect.x + relativeHorizontalOffset * rect.width,
y: rect.y + relativeVerticalOffset * rect.height
};
return clientToViewport(center_point);
}
// Returns the center point of the given element's rect in visual viewport
// coordinates. Returned object is a point with |x| and |y| properties.
function elementCenter(element) {
return elementPosition(element,
ElementAlignment.CENTER,
ElementAlignment.CENTER);
}
// Returns a position in the given element with an offset of |x| and |y| from
// the element's top-left position. Returned object is a point with |x| and |y|
// properties. The returned coordinate is in visual viewport coordinates.
function pointInElement(element, x, y) {
const rect = element.getBoundingClientRect();
const point = {
x: rect.x + x,
y: rect.y + y
};
return clientToViewport(point);
}
// Waits for 'time' ms before resolving the promise.
function waitForMs(time) {
return new Promise((resolve) => {
window.setTimeout(function() { resolve(); }, time);
});
}
// Requests an animation frame.
function raf() {
return new Promise((resolve) => {
requestAnimationFrame(() => {
resolve();
});
});
}
// Resets the scroll position to (x,y). If a scroll is required, then the
// promise is not resolved until the scrollend event is received.
async function waitForScrollReset(scroller = document.scrollingElement,
x = 0, y = 0) {
return new Promise(resolve => {
if (scroller.scrollTop == y &&
scroller.scrollLeft == x) {
resolve();
} else {
scroller.scrollTop = y;
scroller.scrollLeft = x;
// Though setting the scroll position is synchronous, it still triggers a
// scrollend event, and we need to wait for this event before continuing
// so that it is not mistakenly attributed to a later scroll trigger.
waitForScrollendEvent(scrollendEventTarget(scroller)).then(resolve);
}
});
}
function waitForWindowScrollBy(options) {
const scrollPromise = waitForScrollendEvent(document);
window.scrollBy(options);
return scrollPromise;
}
function waitForWindowScrollTo(options) {
const scrollPromise = waitForScrollendEvent(document);
window.scrollTo(options);
return scrollPromise;
}
// Verifies that triggered scroll animations smoothly. Requires at least 2
// scroll updates to be considered smooth.
function animatedScrollPromise(scrollTarget) {
return new Promise((resolve, reject) => {
// Set to roughly a third to a quarter of the expected animation duration.
const maxAllowableFrameIntervalInMs = 80;
let scrollCount = 0;
let ticking = true;
let lastFrameTime = performance.now();
let largestFrameInterval = undefined;
// Pump rAFs until the scrollend event is received inspecting the timing
// between frames. If the frame interval becomes too large, detection of a
// smooth scroll becomes unreliable. Record this outcome as a pass. A fail
// is recorded when we don't see a smooth scroll, but had the opportunity
// to observe one.
const tick = () => {
requestAnimationFrame((frameTime) => {
const frameInterval = frameTime - lastFrameTime;
if (!largestFrameInterval || frameInterval > largestFrameInterval) {
largestFrameInterval = frameInterval;
}
lastFrameTime = frameTime;
if (ticking) {
requestAnimationFrame(tick);
} else {
cleanup();
if (scrollCount > 1) {
resolve();
} else if (largestFrameInterval > maxAllowableFrameIntervalInMs) {
// Though we didn't see a smooth scroll, we didn't have the
// opportunity because of the coarse granularity of main frame
// updates. What this means is that test could trigger a false pass
// should animated scrolls be turned off; however, safer to
// relax expectations than flake.
resolve();
} else {
reject('expected smooth scroll');
}
}
});
};
tick();
const scrollListener = () => {
scrollCount++;
}
const scrollendListener = (event) => {
ticking = false;
}
const scrollendTarget =
scrollTarget == document.scrollingElement ? document : scrollTarget;
scrollTarget.addEventListener('scroll', scrollListener);
scrollendTarget.addEventListener('scrollend', scrollendListener);
const cleanup = () => {
scrollTarget.removeEventListener('scroll', scrollListener);
scrollendTarget.removeEventListener('scrollend', scrollendListener);
};
});
}
// Call with an asynchronous function that triggers a scroll. The promise is
// resolved once |scrollendEventReceiver| gets the scrollend event.
async function triggerScrollAndWaitForScrollEnd(
scrollTriggerFn, scrollendEventReceiver = document) {
const scrollPromise = waitForScrollendEvent(scrollendEventReceiver);
await scrollTriggerFn();
return scrollPromise;
}
function verifyTestDriverLoaded() {
if (!window.test_driver) {
throw new Error('Test requires import of testdriver. Please add ' +
'testdriver.js, testdriver-actions.js and ' +
'testdriver-vendor.js to your test file');
}
}
// Generates a synthetic click and returns a promise that is resolved once
// |scrollendEventReceiver| gets the scrollend event.
async function clickAndWaitForScroll(x, y, scrollendEventReceiver = document) {
verifyTestDriverLoaded();
return triggerScrollAndWaitForScrollEnd(async () => {
return new test_driver.Actions()
.pointerMove(x, y)
.pointerDown()
.addTick()
.pointerUp()
.send();
}, scrollendEventReceiver);
}
// Verify that a point is onscreen. Origin may be "viewport" or an element.
// In the case of an element, (x,y) is relative to the center of the element.
function assert_point_within_viewport(x, y, origin = "viewport") {
if (origin !== "viewport") {
const bounds = elementCenter(origin);
x += bounds.x;
y += bounds.y;
}
assert_true(x >= 0 && x <= window.innerWidth,
'x coordinate outside viewport');
assert_true(y >= 0 && y <= window.innerHeight,
'y coordinate outside viewport');
}
// Performs a drag operation from a starting point (x,y) which may be relative
// to the viewport or the center of an element as determined by the origin
// option, or "viewport" if missing. Verifies that both ends of the drag are
// inside the viewport. The returned promise is resolved when a pointerup or
// pointercancel event is received. Pointercancel replaces pointerup when
// scrolling takes place. Thus, any scrolling decisions has been made prior to
// dispatching either of these events.
// Supported options:
// origin: May be the string "viewport" (default) or an element.
// eventTarget: Indicates the target for the pointerup or pointercancel
// event. Defaults to document.
// pointerType: 'mouse' (default), 'pen' or 'touch'
// prevent_fling_pause_ms: How long to wait after the move to avoid a
// momentum fling. Default to 0ms.
// adjust_for_touch_slop: Indicates if we should adjust to drag range to
// compensate for touch slop. At the start of a touch
// drag, we do not know if we are scrolling or not.
// Once scrolled past the slop region, a touch
// scroll will stick to the finger position.
// Defaults to false.
// button: String with the button type, 'Left' (default), 'Middle' or 'Right'
function pointerDrag(x, y, deltaX, deltaY, options = {}) {
const origin = options.origin || "viewport";
const eventTarget = options.eventTarget || document;
const pointerType = options.pointerType || 'mouse';
const buttonType = options.button || Buttons.LEFT;
const prevent_fling_pause_ms = options.prevent_fling_pause_ms || 0;
if (options.adjust_for_touch_slop) {
// TODO(kevers): This value may become platform specific, in which case
// we may need to perform a test to measure the slop and then apply in
// subsequent tests.
const TOUCH_SLOP_AMOUNT = 15;
if (deltaX) {
deltaX += TOUCH_SLOP_AMOUNT * Math.sign(deltaX);
}
if (deltaY) {
deltaY += TOUCH_SLOP_AMOUNT * Math.sign(deltaY);
}
}
assert_point_within_viewport(x, y, origin);
assert_point_within_viewport(x + deltaX, y + deltaY, origin);
verifyTestDriverLoaded();
// Expect a pointerup or pointercancel event depending on whether scrolling
// actually took place.
return new Promise(resolve => {
const pointerPromise = new Promise(resolve => {
const pointerListener = (event) => {
eventTarget.removeEventListener('pointerup', pointerListener);
eventTarget.removeEventListener('pointercancel', pointerListener);
resolve(event.type);
};
eventTarget.addEventListener('pointerup', pointerListener);
eventTarget.addEventListener('pointercancel', pointerListener);
});
const actionPromise = new test_driver.Actions()
.addPointer("pointer1", pointerType)
.pointerMove(x, y, { origin: origin })
.pointerDown({button: pointerActionButtonId(buttonType)})
.pointerMove(x + deltaX, y + deltaY, { origin: origin })
.pause(prevent_fling_pause_ms)
.pointerUp({button: pointerActionButtonId(buttonType)})
.send();
Promise.all([actionPromise, pointerPromise]).then(responses => {
resolve(responses[1]);
});
});
}
// Performs a touch drag gesture. The prevent_fling_pause_ms options is used
// to prevent the drag from having fling momentum.
function touchDrag(x, y, deltaX, deltaY, options = {}) {
options.pointerType = 'touch';
if (options.prevent_fling_pause_ms === undefined) {
options.prevent_fling_pause_ms = 100;
}
return pointerDrag(x, y, deltaX, deltaY, options);
}
// Performs a touch scroll operations. The promise is resolved when the
// pointer cancel and scrollend events are received.
// The supported options are documented in pointerDrag.
function touchScroll(x, y, deltaX, deltaY, scroller, options = {}) {
if (!options.eventTarget) {
options.eventTarget = scroller.ownerDocument;
}
const scrollPromise =
waitForScrollendEvent(scrollendEventTarget(scroller));
const dragGesturePromise =
touchDrag(x, y, deltaX, deltaY, options);
return Promise.all([dragGesturePromise, scrollPromise]);
}
function mouseDrag(x, y, deltaX, deltaY, scroller, options = {}) {
return pointerDrag(x, y, deltaX, deltaY, scroller, options);
}
function mouseDragScroll(x, y, deltaX, deltaY, scroller, options = {}) {
if (!options.eventTarget) {
options.eventTarget = scroller.ownerDocument;
}
const scrollPromise = waitForScrollendEvent(scroller);
const dragPromise = mouseDrag(x, y, deltaX, deltaY, options);
return Promise.all([scrollPromise, dragPromise]);
}
function wheelScroll(x, y, deltaX, deltaY, scrollEventListener = document,
origin = "viewport", duration_ms = 250) {
verifyTestDriverLoaded();
const promises = [];
if (scrollEventListener) {
promises.push(waitForScrollendEvent(scrollEventListener));
}
const gesturePromise = new test_driver.Actions()
.scroll(x, y, deltaX, deltaY, origin, duration_ms)
.send();
promises.push(gesturePromise);
return Promise.all(promises);
}
// Simulates a pointer tap gesutre.
// options:
// origin: defaults to "viewport" coordinates. If an element is specified,
// then relative to the center of the element.
// pointerType: Default to mouse, but may be mouse, touch, or pen.
// pointerDownUpOptions: Optional callback function to set parameters for
// pointerDown and pointerUp.
function pointerTap(x, y, options = {}) {
const origin = options.origin || "viewport";
const pointerType = options.pointerType || 'mouse';
const emptyCallback = () => {};
const pointerDownUpOptions = options.pointerDownUpOptions || emptyCallback;
verifyTestDriverLoaded();
assert_point_within_viewport(x, y, origin);
const promises = [];
if (!options.skipWaitOnPointer) {
const pointerPromise = new Promise((resolve) => {
const listener = () => {
document.removeEventListener('pointerup', listener);
document.removeEventListener('pointercancel', listener);
resolve();
}
document.addEventListener('pointerup', listener);
document.addEventListener('pointercancel', listener);
});
promises.push(pointerPromise);
}
const actions = new test_driver.Actions();
actions.addPointer("pointer1", pointerType)
.pointerMove(x, y, { origin: origin })
.pointerDown(pointerDownUpOptions(actions))
.pointerUp(pointerDownUpOptions(actions));
const gesturePromise = actions.send();
promises.push(gesturePromise);
return Promise.all(promises);
}
// Performs a mouse click using the left-mouse button by default. The selected
// button may be set via options.buttons.
function mouseClick(x, y, options = {}) {
options.pointerType = 'mouse';
if (!options.pointerDownUpOptions) {
options.pointerDownUpOptions = (actions) => {
return { button: actions.ButtonType.LEFT };
};
}
return pointerTap(x, y, options);
}
// Performs a touch tap actions.
function touchTap(x, y, options = {}) {
options.pointerType = 'touch';
return pointerTap(x, y, options);
}
// Long press on the target element. The options are of the form:
// {
// x: horizontal offset from midpoint of the element (default 0)
// y: vertical offset from the midpoint of the element (default 0)
// duration: duration of the press in milliseconds (default 400)
// }
//
// Be sure to call preventContextMenu during test setup to avoid a memory leak
// before calling this method. If event handling is permitted to transfer to the
// browser process, we are unable to fully tear down the test resulting in a
// leak.
function touchLongPressElement(target, options) {
// Conservative long-press duration based on timing for a context menu popup.
// Some long-press operations require longer.
const LONG_PRESS_DURATION = 400;
const x = (options && options.x)? options.x : 0;
const y = (options && options.y)? options.y : 0;
const duration = (options && options.duration !== undefined)
? options.duration
: LONG_PRESS_DURATION;
verifyTestDriverLoaded();
const pointerPromise = new Promise((resolve) => {
const listener = () => {
document.removeEventListener('pointerup', listener);
document.removeEventListener('pointercancel', listener);
resolve();
}
document.addEventListener('pointerup', listener);
document.addEventListener('pointercancel', listener);
});
const actionPromise = new test_driver.Actions()
.addPointer('pointer1', 'touch')
.pointerMove(x, y, {origin: target})
.pointerDown()
.pause(duration)
.pointerUp()
.send();
return actionPromise.then(pointerPromise);
}
function preventContextMenu(test) {
const listener = (event) => {
event.preventDefault();
}
document.addEventListener('contextmenu', listener);
test.add_cleanup(() => {
document.removeEventListener('contextmenu', listener);
});
}
// Perform a click action where a scroll is expected such as on a scrollbar
// arrow or on a scrollbar track. The promise will timeout if no scrolling is
// triggered.
function clickScroll(x, y, scroller, options = {}) {
const scrollPromise = waitForScrollendEvent(scroller);
const clickPromise = mouseClick(x, y, options);
return Promise.all([scrollPromise, clickPromise]);
}
// Perform a tap action where a scroll is expected such as on a scrollbar
// arrow or on a scrollbar track. The promise will timeout if no scrolling is
// triggered.
function touchTapScroll(x, y, scroller, options = {}) {
verifyTestDriverLoaded();
const scrollPromise = waitForScrollendEvent(scroller);
// Not seeing pointerup or pointercancel when synthetically tapping on a
// scrollbar button. A pointerup event is observed when testing manually
// suggesting this might be an issue in test driver. We can safely skip the
// check for touch tap scrolls since it is safe to continue the test even if
// we have not had a chance to process a pointerup event.
options.skipWaitOnPointer = true;
const tapPromise = touchTap(x, y, options);
return Promise.all([scrollPromise, tapPromise]);
}
function waitForStableScrollOffset(scroller, timeout) {
timeout = timeout || 5000;
return new Promise((resolve, reject) => {
let last_x = scroller.scrollLeft;
let last_y = scroller.scrollTop;
let start_timestamp = performance.now();
let last_change_timestamp = start_timestamp;
let last_change_frame_number = 0;
function tick(frame_number, timestamp) {
// We run a rAF loop until 100 milliseconds and at least five animation
// frames have elapsed since the last scroll offset change, with a timeout
// after `timeout` milliseconds.
if (scroller.scrollLeft != last_x || scroller.scrollTop != last_y) {
last_change_timestamp = timestamp;
last_x = scroller.scrollLeft;
last_y = scroller.scrollTop;
}
if (timestamp - last_change_timestamp > 100 &&
frame_number - last_change_frame_number > 4) {
resolve();
} else if (timestamp - start_timestamp > timeout) {
reject();
} else {
requestAnimationFrame(tick.bind(null, frame_number + 1));
}
}
tick(0, start_timestamp);
});
}
function keyPress(key) {
return new Promise((resolve, reject) => {
if (window.eventSender) {
eventSender.keyDown(key);
resolve();
}
else {
reject('This test requires window.eventSender');
}
})
}
function keyboardScroll(key, scroller) {
const scrollPromise = waitForScrollendEvent(scroller);
return Promise.all([ keyPress(key), scrollPromise ]);
}
/**
* Trigger a gesture that results in a scroll and wait for scroll
* completion. Where possible, use a specialized test-driver compatible
* method. This is a catch all for cases not explicitly addressed by
* a specialized method.
*/
function gestureScroll(gesturePromiseCallback, scroller) {
const scrollPromise =
waitForScrollendEvent(scrollendEventTarget(scroller));
return Promise.all([ gesturePromiseCallback(), scrollPromise ]);
}