gecko/testing/marionette/dispatcher.js
Andreas Tolfsen 612a488697 Bug 1107706: Part 5: Refactor Marionette chrome/content communication
The Marionette server handles requests separately with a global sense
of state which makes it hard to introduce generalised behaviour to many
commands.  This effectively slows down protocol implementation because
each command request individually needs to do heavy lifting.

This patch introduces a series of abstractions that separates out the
WebDriver implementation to a new class, GeckoDriver.  It also features
a new interface to mediate messages between the chrome- and content
processes.

This allows the code living in the chrome context to make direct calls
on the listener through a promise-based API:

	let listener = new ListenerProxy(mm, sendCallback);
	let res = yield listener.functionOnListener("arg1", "arg2");

The MarionetteServer class that used to live in marionette-server.js
has now been moved to server.js, while the WebDriver implementation
has moved to driver.js.  By introducing more stringent separation,
MarionetteServer now properly encapsulates the server process allowing
us to unit tests for it in the future.

The patch is a refactor in the truest sense, in the meaning that no
input or output should have changed.
2015-03-19 21:12:58 +00:00

282 lines
8.2 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/. */
"use strict";
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("chrome://marionette/content/command.js");
Cu.import("chrome://marionette/content/emulator.js");
Cu.import("chrome://marionette/content/error.js");
Cu.import("chrome://marionette/content/driver.js");
this.EXPORTED_SYMBOLS = ["Dispatcher"];
const logger = Log.repository.getLogger("Marionette");
const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
/**
* Manages a Marionette connection, and dispatches packets received to
* their correct destinations.
*
* @param {number} connId
* Unique identifier of the connection this dispatcher should handle.
* @param {DebuggerTransport} transport
* Debugger transport connection to the client.
* @param {function(Emulator): GeckoDriver} driverFactory
* A factory function that takes an Emulator as argument and produces
* a GeckoDriver.
*/
this.Dispatcher = function(connId, transport, driverFactory) {
this.id = connId;
this.conn = transport;
// Marionette uses a protocol based on the debugger server, which
// requires passing back actor ID's with responses. Unlike the debugger
// server, we don't actually have multiple actors, so just use a dummy
// value of "0".
this.actorId = "0";
// callback for when connection is closed
this.onclose = null;
// transport hooks are Dispatcher.prototype.onPacket
// and Dispatcher.prototype.onClosed
this.conn.hooks = this;
this.emulator = new Emulator(msg => this.sendResponse(msg, -1));
this.driver = driverFactory(this.emulator);
this.commandProcessor = new CommandProcessor(this.driver);
};
/**
* Debugger transport callback that dispatches the request.
* Request handlers defined in this.requests take presedence
* over those defined in this.driver.commands.
*/
Dispatcher.prototype.onPacket = function(packet) {
logger.debug(`${this.id} -> ${packet.toSource()}`);
if (this.requests && this.requests[packet.name]) {
this.requests[packet.name].bind(this)(packet);
} else {
let id = this.beginNewCommand();
let ok = this.sendOk.bind(this);
let send = this.send.bind(this);
this.commandProcessor.execute(packet, ok, send, id);
}
};
/**
* Debugger transport callback that cleans up
* after a connection is closed.
*/
Dispatcher.prototype.onClosed = function(status) {
this.driver.sessionTearDown();
if (this.onclose) {
this.onclose(this);
}
};
// Dispatcher specific command handlers:
Dispatcher.prototype.getMarionetteID = function() {
let id = this.beginNewCommand();
this.sendResponse({from: "root", id: this.actorId}, id);
};
Dispatcher.prototype.emulatorCmdResult = function(msg) {
switch (this.driver.context) {
case Context.CONTENT:
this.driver.sendAsync("emulatorCmdResult", msg);
break;
case Context.CHROME:
let cb = this.emulator.popCallback(msg.id);
if (!cb) {
return;
}
cb.result(msg);
break;
}
};
/**
* Quits Firefox with the provided flags and tears down the current
* session.
*/
Dispatcher.prototype.quitApplication = function(msg) {
let id = this.beginNewCommand();
if (this.driver.appName != "Firefox") {
this.sendError({
"message": "In app initiated quit only supported on Firefox",
"status": 500
}, id);
return;
}
let flags = Ci.nsIAppStartup.eAttemptQuit;
for (let k of msg.parameters.flags) {
flags |= Ci.nsIAppStartup[k];
}
this.driver.sessionTearDown();
Services.startup.quit(flags);
};
// Convenience methods:
Dispatcher.prototype.sayHello = function() {
let id = this.beginNewCommand();
let yo = {from: "root", applicationType: "gecko", traits: []};
this.sendResponse(yo, id);
};
Dispatcher.prototype.sendOk = function(cmdId) {
this.sendResponse({from: this.actorId, ok: true}, cmdId);
};
Dispatcher.prototype.sendError = function(err, cmdId) {
let packet = {
from: this.actorId,
status: err.status,
sessionId: this.driver.sessionId,
error: err
};
this.sendResponse(packet, cmdId);
};
/**
* Marshals and sends message to either client or emulator based on the
* provided {@code cmdId}.
*
* This routine produces a Marionette protocol packet, which is different
* to a WebDriver protocol response in that it contains an extra key
* {@code from} for the debugger transport actor ID. It also replaces the
* key {@code value} with {@code error} when {@code msg.status} isn't
* {@code 0}.
*
* @param {Object} msg
* Object with the properties {@code value}, {@code status}, and
* {@code sessionId}.
* @param {UUID} cmdId
* The unique identifier for the command the message is a response to.
*/
Dispatcher.prototype.send = function(msg, cmdId) {
let packet = {
from: this.actorId,
value: msg.value,
status: msg.status,
sessionId: msg.sessionId,
};
if (typeof packet.value == "undefined") {
packet.value = null;
}
// the Marionette protocol sends errors using the "error"
// key instead of, as Selenium, "value"
if (!error.isSuccess(msg.status)) {
packet.error = packet.value;
delete packet.value;
}
this.sendResponse(packet, cmdId);
};
// Low-level methods:
/**
* Delegates message to client or emulator based on the provided
* {@code cmdId}. The message is sent over the debugger transport socket.
*
* The command ID is a unique identifier assigned to the client's request
* that is used to distinguish the asynchronous responses.
*
* Whilst responses to commands are synchronous and must be sent in the
* correct order, emulator callbacks are more transparent and can be sent
* at any time. These callbacks won't change the current command state.
*
* @param {Object} payload
* The payload to send.
* @param {UUID} cmdId
* The unique identifier for this payload. {@code -1} signifies
* that it's an emulator callback.
*/
Dispatcher.prototype.sendResponse = function(payload, cmdId) {
if (emulator.isCallback(cmdId)) {
this.sendToEmulator(payload);
} else {
this.sendToClient(payload, cmdId);
this.commandId = null;
}
};
/**
* Send message to emulator over the debugger transport socket.
* Notably this skips out-of-sync command checks.
*/
Dispatcher.prototype.sendToEmulator = function(payload) {
this.sendRaw("emulator", payload);
};
/**
* Send given payload as-is to the connected client over the debugger
* transport socket.
*
* If {@code cmdId} evaluates to false, the current command state isn't
* set, or the response is out-of-sync, a warning is logged and this
* routine will return (no-op).
*/
Dispatcher.prototype.sendToClient = function(payload, cmdId) {
if (!cmdId) {
logger.warn("Got response with no command ID");
return;
} else if (this.commandId === null) {
logger.warn(`No current command, ignoring response: ${payload.toSource}`);
return;
} else if (this.isOutOfSync(cmdId)) {
logger.warn(`Ignoring out-of-sync response with command ID: ${cmdId}`);
return;
}
this.driver.responseCompleted();
this.sendRaw("client", payload);
};
/**
* Sends payload as-is over debugger transport socket to client,
* and logs it.
*/
Dispatcher.prototype.sendRaw = function(dest, payload) {
logger.debug(`${this.id} ${dest} <- ${payload.toSource()}`);
this.conn.send(payload);
};
/**
* Begins a new command by generating a unique identifier and assigning
* it to the current command state {@code Dispatcher.prototype.commandId}.
*
* @return {UUID}
* The generated unique identifier for the current command.
*/
Dispatcher.prototype.beginNewCommand = function() {
let uuid = uuidGen.generateUUID().toString();
this.commandId = uuid;
return uuid;
};
Dispatcher.prototype.isOutOfSync = function(cmdId) {
return this.commandId !== cmdId;
};
Dispatcher.prototype.requests = {
getMarionetteID: Dispatcher.prototype.getMarionetteID,
emulatorCmdResult: Dispatcher.prototype.emulatorCmdResult,
quitApplication: Dispatcher.prototype.quitApplication
};