Bug 1223907: Refactor cookies in Marionette

Moves most of the cookie implementation to a new file,
testing/marionette/cookies.js.  The new Cookies class encapsulates all
APIs for manipulating and querying cookies from content space.

It communicates with chrome space, where the cookie manager lives, through
a new SyncContentSender provided by testing/marionette/proxy.js.  This new
interface provides synchronous and transparent communication from content
to chrome, not dissimilar from how the original listener proxy works.

r=dburns
This commit is contained in:
Andreas Tolfsen 2015-11-13 13:35:22 +00:00
parent ce1b87d818
commit 84d1744c1c
7 changed files with 336 additions and 218 deletions

View File

@ -0,0 +1,131 @@
/* 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("chrome://marionette/content/error.js");
const logger = Log.repository.getLogger("Marionette");
this.EXPORTED_SYMBOLS = ["Cookies"];
const IPV4_PORT_EXPR = /:\d+$/;
/**
* Interface for manipulating cookies from content space.
*/
this.Cookies = class {
/**
* @param {function(): Document} documentFn
* Closure that returns the current content document.
* @param {Proxy(SyncChromeSender)} chromeProxy
* A synchronous proxy interface to chrome space.
*/
constructor(documentFn, chromeProxy) {
this.documentFn_ = documentFn;
this.chrome = chromeProxy;
}
get document() {
return this.documentFn_();
}
[Symbol.iterator]() {
let path = this.document.location.pathname || "/";
let cs = this.chrome.getVisibleCookies(path, this.document.location.hostname)[0];
return cs[Symbol.iterator]();
}
/**
* Add a new cookie to a content document.
*
* @param {string} name
* Cookie key.
* @param {string} value
* Cookie value.
* @param {Object.<string, ?>} opts
* An object with the optional fields {@code domain}, {@code path},
* {@code secure}, {@code httpOnly}, and {@code expiry}.
*
* @return {Object.<string, ?>}
* A serialisation of the cookie that was added.
*
* @throws UnableToSetCookieError
* If the document's content type isn't HTML, the current document's
* domain is a mismatch to the cookie's provided domain, or there
* otherwise was issues with the input data.
*/
add(name, value, opts={}) {
if (typeof this.document == "undefined" || !this.document.contentType.match(/html/i)) {
throw new UnableToSetCookieError(
"You may only set cookies on HTML documents: " + this.document.contentType);
}
if (!opts.expiry) {
// date twenty years into future, in seconds
let date = new Date();
let now = new Date(Date.now());
date.setYear(now.getFullYear() + 20);
opts.expiry = date.getTime() / 1000;
}
if (!opts.domain) {
opts.domain = this.document.location.host;
} else if (this.document.location.host.indexOf(opts.domain) < 0) {
throw new InvalidCookieDomainError(
"You may only set cookies for the current domain");
}
// remove port from domain, if present.
// unfortunately this catches IPv6 addresses by mistake
// TODO: Bug 814416
opts.domain = opts.domain.replace(IPV4_PORT_EXPR, "");
let cookie = {
domain: opts.domain,
path: opts.path,
name: name,
value: value,
secure: opts.secure,
httpOnly: opts.httpOnly,
session: false,
expiry: opts.expiry,
};
if (!this.chrome.addCookie(cookie)) {
throw new UnableToSetCookieError();
}
return cookie;
}
/**
* Delete cookie by reference or by name.
*
* @param {(string|Object.<string, ?>)} cookie
* Name of cookie or cookie object.
*
* @throws {UnknownError}
* If unable to delete the cookie.
*/
delete(cookie) {
let name;
if (cookie.hasOwnProperty("name")) {
name = cookie.name;
} else {
name = cookie;
}
for (let candidate of this) {
if (candidate.name == name) {
if (!this.chrome.deleteCookie(candidate)) {
throw new UnknownError("Unable to delete cookie by name: " + name);
}
}
}
}
};

View File

