gecko-dev/testing/marionette/listener.js

1820 строки
53 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/* eslint-env mozilla/frame-script */
/* global XPCNativeWrapper */
/* eslint-disable no-restricted-globals */
"use strict";
const winUtil = content.windowUtils;
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
OS: "resource://gre/modules/osfile.jsm",
accessibility: "chrome://marionette/content/accessibility.js",
action: "chrome://marionette/content/action.js",
atom: "chrome://marionette/content/atom.js",
Capabilities: "chrome://marionette/content/capabilities.js",
ContentEventObserverService: "chrome://marionette/content/dom.js",
element: "chrome://marionette/content/element.js",
error: "chrome://marionette/content/error.js",
evaluate: "chrome://marionette/content/evaluate.js",
event: "chrome://marionette/content/event.js",
interaction: "chrome://marionette/content/interaction.js",
legacyaction: "chrome://marionette/content/legacyaction.js",
Log: "chrome://marionette/content/log.js",
navigate: "chrome://marionette/content/navigate.js",
PageLoadStrategy: "chrome://marionette/content/capabilities.js",
pprint: "chrome://marionette/content/format.js",
proxy: "chrome://marionette/content/proxy.js",
sandbox: "chrome://marionette/content/evaluate.js",
Sandboxes: "chrome://marionette/content/evaluate.js",
truncate: "chrome://marionette/content/format.js",
WebElement: "chrome://marionette/content/element.js",
});
XPCOMUtils.defineLazyGetter(this, "logger", () => Log.getWithPrefix(contentId));
XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
const contentId = content.docShell.browsingContext.id;
const curContainer = {
_frame: null,
shadowRoot: null,
get frame() {
return this._frame;
},
set frame(frame) {
this._frame = frame;
this.id = this._frame.browsingContext.id;
this.shadowRoot = null;
},
};
// Listen for click event to indicate one click has happened, so actions
// code can send dblclick event
addEventListener("click", event.DoubleClickTracker.setClick);
addEventListener("dblclick", event.DoubleClickTracker.resetClick);
addEventListener("unload", event.DoubleClickTracker.resetClick, true);
const seenEls = new element.Store();
Object.defineProperty(this, "capabilities", {
get() {
let payload = sendSyncMessage("Marionette:WebDriver:GetCapabilities");
return Capabilities.fromJSON(payload[0]);
},
configurable: true,
});
let legacyactions = new legacyaction.Chain();
// last touch for each fingerId
let multiLast = {};
// sandbox storage and name of the current sandbox
const sandboxes = new Sandboxes(() => curContainer.frame);
const eventObservers = new ContentEventObserverService(
content,
sendAsyncMessage.bind(this)
);
/**
* The load listener singleton helps to keep track of active page load
* activities, and can be used by any command which might cause a navigation
* to happen. In the specific case of a process change of the frame script it
* allows to continue observing the current page load.
*/
const loadListener = {
commandID: null,
seenBeforeUnload: false,
seenUnload: false,
timeout: null,
timerPageLoad: null,
timerPageUnload: null,
/**
* Start listening for page unload/load events.
*
* @param {number} commandID
* ID of the currently handled message between the driver and
* listener.
* @param {number} timeout
* Timeout in seconds the method has to wait for the page being
* finished loading.
* @param {number} startTime
* Unix timestap when the navitation request got triggered.
* @param {boolean=} waitForUnloaded
* If true wait for page unload events, otherwise only for page
* load events.
*/
start(commandID, timeout, startTime, waitForUnloaded = true) {
this.commandID = commandID;
this.timeout = timeout;
this.seenBeforeUnload = false;
this.seenUnload = false;
this.timerPageLoad = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
this.timerPageUnload = null;
// In case the frame script has been moved to a differnt process,
// wait the remaining time
timeout = startTime + timeout - new Date().getTime();
if (timeout <= 0) {
this.notify(this.timerPageLoad);
return;
}
if (waitForUnloaded) {
addEventListener("beforeunload", this, true);
addEventListener("hashchange", this, true);
addEventListener("pagehide", this, true);
addEventListener("popstate", this, true);
addEventListener("unload", this, true);
Services.obs.addObserver(this, "outer-window-destroyed");
} else {
// The frame script has been moved to a differnt content process.
// Due to the time it takes to re-register the browser in Marionette,
// it can happen that page load events are missed before the listeners
// are getting attached again. By checking the document readyState the
// command can return immediately if the page load is already done.
let readyState = content.document.readyState;
let documentURI = content.document.documentURI;
logger.trace(truncate`Check readyState ${readyState} for ${documentURI}`);
// If the page load has already finished, don't setup listeners and
// timers but return immediatelly.
if (this.handleReadyState(readyState, documentURI)) {
return;
}
addEventListener("DOMContentLoaded", loadListener, true);
addEventListener("pageshow", loadListener, true);
}
this.timerPageLoad.initWithCallback(
this,
timeout,
Ci.nsITimer.TYPE_ONE_SHOT
);
},
/**
* Stop listening for page unload/load events.
*/
stop() {
if (this.timerPageLoad) {
this.timerPageLoad.cancel();
}
if (this.timerPageUnload) {
this.timerPageUnload.cancel();
}
removeEventListener("beforeunload", this, true);
removeEventListener("hashchange", this, true);
removeEventListener("pagehide", this, true);
removeEventListener("popstate", this, true);
removeEventListener("DOMContentLoaded", this, true);
removeEventListener("pageshow", this, true);
removeEventListener("unload", this, true);
// In case the observer was added before the frame script has been moved
// to a different process, it will no longer be available. Exceptions can
// be ignored.
try {
Services.obs.removeObserver(this, "outer-window-destroyed");
} catch (e) {}
},
/**
* Callback for registered DOM events.
*/
handleEvent(event) {
// Only care about events from the currently selected browsing context,
// whereby some of those do not bubble up to the window.
if (
event.target != curContainer.frame &&
event.target != curContainer.frame.document
) {
return;
}
let location = event.target.documentURI || event.target.location.href;
logger.trace(truncate`Received DOM event ${event.type} for ${location}`);
switch (event.type) {
case "beforeunload":
this.seenBeforeUnload = true;
break;
case "unload":
this.seenUnload = true;
break;
case "pagehide":
this.seenUnload = true;
removeEventListener("hashchange", this, true);
removeEventListener("pagehide", this, true);
removeEventListener("popstate", this, true);
// Now wait until the target page has been loaded
addEventListener("DOMContentLoaded", this, true);
addEventListener("pageshow", this, true);
break;
case "hashchange":
case "popstate":
this.stop();
sendOk(this.commandID);
break;
case "DOMContentLoaded":
case "pageshow":
this.handleReadyState(
event.target.readyState,
event.target.documentURI
);
break;
}
},
/**
* Checks the value of readyState for the current page
* load activity, and resolves the command if the load
* has been finished. It also takes care of the selected
* page load strategy.
*
* @param {string} readyState
* Current ready state of the document.
* @param {string} documentURI
* Current document URI of the document.
*
* @return {boolean}
* True if the page load has been finished.
*/
handleReadyState(readyState, documentURI) {
let finished = false;
switch (readyState) {
case "interactive":
if (documentURI.startsWith("about:certerror")) {
this.stop();
sendError(new error.InsecureCertificateError(), this.commandID);
finished = true;
} else if (/about:.*(error)\?/.exec(documentURI)) {
this.stop();
sendError(
new error.UnknownError(`Reached error page: ${documentURI}`),
this.commandID
);
finished = true;
// Return early with a page load strategy of eager, and also
// special-case about:blocked pages which should be treated as
// non-error pages but do not raise a pageshow event. about:blank
// is also treaded specifically here, because it gets temporary
// loaded for new content processes, and we only want to rely on
// complete loads for it.
} else if (
(capabilities.get("pageLoadStrategy") === PageLoadStrategy.Eager &&
documentURI != "about:blank") ||
/about:blocked\?/.exec(documentURI)
) {
this.stop();
sendOk(this.commandID);
finished = true;
}
break;
case "complete":
this.stop();
sendOk(this.commandID);
finished = true;
break;
}
return finished;
},
/**
* Callback for navigation timeout timer.
*/
notify(timer) {
switch (timer) {
case this.timerPageUnload:
// In the case when a document has a beforeunload handler
// registered, the currently active command will return immediately
// due to the modal dialog observer in proxy.js.
//
// Otherwise the timeout waiting for the document to start
// navigating is increased by 5000 ms to ensure a possible load
// event is not missed. In the common case such an event should
// occur pretty soon after beforeunload, and we optimise for this.
if (this.seenBeforeUnload) {
this.seenBeforeUnload = null;
this.timerPageUnload.initWithCallback(
this,
5000,
Ci.nsITimer.TYPE_ONE_SHOT
);
// If no page unload has been detected, ensure to properly stop
// the load listener, and return from the currently active command.
} else if (!this.seenUnload) {
logger.debug(
"Canceled page load listener because no navigation " +
"has been detected"
);
this.stop();
sendOk(this.commandID);
}
break;
case this.timerPageLoad:
this.stop();
sendError(
new error.TimeoutError(
`Timeout loading page after ${this.timeout}ms`
),
this.commandID
);
break;
}
},
observe(subject, topic) {
logger.trace(`Received observer notification ${topic}`);
const winId = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
const bc = BrowsingContext.get(curContainer.id);
switch (topic) {
// In the case when the currently selected frame is closed,
// there will be no further load events. Stop listening immediately.
case "outer-window-destroyed":
if (bc.window.windowUtils.deprecatedOuterWindowID == winId) {
this.stop();
sendOk(this.commandID);
}
break;
}
},
/**
* Continue to listen for page load events after the frame script has been
* moved to a different content process.
*
* @param {number} commandID
* ID of the currently handled message between the driver and
* listener.
* @param {number} timeout
* Timeout in milliseconds the method has to wait for the page
* being finished loading.
* @param {number} startTime
* Unix timestap when the navitation request got triggered.
*/
waitForLoadAfterFramescriptReload(commandID, timeout, startTime) {
this.start(commandID, timeout, startTime, false);
},
/**
* Use a trigger callback to initiate a page load, and attach listeners if
* a page load is expected.
*
* @param {function} trigger
* Callback that triggers the page load.
* @param {number} commandID
* ID of the currently handled message between the driver and listener.
* @param {number} pageTimeout
* Timeout in milliseconds the method has to wait for the page
* finished loading.
* @param {boolean=} loadEventExpected
* Optional flag, which indicates that navigate has to wait for the page
* finished loading.
* @param {string=} url
* Optional URL, which is used to check if a page load is expected.
*/
async navigate(
trigger,
commandID,
timeout,
loadEventExpected = true,
useUnloadTimer = false
) {
// Only wait if the page load strategy is not `none`
loadEventExpected =
loadEventExpected &&
capabilities.get("pageLoadStrategy") !== PageLoadStrategy.None;
if (loadEventExpected) {
let startTime = new Date().getTime();
this.start(commandID, timeout, startTime, true);
}
await trigger();
try {
if (!loadEventExpected) {
sendOk(commandID);
return;
}
// If requested setup a timer to detect a possible page load
if (useUnloadTimer) {
this.timerPageUnload = Cc["@mozilla.org/timer;1"].createInstance(
Ci.nsITimer
);
this.timerPageUnload.initWithCallback(
this,
200,
Ci.nsITimer.TYPE_ONE_SHOT
);
}
} catch (e) {
if (loadEventExpected) {
this.stop();
}
sendError(e, commandID);
}
},
};
/**
* Called when listener is first started up. The listener sends its
* unique window ID and its current URI to the actor. If the actor returns
* an ID, we start the listeners. Otherwise, nothing happens.
*/
function registerSelf() {
logger.trace("Frame script loaded");
curContainer.frame = content;
sandboxes.clear();
legacyactions.mouseEventsOnly = false;
action.inputStateMap = new Map();
action.inputsToCancel = [];
let reply = sendSyncMessage("Marionette:Register", {
frameId: contentId,
});
if (reply.length == 0) {
logger.error("No reply from Marionette:Register");
return;
}
if (reply[0].frameId === contentId) {
logger.trace("Frame script registered");
startListeners();
sendAsyncMessage("Marionette:ListenersAttached", {
frameId: contentId,
});
}
}
// Eventually we will not have a closure for every single command,
// but use a generic dispatch for all listener commands.
//
// Worth nothing that this shares many characteristics with
// server.TCPConnection#execute. Perhaps this could be generalised
// at the point.
function dispatch(fn) {
if (typeof fn != "function") {
throw new TypeError("Provided dispatch handler is not a function");
}
return msg => {
const id = msg.json.commandID;
let req = new Promise(resolve => {
const args = evaluate.fromJSON(msg.json, seenEls, curContainer.frame);
let rv;
if (typeof args == "undefined" || args instanceof Array) {
rv = fn.apply(null, args);
} else {
rv = fn(args);
}
resolve(rv);
});
req
.then(
rv => sendResponse(rv, id),
err => sendError(err, id)
)
.catch(err => sendError(err, id));
};
}
let getActiveElementFn = dispatch(getActiveElement);
let getBrowsingContextIdFn = dispatch(getBrowsingContextId);
let getElementAttributeFn = dispatch(getElementAttribute);
let getElementPropertyFn = dispatch(getElementProperty);
let getElementTextFn = dispatch(getElementText);
let getElementTagNameFn = dispatch(getElementTagName);
let getElementRectFn = dispatch(getElementRect);
let getPageSourceFn = dispatch(getPageSource);
let getScreenshotRectFn = dispatch(getScreenshotRect);
let isElementEnabledFn = dispatch(isElementEnabled);
let findElementContentFn = dispatch(findElementContent);
let findElementsContentFn = dispatch(findElementsContent);
let isElementSelectedFn = dispatch(isElementSelected);
let clearElementFn = dispatch(clearElement);
let isElementDisplayedFn = dispatch(isElementDisplayed);
let getElementValueOfCssPropertyFn = dispatch(getElementValueOfCssProperty);
let switchToShadowRootFn = dispatch(switchToShadowRoot);
let singleTapFn = dispatch(singleTap);
let performActionsFn = dispatch(performActions);
let releaseActionsFn = dispatch(releaseActions);
let actionChainFn = dispatch(actionChain);
let multiActionFn = dispatch(multiAction);
let executeFn = dispatch(execute);
let executeInSandboxFn = dispatch(executeInSandbox);
let sendKeysToElementFn = dispatch(sendKeysToElement);
let reftestWaitFn = dispatch(reftestWait);
function startListeners() {
addMessageListener("Marionette:actionChain", actionChainFn);
addMessageListener("Marionette:cancelRequest", cancelRequest);
addMessageListener("Marionette:clearElement", clearElementFn);
addMessageListener("Marionette:clickElement", clickElement);
addMessageListener("Marionette:Deregister", deregister);
addMessageListener("Marionette:DOM:AddEventListener", domAddEventListener);
addMessageListener(
"Marionette:DOM:RemoveEventListener",
domRemoveEventListener
);
addMessageListener("Marionette:execute", executeFn);
addMessageListener("Marionette:executeInSandbox", executeInSandboxFn);
addMessageListener("Marionette:findElementContent", findElementContentFn);
addMessageListener("Marionette:findElementsContent", findElementsContentFn);
addMessageListener("Marionette:getActiveElement", getActiveElementFn);
addMessageListener("Marionette:getBrowsingContextId", getBrowsingContextIdFn);
addMessageListener("Marionette:getElementAttribute", getElementAttributeFn);
addMessageListener("Marionette:getElementProperty", getElementPropertyFn);
addMessageListener("Marionette:getElementRect", getElementRectFn);
addMessageListener("Marionette:getElementTagName", getElementTagNameFn);
addMessageListener("Marionette:getElementText", getElementTextFn);
addMessageListener(
"Marionette:getElementValueOfCssProperty",
getElementValueOfCssPropertyFn
);
addMessageListener("Marionette:getPageSource", getPageSourceFn);
addMessageListener("Marionette:getScreenshotRect", getScreenshotRectFn);
addMessageListener("Marionette:goBack", goBack);
addMessageListener("Marionette:goForward", goForward);
addMessageListener("Marionette:isElementDisplayed", isElementDisplayedFn);
addMessageListener("Marionette:isElementEnabled", isElementEnabledFn);
addMessageListener("Marionette:isElementSelected", isElementSelectedFn);
addMessageListener("Marionette:multiAction", multiActionFn);
addMessageListener("Marionette:navigateTo", navigateTo);
addMessageListener("Marionette:performActions", performActionsFn);
addMessageListener("Marionette:refresh", refresh);
addMessageListener("Marionette:reftestWait", reftestWaitFn);
addMessageListener("Marionette:releaseActions", releaseActionsFn);
addMessageListener("Marionette:sendKeysToElement", sendKeysToElementFn);
addMessageListener("Marionette:Session:Delete", deleteSession);
addMessageListener("Marionette:singleTap", singleTapFn);
addMessageListener("Marionette:switchToFrame", switchToFrame);
addMessageListener("Marionette:switchToParentFrame", switchToParentFrame);
addMessageListener("Marionette:switchToShadowRoot", switchToShadowRootFn);
addMessageListener("Marionette:waitForPageLoaded", waitForPageLoaded);
}
function deregister() {
removeMessageListener("Marionette:actionChain", actionChainFn);
removeMessageListener("Marionette:cancelRequest", cancelRequest);
removeMessageListener("Marionette:clearElement", clearElementFn);
removeMessageListener("Marionette:clickElement", clickElement);
removeMessageListener("Marionette:Deregister", deregister);
removeMessageListener("Marionette:execute", executeFn);
removeMessageListener("Marionette:executeInSandbox", executeInSandboxFn);
removeMessageListener("Marionette:findElementContent", findElementContentFn);
removeMessageListener(
"Marionette:findElementsContent",
findElementsContentFn
);
removeMessageListener("Marionette:getActiveElement", getActiveElementFn);
removeMessageListener(
"Marionette:getBrowsingContextId",
getBrowsingContextIdFn
);
removeMessageListener(
"Marionette:getElementAttribute",
getElementAttributeFn
);
removeMessageListener("Marionette:getElementProperty", getElementPropertyFn);
removeMessageListener("Marionette:getElementRect", getElementRectFn);
removeMessageListener("Marionette:getElementTagName", getElementTagNameFn);
removeMessageListener("Marionette:getElementText", getElementTextFn);
removeMessageListener(
"Marionette:getElementValueOfCssProperty",
getElementValueOfCssPropertyFn
);
removeMessageListener("Marionette:getPageSource", getPageSourceFn);
removeMessageListener("Marionette:getScreenshotRect", getScreenshotRectFn);
removeMessageListener("Marionette:goBack", goBack);
removeMessageListener("Marionette:goForward", goForward);
removeMessageListener("Marionette:isElementDisplayed", isElementDisplayedFn);
removeMessageListener("Marionette:isElementEnabled", isElementEnabledFn);
removeMessageListener("Marionette:isElementSelected", isElementSelectedFn);
removeMessageListener("Marionette:multiAction", multiActionFn);
removeMessageListener("Marionette:navigateTo", navigateTo);
removeMessageListener("Marionette:performActions", performActionsFn);
removeMessageListener("Marionette:refresh", refresh);
removeMessageListener("Marionette:releaseActions", releaseActionsFn);
removeMessageListener("Marionette:sendKeysToElement", sendKeysToElementFn);
removeMessageListener("Marionette:Session:Delete", deleteSession);
removeMessageListener("Marionette:singleTap", singleTapFn);
removeMessageListener("Marionette:switchToFrame", switchToFrame);
removeMessageListener("Marionette:switchToParentFrame", switchToParentFrame);
removeMessageListener("Marionette:switchToShadowRoot", switchToShadowRootFn);
removeMessageListener("Marionette:waitForPageLoaded", waitForPageLoaded);
}
function deleteSession() {
seenEls.clear();
// reset container frame to the top-most frame
curContainer.frame = content;
curContainer.frame.focus();
legacyactions.touchIds = {};
if (action.inputStateMap !== undefined) {
action.inputStateMap.clear();
}
if (action.inputsToCancel !== undefined) {
action.inputsToCancel.length = 0;
}
}
/**
* Send asynchronous reply to chrome.
*
* @param {UUID} uuid
* Unique identifier of the request.
* @param {AsyncContentSender.ResponseType} type
* Type of response.
* @param {*} [Object] data
* JSON serialisable object to accompany the message. Defaults to
* an empty dictionary.
*/
let sendToServer = (uuid, data = undefined) => {
let channel = new proxy.AsyncMessageChannel(sendAsyncMessage.bind(this));
channel.reply(uuid, data);
};
/**
* Send asynchronous reply with value to chrome.
*
* @param {Object} obj
* JSON serialisable object of arbitrary type and complexity.
* @param {UUID} uuid
* Unique identifier of the request.
*/
function sendResponse(obj, uuid) {
let payload = evaluate.toJSON(obj, seenEls);
sendToServer(uuid, payload);
}
/**
* Send asynchronous reply to chrome.
*
* @param {UUID} uuid
* Unique identifier of the request.
*/
function sendOk(uuid) {
sendToServer(uuid);
}
/**
* Send asynchronous error reply to chrome.
*
* @param {Error} err
* Error to notify chrome of.
* @param {UUID} uuid
* Unique identifier of the request.
*/
function sendError(err, uuid) {
sendToServer(uuid, err);
}
async function execute(script, args, opts) {
let sb = sandbox.createMutable(curContainer.frame);
return evaluate.sandbox(sb, script, args, opts);
}
async function executeInSandbox(script, args, opts) {
let sb = sandboxes.get(opts.sandboxName, opts.newSandbox);
return evaluate.sandbox(sb, script, args, opts);
}
function emitTouchEvent(type, touch) {
logger.info(
`Emitting Touch event of type ${type} ` +
`to element with id: ${touch.target.id} ` +
`and tag name: ${touch.target.tagName} ` +
`at coordinates (${touch.clientX}), ` +
`${touch.clientY}) relative to the viewport`
);
const win = curContainer.frame;
if (win.docShell.asyncPanZoomEnabled && legacyactions.scrolling) {
let ev = {
index: 0,
type,
id: touch.identifier,
clientX: touch.clientX,
clientY: touch.clientY,
screenX: touch.screenX,
screenY: touch.screenY,
radiusX: touch.radiusX,
radiusY: touch.radiusY,
rotation: touch.rotationAngle,
force: touch.force,
};
sendSyncMessage("Marionette:emitTouchEvent", ev);
return;
}
// we get here if we're not in asyncPacZoomEnabled land, or if we're
// the main process
win.windowUtils.sendTouchEvent(
type,
[touch.identifier],
[touch.clientX],
[touch.clientY],
[touch.radiusX],
[touch.radiusY],
[touch.rotationAngle],
[touch.force],
0
);
}
/**
* Function that perform a single tap
*/
async function singleTap(el, corx, cory) {
// after this block, the element will be scrolled into view
let visible = element.isVisible(el, corx, cory);
if (!visible) {
throw new error.ElementNotInteractableError(
"Element is not currently visible and may not be manipulated"
);
}
let a11y = accessibility.get(capabilities.get("moz:accessibilityChecks"));
let acc = await a11y.getAccessible(el, true);
a11y.assertVisible(acc, el, visible);
a11y.assertActionable(acc, el);
if (!curContainer.frame.document.createTouch) {
legacyactions.mouseEventsOnly = true;
}
let c = element.coordinates(el, corx, cory);
if (!legacyactions.mouseEventsOnly) {
let touchId = legacyactions.nextTouchId++;
let touch = createATouch(el, c.x, c.y, touchId);
emitTouchEvent("touchstart", touch);
emitTouchEvent("touchend", touch);
}
legacyactions.mouseTap(el.ownerDocument, c.x, c.y);
}
/**
* Function to create a touch based on the element
* corx and cory are relative to the viewport, id is the touchId
*/
function createATouch(el, corx, cory, touchId) {
let doc = el.ownerDocument;
let win = doc.defaultView;
let [
clientX,
clientY,
pageX,
pageY,
screenX,
screenY,
] = legacyactions.getCoordinateInfo(el, corx, cory);
let atouch = doc.createTouch(
win,
el,
touchId,
pageX,
pageY,
screenX,
screenY,
clientX,
clientY
);
return atouch;
}
/**
* Perform a series of grouped actions at the specified points in time.
*
* @param {obj} msg
* Object with an |actions| attribute that is an Array of objects
* each of which represents an action sequence.
*/
async function performActions(msg) {
let chain = action.Chain.fromJSON(msg.actions);
await action.dispatch(
chain,
curContainer.frame,
!capabilities.get("moz:useNonSpecCompliantPointerOrigin")
);
}
/**
* The release actions command is used to release all the keys and pointer
* buttons that are currently depressed. This causes events to be fired
* as if the state was released by an explicit series of actions. It also
* clears all the internal state of the virtual devices.
*/
async function releaseActions() {
await action.dispatchTickActions(
action.inputsToCancel.reverse(),
0,
curContainer.frame
);
action.inputsToCancel.length = 0;
action.inputStateMap.clear();
event.DoubleClickTracker.resetClick();
}
/**
* Start action chain on one finger.
*/
function actionChain(chain, touchId) {
let touchProvider = {};
touchProvider.createATouch = createATouch;
touchProvider.emitTouchEvent = emitTouchEvent;
return legacyactions.dispatchActions(
chain,
touchId,
curContainer,
seenEls,
touchProvider
);
}
function emitMultiEvents(type, touch, touches) {
let target = touch.target;
let doc = target.ownerDocument;
let win = doc.defaultView;
// touches that are in the same document
let documentTouches = doc.createTouchList(
touches.filter(function(t) {
return t.target.ownerDocument === doc && type != "touchcancel";
})
);
// touches on the same target
let targetTouches = doc.createTouchList(
touches.filter(function(t) {
return (
t.target === target && (type != "touchcancel" || type != "touchend")
);
})
);
// Create changed touches
let changedTouches = doc.createTouchList(touch);
// Create the event object
let event = doc.createEvent("TouchEvent");
event.initTouchEvent(
type,
true,
true,
win,
0,
false,
false,
false,
false,
documentTouches,
targetTouches,
changedTouches
);
target.dispatchEvent(event);
}
function setDispatch(batches, touches, batchIndex = 0) {
// check if all the sets have been fired
if (batchIndex >= batches.length) {
multiLast = {};
return;
}
// a set of actions need to be done
let batch = batches[batchIndex];
// each action for some finger
let pack;
// the touch id for the finger (pack)
let touchId;
// command for the finger
let command;
// touch that will be created for the finger
let el;
let touch;
let lastTouch;
let touchIndex;
let waitTime = 0;
let maxTime = 0;
let c;
// loop through the batch
batchIndex++;
for (let i = 0; i < batch.length; i++) {
pack = batch[i];
touchId = pack[0];
command = pack[1];
switch (command) {
case "press":
el = seenEls.get(pack[2], curContainer.frame);
c = element.coordinates(el, pack[3], pack[4]);
touch = createATouch(el, c.x, c.y, touchId);
multiLast[touchId] = touch;
touches.push(touch);
emitMultiEvents("touchstart", touch, touches);
break;
case "release":
touch = multiLast[touchId];
// the index of the previous touch for the finger may change in
// the touches array
touchIndex = touches.indexOf(touch);
touches.splice(touchIndex, 1);
emitMultiEvents("touchend", touch, touches);
break;
case "move":
el = seenEls.get(pack[2], curContainer.frame);
c = element.coordinates(el);
touch = createATouch(multiLast[touchId].target, c.x, c.y, touchId);
touchIndex = touches.indexOf(lastTouch);
touches[touchIndex] = touch;
multiLast[touchId] = touch;
emitMultiEvents("touchmove", touch, touches);
break;
case "moveByOffset":
el = multiLast[touchId].target;
lastTouch = multiLast[touchId];
touchIndex = touches.indexOf(lastTouch);
let doc = el.ownerDocument;
let win = doc.defaultView;
// since x and y are relative to the last touch, therefore,
// it's relative to the position of the last touch
let clientX = lastTouch.clientX + pack[2];
let clientY = lastTouch.clientY + pack[3];
let pageX = clientX + win.pageXOffset;
let pageY = clientY + win.pageYOffset;
let screenX = clientX + win.mozInnerScreenX;
let screenY = clientY + win.mozInnerScreenY;
touch = doc.createTouch(
win,
el,
touchId,
pageX,
pageY,
screenX,
screenY,
clientX,
clientY
);
touches[touchIndex] = touch;
multiLast[touchId] = touch;
emitMultiEvents("touchmove", touch, touches);
break;
case "wait":
if (typeof pack[2] != "undefined") {
waitTime = pack[2] * 1000;
if (waitTime > maxTime) {
maxTime = waitTime;
}
}
break;
}
}
if (maxTime != 0) {
let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
timer.initWithCallback(
function() {
setDispatch(batches, touches, batchIndex);
},
maxTime,
Ci.nsITimer.TYPE_ONE_SHOT
);
} else {
setDispatch(batches, touches, batchIndex);
}
}
/**
* Start multi-action.
*
* @param {Number} maxLen
* Longest action chain for one finger.
*/
function multiAction(args, maxLen) {
// unwrap the original nested array
let commandArray = evaluate.fromJSON(args, seenEls, curContainer.frame);
let concurrentEvent = [];
let temp;
for (let i = 0; i < maxLen; i++) {
let row = [];
for (let j = 0; j < commandArray.length; j++) {
if (typeof commandArray[j][i] != "undefined") {
// add finger id to the front of each action,
// i.e. [finger_id, action, element]
temp = commandArray[j][i];
temp.unshift(j);
row.push(temp);
}
}
concurrentEvent.push(row);
}
// Now concurrent event is made of sets where each set contain a list
// of actions that need to be fired.
//
// But note that each action belongs to a different finger
// pendingTouches keeps track of current touches that's on the screen.
let pendingTouches = [];
setDispatch(concurrentEvent, pendingTouches);
}
/**
* Cancel the polling and remove the event listener associated with a
* current navigation request in case we're interupted by an onbeforeunload
* handler and navigation doesn't complete.
*/
function cancelRequest() {
loadListener.stop();
}
/**
* This implements the latter part of a get request (for the case we need
* to resume one when the frame script has been moved to a different content
* process in the middle of a navigate request). This is most of of the work
* of a navigate request, but doesn't assume DOMContentLoaded is yet to fire.
*
* @param {number} commandID
* ID of the currently handled message between the driver and
* listener.
* @param {number} pageTimeout
* Timeout in seconds the method has to wait for the page being
* finished loading.
* @param {number} startTime
* Unix timestap when the navitation request got triggered.
*/
function waitForPageLoaded(msg) {
let { commandID, pageTimeout, startTime } = msg.json;
loadListener.waitForLoadAfterFramescriptReload(
commandID,
pageTimeout,
startTime
);
}
/**
* Navigate to the given URL. The operation will be performed on the
* current browsing context, which means it handles the case where we
* navigate within an iframe. All other navigation is handled by the driver
* (in chrome space).
*/
async function navigateTo(msg) {
let { commandID, pageTimeout, url, loadEventExpected } = msg.json;
try {
await loadListener.navigate(
() => {
curContainer.frame.location = url;
},
commandID,
pageTimeout,
loadEventExpected
);
} catch (e) {
sendError(e, commandID);
}
}
/**
* Cause the browser to traverse one step backward in the joint history
* of the current browsing context.
*
* @param {number} commandID
* ID of the currently handled message between the driver and
* listener.
* @param {number} pageTimeout
* Timeout in milliseconds the method has to wait for the page being
* finished loading.
*/
async function goBack(msg) {
let { commandID, pageTimeout } = msg.json;
try {
await loadListener.navigate(
() => {
curContainer.frame.history.back();
},
commandID,
pageTimeout
);
} catch (e) {
sendError(e, commandID);
}
}
/**
* Cause the browser to traverse one step forward in the joint history
* of the current browsing context.
*
* @param {number} commandID
* ID of the currently handled message between the driver and
* listener.
* @param {number} pageTimeout
* Timeout in milliseconds the method has to wait for the page being
* finished loading.
*/
async function goForward(msg) {
let { commandID, pageTimeout } = msg.json;
try {
await loadListener.navigate(
() => {
curContainer.frame.history.forward();
},
commandID,
pageTimeout
);
} catch (e) {
sendError(e, commandID);
}
}
/**
* Causes the browser to reload the page in in current top-level browsing
* context.
*
* @param {number} commandID
* ID of the currently handled message between the driver and
* listener.
* @param {number} pageTimeout
* Timeout in milliseconds the method has to wait for the page being
* finished loading.
*/
async function refresh(msg) {
let { commandID, pageTimeout } = msg.json;
try {
// We need to move to the top frame before navigating
curContainer.frame = content;
sendSyncMessage("Marionette:switchedToFrame", {
browsingContextId: curContainer.id,
});
await loadListener.navigate(
() => {
curContainer.frame.location.reload(true);
},
commandID,
pageTimeout
);
} catch (e) {
sendError(e, commandID);
}
}
/**
* Get source of the current browsing context's DOM.
*/
function getPageSource() {
return curContainer.frame.document.documentElement.outerHTML;
}
/**
* Find an element in the current browsing context's document using the
* given search strategy.
*/
async function findElementContent(strategy, selector, opts = {}) {
opts.all = false;
let el = await element.find(curContainer, strategy, selector, opts);
return seenEls.add(el);
}
/**
* Find elements in the current browsing context's document using the
* given search strategy.
*/
async function findElementsContent(strategy, selector, opts = {}) {
opts.all = true;
let els = await element.find(curContainer, strategy, selector, opts);
let webEls = seenEls.addAll(els);
return webEls;
}
/**
* Return the active element in the document.
*
* @return {WebElement}
* Active element of the current browsing context's document
* element, if the document element is non-null.
*
* @throws {NoSuchElementError}
* If the document does not have an active element, i.e. if
* its document element has been deleted.
*/
function getActiveElement() {
let el = curContainer.frame.document.activeElement;
if (!el) {
throw new error.NoSuchElementError();
}
return evaluate.toJSON(el, seenEls);
}
/**
* Return the current browsing context id.
*
* @param {boolean=} topContext
* If set to true use the window's top-level browsing context,
* otherwise the one from the currently selected frame. Defaults to false.
*
* @return {number}
* Id of the browsing context.
*/
function getBrowsingContextId(topContext = false) {
const bc = curContainer.frame.docShell.browsingContext;
return topContext ? bc.top.id : bc.id;
}
/**
* Send click event to element.
*
* @param {number} commandID
* ID of the currently handled message between the driver and
* listener.
* @param {WebElement} webElRef
* Reference to the web element to click.
* @param {number} pageTimeout
* Timeout in milliseconds the method has to wait for the page being
* finished loading.
*/
async function clickElement(msg) {
let { commandID, webElRef, pageTimeout } = msg.json;
try {
let webEl = WebElement.fromJSON(webElRef);
let el = seenEls.get(webEl, curContainer.frame);
let loadEventExpected = true;
let target = getElementAttribute(el, "target");
if (target === "_blank") {
loadEventExpected = false;
}
await loadListener.navigate(
() => {
return interaction.clickElement(
el,
capabilities.get("moz:accessibilityChecks"),
capabilities.get("moz:webdriverClick")
);
},
commandID,
pageTimeout,
loadEventExpected,
true
);
} catch (e) {
sendError(e, commandID);
}
}
function getElementAttribute(el, name) {
if (element.isBooleanAttribute(el, name)) {
if (el.hasAttribute(name)) {
return "true";
}
return null;
}
return el.getAttribute(name);
}
function getElementProperty(el, name) {
return typeof el[name] != "undefined" ? el[name] : null;
}
/**
* Get the text of this element. This includes text from child
* elements.
*/
function getElementText(el) {
return atom.getElementText(el, curContainer.frame);
}
/**
* Get the tag name of an element.
*
* @param {WebElement} id
* Reference to web element.
*
* @return {string}
* Tag name of element.
*/
function getElementTagName(el) {
return el.tagName.toLowerCase();
}
/**
* Determine the element displayedness of the given web element.
*
* Also performs additional accessibility checks if enabled by session
* capability.
*/
function isElementDisplayed(el) {
return interaction.isElementDisplayed(
el,
capabilities.get("moz:accessibilityChecks")
);
}
/**
* Retrieves the computed value of the given CSS property of the given
* web element.
*/
function getElementValueOfCssProperty(el, prop) {
let st = curContainer.frame.document.defaultView.getComputedStyle(el);
return st.getPropertyValue(prop);
}
/**
* Get the position and dimensions of the element.
*
* @return {Object.<string, number>}
* The x, y, width, and height properties of the element.
*/
function getElementRect(el) {
let clientRect = el.getBoundingClientRect();
return {
x: clientRect.x + curContainer.frame.pageXOffset,
y: clientRect.y + curContainer.frame.pageYOffset,
width: clientRect.width,
height: clientRect.height,
};
}
function isElementEnabled(el) {
return interaction.isElementEnabled(
el,
capabilities.get("moz:accessibilityChecks")
);
}
/**
* Determines if the referenced element is selected or not.
*
* This operation only makes sense on input elements of the Checkbox-
* and Radio Button states, or option elements.
*/
function isElementSelected(el) {
return interaction.isElementSelected(
el,
capabilities.get("moz:accessibilityChecks")
);
}
async function sendKeysToElement(el, val) {
let opts = {
strictFileInteractability: capabilities.get("strictFileInteractability"),
accessibilityChecks: capabilities.get("moz:accessibilityChecks"),
webdriverClick: capabilities.get("moz:webdriverClick"),
};
await interaction.sendKeysToElement(el, val, opts);
}
/** Clear the text of an element. */
function clearElement(el) {
interaction.clearElement(el);
}
/** Switch the current context to the specified host's Shadow DOM. */
function switchToShadowRoot(el) {
if (!element.isElement(el)) {
// If no host element is passed, attempt to find a parent shadow
// root or, if none found, unset the current shadow root
if (curContainer.shadowRoot) {
let parent;
try {
parent = curContainer.shadowRoot.host;
} catch (e) {
// There is a chance that host element is dead and we are trying to
// access a dead object.
curContainer.shadowRoot = null;
return;
}
while (parent && !(parent instanceof curContainer.frame.ShadowRoot)) {
parent = parent.parentNode;
}
curContainer.shadowRoot = parent;
}
return;
}
let foundShadowRoot = el.shadowRoot;
if (!foundShadowRoot) {
throw new error.NoSuchElementError(
pprint`Unable to locate shadow root: ${el}`
);
}
curContainer.shadowRoot = foundShadowRoot;
}
/**
* Switch to the parent frame of the current frame. If the frame is the
* top most is the current frame then no action will happen.
*/
function switchToParentFrame(msg) {
curContainer.frame = curContainer.frame.parent;
sendSyncMessage("Marionette:switchedToFrame", {
browsingContextId: curContainer.id,
});
sendOk(msg.json.commandID);
}
/**
* Switch to the specified frame.
*
* @param {boolean=} focus
* Focus the frame if set to true. Defaults to false.
* @param {(string|Object)=} element
* A web element reference of the frame or its element id.
* @param {number=} id
* The index of the frame to switch to.
* If both element and id are not defined, switch to top-level frame.
*/
function switchToFrame({ json }) {
let { commandID, element, focus, id } = json;
let foundFrame;
let wantedFrame = null;
// check if curContainer.frame reference is dead
let frames = [];
try {
frames = curContainer.frame.frames;
} catch (e) {
// dead comparment, redirect to top frame
id = null;
element = null;
}
// switch to top-level frame
if (id == null && !element) {
curContainer.frame = content;
sendSyncMessage("Marionette:switchedToFrame", {
browsingContextId: curContainer.id,
});
if (focus) {
curContainer.frame.focus();
}
sendOk(commandID);
return;
}
let webEl;
if (typeof element != "undefined") {
webEl = WebElement.fromUUID(element, "content");
}
if (webEl) {
if (!seenEls.has(webEl)) {
let err = new error.NoSuchElementError(
`Unable to locate element: ${webEl}`
);
sendError(err, commandID);
return;
}
try {
wantedFrame = seenEls.get(webEl, curContainer.frame);
} catch (e) {
sendError(e, commandID);
return;
}
if (frames.length > 0) {
// use XPCNativeWrapper to compare elements; see bug 834266
let wrappedWanted = new XPCNativeWrapper(wantedFrame);
foundFrame = Array.prototype.find.call(frames, frame => {
return new XPCNativeWrapper(frame.frameElement) === wrappedWanted;
});
}
if (!foundFrame) {
// Either the frame has been removed or we have a OOP frame
// so lets just get all the iframes and do a quick loop before
// throwing in the towel
let iframes = curContainer.frame.document.getElementsByTagName("iframe");
let wrappedWanted = new XPCNativeWrapper(wantedFrame);
foundFrame = Array.prototype.find.call(iframes, frame => {
return new XPCNativeWrapper(frame) === wrappedWanted;
});
}
}
if (!foundFrame) {
if (typeof id === "number") {
try {
let frameEl;
if (id >= 0 && id < frames.length) {
frameEl = frames[id].frameElement;
if (frameEl !== null) {
foundFrame = frameEl.contentWindow;
} else {
// If foundFrame is null at this point then we have the top
// level browsing context so should treat it accordingly.
curContainer.frame = content;
sendSyncMessage("Marionette:switchedToFrame", {
browsingContextId: curContainer.id,
});
if (focus) {
curContainer.frame.focus();
}
sendOk(commandID);
return;
}
}
} catch (e) {
// Since window.frames does not return OOP frames it will throw
// and we land up here. Let's not give up and check if there are
// iframes and switch to the indexed frame there
let iframes = foundFrame.document.getElementsByTagName("iframe");
if (id >= 0 && id < iframes.length) {
foundFrame = iframes[id];
}
}
}
}
if (!foundFrame) {
let failedFrame = id || element;
let err = new error.NoSuchFrameError(
`Unable to locate frame: ${failedFrame}`
);
sendError(err, commandID);
return;
}
curContainer.frame = foundFrame;
sendSyncMessage("Marionette:switchedToFrame", {
browsingContextId: curContainer.id,
});
if (focus) {
curContainer.frame.focus();
}
sendOk(commandID);
}
/**
* Returns the rect of the element to screenshot.
*
* Because the screen capture takes place in the parent process the dimensions
* for the screenshot have to be determined in the appropriate child process.
*
* Also it takes care of scrolling an element into view if requested.
*
* @param {Object.<string, ?>} opts
* Options.
*
* Accepted values for |opts|:
*
* @param {WebElement} webEl
* Optional element to take a screenshot of.
* @param {boolean=} full
* True to take a screenshot of the entire document element. Is only
* considered if <var>id</var> is not defined. Defaults to true.
* @param {boolean=} scroll
* When <var>id</var> is given, scroll it into view before taking the
* screenshot. Defaults to true.
* @param {capture.Format} format
* Format to return the screenshot in.
* @param {Object.<string, ?>} opts
* Options.
*
* @return {DOMRect}
* The area to take a snapshot from
*/
function getScreenshotRect({ el, full = true, scroll = true } = {}) {
let win = el ? curContainer.frame : content;
let rect;
if (el) {
if (scroll) {
element.scrollIntoView(el);
}
rect = getElementRect(el);
} else if (full) {
const docEl = win.document.documentElement;
rect = new DOMRect(0, 0, docEl.scrollWidth, docEl.scrollHeight);
} else {
// viewport
rect = new DOMRect(
win.pageXOffset,
win.pageYOffset,
win.innerWidth,
win.innerHeight
);
}
return rect;
}
function flushRendering() {
let content = curContainer.frame;
let anyPendingPaintsGeneratedInDescendants = false;
let windowUtils = content.windowUtils;
function flushWindow(win) {
let utils = win.windowUtils;
let afterPaintWasPending = utils.isMozAfterPaintPending;
let root = win.document.documentElement;
if (root) {
try {
// Flush pending restyles and reflows for this window (layout)
root.getBoundingClientRect();
} catch (e) {
logger.error("flushWindow failed", e);
}
}
if (!afterPaintWasPending && utils.isMozAfterPaintPending) {
anyPendingPaintsGeneratedInDescendants = true;
}
for (let i = 0; i < win.frames.length; ++i) {
flushWindow(win.frames[i]);
}
}
flushWindow(content);
if (
anyPendingPaintsGeneratedInDescendants &&
!windowUtils.isMozAfterPaintPending
) {
logger.error(
"Descendant frame generated a MozAfterPaint event, " +
"but the root document doesn't have one!"
);
}
}
async function reftestWait(url, remote) {
let win = curContainer.frame;
let document = curContainer.frame.document;
let reftestWait;
if (document.location.href !== url || document.readyState != "complete") {
reftestWait = await documentLoad(win, url);
win = curContainer.frame;
document = curContainer.frame.document;
} else {
reftestWait = document.documentElement.classList.contains("reftest-wait");
}
logger.debug("Waiting for event loop to spin");
await new Promise(resolve => win.setTimeout(resolve, 0));
await paintComplete(win, remote);
let root = document.documentElement;
if (reftestWait) {
let event = new Event("TestRendered", { bubbles: true });
root.dispatchEvent(event);
logger.info("Emitted TestRendered event");
await reftestWaitRemoved(win, root);
await paintComplete(win, remote);
}
if (
win.innerWidth < document.documentElement.scrollWidth ||
win.innerHeight < document.documentElement.scrollHeight
) {
logger.warn(
`${url} overflows viewport (width: ${document.documentElement.scrollWidth}, height: ${document.documentElement.scrollHeight})`
);
}
}
function documentLoad(win, url) {
logger.debug(truncate`Waiting for page load of ${url}`);
return new Promise(resolve => {
let maybeResolve = event => {
if (
event.target === curContainer.frame.document &&
event.target.location.href === url
) {
let reftestWait = win.document.documentElement.classList.contains(
"reftest-wait"
);
removeEventListener("load", maybeResolve, { once: true });
resolve(reftestWait);
}
};
addEventListener("load", maybeResolve, true);
});
}
function paintComplete(win, remote) {
logger.debug("Waiting for rendering");
let windowUtils = content.windowUtils;
return new Promise(resolve => {
let maybeResolve = () => {
flushRendering();
if (remote) {
// Flush display (paint)
logger.debug("Force update of layer tree");
windowUtils.updateLayerTree();
}
if (windowUtils.isMozAfterPaintPending) {
logger.debug("isMozAfterPaintPending: true");
win.addEventListener("MozAfterPaint", maybeResolve, { once: true });
} else {
// resolve at the start of the next frame in case of leftover paints
logger.debug("isMozAfterPaintPending: false");
win.requestAnimationFrame(() => {
win.requestAnimationFrame(resolve);
});
}
};
maybeResolve();
});
}
function reftestWaitRemoved(win, root) {
logger.debug("Waiting for reftest-wait removal");
return new Promise(resolve => {
let observer = new win.MutationObserver(() => {
if (!root.classList.contains("reftest-wait")) {
observer.disconnect();
logger.debug("reftest-wait removed");
win.setTimeout(resolve, 0);
}
});
if (root.classList.contains("reftest-wait")) {
observer.observe(root, { attributes: true });
} else {
win.setTimeout(resolve, 0);
}
});
}
function domAddEventListener(msg) {
eventObservers.add(msg.json.type);
}
function domRemoveEventListener(msg) {
eventObservers.remove(msg.json.type);
}
// Call register self when we get loaded
registerSelf();