1 # This Source Code Form is subject to the terms of the Mozilla Public
2 # License, v. 2.0. If a copy of the MPL was not distributed with this
3 # file, # You can obtain one at http://mozilla.org/MPL/2.0/.
14 from collections
import OrderedDict
18 from mach
.decorators
import Command
, CommandArgument
, SubCommand
19 from mozbuild
import nodeutil
20 from mozbuild
.base
import BinaryNotFoundException
, MozbuildObject
28 # add node and npm from mozbuild to front of system path
29 npm
, _
= nodeutil
.find_npm_executable()
31 exit(EX_CONFIG
, "could not find npm executable")
32 path
= os
.path
.abspath(os
.path
.join(npm
, os
.pardir
))
33 os
.environ
["PATH"] = "{}{}{}".format(path
, os
.pathsep
, os
.environ
["PATH"])
36 def remotedir(command_context
):
37 return os
.path
.join(command_context
.topsrcdir
, "remote")
40 @Command("remote", category
="misc", description
="Remote protocol related operations.")
41 def remote(command_context
):
42 """The remote subcommands all relate to the remote protocol."""
43 command_context
._sub
_mach
(["help", "remote"])
48 "remote", "vendor-puppeteer", "Pull in latest changes of the Puppeteer client."
54 help="The (possibly remote) repository to clone from.",
60 help="The commit or tag object name to check out.",
67 help="Do not install the just-pulled Puppeteer package,",
69 def vendor_puppeteer(command_context
, repository
, commitish
, install
):
70 puppeteer_dir
= os
.path
.join(remotedir(command_context
), "test", "puppeteer")
72 # Preserve our custom mocha reporter
74 os
.path
.join(puppeteer_dir
, "json-mocha-reporter.js"),
75 remotedir(command_context
),
77 shutil
.rmtree(puppeteer_dir
, ignore_errors
=True)
78 os
.makedirs(puppeteer_dir
)
79 with
TemporaryDirectory() as tmpdir
:
80 git("clone", "-q", repository
, tmpdir
)
81 git("checkout", commitish
, worktree
=tmpdir
)
87 "{}/".format(puppeteer_dir
),
91 # remove files which may interfere with git checkout of central
93 os
.remove(os
.path
.join(puppeteer_dir
, ".gitattributes"))
94 os
.remove(os
.path
.join(puppeteer_dir
, ".gitignore"))
98 unwanted_dirs
= ["experimental", "docs"]
100 for dir in unwanted_dirs
:
101 dir_path
= os
.path
.join(puppeteer_dir
, dir)
102 if os
.path
.isdir(dir_path
):
103 shutil
.rmtree(dir_path
)
106 os
.path
.join(remotedir(command_context
), "json-mocha-reporter.js"),
115 "product": "Remote Protocol",
116 "component": "Agent",
120 "description": "Headless Chrome Node API",
122 "license": "Apache-2.0",
123 "release": commitish
,
126 with
open(os
.path
.join(puppeteer_dir
, "moz.yaml"), "w") as fh
:
130 default_flow_style
=False,
136 env
= {"HUSKY": "0", "PUPPETEER_SKIP_DOWNLOAD": "1"}
139 cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
),
144 def git(*args
, **kwargs
):
146 if kwargs
.get("worktree"):
147 cmd
+= ("-C", kwargs
["worktree"])
150 pipe
= kwargs
.get("pipe")
151 git_p
= subprocess
.Popen(
153 env
={"GIT_CONFIG_NOSYSTEM": "1"},
154 stdout
=subprocess
.PIPE
,
155 stderr
=subprocess
.PIPE
,
159 pipe_p
= subprocess
.Popen(pipe
, stdin
=git_p
.stdout
, stderr
=subprocess
.PIPE
)
162 _
, pipe_err
= pipe_p
.communicate()
163 out
, git_err
= git_p
.communicate()
165 # use error from first program that failed
166 if git_p
.returncode
> 0:
167 exit(EX_SOFTWARE
, git_err
)
168 if pipe
and pipe_p
.returncode
> 0:
169 exit(EX_SOFTWARE
, pipe_err
)
174 def npm(*args
, **kwargs
):
175 from mozprocess
import processhandler
178 npm
, _
= nodeutil
.find_npm_executable()
179 if kwargs
.get("env"):
180 env
= os
.environ
.copy()
181 env
.update(kwargs
["env"])
184 if "processOutputLine" in kwargs
:
185 proc_kwargs
["processOutputLine"] = kwargs
["processOutputLine"]
187 p
= processhandler
.ProcessHandler(
190 cwd
=kwargs
.get("cwd"),
192 universal_newlines
=True,
195 if not kwargs
.get("wait", True):
198 wait_proc(p
, cmd
=npm
, exit_on_fail
=kwargs
.get("exit_on_fail", True))
203 def wait_proc(p
, cmd
=None, exit_on_fail
=True, output_timeout
=None):
205 p
.run(outputTimeout
=output_timeout
)
208 # In some cases, we wait longer for a mocha timeout
209 print("Timed out after {} seconds of no output".format(output_timeout
))
212 if exit_on_fail
and p
.returncode
> 0:
214 "%s: exit code %s" % (cmd
, p
.returncode
)
216 else "exit code %s" % p
.returncode
218 exit(p
.returncode
, msg
)
221 class MochaOutputHandler(object):
222 def __init__(self
, logger
, expected
):
223 self
.hook_re
= re
.compile('"before\b?.*" hook|"after\b?.*" hook')
227 self
.test_results
= OrderedDict()
228 self
.expected
= expected
229 self
.unexpected_skips
= set()
231 self
.has_unexpected
= False
232 self
.logger
.suite_start([], name
="puppeteer-tests")
236 "TERMINATED": "CRASH",
244 return self
.proc
and self
.proc
.pid
246 def __call__(self
, line
):
249 if line
.startswith("[") and line
.endswith("]"):
250 event
= json
.loads(line
)
251 self
.process_event(event
)
255 self
.logger
.process_output(self
.pid
, line
, command
="npm")
257 def process_event(self
, event
):
258 if isinstance(event
, list) and len(event
) > 1:
259 status
= self
.status_map
.get(event
[0])
260 test_start
= event
[0] == "test-start"
261 if not status
and not test_start
:
264 test_full_title
= test_info
.get("fullTitle", "")
265 test_name
= test_full_title
266 test_path
= test_info
.get("file", "")
267 test_file_name
= os
.path
.basename(test_path
).replace(".js", "")
268 test_err
= test_info
.get("err")
269 if status
== "FAIL" and test_err
:
270 if "timeout" in test_err
.lower():
272 if test_name
and test_path
:
273 test_name
= "{} ({})".format(test_name
, os
.path
.basename(test_path
))
274 # mocha hook failures are not tracked in metadata
275 if status
!= "PASS" and self
.hook_re
.search(test_name
):
276 self
.logger
.error("TEST-UNEXPECTED-ERROR %s" % (test_name
,))
279 self
.logger
.test_start(test_name
)
281 expected_name
= "[{}] {}".format(test_file_name
, test_full_title
)
282 expected_item
= next(
285 for expectation
in list(self
.expected
)
286 if expectation
["testIdPattern"] == expected_name
290 if expected_item
is None:
291 # if there is no expectation data for a specific test case,
292 # try to find data for a whole file.
293 expected_item_for_file
= next(
296 for expectation
in list(self
.expected
)
297 if expectation
["testIdPattern"] == f
"[{test_file_name}]"
301 if expected_item_for_file
is None:
304 expected
= expected_item_for_file
["expectations"]
306 expected
= expected_item
["expectations"]
307 # mozlog doesn't really allow unexpected skip,
308 # so if a test is disabled just expect that and note the unexpected skip
309 # Also, mocha doesn't log test-start for skipped tests
311 self
.logger
.test_start(test_name
)
312 if self
.expected
and status
not in expected
:
313 self
.unexpected_skips
.add(test_name
)
315 known_intermittent
= expected
[1:]
316 expected_status
= expected
[0]
318 # check if we've seen a result for this test before this log line
319 result_recorded
= self
.test_results
.get(test_name
)
322 "Received a second status for {}: "
323 "first {}, now {}".format(test_name
, result_recorded
, status
)
325 # mocha intermittently logs an additional test result after the
326 # test has already timed out. Avoid recording this second status.
327 if result_recorded
!= "TIMEOUT":
328 self
.test_results
[test_name
] = status
329 if status
not in expected
:
330 self
.has_unexpected
= True
331 self
.logger
.test_end(
334 expected
=expected_status
,
335 known_intermittent
=known_intermittent
,
339 if self
.unexpected_skips
:
340 self
.has_unexpected
= True
341 for test_name
in self
.unexpected_skips
:
343 "TEST-UNEXPECTED-MISSING Unexpected skipped %s" % (test_name
,)
345 self
.logger
.suite_end()
348 # tempfile.TemporaryDirectory missing from Python 2.7
349 class TemporaryDirectory(object):
351 self
.path
= tempfile
.mkdtemp()
355 return "<{} {!r}>".format(self
.__class
__.__name
__, self
.path
)
360 def __exit__(self
, exc
, value
, tb
):
367 if self
.path
and not self
._closed
:
368 shutil
.rmtree(self
.path
)
372 class PuppeteerRunner(MozbuildObject
):
373 def __init__(self
, *args
, **kwargs
):
374 super(PuppeteerRunner
, self
).__init
__(*args
, **kwargs
)
376 self
.remotedir
= os
.path
.join(self
.topsrcdir
, "remote")
377 self
.puppeteer_dir
= os
.path
.join(self
.remotedir
, "test", "puppeteer")
379 def run_test(self
, logger
, *tests
, **params
):
381 Runs Puppeteer unit tests with npm.
383 Possible optional test parameters:
386 Path for the browser binary to use. Defaults to the local
389 Boolean to indicate whether to activate Firefox' headless mode.
391 Dictionary of extra preferences to write to the profile,
392 before invoking npm. Overrides default preferences.
394 Boolean to indicate whether to enable WebRender compositor in Gecko.
396 Indicates only a subset of tests are being run, so we should
397 skip the check for missing results
401 binary
= params
.get("binary") or self
.get_binary_path()
402 product
= params
.get("product", "firefox")
405 # Print browser process ouptut
407 # Checked by Puppeteer's custom mocha config
409 # Causes some tests to be skipped due to assumptions about install
410 "PUPPETEER_ALT_INSTALL": "1",
413 for k
, v
in params
.get("extra_launcher_options", {}).items():
414 extra_options
[k
] = json
.loads(v
)
416 # Override upstream defaults: no retries, shorter timeout
419 "./json-mocha-reporter.js",
428 env
["HEADLESS"] = str(params
.get("headless", False))
430 if product
== "firefox":
431 env
["BINARY"] = binary
432 env
["PUPPETEER_PRODUCT"] = "firefox"
434 env
["MOZ_WEBRENDER"] = "%d" % params
.get("enable_webrender", False)
436 test_command
= "test:firefox"
437 elif env
["HEADLESS"] == "False":
438 test_command
= "test:chrome:headful"
440 test_command
= "test:chrome:headless"
442 command
= ["run", test_command
, "--"] + mocha_options
445 for k
, v
in params
.get("extra_prefs", {}).items():
446 print("Using extra preference: {}={}".format(k
, v
))
447 prefs
[k
] = mozprofile
.Preferences
.cast(v
)
450 extra_options
["extraPrefsFirefox"] = prefs
453 env
["EXTRA_LAUNCH_OPTIONS"] = json
.dumps(extra_options
)
455 expected_path
= os
.path
.join(
456 os
.path
.dirname(__file__
),
460 "TestExpectations.json",
462 if os
.path
.exists(expected_path
):
463 with
open(expected_path
) as f
:
464 expected_data
= json
.load(f
)
467 # Filter expectation data for the selected browser,
468 # headless or headful mode, and the operating system.
469 expected_platform
= platform
.uname().system
.lower()
470 if expected_platform
== "windows":
471 expected_platform
= "win32"
473 expectations
= filter(
474 lambda el
: product
in el
["parameters"]
476 (env
["HEADLESS"] == "False" and "headless" not in el
["parameters"])
477 or "headful" not in el
["parameters"]
479 and expected_platform
in el
["platforms"],
483 output_handler
= MochaOutputHandler(logger
, list(expectations
))
486 cwd
=self
.puppeteer_dir
,
488 processOutputLine
=output_handler
,
491 output_handler
.proc
= proc
493 # Puppeteer unit tests don't always clean-up child processes in case of
494 # failure, so use an output_timeout as a fallback
495 wait_proc(proc
, "npm", output_timeout
=60, exit_on_fail
=False)
497 output_handler
.after_end()
499 # Non-zero return codes are non-fatal for now since we have some
500 # issues with unresolved promises that shouldn't otherwise block
502 if proc
.returncode
!= 0:
503 logger
.warning("npm exited with code %s" % proc
.returncode
)
505 if output_handler
.has_unexpected
:
506 exit(1, "Got unexpected results")
509 def create_parser_puppeteer():
510 p
= argparse
.ArgumentParser()
512 "--product", type=str, default
="firefox", choices
=["chrome", "firefox"]
517 help="Path to browser binary. Defaults to local Firefox build.",
522 help="Flag that indicates that tests run in a CI environment.",
528 dest
="disable_fission",
529 help="Disable Fission (site isolation) in Gecko.",
532 "--enable-webrender",
534 help="Enable the WebRender compositor in Gecko.",
537 "-z", "--headless", action
="store_true", help="Run browser in headless mode."
543 metavar
="<pref>=<value>",
544 help="Defines additional user preferences.",
549 dest
="extra_options",
550 metavar
="<option>=<value>",
551 help="Defines additional options for `puppeteer.launch`.",
558 help="Increase remote agent logging verbosity to include "
559 "debug level messages with -v, trace messages with -vv,"
560 "and to not truncate long trace messages with -vvv",
566 help="Indicate that only a subset of the tests are running, "
567 "so checks for missing tests should be skipped",
569 p
.add_argument("tests", nargs
="*")
570 mozlog
.commandline
.add_logging_group(p
)
577 description
="Run Puppeteer unit tests.",
578 parser
=create_parser_puppeteer
,
583 action
="store_false",
585 help="Do not install the Puppeteer package",
591 disable_fission
=False,
592 enable_webrender
=False,
604 logger
= mozlog
.commandline
.setup_logging(
605 "puppeteer-test", kwargs
, {"mach": sys
.stdout
}
608 # moztest calls this programmatically with test objects or manifests
609 if "test_objects" in kwargs
and tests
is not None:
610 logger
.error("Expected either 'test_objects' or 'tests'")
613 if product
!= "firefox" and extra_prefs
is not None:
614 logger
.error("User preferences are not recognized by %s" % product
)
617 if "test_objects" in kwargs
:
619 for test
in kwargs
["test_objects"]:
620 tests
.append(test
["path"])
623 for s
in extra_prefs
or []:
626 logger
.error("syntax error in --setpref={}".format(s
))
628 prefs
[kv
[0]] = kv
[1].strip()
631 for s
in extra_options
or []:
634 logger
.error("syntax error in --setopt={}".format(s
))
636 options
[kv
[0]] = kv
[1].strip()
638 prefs
.update({"fission.autostart": True})
640 prefs
.update({"fission.autostart": False})
643 prefs
["remote.log.level"] = "Debug"
645 prefs
["remote.log.level"] = "Trace"
647 prefs
["remote.log.truncate"] = False
650 install_puppeteer(command_context
, product
, ci
)
654 "headless": headless
,
655 "enable_webrender": enable_webrender
,
656 "extra_prefs": prefs
,
658 "extra_launcher_options": options
,
661 puppeteer
= command_context
._spawn
(PuppeteerRunner
)
663 return puppeteer
.run_test(logger
, *tests
, **params
)
664 except BinaryNotFoundException
as e
:
666 logger
.info(e
.help())
668 except Exception as e
:
672 def install_puppeteer(command_context
, product
, ci
):
676 puppeteer_dir
= os
.path
.join("remote", "test", "puppeteer")
678 if product
!= "chrome":
679 env
["PUPPETEER_SKIP_DOWNLOAD"] = "1"
685 cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
),
690 command
= "ci" if ci
else "install"
691 npm(command
, cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
), env
=env
)
695 cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
),
700 def exit(code
, error
=None):
701 if error
is not None:
702 if isinstance(error
, Exception):
705 traceback
.print_exc()
707 message
= str(error
).split("\n")[0].strip()
708 print("{}: {}".format(sys
.argv
[0], message
), file=sys
.stderr
)