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."
53 default
="https://github.com/puppeteer/puppeteer.git",
54 help="The (possibly local) 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 os
.path
.join(remotedir(command_context
), "json-mocha-reporter.js"),
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,
137 "CI": "1", # Force the quiet logger of wireit
138 "HUSKY": "0", # Disable any hook checks
139 "PUPPETEER_SKIP_DOWNLOAD": "1", # Don't download any build
152 cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
),
157 def git(*args
, **kwargs
):
159 if kwargs
.get("worktree"):
160 cmd
+= ("-C", kwargs
["worktree"])
163 pipe
= kwargs
.get("pipe")
164 git_p
= subprocess
.Popen(
166 env
={"GIT_CONFIG_NOSYSTEM": "1"},
167 stdout
=subprocess
.PIPE
,
168 stderr
=subprocess
.PIPE
,
172 pipe_p
= subprocess
.Popen(pipe
, stdin
=git_p
.stdout
, stderr
=subprocess
.PIPE
)
175 _
, pipe_err
= pipe_p
.communicate()
176 out
, git_err
= git_p
.communicate()
178 # use error from first program that failed
179 if git_p
.returncode
> 0:
180 exit(EX_SOFTWARE
, git_err
)
181 if pipe
and pipe_p
.returncode
> 0:
182 exit(EX_SOFTWARE
, pipe_err
)
187 def run_npm(*args
, **kwargs
):
188 from mozprocess
import run_and_wait
190 def output_timeout_handler(proc
):
191 # In some cases, we wait longer for a mocha timeout
193 "Timed out after {} seconds of no output".format(kwargs
["output_timeout"])
196 env
= os
.environ
.copy()
197 npm
, _
= nodeutil
.find_npm_executable()
198 if kwargs
.get("env"):
199 env
.update(kwargs
["env"])
201 proc_kwargs
= {"output_timeout_handler": output_timeout_handler
}
202 for kw
in ["output_line_handler", "output_timeout"]:
204 proc_kwargs
[kw
] = kwargs
[kw
]
207 cmd
.extend(list(args
))
211 cwd
=kwargs
.get("cwd"),
216 post_wait_proc(p
, cmd
=npm
, exit_on_fail
=kwargs
.get("exit_on_fail", True))
221 def post_wait_proc(p
, cmd
=None, exit_on_fail
=True):
224 if exit_on_fail
and p
.returncode
> 0:
226 "%s: exit code %s" % (cmd
, p
.returncode
)
228 else "exit code %s" % p
.returncode
230 exit(p
.returncode
, msg
)
233 class MochaOutputHandler(object):
234 def __init__(self
, logger
, expected
):
235 self
.hook_re
= re
.compile('"before\b?.*" hook|"after\b?.*" hook')
239 self
.test_results
= OrderedDict()
240 self
.expected
= expected
241 self
.unexpected_skips
= set()
243 self
.has_unexpected
= False
244 self
.logger
.suite_start([], name
="puppeteer-tests")
248 "TERMINATED": "CRASH",
256 return self
.proc
and self
.proc
.pid
258 def __call__(self
, proc
, line
):
260 line
= line
.rstrip("\r\n")
263 if line
.startswith("[") and line
.endswith("]"):
264 event
= json
.loads(line
)
265 self
.process_event(event
)
269 self
.logger
.process_output(self
.pid
, line
, command
="npm")
271 def testExpectation(self
, testIdPattern
, expected_name
):
272 if testIdPattern
.find("*") == -1:
273 return expected_name
== testIdPattern
275 return re
.compile(re
.escape(testIdPattern
).replace(r
"\*", ".*")).search(
279 def process_event(self
, event
):
280 if isinstance(event
, list) and len(event
) > 1:
281 status
= self
.status_map
.get(event
[0])
282 test_start
= event
[0] == "test-start"
283 if not status
and not test_start
:
286 test_full_title
= test_info
.get("fullTitle", "")
287 test_name
= test_full_title
288 test_path
= test_info
.get("file", "")
289 test_file_name
= os
.path
.basename(test_path
).replace(".js", "")
290 test_err
= test_info
.get("err")
291 if status
== "FAIL" and test_err
:
292 if "timeout" in test_err
.lower():
294 if test_name
and test_path
:
295 test_name
= "{} ({})".format(test_name
, os
.path
.basename(test_path
))
296 # mocha hook failures are not tracked in metadata
297 if status
!= "PASS" and self
.hook_re
.search(test_name
):
298 self
.logger
.error("TEST-UNEXPECTED-ERROR %s" % (test_name
,))
301 self
.logger
.test_start(test_name
)
303 expected_name
= "[{}] {}".format(test_file_name
, test_full_title
)
304 expected_item
= next(
307 for expectation
in reversed(list(self
.expected
))
308 if self
.testExpectation(expectation
["testIdPattern"], expected_name
)
312 if expected_item
is None:
315 expected
= expected_item
["expectations"]
316 # mozlog doesn't really allow unexpected skip,
317 # so if a test is disabled just expect that and note the unexpected skip
318 # Also, mocha doesn't log test-start for skipped tests
320 self
.logger
.test_start(test_name
)
321 if self
.expected
and status
not in expected
:
322 self
.unexpected_skips
.add(test_name
)
324 known_intermittent
= expected
[1:]
325 expected_status
= expected
[0]
327 # check if we've seen a result for this test before this log line
328 result_recorded
= self
.test_results
.get(test_name
)
331 "Received a second status for {}: "
332 "first {}, now {}".format(test_name
, result_recorded
, status
)
334 # mocha intermittently logs an additional test result after the
335 # test has already timed out. Avoid recording this second status.
336 if result_recorded
!= "TIMEOUT":
337 self
.test_results
[test_name
] = status
338 if status
not in expected
:
339 self
.has_unexpected
= True
340 self
.logger
.test_end(
343 expected
=expected_status
,
344 known_intermittent
=known_intermittent
,
348 if self
.unexpected_skips
:
349 self
.has_unexpected
= True
350 for test_name
in self
.unexpected_skips
:
352 "TEST-UNEXPECTED-MISSING Unexpected skipped %s" % (test_name
,)
354 self
.logger
.suite_end()
357 # tempfile.TemporaryDirectory missing from Python 2.7
358 class TemporaryDirectory(object):
360 self
.path
= tempfile
.mkdtemp()
364 return "<{} {!r}>".format(self
.__class
__.__name
__, self
.path
)
369 def __exit__(self
, exc
, value
, tb
):
376 if self
.path
and not self
._closed
:
377 shutil
.rmtree(self
.path
)
381 class PuppeteerRunner(MozbuildObject
):
382 def __init__(self
, *args
, **kwargs
):
383 super(PuppeteerRunner
, self
).__init
__(*args
, **kwargs
)
385 self
.remotedir
= os
.path
.join(self
.topsrcdir
, "remote")
386 self
.puppeteer_dir
= os
.path
.join(self
.remotedir
, "test", "puppeteer")
388 def run_test(self
, logger
, *tests
, **params
):
390 Runs Puppeteer unit tests with npm.
392 Possible optional test parameters:
395 Path for the browser binary to use. Defaults to the local
398 Boolean to indicate whether to test Firefox with CDP protocol.
400 Boolean to indicate whether to activate Firefox' headless mode.
402 Dictionary of extra preferences to write to the profile,
403 before invoking npm. Overrides default preferences.
405 Boolean to indicate whether to enable WebRender compositor in Gecko.
409 binary
= params
.get("binary")
410 headless
= params
.get("headless", False)
411 product
= params
.get("product", "firefox")
412 with_cdp
= params
.get("cdp", False)
415 for k
, v
in params
.get("extra_launcher_options", {}).items():
416 extra_options
[k
] = json
.loads(v
)
418 # Override upstream defaults: no retries, shorter timeout
421 "./json-mocha-reporter.js",
432 # Checked by Puppeteer's custom mocha config
434 # Print browser process ouptut
436 # Run in headless mode if trueish, otherwise use headful
437 "HEADLESS": str(headless
),
438 # Causes some tests to be skipped due to assumptions about install
439 "PUPPETEER_ALT_INSTALL": "1",
442 if product
== "firefox":
443 env
["BINARY"] = binary
or self
.get_binary_path()
444 env
["PUPPETEER_PRODUCT"] = "firefox"
445 env
["MOZ_WEBRENDER"] = "%d" % params
.get("enable_webrender", False)
448 env
["BINARY"] = binary
449 env
["PUPPETEER_CACHE_DIR"] = os
.path
.join(
458 if product
== "chrome":
461 test_command
= "test:chrome:headless"
463 test_command
= "test:chrome:headful"
465 test_command
= "test:chrome:bidi"
468 "Chrome doesn't support headful mode with the WebDriver BiDi protocol"
470 elif product
== "firefox":
472 test_command
= "test:firefox:cdp"
474 test_command
= "test:firefox:headless"
476 test_command
= "test:firefox:headful"
478 test_command
= "test:" + product
480 command
= ["run", test_command
, "--"] + mocha_options
483 for k
, v
in params
.get("extra_prefs", {}).items():
484 print("Using extra preference: {}={}".format(k
, v
))
485 prefs
[k
] = mozprofile
.Preferences
.cast(v
)
488 extra_options
["extraPrefsFirefox"] = prefs
491 env
["EXTRA_LAUNCH_OPTIONS"] = json
.dumps(extra_options
)
493 expected_path
= os
.path
.join(
494 os
.path
.dirname(__file__
),
498 "TestExpectations.json",
500 if os
.path
.exists(expected_path
):
501 with
open(expected_path
) as f
:
502 expected_data
= json
.load(f
)
506 expected_platform
= platform
.uname().system
.lower()
507 if expected_platform
== "windows":
508 expected_platform
= "win32"
510 # Filter expectation data for the selected browser,
511 # headless or headful mode, the operating system,
512 # run in BiDi mode or not.
515 for expectation
in expected_data
516 if is_relevant_expectation(
517 expectation
, product
, with_cdp
, env
["HEADLESS"], expected_platform
521 output_handler
= MochaOutputHandler(logger
, expectations
)
524 cwd
=self
.puppeteer_dir
,
526 output_line_handler
=output_handler
,
527 # Puppeteer unit tests don't always clean-up child processes in case of
528 # failure, so use an output_timeout as a fallback
533 output_handler
.after_end()
535 if output_handler
.has_unexpected
:
536 logger
.error("Got unexpected results")
540 def create_parser_puppeteer():
541 p
= argparse
.ArgumentParser()
543 "--product", type=str, default
="firefox", choices
=["chrome", "firefox"]
548 help="Path to browser binary. Defaults to local Firefox build.",
553 help="Flag that indicates whether to test Firefox with the CDP protocol.",
558 help="Flag that indicates that tests run in a CI environment.",
564 dest
="disable_fission",
565 help="Disable Fission (site isolation) in Gecko.",
568 "--enable-webrender",
570 help="Enable the WebRender compositor in Gecko.",
573 "-z", "--headless", action
="store_true", help="Run browser in headless mode."
579 metavar
="<pref>=<value>",
580 help="Defines additional user preferences.",
585 dest
="extra_options",
586 metavar
="<option>=<value>",
587 help="Defines additional options for `puppeteer.launch`.",
594 help="Increase remote agent logging verbosity to include "
595 "debug level messages with -v, trace messages with -vv,"
596 "and to not truncate long trace messages with -vvv",
598 p
.add_argument("tests", nargs
="*")
599 mozlog
.commandline
.add_logging_group(p
)
603 def is_relevant_expectation(
604 expectation
, expected_product
, with_cdp
, is_headless
, expected_platform
606 parameters
= expectation
["parameters"]
608 if expected_product
== "firefox":
609 is_expected_product
= (
610 "chrome" not in parameters
and "chrome-headless-shell" not in parameters
613 is_expected_product
= "firefox" not in parameters
616 is_expected_protocol
= "webDriverBiDi" not in parameters
618 is_expected_protocol
= "cdp" not in parameters
621 if is_headless
== "True":
622 is_expected_mode
= "headful" not in parameters
624 is_expected_mode
= "headless" not in parameters
626 is_expected_platform
= expected_platform
in expectation
["platforms"]
630 and is_expected_protocol
632 and is_expected_platform
639 description
="Run Puppeteer unit tests.",
640 parser
=create_parser_puppeteer
,
645 action
="store_false",
647 help="Do not install the Puppeteer package",
654 disable_fission
=False,
655 enable_webrender
=False,
665 logger
= mozlog
.commandline
.setup_logging(
666 "puppeteer-test", kwargs
, {"mach": sys
.stdout
}
669 # moztest calls this programmatically with test objects or manifests
670 if "test_objects" in kwargs
and tests
is not None:
671 logger
.error("Expected either 'test_objects' or 'tests'")
674 if product
!= "firefox" and extra_prefs
is not None:
675 logger
.error("User preferences are not recognized by %s" % product
)
678 if "test_objects" in kwargs
:
680 for test
in kwargs
["test_objects"]:
681 tests
.append(test
["path"])
684 for s
in extra_prefs
or []:
687 logger
.error("syntax error in --setpref={}".format(s
))
689 prefs
[kv
[0]] = kv
[1].strip()
692 for s
in extra_options
or []:
695 logger
.error("syntax error in --setopt={}".format(s
))
697 options
[kv
[0]] = kv
[1].strip()
699 prefs
.update({"fission.autostart": True})
701 prefs
.update({"fission.autostart": False})
704 prefs
["remote.log.level"] = "Debug"
706 prefs
["remote.log.level"] = "Trace"
708 prefs
["remote.log.truncate"] = False
711 install_puppeteer(command_context
, product
, ci
)
716 "headless": headless
,
717 "enable_webrender": enable_webrender
,
718 "extra_prefs": prefs
,
720 "extra_launcher_options": options
,
722 puppeteer
= command_context
._spawn
(PuppeteerRunner
)
724 return puppeteer
.run_test(logger
, *tests
, **params
)
725 except BinaryNotFoundException
as e
:
727 logger
.info(e
.help())
729 except Exception as e
:
733 def install_puppeteer(command_context
, product
, ci
):
737 "CI": "1", # Force the quiet logger of wireit
738 "HUSKY": "0", # Disable any hook checks
741 puppeteer_dir
= os
.path
.join("remote", "test", "puppeteer")
742 puppeteer_dir_full_path
= os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
)
743 puppeteer_test_dir
= os
.path
.join(puppeteer_dir
, "test")
745 if product
== "chrome":
746 env
["PUPPETEER_PRODUCT"] = "chrome"
747 env
["PUPPETEER_CACHE_DIR"] = os
.path
.join(
748 command_context
.topobjdir
, "_tests", puppeteer_dir
, ".cache"
751 env
["PUPPETEER_SKIP_DOWNLOAD"] = "1"
757 cwd
=puppeteer_dir_full_path
,
762 # Always use the `ci` command to not get updated sub-dependencies installed.
763 run_npm("ci", cwd
=puppeteer_dir_full_path
, env
=env
)
765 # Build Puppeteer and the code to download browsers.
769 cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_test_dir
),
773 # Run post install steps, including downloading the Chrome browser if requested
774 run_npm("run", "postinstall", cwd
=puppeteer_dir_full_path
, env
=env
)
777 def exit(code
, error
=None):
778 if error
is not None:
779 if isinstance(error
, Exception):
782 traceback
.print_exc()
784 message
= str(error
).split("\n")[0].strip()
785 print("{}: {}".format(sys
.argv
[0], message
), file=sys
.stderr
)