@ -2290,7 +2290,22 @@ GeckoDriver.prototype.switchToShadowRoot = function(cmd, resp) {
/** Add a cookie to the document. */
GeckoDriver.prototype.addCookie = function(cmd, resp) {
yield this.listener.addCookie({cookie: cmd.parameters.cookie});
let cb = msg => {
this.mm.removeMessageListener("Marionette:addCookie", cb);
let cookie = msg.json;
Services.cookies.add(
cookie.domain,
cookie.path,
cookie.name,
cookie.value,
cookie.secure,
cookie.httpOnly,
cookie.session,
cookie.expiry);
return true;
};
this.mm.addMessageListener("Marionette:addCookie", cb);
yield this.listener.addCookie(cmd.parameters.cookie);
};
/**
@ -2305,12 +2320,34 @@ GeckoDriver.prototype.getCookies = function(cmd, resp) {
/** Delete all cookies that are visible to a document. */
GeckoDriver.prototype.deleteAllCookies = function(cmd, resp) {
let cb = msg => {
let cookie = msg.json;
cookieManager.remove(
cookie.host,
cookie.name,
cookie.path,
false);
return true;
};
this.mm.addMessageListener("Marionette:deleteCookie", cb);
yield this.listener.deleteAllCookies();
this.mm.removeMessageListener("Marionette:deleteCookie", cb);
};
/** Delete a cookie by name. */
GeckoDriver.prototype.deleteCookie = function(cmd, resp) {
yield this.listener.deleteCookie({name: cmd.parameters.name});
let cb = msg => {
this.mm.removeMessageListener("Marionette:deleteCookie", cb);
let cookie = msg.json;
cookieManager.remove(
cookie.host,
cookie.name,
cookie.path,
false);
return true;
};
this.mm.addMessageListener("Marionette:deleteCookie", cb);
yield this.listener.deleteCookie(cmd.parameters.name);
};
/**
@ -2824,7 +2861,7 @@ GeckoDriver.prototype.receiveMessage = function(message) {
break;
case "Marionette:getVisibleCookies":
let [currentPath, host] = message.json.value;
let [currentPath, host] = message.json;
let isForCurrentPath = path => currentPath.indexOf(path) != -1;
let results = [];
@ -2852,28 +2889,6 @@ GeckoDriver.prototype.receiveMessage = function(message) {
}
return results;
case "Marionette:addCookie":
let cookieToAdd = message.json.value;
Services.cookies.add(
cookieToAdd.domain,
cookieToAdd.path,
cookieToAdd.name,
cookieToAdd.value,
cookieToAdd.secure,
cookieToAdd.httpOnly,
false,
cookieToAdd.expiry);
return true;
case "Marionette:deleteCookie":
let cookieToDelete = message.json.value;
cookieManager.remove(
cookieToDelete.host,
cookieToDelete.name,
cookieToDelete.path,
false);
return true;
case "Marionette:getFiles":
// Generates file objects to send back to the content script
// for handling file uploads.

View File

@ -53,11 +53,6 @@ const XPCOM_EXCEPTIONS = [];
this.error = {};
/**
* Determines if the given status is successful.
*/
error.isSuccess = status => status === "success";
/**
* Checks if obj is an instance of the Error prototype in a safe manner.
* Prefer using this over using instanceof since the Error prototype

View File

@ -190,9 +190,7 @@ FrameManager.prototype = {
mm.addWeakMessageListener("Marionette:shareData", this.server);
mm.addWeakMessageListener("Marionette:switchToModalOrigin", this.server);
mm.addWeakMessageListener("Marionette:switchedToFrame", this.server);
mm.addWeakMessageListener("Marionette:addCookie", this.server);
mm.addWeakMessageListener("Marionette:getVisibleCookies", this.server);
mm.addWeakMessageListener("Marionette:deleteCookie", this.server);
mm.addWeakMessageListener("Marionette:register", this.server);
mm.addWeakMessageListener("Marionette:listenersAttached", this.server);
mm.addWeakMessageListener("Marionette:getFiles", this.server);
@ -222,9 +220,7 @@ FrameManager.prototype = {
mm.removeWeakMessageListener("Marionette:runEmulatorCmd", this.server);
mm.removeWeakMessageListener("Marionette:runEmulatorShell", this.server);
mm.removeWeakMessageListener("Marionette:switchedToFrame", this.server);
mm.removeWeakMessageListener("Marionette:addCookie", this.server);
mm.removeWeakMessageListener("Marionette:getVisibleCookies", this.server);
mm.removeWeakMessageListener("Marionette:deleteCookie", this.server);
mm.removeWeakMessageListener("Marionette:listenersAttached", this.server);
mm.removeWeakMessageListener("Marionette:register", this.server);
mm.removeWeakMessageListener("Marionette:getFiles", this.server);

View File

@ -22,6 +22,7 @@ marionette.jar:
content/modal.js (modal.js)
content/proxy.js (proxy.js)
content/capture.js (capture.js)
content/cookies.js (cookies.js)
#ifdef ENABLE_TESTS
content/test.xul (client/marionette/chrome/test.xul)
content/test2.xul (client/marionette/chrome/test2.xul)

View File

@ -14,8 +14,11 @@ loader.loadSubScript("chrome://marionette/content/simpletest.js");
loader.loadSubScript("chrome://marionette/content/common.js");
loader.loadSubScript("chrome://marionette/content/actions.js");
Cu.import("chrome://marionette/content/capture.js");
Cu.import("chrome://marionette/content/cookies.js");
Cu.import("chrome://marionette/content/elements.js");
Cu.import("chrome://marionette/content/error.js");
Cu.import("chrome://marionette/content/proxy.js");
Cu.import("resource://gre/modules/FileUtils.jsm");
Cu.import("resource://gre/modules/NetUtil.jsm");
Cu.import("resource://gre/modules/Task.jsm");
@ -77,6 +80,9 @@ var EVENT_INTERVAL = 30; // milliseconds
// last touch for each fingerId
var multiLast = {};
var chrome = proxy.toChrome(sendSyncMessage.bind(this));
var cookies = new Cookies(() => curContainer.frame.document, chrome);
Cu.import("resource://gre/modules/Log.jsm");
var logger = Log.repository.getLogger("Marionette");
logger.info("loaded listener.js");
@ -219,6 +225,9 @@ var singleTapFn = dispatch(singleTap);
var takeScreenshotFn = dispatch(takeScreenshot);
var actionChainFn = dispatch(actionChain);
var multiActionFn = dispatch(multiAction);
var addCookieFn = dispatch(addCookie);
var deleteCookieFn = dispatch(deleteCookie);
var deleteAllCookiesFn = dispatch(deleteAllCookies);
/**
* Start all message listeners
@ -265,10 +274,10 @@ function startListeners() {
addMessageListenerId("Marionette:getAppCacheStatus", getAppCacheStatus);
addMessageListenerId("Marionette:setTestName", setTestName);
addMessageListenerId("Marionette:takeScreenshot", takeScreenshotFn);
addMessageListenerId("Marionette:addCookie", addCookie);
addMessageListenerId("Marionette:addCookie", addCookieFn);
addMessageListenerId("Marionette:getCookies", getCookiesFn);
addMessageListenerId("Marionette:deleteAllCookies", deleteAllCookies);
addMessageListenerId("Marionette:deleteCookie", deleteCookie);
addMessageListenerId("Marionette:deleteAllCookies", deleteAllCookiesFn);
addMessageListenerId("Marionette:deleteCookie", deleteCookieFn);
}
/**
@ -370,10 +379,10 @@ function deleteSession(msg) {
removeMessageListenerId("Marionette:getAppCacheStatus", getAppCacheStatus);
removeMessageListenerId("Marionette:setTestName", setTestName);
removeMessageListenerId("Marionette:takeScreenshot", takeScreenshotFn);
removeMessageListenerId("Marionette:addCookie", addCookie);
removeMessageListenerId("Marionette:addCookie", addCookieFn);
removeMessageListenerId("Marionette:getCookies", getCookiesFn);
removeMessageListenerId("Marionette:deleteAllCookies", deleteAllCookies);
removeMessageListenerId("Marionette:deleteCookie", deleteCookie);
removeMessageListenerId("Marionette:deleteAllCookies", deleteAllCookiesFn);
removeMessageListenerId("Marionette:deleteCookie", deleteCookieFn);
if (isB2G) {
content.removeEventListener("mozbrowsershowmodalprompt", modalHandler, false);
}
@ -1838,47 +1847,9 @@ function switchToFrame(msg) {
sendResponse({value: rv}, command_id);
}
/**
* Add a cookie to the document
*/
function addCookie(msg) {
let cookie = msg.json.cookie;
if (!cookie.expiry) {
var date = new Date();
var thePresent = new Date(Date.now());
date.setYear(thePresent.getFullYear() + 20);
cookie.expiry = date.getTime() / 1000; // Stored in seconds.
}
if (!cookie.domain) {
var location = curContainer.frame.document.location;
cookie.domain = location.hostname;
} else {
var currLocation = curContainer.frame.location;
var currDomain = currLocation.host;
if (currDomain.indexOf(cookie.domain) == -1) {
sendError(new InvalidCookieDomainError("You may only set cookies for the current domain"), msg.json.command_id);
}
}
// The cookie's domain may include a port. Which is bad. Remove it
// We'll catch ip6 addresses by mistake. Since no-one uses those
// this will be okay for now. See Bug 814416
if (cookie.domain.match(/:\d+$/)) {
cookie.domain = cookie.domain.replace(/:\d+$/, '');
}
var document = curContainer.frame.document;
if (!document || !document.contentType.match(/html/i)) {
sendError(new UnableToSetCookieError("You may only set cookies on html documents"), msg.json.command_id);
}
let added = sendSyncMessage("Marionette:addCookie", {value: cookie});
if (added[0] !== true) {
sendError(new UnableToSetCookieError(), msg.json.command_id);
return;
}
sendOk(msg.json.command_id);
function addCookie(cookie) {
cookies.add(cookie.name, cookie.value, cookie);
}
/**
@ -1886,7 +1857,6 @@ function addCookie(msg) {
*/
function getCookies() {
let rv = [];
let cookies = getVisibleCookies(curContainer.frame.location);
for (let cookie of cookies) {
let expires = cookie.expires;
@ -1912,47 +1882,19 @@ function getCookies() {
}
/**
* Delete a cookie by name
* Delete a cookie by name.
*/
function deleteCookie(msg) {
let toDelete = msg.json.name;
let cookies = getVisibleCookies(curContainer.frame.location);
for (let cookie of cookies) {
if (cookie.name == toDelete) {
let deleted = sendSyncMessage("Marionette:deleteCookie", {value: cookie});
if (deleted[0] !== true) {
sendError(new UnknownError("Could not delete cookie: " + msg.json.name), msg.json.command_id);
return;
}
}
}
sendOk(msg.json.command_id);
function deleteCookie(name) {
cookies.delete(name);
}
/**
* Delete all the visibile cookies on a page
* Delete all the visibile cookies on a page.
*/
function deleteAllCookies(msg) {
let cookies = getVisibleCookies(curContainer.frame.location);
function deleteAllCookies() {
for (let cookie of cookies) {
let deleted = sendSyncMessage("Marionette:deleteCookie", {value: cookie});
if (!deleted[0]) {
sendError(new UnknownError("Could not delete cookie: " + JSON.stringify(cookie)), msg.json.command_id);
return;
}
cookies.delete(cookie);
}
sendOk(msg.json.command_id);
}
/**
* Get all the visible cookies from a location
*/
function getVisibleCookies(location) {
let currentPath = location.pathname || '/';
let result = sendSyncMessage("Marionette:getVisibleCookies",
{value: [currentPath, location.hostname]});
return result[0];
}
function getAppCacheStatus(msg) {

View File

@ -4,7 +4,7 @@
"use strict";
var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("chrome://marionette/content/modal.js");
@ -18,11 +18,22 @@ const MARIONETTE_ERROR = "Marionette:error";
const logger = Log.repository.getLogger("Marionette");
const uuidgen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
// Proxy handler that traps requests to get a property. Will prioritise
// properties that exist on the object's own prototype.
var ownPriorityGetterTrap = {
get: (obj, prop) => {
if (obj.hasOwnProperty(prop)) {
return obj[prop];
}
return (...args) => obj.send(prop, args);
}
};
this.proxy = {};
/**
* Creates a transparent interface between the chrome- and content
* processes.
* contexts.
*
* Calls to this object will be proxied via the message manager to the active
* browsing context (content) and responses will be provided back as
@ -39,20 +50,12 @@ this.proxy = {};
* Callback for sending async messages to the current listener.
*/
proxy.toListener = function(mmFn, sendAsyncFn) {
let sender = new ContentSender(mmFn, sendAsyncFn);
let handler = {
get: (obj, prop) => {
if (obj.hasOwnProperty(prop)) {
return obj[prop];
}
return (...args) => obj.send(prop, args);
}
};
return new Proxy(sender, handler);
let sender = new AsyncContentSender(mmFn, sendAsyncFn);
return new Proxy(sender, ownPriorityGetterTrap);
};
/**
* The ContentSender allows one to make synchronous calls to the
* The AsyncContentSender allows one to make synchronous calls to the
* message listener of the content frame of the current browsing context.
*
* Presumptions about the responses from content space are made so we
@ -62,103 +65,138 @@ proxy.toListener = function(mmFn, sendAsyncFn) {
*
* The promise is guaranteed not to resolve until the execution of the
* command in content space is complete.
*
* @param {function(): (nsIMessageSender|nsIMessageBroadcaster)} mmFn
* Function returning the current message manager.
* @param {function(string, Object, number)} sendAsyncFn
* Callback for sending async messages to the current listener.
*/
var ContentSender = function(mmFn, sendAsyncFn) {
this.curId = null;
this.sendAsync = sendAsyncFn;
this.mmFn_ = mmFn;
this._listeners = [];
};
Object.defineProperty(ContentSender.prototype, "mm", {
get: function() { return this.mmFn_(); }
});
ContentSender.prototype.removeListeners = function () {
this._listeners.map(l => this.mm.removeMessageListener(l[0], l[1]));
this._listeners = [];
}
/**
* Call registered function in the frame script environment of the
* current browsing context's content frame.
*
* @param {string} name
* Function to call in the listener, e.g. for "Marionette:foo8",
* use "foo".
* @param {Array} args
* Argument list to pass the function. If args has a single entry
* that is an object, we assume it's an old style dispatch, and
* the object will passed literally.
*
* @return {Promise}
* A promise that resolves to the result of the command.
*/
ContentSender.prototype.send = function(name, args) {
if (this._listeners[0]) {
// A prior (probably timed-out) request has left listeners behind.
// Remove them before proceeding.
logger.warn("A previous failed command left content listeners behind!");
this.removeListeners();
this.AsyncContentSender = class {
constructor(mmFn, sendAsyncFn) {
this.curId = null;
this.sendAsync = sendAsyncFn;
this.mmFn_ = mmFn;
this._listeners = [];
}
this.curId = uuidgen.generateUUID().toString();
get mm() {
return this.mmFn_();
}
let proxy = new Promise((resolve, reject) => {
let removeListeners = (n, fn) => {
let rmFn = msg => {
if (this.curId !== msg.json.command_id) {
logger.warn("Skipping out-of-sync response from listener: " +
`Expected response to ${name} with ID ${this.curId}, ` +
"but got: " + msg.name + msg.json.toSource());
return;
}
removeListeners() {
this._listeners.map(l => this.mm.removeMessageListener(l[0], l[1]));
this._listeners = [];
}
this.removeListeners();
modal.removeHandler(handleDialog);
fn(msg);
this.curId = null;
};
this._listeners.push([n, rmFn]);
return rmFn;
};
let okListener = () => resolve();
let valListener = msg => resolve(msg.json.value);
let errListener = msg => reject(msg.objects.error);
let handleDialog = (subject, topic) => {
this.removeListeners()
modal.removeHandler(handleDialog);
this.sendAsync("cancelRequest");
resolve();
};
// start content process listeners, and install observers for global-
// and tab modal dialogues
this.mm.addMessageListener(MARIONETTE_OK, removeListeners(MARIONETTE_OK, okListener));
this.mm.addMessageListener(MARIONETTE_DONE, removeListeners(MARIONETTE_DONE, valListener));
this.mm.addMessageListener(MARIONETTE_ERROR, removeListeners(MARIONETTE_ERROR, errListener));
modal.addHandler(handleDialog);
// new style dispatches are arrays of arguments, old style dispatches
// are key-value objects
let msg = args;
if (args.length == 1 && typeof args[0] == "object") {
msg = args[0];
/**
* Call registered function in the frame script environment of the
* current browsing context's content frame.
*
* @param {string} name
* Function to call in the listener, e.g. for "Marionette:foo8",
* use "foo".
* @param {Array} args
* Argument list to pass the function. If args has a single entry
* that is an object, we assume it's an old style dispatch, and
* the object will passed literally.
*
* @return {Promise}
* A promise that resolves to the result of the command.
*/
send(name, args) {
if (this._listeners[0]) {
// A prior (probably timed-out) request has left listeners behind.
// Remove them before proceeding.
logger.warn("A previous failed command left content listeners behind!");
this.removeListeners();
}
this.sendAsync(name, msg, this.curId);
});
this.curId = uuidgen.generateUUID().toString();
return proxy;
let proxy = new Promise((resolve, reject) => {
let removeListeners = (n, fn) => {
let rmFn = msg => {
if (this.curId !== msg.json.command_id) {
logger.warn("Skipping out-of-sync response from listener: " +
`Expected response to ${name} with ID ${this.curId}, ` +
"but got: " + msg.name + msg.json.toSource());
return;
}
this.removeListeners();
modal.removeHandler(handleDialog);
fn(msg);
this.curId = null;
};
this._listeners.push([n, rmFn]);
return rmFn;
};
let okListener = () => resolve();
let valListener = msg => resolve(msg.json.value);
let errListener = msg => reject(msg.objects.error);
let handleDialog = (subject, topic) => {
this.removeListeners()
modal.removeHandler(handleDialog);
this.sendAsync("cancelRequest");
resolve();
};
// start content process listeners, and install observers for global-
// and tab modal dialogues
this.mm.addMessageListener(MARIONETTE_OK, removeListeners(MARIONETTE_OK, okListener));
this.mm.addMessageListener(MARIONETTE_DONE, removeListeners(MARIONETTE_DONE, valListener));
this.mm.addMessageListener(MARIONETTE_ERROR, removeListeners(MARIONETTE_ERROR, errListener));
modal.addHandler(handleDialog);
this.sendAsync(name, marshal(args), this.curId);
});
return proxy;
}
};
proxy.ContentSender = ContentSender;
/**
* Creates a transparent interface from the content- to the chrome context.
*
* Calls to this object will be proxied via the frame's sendSyncMessage
* (nsISyncMessageSender) function. Since the message is synchronous,
* the return value is presented as a return value.
*
* Example on how to use from a frame content script:
*
* let chrome = proxy.toChrome(sendSyncMessage.bind(this));
* let cookie = chrome.getCookie("foo");
*
* @param {nsISyncMessageSender} sendSyncMessageFn
* The frame message manager's sendSyncMessage function.
*/
proxy.toChrome = function(sendSyncMessageFn) {
let sender = new SyncChromeSender(sendSyncMessageFn);
return new Proxy(sender, ownPriorityGetterTrap);
};
/**
* The SyncChromeSender sends synchronous RPC messages to the chrome
* context, using a frame's sendSyncMessage (nsISyncMessageSender) function.
*
* Example on how to use from a frame content script:
*
* let sender = new SyncChromeSender(sendSyncMessage.bind(this));
* let res = sender.send("addCookie", cookie);
*/
this.SyncChromeSender = class {
constructor(sendSyncMessage) {
this.sendSyncMessage_ = sendSyncMessage;
}
send(func, args) {
let name = "Marionette:" + func;
return this.sendSyncMessage_(name, marshal(args));
}
};
var marshal = function(args) {
if (args.length == 1 && typeof args[0] == "object") {
return args[0];
}
return args;
};