From 306af8a37f1c17c7efcd5364f51efca5ca7f9f0f Mon Sep 17 00:00:00 2001 From: Andreas Tolfsen Date: Thu, 2 Apr 2015 15:16:00 +0100 Subject: [PATCH] Bug 941085: File uploads support in Marionette Adds support for W3C WebDriver compatible file uploads, where additional calls to sendKeys on will append files, rather than reset the field. r=dburns --HG-- extra : rebase_source : a3b3ace100e68be1d2df7a9c98dfa75f5b1afa40 extra : source : 93166201fca032157ecb88923a62e8b2e8f9529d --- .../marionette/client/marionette/__init__.py | 1 - .../client/marionette/runner/__init__.py | 2 +- .../client/marionette/runner/base.py | 38 ++--- .../client/marionette/runner/httpd.py | 61 ++++++++ .../marionette/tests/unit/test_file_upload.py | 135 ++++++++++++++++++ .../marionette/tests/unit/unit-tests.ini | 3 + testing/marionette/driver.js | 36 ++++- testing/marionette/error.js | 10 ++ testing/marionette/listener.js | 46 +++++- 9 files changed, 303 insertions(+), 29 deletions(-) create mode 100644 testing/marionette/client/marionette/runner/httpd.py create mode 100644 testing/marionette/client/marionette/tests/unit/test_file_upload.py diff --git a/testing/marionette/client/marionette/__init__.py b/testing/marionette/client/marionette/__init__.py index 1894eb274cfe..fd39434f0f9e 100644 --- a/testing/marionette/client/marionette/__init__.py +++ b/testing/marionette/client/marionette/__init__.py @@ -18,7 +18,6 @@ from runner import ( MarionetteTestResult, MarionetteTextTestRunner, MemoryEnduranceTestCaseMixin, - MozHttpd, OptionParser, TestManifest, TestResult, diff --git a/testing/marionette/client/marionette/runner/__init__.py b/testing/marionette/client/marionette/runner/__init__.py index c0ba0a7d487a..7458855bf1a5 100644 --- a/testing/marionette/client/marionette/runner/__init__.py +++ b/testing/marionette/client/marionette/runner/__init__.py @@ -5,7 +5,7 @@ from base import ( B2GTestResultMixin, BaseMarionetteOptions, BaseMarionetteTestRunner, Marionette, MarionetteTest, MarionetteTestResult, MarionetteTextTestRunner, - MozHttpd, OptionParser, TestManifest, TestResult, TestResultCollection + OptionParser, TestManifest, TestResult, TestResultCollection ) from mixins import ( B2GTestCaseMixin, B2GTestResultMixin, EnduranceOptionsMixin, diff --git a/testing/marionette/client/marionette/runner/base.py b/testing/marionette/client/marionette/runner/base.py index 9690104967f5..55c2ea6fef3e 100644 --- a/testing/marionette/client/marionette/runner/base.py +++ b/testing/marionette/client/marionette/runner/base.py @@ -14,18 +14,20 @@ import sys import time import traceback import unittest +import warnings import xml.dom.minidom as dom from manifestparser import TestManifest from manifestparser.filters import tags from marionette_driver.marionette import Marionette from mixins.b2g import B2GTestResultMixin, get_b2g_pid, get_dm -from mozhttpd import MozHttpd from mozlog.structured.structuredlog import get_default_logger from moztest.adapters.unit import StructuredTestRunner, StructuredTestResult from moztest.results import TestResultCollection, TestResult, relevant_line import mozversion +import httpd + here = os.path.abspath(os.path.dirname(__file__)) @@ -638,24 +640,6 @@ class BaseMarionetteTestRunner(object): self.skipped = 0 self.failures = [] - def start_httpd(self, need_external_ip): - if self.server_root is None or os.path.isdir(self.server_root): - host = '127.0.0.1' - if need_external_ip: - host = moznetwork.get_ip() - docroot = self.server_root or os.path.join(os.path.dirname(here), 'www') - if not os.path.isdir(docroot): - raise Exception('Server root %s is not a valid path' % docroot) - self.httpd = MozHttpd(host=host, - port=0, - docroot=docroot) - self.httpd.start() - self.marionette.baseurl = 'http://%s:%d/' % (host, self.httpd.httpd.server_port) - self.logger.info('running webserver on %s' % self.marionette.baseurl) - else: - self.marionette.baseurl = self.server_root - self.logger.info('using content from %s' % self.marionette.baseurl) - def _build_kwargs(self): kwargs = { 'device_serial': self.device_serial, @@ -781,6 +765,8 @@ setReq.onerror = function() { if not self.httpd: self.logger.info("starting httpd") self.start_httpd(need_external_ip) + self.marionette.baseurl = self.httpd.get_url() + self.logger.info("running httpd on %s" % self.marionette.baseurl) for test in tests: self.add_test(test) @@ -861,6 +847,20 @@ setReq.onerror = function() { self.logger.suite_end() + def start_httpd(self, need_external_ip): + warnings.warn("start_httpd has been deprecated in favour of create_httpd", + DeprecationWarning) + self.httpd = self.create_httpd(need_external_ip) + + def create_httpd(self, need_external_ip): + host = "127.0.0.1" + if need_external_ip: + host = moznetwork.get_ip() + root = self.server_root or os.path.join(os.path.dirname(here), "www") + rv = httpd.FixtureServer(root, host=host) + rv.start() + return rv + def add_test(self, test, expected='pass', test_container=None): filepath = os.path.abspath(test) diff --git a/testing/marionette/client/marionette/runner/httpd.py b/testing/marionette/client/marionette/runner/httpd.py new file mode 100644 index 000000000000..1c787c9e22cb --- /dev/null +++ b/testing/marionette/client/marionette/runner/httpd.py @@ -0,0 +1,61 @@ +# 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/. + +import os + +from mozhttpd import MozHttpd + + +class FixtureServer(object): + + def __init__(self, root, host="127.0.0.1", port=0): + if not os.path.isdir(root): + raise Exception("Server root is not a valid path: %s" % root) + self.root = root + self.host = host + self.port = port + self._server = None + + def start(self, block=False): + if self.alive: + return + self._server = MozHttpd(host=self.host, port=self.port, docroot=self.root, urlhandlers=[ + {"method": "POST", "path": "/file_upload", "function": upload_handler}]) + self._server.start(block=block) + self.port = self._server.httpd.server_port + self.base_url = self.get_url() + + def stop(self): + if not self.alive: + return + self._server.stop() + self._server = None + + @property + def alive(self): + return self._server is not None + + def get_url(self, path="/"): + if not self.alive: + raise "Server not started" + return self._server.get_url(path) + + @property + def urlhandlers(self): + return self._server.urlhandlers + + +def upload_handler(query, postdata=None): + return (200, {}, query.headers.getheader("Content-Type")) + + +if __name__ == "__main__": + here = os.path.abspath(os.path.dirname(__file__)) + root = os.path.join(os.path.dirname(here), "www") + httpd = FixtureServer(root, port=2829) + print "Started fixture server on http://%s:%d/" % (httpd.host, httpd.port) + try: + httpd.start(True) + except KeyboardInterrupt: + pass diff --git a/testing/marionette/client/marionette/tests/unit/test_file_upload.py b/testing/marionette/client/marionette/tests/unit/test_file_upload.py new file mode 100644 index 000000000000..8d1201470b88 --- /dev/null +++ b/testing/marionette/client/marionette/tests/unit/test_file_upload.py @@ -0,0 +1,135 @@ +# 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/. + +import contextlib +import urllib + +from tempfile import NamedTemporaryFile as tempfile + +from marionette import MarionetteTestCase, skip +from marionette_driver import By, errors, expected +from marionette_driver.wait import Wait + + +single = "data:text/html,%s" % urllib.quote("") +multiple = "data:text/html,%s" % urllib.quote("") +upload = lambda url: "data:text/html,%s" % urllib.quote(""" +
+ + +
""" % url) + + +class TestFileUpload(MarionetteTestCase): + + def test_sets_one_file(self): + self.marionette.navigate(single) + input = self.input + + exp = None + with tempfile() as f: + input.send_keys(f.name) + exp = [f.name] + + files = self.get_files(input) + self.assertEqual(len(files), 1) + self.assertFilesEqual(files, exp) + + def test_sets_multiple_files(self): + self.marionette.navigate(multiple) + input = self.input + + exp = None + with contextlib.nested(tempfile(), tempfile()) as (a, b): + input.send_keys(a.name) + input.send_keys(b.name) + exp = [a.name, b.name] + + files = self.get_files(input) + self.assertEqual(len(files), 2) + self.assertFilesEqual(files, exp) + + def test_sets_multiple_indentical_files(self): + self.marionette.navigate(multiple) + input = self.input + + exp = [] + with tempfile() as f: + input.send_keys(f.name) + input.send_keys(f.name) + exp = f.name + + files = self.get_files(input) + self.assertEqual(len(files), 2) + self.assertFilesEqual(files, exp) + + def test_clear_file(self): + self.marionette.navigate(single) + input = self.input + + with tempfile() as f: + input.send_keys(f.name) + + self.assertEqual(len(self.get_files(input)), 1) + input.clear() + self.assertEqual(len(self.get_files(input)), 0) + + def test_clear_files(self): + self.marionette.navigate(multiple) + input = self.input + + with contextlib.nested(tempfile(), tempfile()) as (a, b): + input.send_keys(a.name) + input.send_keys(b.name) + + self.assertEqual(len(self.get_files(input)), 2) + input.clear() + self.assertEqual(len(self.get_files(input)), 0) + + def test_illegal_file(self): + self.marionette.navigate(single) + with self.assertRaisesRegexp(errors.MarionetteException, "File not found"): + self.input.send_keys("rochefort") + + def test_upload(self): + self.marionette.navigate( + upload(self.marionette.absolute_url("file_upload"))) + + with tempfile() as f: + f.write("camembert") + f.flush() + self.input.send_keys(f.name) + self.submit.click() + + self.assertIn("multipart/form-data", self.body.text) + + def find_inputs(self): + return self.marionette.find_elements("tag name", "input") + + @property + def input(self): + return self.find_inputs()[0] + + @property + def submit(self): + return self.find_inputs()[1] + + @property + def body(self): + return Wait(self.marionette).until( + expected.element_present(By.TAG_NAME, "body")) + + def get_files(self, el): + # This is horribly complex because (1) Marionette doesn't serialise arrays properly, + # and (2) accessing File.name in the content JS throws a permissions + # error. + fl = self.marionette.execute_script( + "return arguments[0].files", script_args=[el]) + return [f["name"] for f in [v for k, v in fl.iteritems() if k.isdigit()]] + + def assertFilesEqual(self, act, exp): + # File array returned from browser doesn't contain full path names, + # this cuts off the path of the expected files. + filenames = [f.rsplit("/", 0)[-1] for f in act] + self.assertListEqual(filenames, act) diff --git a/testing/marionette/client/marionette/tests/unit/unit-tests.ini b/testing/marionette/client/marionette/tests/unit/unit-tests.ini index 5ad6d7f47024..2e539e9f66d0 100644 --- a/testing/marionette/client/marionette/tests/unit/unit-tests.ini +++ b/testing/marionette/client/marionette/tests/unit/unit-tests.ini @@ -154,3 +154,6 @@ b2g = false b2g = false [test_teardown_context_preserved.py] b2g = false +[test_file_upload.py] +b2g = false +skip-if = os == "win" # http://bugs.python.org/issue14574 diff --git a/testing/marionette/driver.js b/testing/marionette/driver.js index 7551fa07b628..fa0bc8631766 100644 --- a/testing/marionette/driver.js +++ b/testing/marionette/driver.js @@ -884,8 +884,9 @@ GeckoDriver.prototype.executeScriptInSandbox = function( directInject, async, timeout) { - if (directInject && async && (timeout == null || timeout == 0)) + if (directInject && async && (timeout == null || timeout == 0)) { throw new TimeoutError("Please set a timeout"); + } if (this.importedScripts.exists()) { let stream = Cc["@mozilla.org/network/file-input-stream;1"] @@ -2307,6 +2308,10 @@ GeckoDriver.prototype.getElementRect = function(cmd, resp) { GeckoDriver.prototype.sendKeysToElement = function(cmd, resp) { let {id, value} = cmd.parameters; + if (!value) { + throw new IllegalArgumentError(`Expected character sequence: ${value}`); + } + switch (this.context) { case Context.CHROME: let win = this.getCurrentWindow(); @@ -2322,7 +2327,36 @@ GeckoDriver.prototype.sendKeysToElement = function(cmd, resp) { break; case Context.CONTENT: + let err; + let listener = function(msg) { + this.mm.removeMessageListener("Marionette:setElementValue", listener); + + let val = msg.data.value; + let el = msg.objects.element; + let win = this.getCurrentWindow(); + + if (el.type == "file") { + Cu.importGlobalProperties(["File"]); + let fs = Array.prototype.slice.call(el.files); + let file; + try { + file = new File(val); + } catch (e) { + err = new IllegalArgumentError(`File not found: ${val}`); + } + fs.push(file); + el.mozSetFileArray(fs); + } else { + el.value = val; + } + }.bind(this); + + this.mm.addMessageListener("Marionette:setElementValue", listener); yield this.listener.sendKeysToElement({id: id, value: value}); + this.mm.removeMessageListener("Marionette:setElementValue", listener); + if (err) { + throw err; + } break; } }; diff --git a/testing/marionette/error.js b/testing/marionette/error.js index db5edcaaf236..9f664fa37260 100644 --- a/testing/marionette/error.js +++ b/testing/marionette/error.js @@ -10,6 +10,7 @@ const errors = [ "ElementNotVisibleError", "FrameSendFailureError", "FrameSendNotInitializedError", + "IllegalArgumentError", "InvalidElementStateError", "JavaScriptError", "NoAlertOpenError", @@ -159,6 +160,14 @@ this.FrameSendNotInitializedError = function(frame) { }; FrameSendNotInitializedError.prototype = Object.create(WebDriverError.prototype); +this.IllegalArgumentError = function(msg) { + WebDriverError.call(this, msg); + this.name = "IllegalArgumentError"; + this.status = "illegal argument"; + this.code = 13; // unknown error +}; +IllegalArgumentError.prototype = Object.create(WebDriverError.prototype); + this.InvalidElementStateError = function(msg) { WebDriverError.call(this, msg); this.name = "InvalidElementStateError"; @@ -300,6 +309,7 @@ const errorObjs = [ this.ElementNotVisibleError, this.FrameSendFailureError, this.FrameSendNotInitializedError, + this.IllegalArgumentError, this.InvalidElementStateError, this.JavaScriptError, this.NoAlertOpenError, diff --git a/testing/marionette/listener.js b/testing/marionette/listener.js index c10b3d9e2e3d..33822d5cd4db 100644 --- a/testing/marionette/listener.js +++ b/testing/marionette/listener.js @@ -35,9 +35,10 @@ let isB2G = false; let marionetteTestName; let winUtil = content.QueryInterface(Ci.nsIInterfaceRequestor) - .getInterface(Ci.nsIDOMWindowUtils); -let listenerId = null; //unique ID of this listener + .getInterface(Ci.nsIDOMWindowUtils); +let listenerId = null; // unique ID of this listener let curFrame = content; +let isRemoteBrowser = () => curFrame.contentWindow !== null; let previousFrame = null; let elementManager = new ElementManager([]); let accessibility = new Accessibility(); @@ -1548,11 +1549,39 @@ function isElementSelected(msg) { */ function sendKeysToElement(msg) { let command_id = msg.json.command_id; + let val = msg.json.value; let el = elementManager.getKnownElement(msg.json.id, curFrame); - let keysToSend = msg.json.value; + if (el.type == "file") { + let p = val.join(""); - utils.sendKeysToElement(curFrame, el, keysToSend, sendOk, sendError, command_id); + // for some reason using mozSetFileArray doesn't work with e10s + // enabled (probably a bug), but a workaround is to elevate the element's + // privileges with SpecialPowers + // + // this extra branch can be removed when the e10s bug 1149998 is fixed + if (isRemoteBrowser()) { + let fs = Array.prototype.slice.call(el.files); + let file; + try { + file = new File(p); + } catch (e) { + let err = new IllegalArgumentError(`File not found: ${val}`); + sendError(err.message, err.code, err.stack, command_id); + return; + } + fs.push(file); + + let wel = new SpecialPowers(utils.window).wrap(el); + wel.mozSetFileArray(fs); + } else { + sendSyncMessage("Marionette:setElementValue", {value: p}, {element: el}); + } + + sendOk(command_id); + } else { + utils.sendKeysToElement(curFrame, el, val, sendOk, sendError, command_id); + } } /** @@ -1582,10 +1611,13 @@ function clearElement(msg) { let command_id = msg.json.command_id; try { let el = elementManager.getKnownElement(msg.json.id, curFrame); - utils.clearElement(el); + if (el.type == "file") { + el.value = null; + } else { + utils.clearElement(el); + } sendOk(command_id); - } - catch (e) { + } catch (e) { sendError(e.message, e.code, e.stack, command_id); } }