Auto merge of #6133 - jgraham:servodriver, r=Ms2ger

This runs tests using the WebDriver protocol without restarting
the browser each time, and is currently enabled with
--product servodriver on the command line

<!-- Reviewable:start -->
[<img src="https://reviewable.io/review_button.png" height=40 alt="Review on Reviewable"/>](https://reviewable.io/reviews/servo/servo/6133)
<!-- Reviewable:end -->
This commit is contained in:
bors-servo 2015-05-19 07:14:24 -05:00
commit b23f4266ad
11 changed files with 1020 additions and 8 deletions

View file

@ -1,5 +1,6 @@
[products]
servo =
servodriver =
[web-platform-tests]
remote_url = https://github.com/w3c/web-platform-tests.git

View file

@ -1,5 +1,6 @@
[products]
servo =
servodriver =
[web-platform-tests]
name = CSS tests

View file

@ -29,4 +29,5 @@ module global scope.
product_list = ["b2g",
"chrome",
"firefox",
"servo"]
"servo",
"servodriver"]

View file

@ -4,6 +4,7 @@
import os
import subprocess
import sys
import mozinfo
from mozprocess import ProcessHandler
@ -70,7 +71,6 @@ class FirefoxBrowser(Browser):
self.binary = binary
self.prefs_root = prefs_root
self.marionette_port = None
self.used_ports.add(self.marionette_port)
self.runner = None
self.debug_info = debug_info
self.profile = None
@ -81,6 +81,7 @@ class FirefoxBrowser(Browser):
def start(self):
self.marionette_port = get_free_port(2828, exclude=self.used_ports)
self.used_ports.add(self.marionette_port)
env = os.environ.copy()
env["MOZ_DISABLE_NONLOCAL_CONNECTIONS"] = "1"
@ -191,7 +192,8 @@ class FirefoxBrowser(Browser):
env[env_var] = (os.path.pathsep.join([certutil_dir, env[env_var]])
if env_var in env else certutil_dir)
if env_var in env else certutil_dir).encode(
sys.getfilesystemencoding() or 'utf-8', 'replace')
def certutil(*args):
cmd = [self.certutil_binary] + list(args)

View file

@ -0,0 +1,141 @@
# 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
import subprocess
import tempfile
from mozprocess import ProcessHandler
from .base import Browser, require_arg, get_free_port, browser_command, ExecutorBrowser
from ..executors import executor_kwargs as base_executor_kwargs
from ..executors.executorservodriver import (ServoWebDriverTestharnessExecutor,
ServoWebDriverRefTestExecutor)
here = os.path.join(os.path.split(__file__)[0])
__wptrunner__ = {"product": "servodriver",
"check_args": "check_args",
"browser": "ServoWebDriverBrowser",
"executor": {"testharness": "ServoWebDriverTestharnessExecutor",
"reftest": "ServoWebDriverRefTestExecutor"},
"browser_kwargs": "browser_kwargs",
"executor_kwargs": "executor_kwargs",
"env_options": "env_options"}
hosts_text = """127.0.0.1 web-platform.test
127.0.0.1 www.web-platform.test
127.0.0.1 www1.web-platform.test
127.0.0.1 www2.web-platform.test
127.0.0.1 xn--n8j6ds53lwwkrqhv28a.web-platform.test
127.0.0.1 xn--lve-6lad.web-platform.test
"""
def check_args(**kwargs):
require_arg(kwargs, "binary")
def browser_kwargs(**kwargs):
return {"binary": kwargs["binary"],
"debug_info": kwargs["debug_info"]}
def executor_kwargs(test_type, server_config, cache_manager, **kwargs):
rv = base_executor_kwargs(test_type, server_config,
cache_manager, **kwargs)
return rv
def env_options():
return {"host": "web-platform.test",
"bind_hostname": "true",
"testharnessreport": "testharnessreport-servodriver.js",
"supports_debugger": True}
def make_hosts_file():
hosts_fd, hosts_path = tempfile.mkstemp()
with os.fdopen(hosts_fd, "w") as f:
f.write(hosts_text)
return hosts_path
class ServoWebDriverBrowser(Browser):
used_ports = set()
def __init__(self, logger, binary, debug_info=None, webdriver_host="127.0.0.1"):
Browser.__init__(self, logger)
self.binary = binary
self.webdriver_host = webdriver_host
self.webdriver_port = None
self.proc = None
self.debug_info = debug_info
self.hosts_path = make_hosts_file()
self.command = None
def start(self):
self.webdriver_port = get_free_port(4444, exclude=self.used_ports)
self.used_ports.add(self.webdriver_port)
env = os.environ.copy()
env["HOST_FILE"] = self.hosts_path
debug_args, command = browser_command(self.binary,
["--cpu", "--hard-fail",
"--webdriver", str(self.webdriver_port),
"about:blank"],
self.debug_info)
self.command = command
self.command = debug_args + self.command
if not self.debug_info or not self.debug_info.interactive:
self.proc = ProcessHandler(self.command,
processOutputLine=[self.on_output],
env=env,
storeOutput=False)
self.proc.run()
else:
self.proc = subprocess.Popen(self.command, env=env)
self.logger.debug("Servo Started")
def stop(self):
self.logger.debug("Stopping browser")
if self.proc is not None:
try:
self.proc.kill()
except OSError:
# This can happen on Windows if the process is already dead
pass
def pid(self):
if self.proc is None:
return None
try:
return self.proc.pid
except AttributeError:
return None
def on_output(self, line):
"""Write a line of output from the process to the log"""
self.logger.process_output(self.pid(),
line.decode("utf8", "replace"),
command=" ".join(self.command))
def is_alive(self):
if self.runner:
return self.runner.is_running()
return False
def cleanup(self):
self.stop()
def executor_browser(self):
assert self.webdriver_port is not None
return ExecutorBrowser, {"webdriver_host": self.webdriver_host,
"webdriver_port": self.webdriver_port}

