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/.
5 from __future__
import (
20 from collections
import OrderedDict
22 from six
import iteritems
24 from mach
.decorators
import (
30 from mozbuild
.base
import (
32 BinaryNotFoundException
,
34 from mozbuild
import nodeutil
45 # add node and npm from mozbuild to front of system path
46 npm
, _
= nodeutil
.find_npm_executable()
48 exit(EX_CONFIG
, "could not find npm executable")
49 path
= os
.path
.abspath(os
.path
.join(npm
, os
.pardir
))
50 os
.environ
["PATH"] = "{}{}{}".format(path
, os
.pathsep
, os
.environ
["PATH"])
53 def remotedir(command_context
):
54 return os
.path
.join(command_context
.topsrcdir
, "remote")
57 @Command("remote", category
="misc", description
="Remote protocol related operations.")
58 def remote(command_context
):
59 """The remote subcommands all relate to the remote protocol."""
60 command_context
._sub
_mach
(["help", "remote"])
65 "remote", "vendor-puppeteer", "Pull in latest changes of the Puppeteer client."
71 help="The (possibly remote) repository to clone from.",
77 help="The commit or tag object name to check out.",
84 help="Do not install the just-pulled Puppeteer package,",
86 def vendor_puppeteer(command_context
, repository
, commitish
, install
):
87 puppeteer_dir
= os
.path
.join(remotedir(command_context
), "test", "puppeteer")
89 # Preserve our custom mocha reporter
91 os
.path
.join(puppeteer_dir
, "json-mocha-reporter.js"),
92 remotedir(command_context
),
94 shutil
.rmtree(puppeteer_dir
, ignore_errors
=True)
95 os
.makedirs(puppeteer_dir
)
96 with
TemporaryDirectory() as tmpdir
:
97 git("clone", "-q", repository
, tmpdir
)
98 git("checkout", commitish
, worktree
=tmpdir
)
104 "{}/".format(puppeteer_dir
),
108 # remove files which may interfere with git checkout of central
110 os
.remove(os
.path
.join(puppeteer_dir
, ".gitattributes"))
111 os
.remove(os
.path
.join(puppeteer_dir
, ".gitignore"))
115 unwanted_dirs
= ["experimental", "docs"]
117 for dir in unwanted_dirs
:
118 dir_path
= os
.path
.join(puppeteer_dir
, dir)
119 if os
.path
.isdir(dir_path
):
120 shutil
.rmtree(dir_path
)
123 os
.path
.join(remotedir(command_context
), "json-mocha-reporter.js"),
132 "product": "Remote Protocol",
133 "component": "Agent",
137 "description": "Headless Chrome Node API",
139 "license": "Apache-2.0",
140 "release": commitish
,
143 with
open(os
.path
.join(puppeteer_dir
, "moz.yaml"), "w") as fh
:
147 default_flow_style
=False,
153 env
= {"HUSKY": "0", "PUPPETEER_SKIP_DOWNLOAD": "1"}
156 cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
),
161 def git(*args
, **kwargs
):
163 if kwargs
.get("worktree"):
164 cmd
+= ("-C", kwargs
["worktree"])
167 pipe
= kwargs
.get("pipe")
168 git_p
= subprocess
.Popen(
170 env
={"GIT_CONFIG_NOSYSTEM": "1"},
171 stdout
=subprocess
.PIPE
,
172 stderr
=subprocess
.PIPE
,
176 pipe_p
= subprocess
.Popen(pipe
, stdin
=git_p
.stdout
, stderr
=subprocess
.PIPE
)
179 _
, pipe_err
= pipe_p
.communicate()
180 out
, git_err
= git_p
.communicate()
182 # use error from first program that failed
183 if git_p
.returncode
> 0:
184 exit(EX_SOFTWARE
, git_err
)
185 if pipe
and pipe_p
.returncode
> 0:
186 exit(EX_SOFTWARE
, pipe_err
)
191 def npm(*args
, **kwargs
):
192 from mozprocess
import processhandler
195 npm
, _
= nodeutil
.find_npm_executable()
196 if kwargs
.get("env"):
197 env
= os
.environ
.copy()
198 env
.update(kwargs
["env"])
201 if "processOutputLine" in kwargs
:
202 proc_kwargs
["processOutputLine"] = kwargs
["processOutputLine"]
204 p
= processhandler
.ProcessHandler(
207 cwd
=kwargs
.get("cwd"),
209 universal_newlines
=True,
212 if not kwargs
.get("wait", True):
215 wait_proc(p
, cmd
=npm
, exit_on_fail
=kwargs
.get("exit_on_fail", True))
220 def wait_proc(p
, cmd
=None, exit_on_fail
=True, output_timeout
=None):
222 p
.run(outputTimeout
=output_timeout
)
225 # In some cases, we wait longer for a mocha timeout
226 print("Timed out after {} seconds of no output".format(output_timeout
))
229 if exit_on_fail
and p
.returncode
> 0:
231 "%s: exit code %s" % (cmd
, p
.returncode
)
233 else "exit code %s" % p
.returncode
235 exit(p
.returncode
, msg
)
238 class MochaOutputHandler(object):
239 def __init__(self
, logger
, expected
):
240 self
.hook_re
= re
.compile('"before\b?.*" hook|"after\b?.*" hook')
244 self
.test_results
= OrderedDict()
245 self
.expected
= expected
246 self
.unexpected_skips
= set()
248 self
.has_unexpected
= False
249 self
.logger
.suite_start([], name
="puppeteer-tests")
253 "TERMINATED": "CRASH",
261 return self
.proc
and self
.proc
.pid
263 def __call__(self
, line
):
266 if line
.startswith("[") and line
.endswith("]"):
267 event
= json
.loads(line
)
268 self
.process_event(event
)
272 self
.logger
.process_output(self
.pid
, line
, command
="npm")
274 def process_event(self
, event
):
275 if isinstance(event
, list) and len(event
) > 1:
276 status
= self
.status_map
.get(event
[0])
277 test_start
= event
[0] == "test-start"
278 if not status
and not test_start
:
281 test_name
= test_info
.get("fullTitle", "")
282 test_path
= test_info
.get("file", "")
283 test_err
= test_info
.get("err")
284 if status
== "FAIL" and test_err
:
285 if "timeout" in test_err
.lower():
287 if test_name
and test_path
:
288 test_name
= "{} ({})".format(test_name
, os
.path
.basename(test_path
))
289 # mocha hook failures are not tracked in metadata
290 if status
!= "PASS" and self
.hook_re
.search(test_name
):
291 self
.logger
.error("TEST-UNEXPECTED-ERROR %s" % (test_name
,))
294 self
.logger
.test_start(test_name
)
296 expected
= self
.expected
.get(test_name
, ["PASS"])
297 # mozlog doesn't really allow unexpected skip,
298 # so if a test is disabled just expect that and note the unexpected skip
299 # Also, mocha doesn't log test-start for skipped tests
301 self
.logger
.test_start(test_name
)
302 if self
.expected
and status
not in expected
:
303 self
.unexpected_skips
.add(test_name
)
305 known_intermittent
= expected
[1:]
306 expected_status
= expected
[0]
308 # check if we've seen a result for this test before this log line
309 result_recorded
= self
.test_results
.get(test_name
)
312 "Received a second status for {}: "
313 "first {}, now {}".format(test_name
, result_recorded
, status
)
315 # mocha intermittently logs an additional test result after the
316 # test has already timed out. Avoid recording this second status.
317 if result_recorded
!= "TIMEOUT":
318 self
.test_results
[test_name
] = status
319 if status
not in expected
:
320 self
.has_unexpected
= True
321 self
.logger
.test_end(
324 expected
=expected_status
,
325 known_intermittent
=known_intermittent
,
328 def new_expected(self
):
329 new_expected
= OrderedDict()
330 for test_name
, status
in iteritems(self
.test_results
):
331 if test_name
not in self
.expected
:
332 new_status
= [status
]
334 if status
in self
.expected
[test_name
]:
335 new_status
= self
.expected
[test_name
]
337 new_status
= [status
]
338 new_expected
[test_name
] = new_status
341 def after_end(self
, subset
=False):
343 missing
= set(self
.expected
) - set(self
.test_results
)
344 extra
= set(self
.test_results
) - set(self
.expected
)
346 self
.has_unexpected
= True
347 for test_name
in missing
:
348 self
.logger
.error("TEST-UNEXPECTED-MISSING %s" % (test_name
,))
349 if self
.expected
and extra
:
350 self
.has_unexpected
= True
351 for test_name
in extra
:
353 "TEST-UNEXPECTED-MISSING Unknown new test %s" % (test_name
,)
356 if self
.unexpected_skips
:
357 self
.has_unexpected
= True
358 for test_name
in self
.unexpected_skips
:
360 "TEST-UNEXPECTED-MISSING Unexpected skipped %s" % (test_name
,)
362 self
.logger
.suite_end()
365 # tempfile.TemporaryDirectory missing from Python 2.7
366 class TemporaryDirectory(object):
368 self
.path
= tempfile
.mkdtemp()
372 return "<{} {!r}>".format(self
.__class
__.__name
__, self
.path
)
377 def __exit__(self
, exc
, value
, tb
):
384 if self
.path
and not self
._closed
:
385 shutil
.rmtree(self
.path
)
389 class PuppeteerRunner(MozbuildObject
):
390 def __init__(self
, *args
, **kwargs
):
391 super(PuppeteerRunner
, self
).__init
__(*args
, **kwargs
)
393 self
.remotedir
= os
.path
.join(self
.topsrcdir
, "remote")
394 self
.puppeteer_dir
= os
.path
.join(self
.remotedir
, "test", "puppeteer")
396 def run_test(self
, logger
, *tests
, **params
):
398 Runs Puppeteer unit tests with npm.
400 Possible optional test parameters:
403 Path for the browser binary to use. Defaults to the local
406 Boolean to indicate whether to activate Firefox' headless mode.
408 Dictionary of extra preferences to write to the profile,
409 before invoking npm. Overrides default preferences.
411 Boolean to indicate whether to enable WebRender compositor in Gecko.
413 Path to write the results json file
415 Indicates only a subset of tests are being run, so we should
416 skip the check for missing results
420 binary
= params
.get("binary") or self
.get_binary_path()
421 product
= params
.get("product", "firefox")
424 # Print browser process ouptut
426 # Checked by Puppeteer's custom mocha config
428 # Causes some tests to be skipped due to assumptions about install
429 "PUPPETEER_ALT_INSTALL": "1",
432 for k
, v
in params
.get("extra_launcher_options", {}).items():
433 extra_options
[k
] = json
.loads(v
)
435 # Override upstream defaults: no retries, shorter timeout
438 "./json-mocha-reporter.js",
446 env
["HEADLESS"] = str(params
.get("headless", False))
448 if product
== "firefox":
449 env
["BINARY"] = binary
450 env
["PUPPETEER_PRODUCT"] = "firefox"
452 env
["MOZ_WEBRENDER"] = "%d" % params
.get("enable_webrender", False)
454 test_command
= "test:firefox"
455 elif env
["HEADLESS"] == "False":
456 test_command
= "test:chrome:headful"
458 test_command
= "test:chrome:headless"
460 command
= ["run", test_command
, "--"] + mocha_options
463 for k
, v
in params
.get("extra_prefs", {}).items():
464 print("Using extra preference: {}={}".format(k
, v
))
465 prefs
[k
] = mozprofile
.Preferences
.cast(v
)
468 extra_options
["extraPrefsFirefox"] = prefs
471 env
["EXTRA_LAUNCH_OPTIONS"] = json
.dumps(extra_options
)
473 expected_path
= os
.path
.join(
474 os
.path
.dirname(__file__
), "test", "puppeteer-expected.json"
476 if product
== "firefox" and os
.path
.exists(expected_path
):
477 with
open(expected_path
) as f
:
478 expected_data
= json
.load(f
)
482 output_handler
= MochaOutputHandler(logger
, expected_data
)
485 cwd
=self
.puppeteer_dir
,
487 processOutputLine
=output_handler
,
490 output_handler
.proc
= proc
492 # Puppeteer unit tests don't always clean-up child processes in case of
493 # failure, so use an output_timeout as a fallback
494 wait_proc(proc
, "npm", output_timeout
=60, exit_on_fail
=False)
496 output_handler
.after_end(params
.get("subset", False))
498 # Non-zero return codes are non-fatal for now since we have some
499 # issues with unresolved promises that shouldn't otherwise block
501 if proc
.returncode
!= 0:
502 logger
.warning("npm exited with code %s" % proc
.returncode
)
504 if params
["write_results"]:
505 with
open(params
["write_results"], "w") as f
:
507 output_handler
.new_expected(), f
, indent
=2, separators
=(",", ": ")
510 if output_handler
.has_unexpected
:
511 exit(1, "Got unexpected results")
514 def create_parser_puppeteer():
515 p
= argparse
.ArgumentParser()
517 "--product", type=str, default
="firefox", choices
=["chrome", "firefox"]
522 help="Path to browser binary. Defaults to local Firefox build.",
527 help="Flag that indicates that tests run in a CI environment.",
533 dest
="disable_fission",
534 help="Disable Fission (site isolation) in Gecko.",
537 "--enable-webrender",
539 help="Enable the WebRender compositor in Gecko.",
542 "-z", "--headless", action
="store_true", help="Run browser in headless mode."
548 metavar
="<pref>=<value>",
549 help="Defines additional user preferences.",
554 dest
="extra_options",
555 metavar
="<option>=<value>",
556 help="Defines additional options for `puppeteer.launch`.",
563 help="Increase remote agent logging verbosity to include "
564 "debug level messages with -v, trace messages with -vv,"
565 "and to not truncate long trace messages with -vvv",
573 os
.path
.dirname(__file__
), "test", "puppeteer-expected.json"
575 help="Path to write updated results to (defaults to the "
576 "expectations file if the argument is provided but "
577 "no path is passed)",
583 help="Indicate that only a subset of the tests are running, "
584 "so checks for missing tests should be skipped",
586 p
.add_argument("tests", nargs
="*")
587 mozlog
.commandline
.add_logging_group(p
)
594 description
="Run Puppeteer unit tests.",
595 parser
=create_parser_puppeteer
,
600 action
="store_false",
602 help="Do not install the Puppeteer package",
608 disable_fission
=False,
609 enable_webrender
=False,
622 logger
= mozlog
.commandline
.setup_logging(
623 "puppeteer-test", kwargs
, {"mach": sys
.stdout
}
626 # moztest calls this programmatically with test objects or manifests
627 if "test_objects" in kwargs
and tests
is not None:
628 logger
.error("Expected either 'test_objects' or 'tests'")
631 if product
!= "firefox" and extra_prefs
is not None:
632 logger
.error("User preferences are not recognized by %s" % product
)
635 if "test_objects" in kwargs
:
637 for test
in kwargs
["test_objects"]:
638 tests
.append(test
["path"])
641 for s
in extra_prefs
or []:
644 logger
.error("syntax error in --setpref={}".format(s
))
646 prefs
[kv
[0]] = kv
[1].strip()
649 for s
in extra_options
or []:
652 logger
.error("syntax error in --setopt={}".format(s
))
654 options
[kv
[0]] = kv
[1].strip()
656 prefs
.update({"fission.autostart": True})
658 prefs
.update({"fission.autostart": False})
661 prefs
["remote.log.level"] = "Debug"
663 prefs
["remote.log.level"] = "Trace"
665 prefs
["remote.log.truncate"] = False
668 install_puppeteer(command_context
, product
, ci
)
672 "headless": headless
,
673 "enable_webrender": enable_webrender
,
674 "extra_prefs": prefs
,
676 "extra_launcher_options": options
,
677 "write_results": write_results
,
680 puppeteer
= command_context
._spawn
(PuppeteerRunner
)
682 return puppeteer
.run_test(logger
, *tests
, **params
)
683 except BinaryNotFoundException
as e
:
685 logger
.info(e
.help())
687 except Exception as e
:
691 def install_puppeteer(command_context
, product
, ci
):
694 from mozversioncontrol
import get_repository_object
696 repo
= get_repository_object(command_context
.topsrcdir
)
697 puppeteer_dir
= os
.path
.join("remote", "test", "puppeteer")
698 changed_files
= False
699 for f
in repo
.get_changed_files():
700 if f
.startswith(puppeteer_dir
) and f
.endswith(".ts"):
704 if product
!= "chrome":
705 env
["PUPPETEER_SKIP_DOWNLOAD"] = "1"
706 lib_dir
= os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
, "lib")
707 if changed_files
and os
.path
.isdir(lib_dir
):
708 # clobber lib to force `tsc compile` step
709 shutil
.rmtree(lib_dir
)
711 command
= "ci" if ci
else "install"
712 npm(command
, cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
), env
=env
)
716 cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
),
721 def exit(code
, error
=None):
722 if error
is not None:
723 if isinstance(error
, Exception):
726 traceback
.print_exc()
728 message
= str(error
).split("\n")[0].strip()
729 print("{}: {}".format(sys
.argv
[0], message
), file=sys
.stderr
)