Bug 1211489: Provide message sequencing in Marionette

Message sequencing allows Marionette to provide an asynchronous,
parallel pipelining user-facing interface, limit chances of payload
race conditions, and remove stylistic inconsistencies in how commands
and responses are dispatched internally.

Clients that deliver a blocking WebDriver interface are still be expected
to not send further command requests before the response from the last
command has come back, but if they still happen to do so because of
programming error or otherwise, no harm will be done.  This will guard
against bugs such as bug 1207125.

This patch formalises the command and response concepts, and applies
these concepts to emulator callbacks. Through the new message format,
Marionette is able to provide two-way parallel communication.  In other
words, the server will be able to instruct the client to perform a
command in a non ad-hoc way.

runEmulatorCmd and runEmulatorShell are both turned into command
instructions originating from the server.  This resolves a lot of
technical debt in the server code because they are no longer special-cased
to circumvent the dispatching technique used for all other commands;
commands may originate from either the client or the server providing
parallel pipelining enforced through message sequencing:

             client      server
               |            |
    msgid=1    |----------->|
               |  command   |
               |            |
    msgid=2    |<-----------|
               |  command   |
               |            |
    msgid=2    |----------->|
               |  response  |
               |            |
    msgid=1    |<-----------|
               |  response  |
               |            |

The protocol now consists of a "Command" message and the corresponding
"Response" message.  A "Response" message must always be sent in reply
to a "Command" message.

This bumps the Marionette protocol level to 3.

r=dburns
r=jgriffin

--HG--
extra : commitid : 1kz4Oa2q3Un
This commit is contained in:
Andreas Tolfsen 2015-09-26 17:12:01 +01:00
parent 1402113a05
commit 3421774080
10 changed files with 592 additions and 440 deletions

View File

@ -1,163 +0,0 @@
/* 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";
var {utils: Cu} = Components;
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("chrome://marionette/content/error.js");
this.EXPORTED_SYMBOLS = ["CommandProcessor", "Response"];
const logger = Log.repository.getLogger("Marionette");
const validator = {
exclusionary: {
"capabilities": ["error", "value"],
"error": ["value", "sessionId", "capabilities"],
"sessionId": ["error", "value"],
"value": ["error", "sessionId", "capabilities"],
},
set: function(obj, prop, val) {
let tests = this.exclusionary[prop];
if (tests) {
for (let t of tests) {
if (obj.hasOwnProperty(t)) {
throw new TypeError(`${t} set, cannot set ${prop}`);
}
}
}
obj[prop] = val;
return true;
},
};
/**
* The response body is exposed as an argument to commands.
* Commands can set fields on the body through defining properties.
*
* Setting properties invokes a validator that performs tests for
* mutually exclusionary fields on the input against the existing data
* in the body.
*
* For example setting the {@code error} property on the body when
* {@code value}, {@code sessionId}, or {@code capabilities} have been
* set previously will cause an error.
*/
this.ResponseBody = () => new Proxy({}, validator);
/**
* Represents the response returned from the remote end after execution
* of its corresponding command.
*
* The response is a mutable object passed to each command for
* modification through the available setters. To send data in a response,
* you modify the body property on the response. The body property can
* also be replaced completely.
*
* The response is sent implicitly by CommandProcessor when a command
* has finished executing, and any modifications made subsequent to that
* will have no effect.
*
* @param {number} cmdId
* UUID tied to the corresponding command request this is
* a response for.
* @param {function(Object, number)} respHandler
* Callback function called on responses.
*/
this.Response = function(cmdId, respHandler) {
this.id = cmdId;
this.respHandler = respHandler;
this.sent = false;
this.body = ResponseBody();
};
Response.prototype.send = function() {
if (this.sent) {
throw new RangeError("Response has already been sent: " + this.toString());
}
this.respHandler(this.body, this.id);
this.sent = true;
};
Response.prototype.sendError = function(err) {
let wd = error.isWebDriverError(err);
let we = wd ? err : new WebDriverError(err.message);
this.body.error = we.status;
this.body.message = we.message || null;
this.body.stacktrace = we.stack || null;
this.send();
// propagate errors that are implementation problems
if (!wd) {
throw err;
}
};
/**
* The command processor receives messages on execute(payload, )
* from the dispatcher, processes them, and wraps the functions that
* it executes from the WebDriver implementation, driver.
*
* @param {GeckoDriver} driver
* Reference to the driver implementation.
*/
this.CommandProcessor = function(driver) {
this.driver = driver;
};
/**
* Executes a WebDriver command based on the received payload,
* which is expected to be an object with a "parameters" property
* that is a simple key/value collection of arguments.
*
* The respHandler function will be called with the JSON object to
* send back to the client.
*
* The cmdId is the UUID tied to this request that prevents
* the dispatcher from sending responses in the wrong order.
*
* @param {Object} payload
* Message as received from client.
* @param {function(Object, number)} respHandler
* Callback function called on responses.
* @param {number} cmdId
* The unique identifier for the command to execute.
*/
CommandProcessor.prototype.execute = function(payload, respHandler, cmdId) {
let cmd = payload;
let resp = new Response(cmdId, respHandler);
let sendResponse = resp.send.bind(resp);
let sendError = resp.sendError.bind(resp);
// Ideally handlers shouldn't have to care about the command ID,
// but some methods (newSession, executeScript, et al.) have not
// yet been converted to use the new form of request dispatching.
cmd.id = cmdId;
let req = Task.spawn(function*() {
let fn = this.driver.commands[cmd.name];
if (typeof fn == "undefined") {
throw new UnknownCommandError(cmd.name);
}
let rv = yield fn.bind(this.driver)(cmd, resp);
if (typeof rv != "undefined") {
if (typeof rv != "object") {
resp.body = {value: rv};
} else {
resp.body = rv;
}
}
}.bind(this));
req.then(sendResponse, sendError).catch(error.report);
};