View file

@ -157,20 +157,19 @@ class MarionetteProtocol(Protocol):
let prefInterface = Components.classes["@mozilla.org/preferences-service;1"]
.getService(Components.interfaces.nsIPrefBranch);
let pref = '%s';
let value = '%s';
let type = prefInterface.getPrefType(pref);
switch(type) {
case prefInterface.PREF_STRING:
prefInterface.setCharPref(pref, value);
prefInterface.setCharPref(pref, '%s');
break;
case prefInterface.PREF_BOOL:
prefInterface.setBoolPref(pref, value);
prefInterface.setBoolPref(pref, %s);
break;
case prefInterface.PREF_INT:
prefInterface.setIntPref(pref, value);
prefInterface.setIntPref(pref, %s);
break;
}
""" % (name, value)
""" % (name, value, value, value)
self.marionette.execute_script(script)
self.marionette.set_context(self.marionette.CONTEXT_CONTENT)

View file

@ -0,0 +1,229 @@
# 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 json
import os
import socket
import threading
import time
import traceback
from .base import (Protocol,
RefTestExecutor,
RefTestImplementation,
TestharnessExecutor,
strip_server)
import webdriver
from ..testrunner import Stop
here = os.path.join(os.path.split(__file__)[0])
extra_timeout = 5
class ServoWebDriverProtocol(Protocol):
def __init__(self, executor, browser, capabilities, **kwargs):
Protocol.__init__(self, executor, browser)
self.capabilities = capabilities
self.host = browser.webdriver_host
self.port = browser.webdriver_port
self.session = None
def setup(self, runner):
"""Connect to browser via WebDriver."""
self.runner = runner
session_started = False
try:
self.session = webdriver.Session(self.host, self.port)
self.session.start()
except:
self.logger.warning(
"Connecting with WebDriver failed:\n%s" % traceback.format_exc())
else:
self.logger.debug("session started")
session_started = True
if not session_started:
self.logger.warning("Failed to connect via WebDriver")
self.executor.runner.send_message("init_failed")
else:
self.executor.runner.send_message("init_succeeded")
def teardown(self):
self.logger.debug("Hanging up on WebDriver session")
try:
self.session.end()
except:
pass
def is_alive(self):
try:
# Get a simple property over the connection
self.session.handle
# TODO what exception?
except Exception:
return False
return True
def after_connect(self):
pass
def wait(self):
while True:
try:
self.session.execute_async_script("")
except webdriver.TimeoutException:
pass
except (socket.timeout, IOError):
break
except Exception as e:
self.logger.error(traceback.format_exc(e))
break
class ServoWebDriverRun(object):
def __init__(self, func, session, url, timeout):
self.func = func
self.result = None
self.session = session
self.url = url
self.timeout = timeout
self.result_flag = threading.Event()
def run(self):
timeout = self.timeout
try:
self.session.timeouts.script = timeout + extra_timeout
except IOError:
self.logger.error("Lost webdriver connection")
return Stop
executor = threading.Thread(target=self._run)
executor.start()
flag = self.result_flag.wait(timeout + 2 * extra_timeout)
if self.result is None:
assert not flag
self.result = False, ("EXTERNAL-TIMEOUT", None)
return self.result
def _run(self):
try:
self.result = True, self.func(self.session, self.url, self.timeout)
except webdriver.TimeoutException:
self.result = False, ("EXTERNAL-TIMEOUT", None)
except (socket.timeout, IOError):
self.result = False, ("CRASH", None)
except Exception as e:
message = getattr(e, "message", "")
if message:
message += "\n"
message += traceback.format_exc(e)
self.result = False, ("ERROR", e)
finally:
self.result_flag.set()
def timeout_func(timeout):
if timeout:
t0 = time.time()
return lambda: time.time() - t0 > timeout + extra_timeout
else:
return lambda: False
class ServoWebDriverTestharnessExecutor(TestharnessExecutor):
def __init__(self, browser, server_config, timeout_multiplier=1,
close_after_done=True, capabilities=None, debug_info=None):
TestharnessExecutor.__init__(self, browser, server_config, timeout_multiplier=1,
debug_info=None)
self.protocol = ServoWebDriverProtocol(self, browser, capabilities=capabilities)
with open(os.path.join(here, "testharness_servodriver.js")) as f:
self.script = f.read()
def on_protocol_change(self, new_protocol):
pass
def is_alive(self):
return self.protocol.is_alive()
def do_test(self, test):
url = self.test_url(test)
success, data = ServoWebDriverRun(self.do_testharness,
self.protocol.session,
url,
test.timeout * self.timeout_multiplier).run()
if success:
return self.convert_result(test, data)
return (test.result_cls(*data), [])
def do_testharness(self, session, url, timeout):
session.url = url
result = json.loads(
session.execute_async_script(
self.script % {"abs_url": url,
"url": strip_server(url),
"timeout_multiplier": self.timeout_multiplier,
"timeout": timeout * 1000}))
if "test" not in result:
result["test"] = strip_server(url)
return result
class TimeoutError(Exception):
pass
class ServoWebDriverRefTestExecutor(RefTestExecutor):
def __init__(self, browser, server_config, timeout_multiplier=1,
screenshot_cache=None, capabilities=None, debug_info=None):
"""Selenium WebDriver-based executor for reftests"""
RefTestExecutor.__init__(self,
browser,
server_config,
screenshot_cache=screenshot_cache,
timeout_multiplier=timeout_multiplier,
debug_info=debug_info)
self.protocol = ServoWebDriverProtocol(self, browser,
capabilities=capabilities)
self.implementation = RefTestImplementation(self)
with open(os.path.join(here, "reftest-wait_servodriver.js")) as f:
self.wait_script = f.read()
def is_alive(self):
return self.protocol.is_alive()
def do_test(self, test):
try:
result = self.implementation.run_test(test)
return self.convert_result(test, result)
except IOError:
return test.result_cls("CRASH", None), []
except TimeoutError:
return test.result_cls("TIMEOUT", None), []
except Exception as e:
message = getattr(e, "message", "")
if message:
message += "\n"
message += traceback.format_exc(e)
return test.result_cls("ERROR", message), []
def screenshot(self, test):
timeout = test.timeout * self.timeout_multiplier if self.debug_info is None else None
return ServoWebDriverRun(self._screenshot,
self.protocol.session,
self.test_url(test),
timeout).run()
def _screenshot(self, session, url, timeout):
session.url = url
session.execute_async_script(self.wait_script)
return session.screenshot()

