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 test_command
= "test:" + product
462 test_command
= test_command
+ ":headless"
464 test_command
= test_command
+ ":headful"
467 test_command
= test_command
+ ":bidi"
469 if product
== "chrome":
471 "Chrome doesn't support headful mode with the WebDriver BiDi protocol"
474 test_command
= test_command
+ ":bidi:headful"
476 command
= ["run", test_command
, "--"] + mocha_options
479 for k
, v
in params
.get("extra_prefs", {}).items():
480 print("Using extra preference: {}={}".format(k
, v
))
481 prefs
[k
] = mozprofile
.Preferences
.cast(v
)
484 extra_options
["extraPrefsFirefox"] = prefs
487 env
["EXTRA_LAUNCH_OPTIONS"] = json
.dumps(extra_options
)
489 expected_path
= os
.path
.join(
490 os
.path
.dirname(__file__
),
494 "TestExpectations.json",
496 if os
.path
.exists(expected_path
):
497 with
open(expected_path
) as f
:
498 expected_data
= json
.load(f
)
502 expected_platform
= platform
.uname().system
.lower()
503 if expected_platform
== "windows":
504 expected_platform
= "win32"
506 # Filter expectation data for the selected browser,
507 # headless or headful mode, the operating system,
508 # run in BiDi mode or not.
511 for expectation
in expected_data
512 if is_relevant_expectation(
513 expectation
, product
, with_cdp
, env
["HEADLESS"], expected_platform
517 output_handler
= MochaOutputHandler(logger
, expectations
)
520 cwd
=self
.puppeteer_dir
,
522 output_line_handler
=output_handler
,
523 # Puppeteer unit tests don't always clean-up child processes in case of
524 # failure, so use an output_timeout as a fallback
529 output_handler
.after_end()
531 if output_handler
.has_unexpected
:
532 logger
.error("Got unexpected results")
536 def create_parser_puppeteer():
537 p
= argparse
.ArgumentParser()
539 "--product", type=str, default
="firefox", choices
=["chrome", "firefox"]
544 help="Path to browser binary. Defaults to local Firefox build.",
549 help="Flag that indicates whether to test Firefox with the CDP protocol.",
554 help="Flag that indicates that tests run in a CI environment.",
560 dest
="disable_fission",
561 help="Disable Fission (site isolation) in Gecko.",
564 "--enable-webrender",
566 help="Enable the WebRender compositor in Gecko.",
569 "-z", "--headless", action
="store_true", help="Run browser in headless mode."
575 metavar
="<pref>=<value>",
576 help="Defines additional user preferences.",
581 dest
="extra_options",
582 metavar
="<option>=<value>",
583 help="Defines additional options for `puppeteer.launch`.",
590 help="Increase remote agent logging verbosity to include "
591 "debug level messages with -v, trace messages with -vv,"
592 "and to not truncate long trace messages with -vvv",
594 p
.add_argument("tests", nargs
="*")
595 mozlog
.commandline
.add_logging_group(p
)
599 def is_relevant_expectation(
600 expectation
, expected_product
, with_cdp
, is_headless
, expected_platform
602 parameters
= expectation
["parameters"]
604 if expected_product
== "firefox":
605 is_expected_product
= "chrome" not in parameters
607 is_expected_product
= "firefox" not in parameters
610 is_expected_protocol
= "webDriverBiDi" not in parameters
612 is_expected_protocol
= "cdp" not in parameters
615 if is_headless
== "True":
616 is_expected_mode
= "headful" not in parameters
618 is_expected_mode
= "headless" not in parameters
620 is_expected_platform
= expected_platform
in expectation
["platforms"]
624 and is_expected_protocol
626 and is_expected_platform
633 description
="Run Puppeteer unit tests.",
634 parser
=create_parser_puppeteer
,
639 action
="store_false",
641 help="Do not install the Puppeteer package",
648 disable_fission
=False,
649 enable_webrender
=False,
659 logger
= mozlog
.commandline
.setup_logging(
660 "puppeteer-test", kwargs
, {"mach": sys
.stdout
}
663 # moztest calls this programmatically with test objects or manifests
664 if "test_objects" in kwargs
and tests
is not None:
665 logger
.error("Expected either 'test_objects' or 'tests'")
668 if product
!= "firefox" and extra_prefs
is not None:
669 logger
.error("User preferences are not recognized by %s" % product
)
672 if "test_objects" in kwargs
:
674 for test
in kwargs
["test_objects"]:
675 tests
.append(test
["path"])
678 for s
in extra_prefs
or []:
681 logger
.error("syntax error in --setpref={}".format(s
))
683 prefs
[kv
[0]] = kv
[1].strip()
686 for s
in extra_options
or []:
689 logger
.error("syntax error in --setopt={}".format(s
))
691 options
[kv
[0]] = kv
[1].strip()
693 prefs
.update({"fission.autostart": True})
695 prefs
.update({"fission.autostart": False})
698 prefs
["remote.log.level"] = "Debug"
700 prefs
["remote.log.level"] = "Trace"
702 prefs
["remote.log.truncate"] = False
705 install_puppeteer(command_context
, product
, ci
)
710 "headless": headless
,
711 "enable_webrender": enable_webrender
,
712 "extra_prefs": prefs
,
714 "extra_launcher_options": options
,
716 puppeteer
= command_context
._spawn
(PuppeteerRunner
)
718 return puppeteer
.run_test(logger
, *tests
, **params
)
719 except BinaryNotFoundException
as e
:
721 logger
.info(e
.help())
723 except Exception as e
:
727 def install_puppeteer(command_context
, product
, ci
):
731 "CI": "1", # Force the quiet logger of wireit
732 "HUSKY": "0", # Disable any hook checks
735 puppeteer_dir
= os
.path
.join("remote", "test", "puppeteer")
736 puppeteer_dir_full_path
= os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
)
737 puppeteer_test_dir
= os
.path
.join(puppeteer_dir
, "test")
739 if product
== "chrome":
740 env
["PUPPETEER_PRODUCT"] = "chrome"
741 env
["PUPPETEER_CACHE_DIR"] = os
.path
.join(
742 command_context
.topobjdir
, "_tests", puppeteer_dir
, ".cache"
745 env
["PUPPETEER_SKIP_DOWNLOAD"] = "1"
751 cwd
=puppeteer_dir_full_path
,
756 # Always use the `ci` command to not get updated sub-dependencies installed.
757 run_npm("ci", cwd
=puppeteer_dir_full_path
, env
=env
)
759 # Build Puppeteer and the code to download browsers.
763 cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_test_dir
),
767 # Run post install steps, including downloading the Chrome browser if requested
768 run_npm("run", "postinstall", cwd
=puppeteer_dir_full_path
, env
=env
)
771 def exit(code
, error
=None):
772 if error
is not None:
773 if isinstance(error
, Exception):
776 traceback
.print_exc()
778 message
= str(error
).split("\n")[0].strip()
779 print("{}: {}".format(sys
.argv
[0], message
), file=sys
.stderr
)