View File

@ -4,23 +4,21 @@
"use strict";
var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
const {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/driver.js");
Cu.import("chrome://marionette/content/emulator.js");
Cu.import("chrome://marionette/content/error.js");
Cu.import("chrome://marionette/content/driver.js");
Cu.import("chrome://marionette/content/message.js");
this.EXPORTED_SYMBOLS = ["Dispatcher"];
const PROTOCOL_VERSION = 2;
const PROTOCOL_VERSION = 3;
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
@ -33,111 +31,109 @@ const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerat
* @param {function(Emulator): GeckoDriver} driverFactory
* A factory function that takes an Emulator as argument and produces
* a GeckoDriver.
* @param {function()} stopSignal
* Signal to stop the Marionette server.
*/
this.Dispatcher = function(connId, transport, driverFactory, stopSignal) {
this.id = connId;
this.Dispatcher = function(connId, transport, driverFactory) {
this.connId = connId;
this.conn = transport;
// transport hooks are Dispatcher#onPacket
// and Dispatcher#onClosed
this.conn.hooks = this;
// callback for when connection is closed
this.onclose = null;
// transport hooks are Dispatcher.prototype.onPacket
// and Dispatcher.prototype.onClosed
this.conn.hooks = this;
// last received/sent message ID
this.lastId = 0;
this.emulator = new Emulator(msg => this.send(msg, -1));
this.emulator = new Emulator(this.sendEmulator.bind(this));
this.driver = driverFactory(this.emulator);
this.commandProcessor = new CommandProcessor(this.driver);
this.stopSignal_ = stopSignal;
};
/**
* 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) {
if (logger.level <= Log.Level.Debug) {
logger.debug(this.id + " -> " + JSON.stringify(packet));
}
if (this.requests && this.requests[packet.name]) {
this.requests[packet.name].bind(this)(packet);
} else {
let id = this.beginNewCommand();
let send = this.send.bind(this);
this.commandProcessor.execute(packet, send, id);
}
// lookup of commands sent by server to client by message ID
this.commands_ = new Map();
};
/**
* Debugger transport callback that cleans up
* after a connection is closed.
*/
Dispatcher.prototype.onClosed = function(status) {
Dispatcher.prototype.onClosed = function(reason) {
this.driver.sessionTearDown();
if (this.onclose) {
this.onclose(this);
}
};
// Dispatcher specific command handlers:
/**
* Callback that receives data packets from the client.
*
* If the message is a Response, we look up the command previously issued
* to the client and run its callback, if any. In case of a Command,
* the corresponding is executed.
*
* @param {Array.<number, number, ?, ?>} data
* A four element array where the elements, in sequence, signifies
* message type, message ID, method name or error, and parameters
* or result.
*/
Dispatcher.prototype.onPacket = function(data) {
let msg = Message.fromMsg(data);
msg.origin = MessageOrigin.Client;
this.log_(msg);
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;
if (msg instanceof Response) {
let cmd = this.commands_.get(msg.id);
this.commands_.delete(msg.id);
cmd.onresponse(msg);
} else if (msg instanceof Command) {
this.lastId = msg.id;
this.execute(msg);
}
};
/**
* Quits Firefox with the provided flags and tears down the current
* session.
* Executes a WebDriver command and sends back a response when it has
* finished executing.
*
* Commands implemented in GeckoDriver and registered in its
* {@code GeckoDriver.commands} attribute. The return values from
* commands are expected to be Promises. If the resolved value of said
* promise is not an object, the response body will be wrapped in an object
* under a "value" field.
*
* If the command implementation sends the response itself by calling
* {@code resp.send()}, the response is guaranteed to not be sent twice.
*
* Errors thrown in commands are marshaled and sent back, and if they
* are not WebDriverError instances, they are additionally propagated and
* reported to {@code Components.utils.reportError}.
*
* @param {Command} cmd
* The requested command to execute.
*/
Dispatcher.prototype.quitApplication = function(msg) {
let id = this.beginNewCommand();
Dispatcher.prototype.execute = function(cmd) {
let resp = new Response(cmd.id, this.send.bind(this));
let sendResponse = () => resp.sendConditionally(resp => !resp.sent);
let sendError = resp.sendError.bind(resp);
if (this.driver.appName != "Firefox") {
this.sendError(new WebDriverError("In app initiated quit only supported in Firefox"));
return;
}
let req = Task.spawn(function*() {
let fn = this.driver.commands[cmd.name];
if (typeof fn == "undefined") {
throw new UnknownCommandError(cmd.name);
}
let flags = Ci.nsIAppStartup.eAttemptQuit;
for (let k of msg.parameters.flags) {
flags |= Ci.nsIAppStartup[k];
}
let rv = yield fn.bind(this.driver)(cmd, resp);
this.stopSignal_();
this.sendOk(id);
if (typeof rv != "undefined") {
if (typeof rv != "object") {
resp.body = {value: rv};
} else {
resp.body = rv;
}
}
}.bind(this));
this.driver.sessionTearDown();
Services.startup.quit(flags);
};
// Convenience methods:
Dispatcher.prototype.sayHello = function() {
let id = this.beginNewCommand();
let whatHo = {
applicationType: "gecko",
marionetteProtocol: PROTOCOL_VERSION,
};
this.send(whatHo, id);
};
Dispatcher.prototype.sendOk = function(cmdId) {
this.send({}, cmdId);
req.then(sendResponse, sendError).catch(error.report);
};
Dispatcher.prototype.sendError = function(err, cmdId) {
@ -145,6 +141,30 @@ Dispatcher.prototype.sendError = function(err, cmdId) {
resp.sendError(err);
};
// Convenience methods:
/**
* When a client connects we send across a JSON Object defining the
* protocol level.
*
* This is the only message sent by Marionette that does not follow
* the regular message format.
*/
Dispatcher.prototype.sayHello = function() {
let whatHo = {
applicationType: "gecko",
marionetteProtocol: PROTOCOL_VERSION,
};
this.sendRaw(whatHo);
};
Dispatcher.prototype.sendEmulator = function(name, params, resCb, errCb) {
let cmd = new Command(++this.lastId, name, params);
cmd.onresult = resCb;
cmd.onerror = errCb;
this.send(cmd);
};
/**
* Delegates message to client or emulator based on the provided
* {@code cmdId}. The message is sent over the debugger transport socket.
@ -156,83 +176,69 @@ Dispatcher.prototype.sendError = function(err, cmdId) {
* 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.
* @param {Command,Response} msg
* The command or response to send.
*/
Dispatcher.prototype.send = function(payload, cmdId) {
if (emulator.isCallback(cmdId)) {
this.sendToEmulator(payload);
} else {
this.sendToClient(payload, cmdId);
this.commandId = null;
Dispatcher.prototype.send = function(msg) {
msg.origin = MessageOrigin.Server;
if (msg instanceof Command) {
this.commands_.set(msg.id, msg);
this.sendToEmulator(msg);
} else if (msg instanceof Response) {
this.sendToClient(msg);
}
};
// Low-level methods:
/**
* 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.
* Send command to emulator 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).
* @param {Command} cmd
* The command to issue to the emulator.
*/
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);
Dispatcher.prototype.sendToEmulator = function(cmd) {
this.sendMessage(cmd);
};
/**
* Sends payload as-is over debugger transport socket to client,
* and logs it.
* Send given response to the client over the debugger transport socket.
*
* @param {Response} resp
* The response to send back to the client.
*/
Dispatcher.prototype.sendRaw = function(dest, payload) {
if (logger.level <= Log.Level.Debug) {
logger.debug(this.id + " " + dest + " <- " + JSON.stringify(payload));
}
Dispatcher.prototype.sendToClient = function(resp) {
this.driver.responseCompleted();
this.sendMessage(resp);
};
/**
* Marshal message to the Marionette message format and send it.
*
* @param {Command,Response} msg
* The message to send.
*/
Dispatcher.prototype.sendMessage = function(msg) {
this.log_(msg);
let payload = msg.toMsg();
this.sendRaw(payload);
};
/**
* Send the given payload over the debugger transport socket to the
* connected client.
*
* @param {Object} payload
* The payload to ship.
*/
Dispatcher.prototype.sendRaw = function(payload) {
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 = {
emulatorCmdResult: Dispatcher.prototype.emulatorCmdResult,
quitApplication: Dispatcher.prototype.quitApplication
Dispatcher.prototype.log_ = function(msg) {
if (logger.level > Log.Level.Debug) {
return;
}
let a = (msg.origin == MessageOrigin.Client ? " -> " : " <- ");
logger.debug(this.connId + a + msg);
};

View File

@ -24,7 +24,6 @@ XPCOMUtils.defineLazyServiceGetter(
Cu.import("chrome://marionette/content/actions.js");
Cu.import("chrome://marionette/content/elements.js");
Cu.import("chrome://marionette/content/emulator.js");
Cu.import("chrome://marionette/content/error.js");
Cu.import("chrome://marionette/content/modal.js");
Cu.import("chrome://marionette/content/proxy.js");
@ -95,12 +94,17 @@ this.Context.fromString = function(s) {
* Description of the product, for example "B2G" or "Firefox".
* @param {string} device
* Device this driver should assume.
* @param {function()} stopSignal
* Signal to stop the Marionette server.
* @param {Emulator=} emulator
* Reference to the emulator connection, if running on an emulator.
*/
this.GeckoDriver = function(appName, device, emulator) {
this.GeckoDriver = function(appName, device, stopSignal, emulator) {
this.appName = appName;
this.stopSignal_ = stopSignal;
this.emulator = emulator;
// TODO(ato): hack
this.emulator.sendToListener = this.sendAsync.bind(this);
this.sessionId = null;
// holds list of BrowserObjs
@ -164,6 +168,7 @@ this.GeckoDriver = function(appName, device, emulator) {
this.mm = globalMessageManager;
this.listener = proxy.toListener(() => this.mm, this.sendAsync.bind(this));
// always keep weak reference to current dialogue
this.dialog = null;
let handleDialog = (subject, topic) => {
let winr;
@ -1083,11 +1088,6 @@ GeckoDriver.prototype.executeWithCallback = function(cmd, resp, directInject) {
let res = yield new Promise(function(resolve, reject) {
let chromeAsyncReturnFunc = function(val) {
if (that.emulator.cbs.length > 0) {
that.emulator.cbs = [];
throw new WebDriverError("Emulator callback still pending when finish() called");
}
if (cmd.id == that.sandboxes[sandboxName].command_id) {
if (that.timer !== null) {
that.timer.cancel();
@ -1133,20 +1133,11 @@ GeckoDriver.prototype.executeWithCallback = function(cmd, resp, directInject) {
}
this.sandboxes[sandboxName].command_id = cmd.id;
this.sandboxes[sandboxName].runEmulatorCmd = (cmd, cb) => {
let ecb = new EmulatorCallback();
ecb.onresult = cb;
ecb.onerror = chromeAsyncError;
this.emulator.pushCallback(ecb);
this.emulator.send({emulator_cmd: cmd, id: ecb.id});
};
this.sandboxes[sandboxName].runEmulatorShell = (args, cb) => {
let ecb = new EmulatorCallback();
ecb.onresult = cb;
ecb.onerror = chromeAsyncError;
this.emulator.pushCallback(ecb);
this.emulator.send({emulator_shell: args, id: ecb.id});
};
this.sandboxes[sandboxName].runEmulatorCmd =
(cmd, cb) => this.emulator.command(cmd, cb, chromeAsyncError);
this.sandboxes[sandboxName].runEmulatorShell =
(args, cb) => this.emulator.shell(args, cb, chromeAsyncError);
this.applyArgumentsToSandbox(win, this.sandboxes[sandboxName], args);
// NB: win.onerror is not hooked by default due to the inability to
@ -2800,6 +2791,27 @@ GeckoDriver.prototype.sendKeysToDialog = function(cmd, resp) {
true /* ignore visibility check */);
};
/**
* Quits Firefox with the provided flags and tears down the current
* session.
*/
GeckoDriver.prototype.quitApplication = function(cmd, resp) {
if (this.appName != "Firefox") {
throw new WebDriverError("In app initiated quit only supported in Firefox");
}
let flags = Ci.nsIAppStartup.eAttemptQuit;
for (let k of cmd.parameters.flags) {
flags |= Ci.nsIAppStartup[k];
}
this.stopSignal_();
resp.send();
this.sessionTearDown();
Services.startup.quit(flags);
};
/**
* Helper function to convert an outerWindowID into a UID that Marionette
* tracks.
@ -2835,11 +2847,6 @@ GeckoDriver.prototype.receiveMessage = function(message) {
}
break;
case "Marionette:runEmulatorCmd":
case "Marionette:runEmulatorShell":
this.emulator.send(message.json);
break;
case "Marionette:switchToModalOrigin":
this.curBrowser.frameManager.switchToModalOrigin(message);
this.mm = this.curBrowser.frameManager
@ -3022,7 +3029,8 @@ GeckoDriver.prototype.commands = {
"dismissDialog": GeckoDriver.prototype.dismissDialog,
"acceptDialog": GeckoDriver.prototype.acceptDialog,
"getTextFromDialog": GeckoDriver.prototype.getTextFromDialog,
"sendKeysToDialog": GeckoDriver.prototype.sendKeysToDialog
"sendKeysToDialog": GeckoDriver.prototype.sendKeysToDialog,
"quitApplication": GeckoDriver.prototype.quitApplication,
};
/**

View File

@ -4,18 +4,14 @@
"use strict";
var {classes: Cc, interfaces: Ci} = Components;
const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
this.EXPORTED_SYMBOLS = ["emulator", "Emulator", "EmulatorCallback"];
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
this.emulator = {};
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
/**
* Determines if command ID is an emulator callback.
*/
this.emulator.isCallback = function(cmdId) {
return cmdId < 0;
};
const logger = Log.repository.getLogger("Marionette");
this.EXPORTED_SYMBOLS = ["Emulator"];
/**
* Represents the connection between Marionette and the emulator it's
@ -26,97 +22,90 @@ this.emulator.isCallback = function(cmdId) {
* which is stored in cbs. They are later retreived by their unique ID
* using popCallback.
*
* @param {function(Object)} sendFn
* @param {function(Object)} sendToEmulatorFn
* Callback function that sends a message to the emulator.
* @param {function(Object)} sendToEmulatorFn
* Callback function that sends a message asynchronously to the
* current listener.
*/
this.Emulator = function(sendFn) {
this.send = sendFn;
this.cbs = [];
this.Emulator = function(sendToEmulatorFn) {
this.sendToEmulator = sendToEmulatorFn;
};
/**
* Pops a callback off the stack if found. Otherwise this is a no-op.
* Instruct the client to run an Android emulator command.
*
* @param {number} id
* Unique ID associated with the callback.
*
* @return {?function(Object)}
* Callback function that takes an emulator response message as
* an argument.
* @param {string} cmd
* The command to run.
* @param {function(?)} resCb
* Callback on a result response from the emulator.
* @param {function(?)} errCb
* Callback on an error in running the command.
*/
Emulator.prototype.popCallback = function(id) {
let f, fi;
for (let i = 0; i < this.cbs.length; ++i) {
if (this.cbs[i].id == id) {
f = this.cbs[i];
fi = i;
}
}
if (!f) {
return null;
}
this.cbs.splice(fi, 1);
return f;
Emulator.prototype.command = function(cmd, resCb, errCb) {
assertDefined(cmd, "runEmulatorCmd");
this.sendToEmulator(
"runEmulatorCmd", {emulator_cmd: cmd}, resCb, errCb);
};
/**
* Pushes callback on to the stack.
* Instruct the client to execute Android emulator shell arguments.
*
* @param {function(Object)} cb
* Callback function that takes an emulator response message as
* an argument.
* @param {Array.<string>} args
* The shell instruction for the emulator to execute.
* @param {function(?)} resCb
* Callback on a result response from the emulator.
* @param {function(?)} errCb
* Callback on an error in executing the shell arguments.
*/
Emulator.prototype.pushCallback = function(cb) {
cb.send_ = this.sendFn;
this.cbs.push(cb);
Emulator.prototype.shell = function(args, resCb, errCb) {
assertDefined(args, "runEmulatorShell");
this.sendToEmulator(
"runEmulatorShell", {emulator_shell: args}, resCb, errCb);
};
/**
* Encapsulates a callback to the emulator and provides an execution
* environment for them.
*
* Each callback is assigned a unique identifier, id, that can be used
* to retrieve them from Emulator's stack using popCallback.
*
* The onresult event listener is triggered when a result arrives on
* the callback.
*
* The onerror event listener is triggered when an error occurs during
* the execution of that callback.
*/
this.EmulatorCallback = function() {
this.id = uuidGen.generateUUID().toString();
this.onresult = null;
this.onerror = null;
this.send_ = null;
};
Emulator.prototype.processMessage = function(msg) {
let resCb = this.resultCallback(msg.json.id);
let errCb = this.errorCallback(msg.json.id);
EmulatorCallback.prototype.command = function(cmd, cb) {
this.onresult = cb;
this.send_({emulator_cmd: cmd, id: this.id});
};
switch (msg.name) {
case "Marionette:runEmulatorCmd":
this.command(msg.json.command, resCb, errCb);
break;
EmulatorCallback.prototype.shell = function(args, cb) {
this.onresult = cb;
this.send_({emulator_shell: args, id: this.id});
};
EmulatorCallback.prototype.result = function(msg) {
if (this.send_ === null) {
throw new TypeError(
"EmulatorCallback must be registered with Emulator to fire");
case "Marionette:runEmulatorShell":
this.shell(msg.json.arguments, resCb, errCb);
break;
}
};
Emulator.prototype.resultCallback = function(msgId) {
return res => this.sendResult({result: res, id: msgId});
};
Emulator.prototype.errorCallback = function(msgId) {
return err => this.sendResult({error: err, id: msgId});
};
Emulator.prototype.sendResult = function(msg) {
// sendToListener set explicitly in GeckoDriver's ctor
this.sendToListener("emulatorCmdResult", msg);
};
/** Receives IPC messages from the listener. */
Emulator.prototype.receiveMessage = function(msg) {
try {
if (!this.onresult) {
return;
}
this.onresult(msg.result);
this.processMessage(msg);
} catch (e) {
if (this.onerror) {
this.onerror(e);
}
this.sendResult({error: `${e.name}: ${e.message}`, id: msg.json.id});
}
};
Emulator.prototype.QueryInterface = XPCOMUtils.generateQI(
[Ci.nsIMessageListener, Ci.nsISupportsWeakReference]);
function assertDefined(arg, action) {
if (typeof arg == "undefined") {
throw new TypeError("Not enough arguments to " + action);
}
}

View File

@ -109,6 +109,24 @@ error.stringify = function(err) {
}
};
/**
* Marshal an Error to a JSON structure.
*
* @param {Error} err
* The Error to serialise.
*
* @return {Object.<string, Object>}
* JSON structure with the keys "error", "message", and "stacktrace".
*/
error.toJson = function(err) {
let json = {
error: err.status,
message: err.message || null,
stacktrace: err.stack || null,
};
return json;
};
/**
* WebDriverError is the prototypal parent of all WebDriver errors.
* It should not be used directly, as it does not correspond to a real
@ -216,7 +234,7 @@ this.NoAlertOpenError = function(msg) {
WebDriverError.call(this, msg);
this.name = "NoAlertOpenError";
this.status = "no such alert";
}
};
NoAlertOpenError.prototype = Object.create(WebDriverError.prototype);
this.NoSuchElementError = function(msg) {

View File

@ -185,8 +185,8 @@ FrameManager.prototype = {
mm.addWeakMessageListener("Marionette:error", this.server);
mm.addWeakMessageListener("Marionette:emitTouchEvent", this.server);
mm.addWeakMessageListener("Marionette:log", this.server);
mm.addWeakMessageListener("Marionette:runEmulatorCmd", this.server);
mm.addWeakMessageListener("Marionette:runEmulatorShell", this.server);
mm.addWeakMessageListener("Marionette:runEmulatorCmd", this.server.emulator);
mm.addWeakMessageListener("Marionette:runEmulatorShell", this.server.emulator);
mm.addWeakMessageListener("Marionette:shareData", this.server);
mm.addWeakMessageListener("Marionette:switchToModalOrigin", this.server);
mm.addWeakMessageListener("Marionette:switchedToFrame", this.server);
@ -217,8 +217,8 @@ FrameManager.prototype = {
mm.removeWeakMessageListener("Marionette:error", this.server);
mm.removeWeakMessageListener("Marionette:log", this.server);
mm.removeWeakMessageListener("Marionette:shareData", this.server);
mm.removeWeakMessageListener("Marionette:runEmulatorCmd", this.server);
mm.removeWeakMessageListener("Marionette:runEmulatorShell", this.server);
mm.removeWeakMessageListener("Marionette:runEmulatorCmd", this.server.emulator);
mm.removeWeakMessageListener("Marionette:runEmulatorShell", this.server.emulator);
mm.removeWeakMessageListener("Marionette:switchedToFrame", this.server);
mm.removeWeakMessageListener("Marionette:getVisibleCookies", this.server);
mm.removeWeakMessageListener("Marionette:listenersAttached", this.server);

View File

@ -16,7 +16,7 @@ marionette.jar:
content/EventUtils.js (EventUtils.js)
content/ChromeUtils.js (ChromeUtils.js)
content/error.js (error.js)
content/command.js (command.js)
content/message.js (message.js)
content/dispatcher.js (dispatcher.js)
content/emulator.js (emulator.js)
content/modal.js (modal.js)

View File

@ -1903,40 +1903,48 @@ function getAppCacheStatus(msg) {
}
// emulator callbacks
var _emu_cb_id = 0;
var _emu_cbs = {};
function runEmulatorCmd(cmd, callback) {
logger.info("listener runEmulatorCmd cmd=" + cmd);
if (callback) {
_emu_cbs[_emu_cb_id] = callback;
_emu_cbs[asyncTestCommandId] = callback;
}
sendAsyncMessage("Marionette:runEmulatorCmd", {emulator_cmd: cmd, id: _emu_cb_id});
_emu_cb_id += 1;
sendAsyncMessage("Marionette:runEmulatorCmd",
{command: cmd, id: asyncTestCommandId});
}
function runEmulatorShell(args, callback) {
if (callback) {
_emu_cbs[_emu_cb_id] = callback;
_emu_cbs[asyncTestCommandId] = callback;
}
sendAsyncMessage("Marionette:runEmulatorShell", {emulator_shell: args, id: _emu_cb_id});
_emu_cb_id += 1;
sendAsyncMessage("Marionette:runEmulatorShell",
{arguments: args, id: asyncTestCommandId});
}
function emulatorCmdResult(msg) {
let message = msg.json;
let {error, result, id} = msg.json;
if (error) {
let err = new JavaScriptError(error);
sendError(err, id);
return;
}
if (!sandboxes[sandboxName]) {
return;
}
let cb = _emu_cbs[message.id];
delete _emu_cbs[message.id];
let cb = _emu_cbs[id];
delete _emu_cbs[id];
if (!cb) {
return;
}
try {
cb(message.result);
cb(result);
} catch (e) {
sendError(e, -1);
return;
let err = new JavaScriptError(e);
sendError(err, id);
}
}

View File

@ -0,0 +1,288 @@
/* 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";
var {utils: Cu} = Components;
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("chrome://marionette/content/error.js");
this.EXPORTED_SYMBOLS = [
"Command",
"Message",
"MessageOrigin",
"Response",
];
const logger = Log.repository.getLogger("Marionette");
this.MessageOrigin = {
Client: 0,
Server: 1,
};
this.Message = {};
/**
* Converts a data packet into a Command or Response type.
*
* @param {Array.<number, number, ?, ?>} data
* A four element array where the elements, in sequence, signifies
* message type, message ID, method name or error, and parameters
* or result.
*
* @return {(Command,Response)}
* Based on the message type, a Command or Response instance.
*
* @throws {TypeError}
* If the message type is not recognised.
*/
Message.fromMsg = function(data) {
switch (data[0]) {
case Command.TYPE:
return Command.fromMsg(data);
case Response.TYPE:
return Response.fromMsg(data);
default:
throw new TypeError(
"Unrecognised message type in packet: " + JSON.stringify(data));
}
};
/**
* A command is a request from the client to run a series of remote end
* steps and return a fitting response.
*
* The command can be synthesised from the message passed over the
* Marionette socket using the {@code fromMsg} function. The format of
* a message is:
*
* [type, id, name, params]
*
* where
*
* type:
* Must be zero (integer). Zero means that this message is a command.
*
* id:
* Number used as a sequence number. The server replies with a
* requested id.
*
* name:
* String representing the command name with an associated set of
* remote end steps.
*
* params:
* Object of command function arguments. The keys of this object
* must be strings, but the values can be arbitrary values.
*
* A command has an associated message {@code id} that prevents the
* dispatcher from sending responses in the wrong order.
*
* The command may also have optional error- and result handlers that
* are called when the client returns with a response. These are
* {@code function onerror({Object})}, {@code function onresult({Object})},
* and {@code function onresult({Response})}.
*
* @param {number} msgId
* Message ID unique identifying this message.
* @param {string} name
* Command name.
* @param {Object<string, ?>} params
* Command parameters.
*/
this.Command = class {
constructor(msgId, name, params={}) {
this.id = msgId;
this.name = name;
this.parameters = params;
this.onerror = null;
this.onresult = null;
this.origin = MessageOrigin.Client;
this.sent = false;
}
/**
* Calls the error- or result handler associated with this command.
* This function can be replaced with a custom response handler.
*
* @param {Response} resp
* The response to pass on to the result or error to the
* {@code onerror} or {@code onresult} handlers to.
*/
onresponse(resp) {
if (resp.error && this.onerror) {
this.onerror(resp.error);
} else if (resp.body && this.onresult) {
this.onresult(resp.body);
}
}
toMsg() {
return [Command.TYPE, this.id, this.name, this.parameters];
}
toString() {
return "Command {id: " + this.id + ", " +
"name: " + JSON.stringify(this.name) + ", " +
"parameters: " + JSON.stringify(this.parameters) + "}"
}
static fromMsg(msg) {
return new Command(msg[1], msg[2], msg[3]);
}
};
Command.TYPE = 0;
const validator = {
exclusionary: {
"capabilities": ["error", "value"],
"error": ["value", "sessionId", "capabilities"],
"sessionId": ["error", "value"],
"value": ["error", "sessionId", "capabilities"],
},
set: function(obj, prop, val) {
let tests = this.exclusionary[prop];
if (tests) {
for (let t of tests) {
if (obj.hasOwnProperty(t)) {
throw new TypeError(`${t} set, cannot set ${prop}`);
}
}
}
obj[prop] = val;
return true;
},
};
/**
* The response body is exposed as an argument to commands.
* Commands can set fields on the body through defining properties.
*
* Setting properties invokes a validator that performs tests for
* mutually exclusionary fields on the input against the existing data
* in the body.
*
* For example setting the {@code error} property on the body when
* {@code value}, {@code sessionId}, or {@code capabilities} have been
* set previously will cause an error.
*/
this.ResponseBody = () => new Proxy({}, validator);
/**
* Represents the response returned from the remote end after execution
* of its corresponding command.
*
* The response is a mutable object passed to each command for
* modification through the available setters. To send data in a response,
* you modify the body property on the response. The body property can
* also be replaced completely.
*
* The response is sent implicitly by CommandProcessor when a command
* has finished executing, and any modifications made subsequent to that
* will have no effect.
*
* @param {number} msgId
* Message ID tied to the corresponding command request this is a
* response for.
* @param {function(Response|Message)} respHandler
* Function callback called on sending the response.
*/
this.Response = class {
constructor(msgId, respHandler) {
this.id = msgId;
this.error = null;
this.body = ResponseBody();
this.origin = MessageOrigin.Server;
this.sent = false;
this.respHandler_ = respHandler;
}
/**
* Sends response conditionally, given a predicate.
*
* @param {function(Response): boolean} predicate
* A predicate taking a Response object and returning a boolean.
*/
sendConditionally(predicate) {
if (predicate(this)) {
this.send();
}
}
/**
* Sends response using the response handler provided on construction.
*
* @throws {RangeError}
* If the response has already been sent.
*/
send() {
if (this.sent) {
throw new RangeError("Response has already been sent: " + this);
}
this.respHandler_(this);
this.sent = true;
}
/**
* Send given Error to client.
*
* Turns the response into an error response, clears any previously
* set body data, and sends it using the response handler provided
* on construction.
*
* @param {Error} err
* The Error instance to send.
*
* @throws {Error}
* If the {@code error} is not a WebDriverError, the error is
* propagated.
*/
sendError(err) {
let wd = error.isWebDriverError(err);
let we = wd ? err : new WebDriverError(err.message);
this.error = error.toJson(err);
this.body = null;
this.send();
// propagate errors that are implementation problems
if (!wd) {
throw err;
}
}
toMsg() {
return [Response.TYPE, this.id, this.error, this.body];
}
toString() {
return "Response {id: " + this.id + ", " +
"error: " + JSON.stringify(this.error) + ", " +
"body: " + JSON.stringify(this.body) + "}";
}
static fromMsg(msg) {
let resp = new Response(msg[1], null);
resp.error = msg[2];
resp.body = msg[3];
return resp;
}
};
Response.TYPE = 1;

View File

@ -93,7 +93,8 @@ MarionetteServer.prototype.driverFactory = function(emulator) {
Services.io.offline = false;
}
return new GeckoDriver(appName, device, emulator);
let stopSignal = () => this.stop();
return new GeckoDriver(appName, device, stopSignal, emulator);
};
MarionetteServer.prototype.start = function() {
@ -129,20 +130,17 @@ MarionetteServer.prototype.onSocketAccepted = function(
let transport = new DebuggerTransport(input, output);
let connId = "conn" + this.nextConnId++;
let stopSignal = () => this.stop();
let dispatcher = new Dispatcher(connId, transport, this.driverFactory, stopSignal);
let dispatcher = new Dispatcher(connId, transport, this.driverFactory.bind(this));
dispatcher.onclose = this.onConnectionClosed.bind(this);
this.conns[connId] = dispatcher;
logger.info(`Accepted connection ${connId} from ${clientSocket.host}:${clientSocket.port}`);
// Create a root actor for the connection and send the hello packet
dispatcher.sayHello();
transport.ready();
};
MarionetteServer.prototype.onConnectionClosed = function(conn) {
let id = conn.id;
let id = conn.connId;
delete this.conns[id];
logger.info(`Closed connection ${id}`);
};