OLD | NEW |
(Empty) | |
| 1 import argparse |
| 2 import json |
| 3 import logging |
| 4 import os |
| 5 import stat |
| 6 import subprocess |
| 7 import sys |
| 8 import tarfile |
| 9 import traceback |
| 10 import zipfile |
| 11 from cStringIO import StringIO |
| 12 from collections import defaultdict |
| 13 from urlparse import urljoin |
| 14 |
| 15 import requests |
| 16 |
| 17 wptrunner = None |
| 18 wptcommandline = None |
| 19 reader = None |
| 20 LogHandler = None |
| 21 |
| 22 logger = logging.getLogger(os.path.splitext(__file__)[0]) |
| 23 |
| 24 |
| 25 def do_delayed_imports(): |
| 26 global wptrunner, wptcommandline, reader |
| 27 from wptrunner import wptrunner |
| 28 from wptrunner import wptcommandline |
| 29 from mozlog import reader |
| 30 setup_log_handler() |
| 31 |
| 32 |
| 33 def setup_logging(): |
| 34 handler = logging.StreamHandler(sys.stdout) |
| 35 formatter = logging.Formatter(logging.BASIC_FORMAT, None) |
| 36 handler.setFormatter(formatter) |
| 37 logger.addHandler(handler) |
| 38 logger.setLevel(logging.DEBUG) |
| 39 |
| 40 setup_logging() |
| 41 |
| 42 |
| 43 class GitHub(object): |
| 44 def __init__(self, org, repo, token): |
| 45 self.token = token |
| 46 self.headers = {"Accept": "application/vnd.github.v3+json"} |
| 47 self.auth = (self.token, "x-oauth-basic") |
| 48 self.org = org |
| 49 self.repo = repo |
| 50 self.base_url = "https://api.github.com/repos/%s/%s/" % (org, repo) |
| 51 |
| 52 def _headers(self, headers): |
| 53 if headers is None: |
| 54 headers = {} |
| 55 rv = self.headers.copy() |
| 56 rv.update(headers) |
| 57 return rv |
| 58 |
| 59 def post(self, url, data, headers=None): |
| 60 logger.debug("POST %s" % url) |
| 61 if data is not None: |
| 62 data = json.dumps(data) |
| 63 resp = requests.post( |
| 64 url, |
| 65 data=data, |
| 66 headers=self._headers(headers), |
| 67 auth=self.auth |
| 68 ) |
| 69 resp.raise_for_status() |
| 70 return resp |
| 71 |
| 72 def get(self, url, headers=None): |
| 73 logger.debug("GET %s" % url) |
| 74 resp = requests.get( |
| 75 url, |
| 76 headers=self._headers(headers), |
| 77 auth=self.auth |
| 78 ) |
| 79 resp.raise_for_status() |
| 80 return resp |
| 81 |
| 82 def post_comment(self, issue_number, body): |
| 83 url = urljoin(self.base_url, "issues/%s/comments" % issue_number) |
| 84 return self.post(url, {"body": body}) |
| 85 |
| 86 def releases(self): |
| 87 url = urljoin(self.base_url, "releases/latest") |
| 88 return self.get(url) |
| 89 |
| 90 |
| 91 class GitHubCommentHandler(logging.Handler): |
| 92 def __init__(self, github, pull_number): |
| 93 logging.Handler.__init__(self) |
| 94 self.github = github |
| 95 self.pull_number = pull_number |
| 96 self.log_data = [] |
| 97 |
| 98 def emit(self, record): |
| 99 try: |
| 100 msg = self.format(record) |
| 101 self.log_data.append(msg) |
| 102 except Exception: |
| 103 self.handleError(record) |
| 104 |
| 105 def send(self): |
| 106 self.github.post_comment(self.pull_number, "\n".join(self.log_data)) |
| 107 self.log_data = [] |
| 108 |
| 109 |
| 110 class Browser(object): |
| 111 product = None |
| 112 |
| 113 def __init__(self, github_token): |
| 114 self.github_token = github_token |
| 115 |
| 116 |
| 117 class Firefox(Browser): |
| 118 product = "firefox" |
| 119 |
| 120 def install(self): |
| 121 call("pip", "install", "-r", "w3c/wptrunner/requirements_firefox.txt") |
| 122 resp = get("https://archive.mozilla.org/pub/firefox/nightly/latest-mozil
la-central/firefox-52.0a1.en-US.linux-x86_64.tar.bz2") |
| 123 untar(resp.raw) |
| 124 |
| 125 if not os.path.exists("profiles"): |
| 126 os.mkdir("profiles") |
| 127 with open(os.path.join("profiles", "prefs_general.js"), "wb") as f: |
| 128 resp = get("https://hg.mozilla.org/mozilla-central/raw-file/tip/test
ing/profiles/prefs_general.js") |
| 129 f.write(resp.content) |
| 130 call("pip", "install", "-r", os.path.join("w3c", "wptrunner", "requireme
nts_firefox.txt")) |
| 131 |
| 132 def install_webdriver(self): |
| 133 github = GitHub("mozilla", "geckodriver", self.github_token) |
| 134 releases = github.releases().json() |
| 135 url = (item["browser_download_url"] for item in releases["assets"] |
| 136 if "linux64" in item["browser_download_url"]).next() |
| 137 untar(get(url).raw) |
| 138 |
| 139 def wptrunner_args(self, root): |
| 140 return { |
| 141 "product": "firefox", |
| 142 "binary": "%s/firefox/firefox" % root, |
| 143 "certutil_binary": "certutil", |
| 144 "webdriver_binary": "%s/geckodriver" % root, |
| 145 "prefs_root": "%s/profiles" % root, |
| 146 } |
| 147 |
| 148 |
| 149 class Chrome(Browser): |
| 150 product = "chrome" |
| 151 |
| 152 def install(self): |
| 153 latest = get("https://www.googleapis.com/download/storage/v1/b/chromium-
browser-snapshots/o/Linux_x64%2FLAST_CHANGE?alt=media").text.strip() |
| 154 url = "https://www.googleapis.com/download/storage/v1/b/chromium-browser
-snapshots/o/Linux_x64%%2F%s%%2Fchrome-linux.zip?alt=media" % latest |
| 155 unzip(get(url).raw) |
| 156 logger.debug(call("ls", "-lhrt", "chrome-linux")) |
| 157 call("pip", "install", "-r", os.path.join("w3c", "wptrunner", "requireme
nts_chrome.txt")) |
| 158 |
| 159 def install_webdriver(self): |
| 160 latest = get("http://chromedriver.storage.googleapis.com/LATEST_RELEASE"
).text.strip() |
| 161 url = "http://chromedriver.storage.googleapis.com/%s/chromedriver_linux6
4.zip" % latest |
| 162 unzip(get(url).raw) |
| 163 st = os.stat('chromedriver') |
| 164 os.chmod('chromedriver', st.st_mode | stat.S_IEXEC) |
| 165 |
| 166 def wptrunner_args(self, root): |
| 167 return { |
| 168 "product": "chrome", |
| 169 "binary": "%s/chrome-linux/chrome" % root, |
| 170 "webdriver_binary": "%s/chromedriver" % root, |
| 171 "test_types": ["testharness", "reftest"] |
| 172 } |
| 173 |
| 174 |
| 175 def get(url): |
| 176 logger.debug("GET %s" % url) |
| 177 resp = requests.get(url, stream=True) |
| 178 resp.raise_for_status() |
| 179 return resp |
| 180 |
| 181 |
| 182 def call(*args): |
| 183 logger.debug("%s" % " ".join(args)) |
| 184 return subprocess.check_output(args) |
| 185 |
| 186 |
| 187 def get_git_cmd(repo_path): |
| 188 def git(cmd, *args): |
| 189 full_cmd = ["git", cmd] + list(args) |
| 190 try: |
| 191 return subprocess.check_output(full_cmd, cwd=repo_path, stderr=subpr
ocess.STDOUT) |
| 192 except subprocess.CalledProcessError as e: |
| 193 logger.error("Git command exited with status %i" % e.returncode) |
| 194 logger.error(e.output) |
| 195 sys.exit(1) |
| 196 return git |
| 197 |
| 198 |
| 199 def seekable(fileobj): |
| 200 try: |
| 201 fileobj.seek(fileobj.tell()) |
| 202 except Exception: |
| 203 return StringIO(fileobj.read()) |
| 204 else: |
| 205 return fileobj |
| 206 |
| 207 |
| 208 def untar(fileobj): |
| 209 logger.debug("untar") |
| 210 fileobj = seekable(fileobj) |
| 211 with tarfile.open(fileobj=fileobj) as tar_data: |
| 212 tar_data.extractall() |
| 213 |
| 214 |
| 215 def unzip(fileobj): |
| 216 logger.debug("unzip") |
| 217 fileobj = seekable(fileobj) |
| 218 with zipfile.ZipFile(fileobj) as zip_data: |
| 219 for info in zip_data.infolist(): |
| 220 zip_data.extract(info) |
| 221 perm = info.external_attr >> 16 & 0x1FF |
| 222 os.chmod(info.filename, perm) |
| 223 |
| 224 |
| 225 def setup_github_logging(args): |
| 226 gh_handler = None |
| 227 if args.comment_pr: |
| 228 github = GitHub("w3c", "web-platform-tests", args.gh_token) |
| 229 try: |
| 230 pr_number = int(args.comment_pr) |
| 231 except ValueError: |
| 232 pass |
| 233 else: |
| 234 gh_handler = GitHubCommentHandler(github, pr_number) |
| 235 gh_handler.setLevel(logging.INFO) |
| 236 logger.debug("Setting up GitHub logging") |
| 237 logger.addHandler(gh_handler) |
| 238 else: |
| 239 logger.warning("No PR number found; not posting to GitHub") |
| 240 return gh_handler |
| 241 |
| 242 |
| 243 class pwd(object): |
| 244 def __init__(self, dir): |
| 245 self.dir = dir |
| 246 self.old_dir = None |
| 247 |
| 248 def __enter__(self): |
| 249 self.old_dir = os.path.abspath(os.curdir) |
| 250 os.chdir(self.dir) |
| 251 |
| 252 def __exit__(self, *args, **kwargs): |
| 253 os.chdir(self.old_dir) |
| 254 self.old_dir = None |
| 255 |
| 256 |
| 257 def fetch_wpt_master(): |
| 258 git = get_git_cmd(os.path.join(os.path.abspath(os.curdir), "w3c", "web-platf
orm-tests")) |
| 259 git("fetch", "https://github.com/w3c/web-platform-tests.git", "master:master
") |
| 260 |
| 261 |
| 262 def get_sha1(): |
| 263 git = get_git_cmd(os.path.join(os.path.abspath(os.curdir), "w3c", "web-platf
orm-tests")) |
| 264 return git("rev-parse", "HEAD").strip() |
| 265 |
| 266 def build_manifest(): |
| 267 with pwd(os.path.join(os.path.abspath(os.curdir), "w3c", "web-platform-tests
")): |
| 268 # TODO: Call the manifest code directly |
| 269 call("python", "manifest") |
| 270 |
| 271 |
| 272 def install_wptrunner(): |
| 273 call("git", "clone", "--depth=1", "https://github.com/w3c/wptrunner.git", "w
3c/wptrunner") |
| 274 git = get_git_cmd(os.path.join(os.path.abspath(os.curdir), "w3c", "wptrunner
")) |
| 275 git("submodule", "update", "--init", "--recursive") |
| 276 call("pip", "install", os.path.join("w3c", "wptrunner")) |
| 277 |
| 278 |
| 279 def get_files_changed(): |
| 280 root = os.path.abspath(os.curdir) |
| 281 git = get_git_cmd("%s/w3c/web-platform-tests" % root) |
| 282 branch_point = git("merge-base", "HEAD", "master").strip() |
| 283 logger.debug("Branch point from master: %s" % branch_point) |
| 284 logger.debug(git("log", "--oneline", "%s.." % branch_point)) |
| 285 files = git("diff", "--name-only", "-z", "%s.." % branch_point) |
| 286 if not files: |
| 287 return [] |
| 288 assert files[-1] == "\0" |
| 289 return ["%s/w3c/web-platform-tests/%s" % (root, item) |
| 290 for item in files[:-1].split("\0")] |
| 291 |
| 292 |
| 293 def wptrunner_args(root, files_changed, iterations, browser): |
| 294 parser = wptcommandline.create_parser([browser.product]) |
| 295 args = vars(parser.parse_args([])) |
| 296 wpt_root = os.path.join(root, "w3c", "web-platform-tests") |
| 297 args.update(browser.wptrunner_args(root)) |
| 298 args.update({ |
| 299 "tests_root": wpt_root, |
| 300 "metadata_root": wpt_root, |
| 301 "repeat": iterations, |
| 302 "config": "%s/w3c/wptrunner/wptrunner.default.ini" % root, |
| 303 "test_list": files_changed, |
| 304 "restart_on_unexpected": False, |
| 305 "pause_after_test": False |
| 306 }) |
| 307 wptcommandline.check_args(args) |
| 308 return args |
| 309 |
| 310 |
| 311 def setup_log_handler(): |
| 312 global LogHandler |
| 313 |
| 314 class LogHandler(reader.LogHandler): |
| 315 def __init__(self): |
| 316 self.results = defaultdict(lambda: defaultdict(lambda: defaultdict(i
nt))) |
| 317 |
| 318 def test_status(self, data): |
| 319 self.results[data["test"]][data.get("subtest")][data["status"]] += 1 |
| 320 |
| 321 def test_end(self, data): |
| 322 self.results[data["test"]][None][data["status"]] += 1 |
| 323 |
| 324 |
| 325 def is_inconsistent(results_dict, iterations): |
| 326 return len(results_dict) > 1 or sum(results_dict.values()) != iterations |
| 327 |
| 328 |
| 329 def err_string(results_dict, iterations): |
| 330 rv = [] |
| 331 total_results = sum(results_dict.values()) |
| 332 for key, value in sorted(results_dict.items()): |
| 333 rv.append("%s%s" % |
| 334 (key, ": %s/%s" % (value, iterations) if value != iterations e
lse "")) |
| 335 rv = ", ".join(rv) |
| 336 if total_results < iterations: |
| 337 rv.append("MISSING: %s/%s" % (iterations - total_results, iterations)) |
| 338 if len(results_dict) > 1 or total_results != iterations: |
| 339 rv = "**%s**" % rv |
| 340 return rv |
| 341 |
| 342 |
| 343 def process_results(log, iterations): |
| 344 inconsistent = [] |
| 345 handler = LogHandler() |
| 346 reader.handle_log(reader.read(log), handler) |
| 347 results = handler.results |
| 348 for test, test_results in results.iteritems(): |
| 349 for subtest, result in test_results.iteritems(): |
| 350 if is_inconsistent(result, iterations): |
| 351 inconsistent.append((test, subtest, result)) |
| 352 return results, inconsistent |
| 353 |
| 354 |
| 355 def write_inconsistent(inconsistent, iterations): |
| 356 logger.error("## Unstable results ##\n") |
| 357 logger.error("| Test | Subtest | Results |") |
| 358 logger.error("|------|---------|---------|") |
| 359 for test, subtest, results in inconsistent: |
| 360 logger.error("%s | %s | %s" % (test, |
| 361 subtest if subtest else "", |
| 362 err_string(results, iterations))) |
| 363 |
| 364 |
| 365 def write_results(results, iterations): |
| 366 logger.info("## All results ##\n") |
| 367 for test, test_results in results.iteritems(): |
| 368 logger.info("### %s ###" % test) |
| 369 logger.info("| Subtest | Results |") |
| 370 logger.info("|---------|---------|") |
| 371 parent = test_results.pop(None) |
| 372 logger.info("| | %s |" % (err_string(parent, iterations))) |
| 373 for subtest, result in test_results.iteritems(): |
| 374 logger.info("| %s | %s |" % (subtest, err_string(result, iterations)
)) |
| 375 |
| 376 |
| 377 def get_parser(): |
| 378 parser = argparse.ArgumentParser() |
| 379 parser.add_argument("--root", |
| 380 action="store", |
| 381 default=os.path.join(os.path.expanduser("~"), "build"), |
| 382 help="Root path") |
| 383 parser.add_argument("--iterations", |
| 384 action="store", |
| 385 default=10, |
| 386 type=int, |
| 387 help="Number of times to run tests") |
| 388 parser.add_argument("--gh-token", |
| 389 action="store", |
| 390 default=os.environ.get("GH_TOKEN"), |
| 391 help="OAuth token to use for accessing GitHub api") |
| 392 parser.add_argument("--comment-pr", |
| 393 action="store", |
| 394 default=os.environ.get("TRAVIS_PULL_REQUEST"), |
| 395 help="PR to comment on with stability results") |
| 396 parser.add_argument("browser", |
| 397 action="store", |
| 398 help="Browser to run against") |
| 399 return parser |
| 400 |
| 401 |
| 402 def main(): |
| 403 retcode = 0 |
| 404 parser = get_parser() |
| 405 args = parser.parse_args() |
| 406 |
| 407 if not os.path.exists(args.root): |
| 408 logger.critical("Root directory %s does not exist" % args.root) |
| 409 return 1 |
| 410 |
| 411 os.chdir(args.root) |
| 412 |
| 413 if args.gh_token is None: |
| 414 logger.critical("Must provide a GitHub token via --gh-token or $GITHUB_T
OKEN") |
| 415 return 1 |
| 416 |
| 417 gh_handler = setup_github_logging(args) |
| 418 |
| 419 logger.info("# %s #" % args.browser.title()) |
| 420 |
| 421 browser_cls = {"firefox": Firefox, |
| 422 "chrome": Chrome}.get(args.browser) |
| 423 if browser_cls is None: |
| 424 logger.critical("Unrecognised browser %s" % args.browser) |
| 425 return 1 |
| 426 |
| 427 fetch_wpt_master() |
| 428 |
| 429 head_sha1 = get_sha1() |
| 430 logger.info("Testing revision %s" % head_sha1) |
| 431 |
| 432 # For now just pass the whole list of changed files to wptrunner and |
| 433 # assume that it will run everything that's actually a test |
| 434 files_changed = get_files_changed() |
| 435 |
| 436 if not files_changed: |
| 437 logger.info("No files changed") |
| 438 return 0 |
| 439 |
| 440 build_manifest() |
| 441 install_wptrunner() |
| 442 do_delayed_imports() |
| 443 |
| 444 logger.debug("Files changed:\n%s" % "".join(" * %s\n" % item for item in fil
es_changed)) |
| 445 |
| 446 browser = browser_cls(args.gh_token) |
| 447 |
| 448 browser.install() |
| 449 browser.install_webdriver() |
| 450 |
| 451 kwargs = wptrunner_args(args.root, |
| 452 files_changed, |
| 453 args.iterations, |
| 454 browser) |
| 455 with open("raw.log", "wb") as log: |
| 456 wptrunner.setup_logging(kwargs, |
| 457 {"tbpl": sys.stdout, |
| 458 "raw": log}) |
| 459 wptrunner.run_tests(**kwargs) |
| 460 |
| 461 with open("raw.log", "rb") as log: |
| 462 results, inconsistent = process_results(log, args.iterations) |
| 463 |
| 464 if results: |
| 465 if inconsistent: |
| 466 write_inconsistent(inconsistent, args.iterations) |
| 467 retcode = 2 |
| 468 else: |
| 469 logger.info("All results were stable\n") |
| 470 write_results(results, args.iterations) |
| 471 else: |
| 472 logger.info("No tests run.") |
| 473 |
| 474 try: |
| 475 if gh_handler: |
| 476 gh_handler.send() |
| 477 except Exception: |
| 478 logger.error(traceback.format_exc()) |
| 479 return retcode |
| 480 |
| 481 |
| 482 if __name__ == "__main__": |
| 483 try: |
| 484 retcode = main() |
| 485 except: |
| 486 raise |
| 487 else: |
| 488 sys.exit(retcode) |
OLD | NEW |