View file

@ -0,0 +1,20 @@
/* 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/.
*/
callback = arguments[arguments.length - 1];
function check_done() {
if (!document.body.classList.contains('reftest-wait')) {
callback();
} else {
setTimeout(check_done, 50);
}
}
if (document.readyState === 'complete') {
check_done();
} else {
addEventListener("load", check_done);
}

View file

@ -0,0 +1,6 @@
/* 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/. */
window.__wd_results_callback__ = arguments[arguments.length - 1];
window.__wd_results_timer__ = setTimeout(timeout, %(timeout)s);

View file

@ -0,0 +1,587 @@
import errno
import httplib
import json
import socket
import time
import urlparse
from collections import defaultdict
element_key = "element-6066-11e4-a52e-4f735466cecf"
class WebDriverException(Exception):
http_status = None
status_code = None
def __init__(self, message):
self.message = message
class ElementNotSelectableException(WebDriverException):
http_status = 400
status_code = "element not selectable"
class ElementNotVisibleException(WebDriverException):
http_status = 400
status_code = "element not visible"
class InvalidArgumentException(WebDriverException):
http_status = 400
status_code = "invalid argument"
class InvalidCookieDomainException(WebDriverException):
http_status = 400
status_code = "invalid cookie domain"
class InvalidElementCoordinatesException(WebDriverException):
http_status = 400
status_code = "invalid element coordinates"
class InvalidElementStateException(WebDriverException):
http_status = 400
status_code = "invalid cookie domain"
class InvalidSelectorException(WebDriverException):
http_status = 400
status_code = "invalid selector"
class InvalidSessionIdException(WebDriverException):
http_status = 404
status_code = "invalid session id"
class JavascriptErrorException(WebDriverException):
http_status = 500
status_code = "javascript error"
class MoveTargetOutOfBoundsException(WebDriverException):
http_status = 500
status_code = "move target out of bounds"
class NoSuchAlertException(WebDriverException):
http_status = 400
status_code = "no such alert"
class NoSuchElementException(WebDriverException):
http_status = 404
status_code = "no such element"
class NoSuchFrameException(WebDriverException):
http_status = 400
status_code = "no such frame"
class NoSuchWindowException(WebDriverException):
http_status = 400
status_code = "no such window"
class ScriptTimeoutException(WebDriverException):
http_status = 408
status_code = "script timeout"
class SessionNotCreatedException(WebDriverException):
http_status = 500
status_code = "session not created"
class StaleElementReferenceException(WebDriverException):
http_status = 400
status_code = "stale element reference"
class TimeoutException(WebDriverException):
http_status = 408
status_code = "timeout"
class UnableToSetCookieException(WebDriverException):
http_status = 500
status_code = "unable to set cookie"
class UnexpectedAlertOpenException(WebDriverException):
http_status = 500
status_code = "unexpected alert open"
class UnknownErrorException(WebDriverException):
http_status = 500
status_code = "unknown error"
class UnknownCommandException(WebDriverException):
http_status = (404, 405)
status_code = "unknown command"
class UnsupportedOperationException(WebDriverException):
http_status = 500
status_code = "unsupported operation"
def group_exceptions():
exceptions = defaultdict(dict)
for item in _objs:
if type(item) == type and issubclass(item, WebDriverException):
if not isinstance(item.http_status, tuple):
statuses = (item.http_status,)
else:
statuses = item.http_status
for status in statuses:
exceptions[status][item.status_code] = item
return exceptions
_objs = locals().values()
_exceptions = group_exceptions()
del _objs
del group_exceptions
def wait_for_port(host, port, timeout=60):
""" Wait for the specified Marionette host/port to be available."""
starttime = time.time()
poll_interval = 0.1
while time.time() - starttime < timeout:
sock = None
try:
sock = socket.socket()
sock.connect((host, port))
return True
except socket.error as e:
if e[0] != errno.ECONNREFUSED:
raise
finally:
if sock:
sock.close()
time.sleep(poll_interval)
return False
class Transport(object):
def __init__(self, host, port, url_prefix="", port_timeout=60):
self.host = host
self.port = port
self.port_timeout = port_timeout
if url_prefix == "":
self.path_prefix = "/"
else:
self.path_prefix = "/%s/" % url_prefix.strip("/")
self._connection = None
def connect(self):
wait_for_port(self.host, self.port, self.port_timeout)
self._connection = httplib.HTTPConnection(self.host, self.port)
def close_connection(self):
if self._connection:
self._connection.close()
self._connection = None
def url(self, suffix):
return urlparse.urljoin(self.url_prefix, suffix)
def send(self, method, url, body=None, headers=None, key=None):
if not self._connection:
self.connect()
if body is None and method == "POST":
body = {}
if isinstance(body, dict):
body = json.dumps(body)
if isinstance(body, unicode):
body = body.encode("utf-8")
if headers is None:
headers = {}
url = self.path_prefix + url
self._connection.request(method, url, body, headers)
try:
resp = self._connection.getresponse()
except Exception:
# This should probably be more specific
raise IOError
body = resp.read()
try:
data = json.loads(body)
except:
raise
raise WebDriverException("Could not parse response body as JSON: %s" % body)
if resp.status != 200:
cls = _exceptions.get(resp.status, {}).get(data.get("status", None), WebDriverException)
raise cls(data.get("message", ""))
if key is not None:
data = data[key]
if not data:
data = None
return data
def command(func):
def inner(self, *args, **kwargs):
if hasattr(self, "session"):
session_id = self.session.session_id
else:
session_id = self.session_id
if session_id is None:
raise SessionNotCreatedException("Session not created")
return func(self, *args, **kwargs)
inner.__name__ = func.__name__
inner.__doc__ = func.__doc__
return inner
class Timeouts(object):
def __init__(self, session):
self.session = session
self._script = 30
self._load = 0
self._implicit_wait = 0
def _set_timeouts(self, name, value):
body = {"type": name,
"ms": value * 1000}
return self.session.send_command("POST", "timeouts", body)
@property
def script(self):
return self._script
@script.setter
def script(self, value):
self._set_timeouts("script", value)
self._script = value
@property
def load(self):
return self._load
@load.setter
def set_load(self, value):
self._set_timeouts("page load", value)
self._script = value
@property
def implicit_wait(self):
return self._implicit_wait
@implicit_wait.setter
def implicit_wait(self, value):
self._set_timeouts("implicit wait", value)
self._implicit_wait = value
class Window(object):
def __init__(self, session):
self.session = session
@property
@command
def size(self):
return self.session.send_command("GET", "window/size")
@size.setter
@command
def size(self, (height, width)):
body = {"width": width,
"height": height}
return self.session.send_command("POST", "window/size", body)
@property
@command
def maximize(self):
return self.session.send_command("POST", "window/maximize")
class Find(object):
def __init__(self, session):
self.session = session
@command
def css(self, selector, all=True):
return self._find_element("css selector", selector, all)
def _find_element(self, strategy, selector, all):
route = "elements" if all else "element"
body = {"using": strategy,
"value": selector}
data = self.session.send_command("POST", route, body, key="value")
if all:
rv = [self.session._element(item) for item in data]
else:
rv = self.session._element(data)
return rv
class Session(object):
def __init__(self, host, port, url_prefix="", desired_capabilities=None, port_timeout=60):
self.transport = Transport(host, port, url_prefix, port_timeout)
self.desired_capabilities = desired_capabilities
self.session_id = None
self.timeouts = None
self.window = None
self.find = None
self._element_cache = {}
def start(self):
desired_capabilities = self.desired_capabilities if self.desired_capabilities else {}
body = {"capabilities": {"desiredCapabilites": desired_capabilities}}
rv = self.transport.send("POST", "session", body=body)
self.session_id = rv["sessionId"]
self.timeouts = Timeouts(self)
self.window = Window(self)
self.find = Find(self)
return rv["value"]
@command
def end(self):
url = "session/%s" % self.session_id
self.transport.send("DELETE", url)
self.session_id = None
self.timeouts = None
self.window = None
self.find = None
self.transport.close_connection()
def __enter__(self):
resp = self.start()
if resp.error:
raise Exception(resp)
return self
def __exit__(self, *args, **kwargs):
resp = self.end()
if resp.error:
raise Exception(resp)
def send_command(self, method, url, body=None, key=None):
url = urlparse.urljoin("session/%s/" % self.session_id, url)
return self.transport.send(method, url, body, key=key)
@property
@command
def url(self):
return self.send_command("GET", "url", key="value")
@url.setter
@command
def url(self, url):
if urlparse.urlsplit(url).netloc is None:
return self.url(url)
body = {"url": url}
return self.send_command("POST", "url", body)
@command
def back(self):
return self.send_command("POST", "back")
@command
def forward(self):
return self.send_command("POST", "forward")
@command
def refresh(self):
return self.send_command("POST", "refresh")
@property
@command
def title(self):
return self.send_command("GET", "title", key="value")
@property
@command
def handle(self):
return self.send_command("GET", "window_handle", key="value")
@handle.setter
@command
def handle(self, handle):
body = {"handle": handle}
return self.send_command("POST", "window", body=body)
def switch_frame(self, frame):
if frame == "parent":
url = "frame/parent"
body = None
else:
url = "frame"
if isinstance(frame, Element):
body = {"id": frame.json()}
else:
body = {"id": frame}
print body
return self.send_command("POST", url, body)
@command
def close(self):
return self.send_command("DELETE", "window_handle")
@property
@command
def handles(self):
return self.send_command("GET", "window_handles", key="value")
@property
@command
def active_element(self):
data = self.send_command("GET", "element/active", key="value")
if data is not None:
return self._element(data)
def _element(self, data):
elem_id = data[element_key]
assert elem_id
if elem_id in self._element_cache:
return self._element_cache[elem_id]
return Element(self, elem_id)
@command
def cookies(self, name=None):
if name is None:
url = "cookie"
else:
url = "cookie/%s" % name
return self.send_command("GET", url, {}, key="value")
@command
def set_cookie(self, name, value, path=None, domain=None, secure=None, expiry=None):
body = {"name": name,
"value": value}
if path is not None:
body["path"] = path
if domain is not None:
body["domain"] = domain
if secure is not None:
body["secure"] = secure
if expiry is not None:
body["expiry"] = expiry
self.send_command("POST", "cookie", body)
def delete_cookie(self, name=None):
if name is None:
url = "cookie"
else:
url = "cookie/%s" % name
self.send_command("DELETE", url, {}, key="value")
#[...]
@command
def execute_script(self, script, args=None):
if args is None:
args = []
body = {
"script": script,
"args": args
}
return self.send_command("POST", "execute", body, key="value")
@command
def execute_async_script(self, script, args=None):
if args is None:
args = []
body = {
"script": script,
"args": args
}
return self.send_command("POST", "execute_async", body, key="value")
#[...]
@command
def screenshot(self):
return self.send_command("GET", "screenshot", key="value")
class Element(object):
def __init__(self, session, id):
self.session = session
self.id = id
assert id not in self.session._element_cache
self.session._element_cache[self.id] = self
def json(self):
return {element_key: self.id}
@property
def session_id(self):
return self.session.session_id
def url(self, suffix):
return "element/%s/%s" % (self.id, suffix)
@command
def find_element(self, strategy, selector):
body = {"using": strategy,
"value": selector}
elem = self.session.send_command("POST", self.url("element"), body, key="value")
return self.session.element(elem)
@command
def click(self):
self.session.send_command("POST", self.url("click"), {})
@command
def tap(self):
self.session.send_command("POST", self.url("tap"), {})
@command
def clear(self):
self.session.send_command("POST", self.url("clear"), {})
@command
def send_keys(self, keys):
if isinstance(keys, (str, unicode)):
keys = [char for char in keys]
body = {"value": keys}
return self.session.send_command("POST", self.url("value"), body)
@property
@command
def text(self):
return self.session.send_command("GET", self.url("text"), key="value")
@property
@command
def name(self):
return self.session.send_command("GET", self.url("name"), key="value")

View file

@ -0,0 +1,25 @@
/* 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/. */
setup({output:%(output)d});
add_completion_callback(function() {
add_completion_callback(function (tests, status) {
var test_results = tests.map(function(x) {
return {name:x.name, status:x.status, message:x.message, stack:x.stack}
});
var results = JSON.stringify({tests:test_results,
status: status.status,
message: status.message,
stack: status.stack});
(function done() {
if (window.__wd_results_callback__) {
clearTimeout(__wd_results_timer__);
__wd_results_callback__(results)
} else {
setTimeout(done, 20);
}
})()
})
});