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 if product
== "firefox":
447 env
["BINARY"] = binary
448 env
["PUPPETEER_PRODUCT"] = "firefox"
450 env
["MOZ_WEBRENDER"] = "%d" % params
.get("enable_webrender", False)
452 command
= ["run", "unit", "--"] + mocha_options
454 env
["HEADLESS"] = str(params
.get("headless", False))
457 for k
, v
in params
.get("extra_prefs", {}).items():
458 print("Using extra preference: {}={}".format(k
, v
))
459 prefs
[k
] = mozprofile
.Preferences
.cast(v
)
462 extra_options
["extraPrefsFirefox"] = prefs
465 env
["EXTRA_LAUNCH_OPTIONS"] = json
.dumps(extra_options
)
467 expected_path
= os
.path
.join(
468 os
.path
.dirname(__file__
), "test", "puppeteer-expected.json"
470 if product
== "firefox" and os
.path
.exists(expected_path
):
471 with
open(expected_path
) as f
:
472 expected_data
= json
.load(f
)
476 output_handler
= MochaOutputHandler(logger
, expected_data
)
479 cwd
=self
.puppeteer_dir
,
481 processOutputLine
=output_handler
,
484 output_handler
.proc
= proc
486 # Puppeteer unit tests don't always clean-up child processes in case of
487 # failure, so use an output_timeout as a fallback
488 wait_proc(proc
, "npm", output_timeout
=60, exit_on_fail
=False)
490 output_handler
.after_end(params
.get("subset", False))
492 # Non-zero return codes are non-fatal for now since we have some
493 # issues with unresolved promises that shouldn't otherwise block
495 if proc
.returncode
!= 0:
496 logger
.warning("npm exited with code %s" % proc
.returncode
)
498 if params
["write_results"]:
499 with
open(params
["write_results"], "w") as f
:
501 output_handler
.new_expected(), f
, indent
=2, separators
=(",", ": ")
504 if output_handler
.has_unexpected
:
505 exit(1, "Got unexpected results")
508 def create_parser_puppeteer():
509 p
= argparse
.ArgumentParser()
511 "--product", type=str, default
="firefox", choices
=["chrome", "firefox"]
516 help="Path to browser binary. Defaults to local Firefox build.",
521 help="Flag that indicates that tests run in a CI environment.",
527 dest
="disable_fission",
528 help="Disable Fission (site isolation) in Gecko.",
531 "--enable-webrender",
533 help="Enable the WebRender compositor in Gecko.",
536 "-z", "--headless", action
="store_true", help="Run browser in headless mode."
542 metavar
="<pref>=<value>",
543 help="Defines additional user preferences.",
548 dest
="extra_options",
549 metavar
="<option>=<value>",
550 help="Defines additional options for `puppeteer.launch`.",
557 help="Increase remote agent logging verbosity to include "
558 "debug level messages with -v, trace messages with -vv,"
559 "and to not truncate long trace messages with -vvv",
567 os
.path
.dirname(__file__
), "test", "puppeteer-expected.json"
569 help="Path to write updated results to (defaults to the "
570 "expectations file if the argument is provided but "
571 "no path is passed)",
577 help="Indicate that only a subset of the tests are running, "
578 "so checks for missing tests should be skipped",
580 p
.add_argument("tests", nargs
="*")
581 mozlog
.commandline
.add_logging_group(p
)
588 description
="Run Puppeteer unit tests.",
589 parser
=create_parser_puppeteer
,
594 action
="store_false",
596 help="Do not install the Puppeteer package",
602 disable_fission
=False,
603 enable_webrender
=False,
616 logger
= mozlog
.commandline
.setup_logging(
617 "puppeteer-test", kwargs
, {"mach": sys
.stdout
}
620 # moztest calls this programmatically with test objects or manifests
621 if "test_objects" in kwargs
and tests
is not None:
622 logger
.error("Expected either 'test_objects' or 'tests'")
625 if product
!= "firefox" and extra_prefs
is not None:
626 logger
.error("User preferences are not recognized by %s" % product
)
629 if "test_objects" in kwargs
:
631 for test
in kwargs
["test_objects"]:
632 tests
.append(test
["path"])
635 for s
in extra_prefs
or []:
638 logger
.error("syntax error in --setpref={}".format(s
))
640 prefs
[kv
[0]] = kv
[1].strip()
643 for s
in extra_options
or []:
646 logger
.error("syntax error in --setopt={}".format(s
))
648 options
[kv
[0]] = kv
[1].strip()
650 prefs
.update({"fission.autostart": True})
652 prefs
.update({"fission.autostart": False})
655 prefs
["remote.log.level"] = "Debug"
657 prefs
["remote.log.level"] = "Trace"
659 prefs
["remote.log.truncate"] = False
662 install_puppeteer(command_context
, product
, ci
)
666 "headless": headless
,
667 "enable_webrender": enable_webrender
,
668 "extra_prefs": prefs
,
670 "extra_launcher_options": options
,
671 "write_results": write_results
,
674 puppeteer
= command_context
._spawn
(PuppeteerRunner
)
676 return puppeteer
.run_test(logger
, *tests
, **params
)
677 except BinaryNotFoundException
as e
:
679 logger
.info(e
.help())
681 except Exception as e
:
685 def install_puppeteer(command_context
, product
, ci
):
688 from mozversioncontrol
import get_repository_object
690 repo
= get_repository_object(command_context
.topsrcdir
)
691 puppeteer_dir
= os
.path
.join("remote", "test", "puppeteer")
692 changed_files
= False
693 for f
in repo
.get_changed_files():
694 if f
.startswith(puppeteer_dir
) and f
.endswith(".ts"):
698 if product
!= "chrome":
699 env
["PUPPETEER_SKIP_DOWNLOAD"] = "1"
700 lib_dir
= os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
, "lib")
701 if changed_files
and os
.path
.isdir(lib_dir
):
702 # clobber lib to force `tsc compile` step
703 shutil
.rmtree(lib_dir
)
705 command
= "ci" if ci
else "install"
706 npm(command
, cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
), env
=env
)
709 def exit(code
, error
=None):
710 if error
is not None:
711 if isinstance(error
, Exception):
714 traceback
.print_exc()
716 message
= str(error
).split("\n")[0].strip()
717 print("{}: {}".format(sys
.argv
[0], message
), file=sys
.stderr
)