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 (
31 from mozbuild
.base
import (
34 BinaryNotFoundException
,
36 from mozbuild
import nodeutil
47 # add node and npm from mozbuild to front of system path
48 npm
, _
= nodeutil
.find_npm_executable()
50 exit(EX_CONFIG
, "could not find npm executable")
51 path
= os
.path
.abspath(os
.path
.join(npm
, os
.pardir
))
52 os
.environ
["PATH"] = "{}:{}".format(path
, os
.environ
["PATH"])
56 class RemoteCommands(MachCommandBase
):
58 return os
.path
.join(self
.topsrcdir
, "remote")
61 "remote", category
="misc", description
="Remote protocol related operations."
63 def remote(self
, command_context
):
64 """The remote subcommands all relate to the remote protocol."""
65 self
._sub
_mach
(["help", "remote"])
69 "remote", "vendor-puppeteer", "Pull in latest changes of the Puppeteer client."
75 help="The (possibly remote) repository to clone from.",
81 help="The commit or tag object name to check out.",
88 help="Do not install the just-pulled Puppeteer package,",
90 def vendor_puppeteer(self
, command_context
, repository
, commitish
, install
):
91 puppeteer_dir
= os
.path
.join(self
.remotedir(), "test", "puppeteer")
93 # Preserve our custom mocha reporter
95 os
.path
.join(puppeteer_dir
, "json-mocha-reporter.js"), self
.remotedir()
97 shutil
.rmtree(puppeteer_dir
, ignore_errors
=True)
98 os
.makedirs(puppeteer_dir
)
99 with
TemporaryDirectory() as tmpdir
:
100 git("clone", "-q", repository
, tmpdir
)
101 git("checkout", commitish
, worktree
=tmpdir
)
107 "{}/".format(puppeteer_dir
),
111 # remove files which may interfere with git checkout of central
113 os
.remove(os
.path
.join(puppeteer_dir
, ".gitattributes"))
114 os
.remove(os
.path
.join(puppeteer_dir
, ".gitignore"))
118 unwanted_dirs
= ["experimental", "docs"]
120 for dir in unwanted_dirs
:
121 dir_path
= os
.path
.join(puppeteer_dir
, dir)
122 if os
.path
.isdir(dir_path
):
123 shutil
.rmtree(dir_path
)
126 os
.path
.join(self
.remotedir(), "json-mocha-reporter.js"), puppeteer_dir
134 "product": "Remote Protocol",
135 "component": "Agent",
139 "description": "Headless Chrome Node API",
141 "license": "Apache-2.0",
142 "release": commitish
,
145 with
open(os
.path
.join(puppeteer_dir
, "moz.yaml"), "w") as fh
:
149 default_flow_style
=False,
155 env
= {"PUPPETEER_SKIP_DOWNLOAD": "1"}
156 npm("install", cwd
=os
.path
.join(self
.topsrcdir
, puppeteer_dir
), env
=env
)
159 def git(*args
, **kwargs
):
161 if kwargs
.get("worktree"):
162 cmd
+= ("-C", kwargs
["worktree"])
165 pipe
= kwargs
.get("pipe")
166 git_p
= subprocess
.Popen(
168 env
={"GIT_CONFIG_NOSYSTEM": "1"},
169 stdout
=subprocess
.PIPE
,
170 stderr
=subprocess
.PIPE
,
174 pipe_p
= subprocess
.Popen(pipe
, stdin
=git_p
.stdout
, stderr
=subprocess
.PIPE
)
177 _
, pipe_err
= pipe_p
.communicate()
178 out
, git_err
= git_p
.communicate()
180 # use error from first program that failed
181 if git_p
.returncode
> 0:
182 exit(EX_SOFTWARE
, git_err
)
183 if pipe
and pipe_p
.returncode
> 0:
184 exit(EX_SOFTWARE
, pipe_err
)
189 def npm(*args
, **kwargs
):
190 from mozprocess
import processhandler
193 if kwargs
.get("env"):
194 env
= os
.environ
.copy()
195 env
.update(kwargs
["env"])
198 if "processOutputLine" in kwargs
:
199 proc_kwargs
["processOutputLine"] = kwargs
["processOutputLine"]
201 p
= processhandler
.ProcessHandler(
204 cwd
=kwargs
.get("cwd"),
206 universal_newlines
=True,
209 if not kwargs
.get("wait", True):
212 wait_proc(p
, cmd
="npm", exit_on_fail
=kwargs
.get("exit_on_fail", True))
217 def wait_proc(p
, cmd
=None, exit_on_fail
=True, output_timeout
=None):
219 p
.run(outputTimeout
=output_timeout
)
222 # In some cases, we wait longer for a mocha timeout
223 print("Timed out after {} seconds of no output".format(output_timeout
))
226 if exit_on_fail
and p
.returncode
> 0:
228 "%s: exit code %s" % (cmd
, p
.returncode
)
230 else "exit code %s" % p
.returncode
232 exit(p
.returncode
, msg
)
235 class MochaOutputHandler(object):
236 def __init__(self
, logger
, expected
):
237 self
.hook_re
= re
.compile('"before\b?.*" hook|"after\b?.*" hook')
241 self
.test_results
= OrderedDict()
242 self
.expected
= expected
243 self
.unexpected_skips
= set()
245 self
.has_unexpected
= False
246 self
.logger
.suite_start([], name
="puppeteer-tests")
250 "TERMINATED": "CRASH",
258 return self
.proc
and self
.proc
.pid
260 def __call__(self
, line
):
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 process_event(self
, event
):
272 if isinstance(event
, list) and len(event
) > 1:
273 status
= self
.status_map
.get(event
[0])
274 test_start
= event
[0] == "test-start"
275 if not status
and not test_start
:
278 test_name
= test_info
.get("fullTitle", "")
279 test_path
= test_info
.get("file", "")
280 test_err
= test_info
.get("err")
281 if status
== "FAIL" and test_err
:
282 if "timeout" in test_err
.lower():
284 if test_name
and test_path
:
285 test_name
= "{} ({})".format(test_name
, os
.path
.basename(test_path
))
286 # mocha hook failures are not tracked in metadata
287 if status
!= "PASS" and self
.hook_re
.search(test_name
):
288 self
.logger
.error("TEST-UNEXPECTED-ERROR %s" % (test_name
,))
291 self
.logger
.test_start(test_name
)
293 expected
= self
.expected
.get(test_name
, ["PASS"])
294 # mozlog doesn't really allow unexpected skip,
295 # so if a test is disabled just expect that and note the unexpected skip
296 # Also, mocha doesn't log test-start for skipped tests
298 self
.logger
.test_start(test_name
)
299 if self
.expected
and status
not in expected
:
300 self
.unexpected_skips
.add(test_name
)
302 known_intermittent
= expected
[1:]
303 expected_status
= expected
[0]
305 # check if we've seen a result for this test before this log line
306 result_recorded
= self
.test_results
.get(test_name
)
309 "Received a second status for {}: "
310 "first {}, now {}".format(test_name
, result_recorded
, status
)
312 # mocha intermittently logs an additional test result after the
313 # test has already timed out. Avoid recording this second status.
314 if result_recorded
!= "TIMEOUT":
315 self
.test_results
[test_name
] = status
316 if status
not in expected
:
317 self
.has_unexpected
= True
318 self
.logger
.test_end(
321 expected
=expected_status
,
322 known_intermittent
=known_intermittent
,
325 def new_expected(self
):
326 new_expected
= OrderedDict()
327 for test_name
, status
in iteritems(self
.test_results
):
328 if test_name
not in self
.expected
:
329 new_status
= [status
]
331 if status
in self
.expected
[test_name
]:
332 new_status
= self
.expected
[test_name
]
334 new_status
= [status
]
335 new_expected
[test_name
] = new_status
338 def after_end(self
, subset
=False):
340 missing
= set(self
.expected
) - set(self
.test_results
)
341 extra
= set(self
.test_results
) - set(self
.expected
)
343 self
.has_unexpected
= True
344 for test_name
in missing
:
345 self
.logger
.error("TEST-UNEXPECTED-MISSING %s" % (test_name
,))
346 if self
.expected
and extra
:
347 self
.has_unexpected
= True
348 for test_name
in extra
:
350 "TEST-UNEXPECTED-MISSING Unknown new test %s" % (test_name
,)
353 if self
.unexpected_skips
:
354 self
.has_unexpected
= True
355 for test_name
in self
.unexpected_skips
:
357 "TEST-UNEXPECTED-MISSING Unexpected skipped %s" % (test_name
,)
359 self
.logger
.suite_end()
362 # tempfile.TemporaryDirectory missing from Python 2.7
363 class TemporaryDirectory(object):
365 self
.path
= tempfile
.mkdtemp()
369 return "<{} {!r}>".format(self
.__class
__.__name
__, self
.path
)
374 def __exit__(self
, exc
, value
, tb
):
381 if self
.path
and not self
._closed
:
382 shutil
.rmtree(self
.path
)
386 class PuppeteerRunner(MozbuildObject
):
387 def __init__(self
, *args
, **kwargs
):
388 super(PuppeteerRunner
, self
).__init
__(*args
, **kwargs
)
390 self
.remotedir
= os
.path
.join(self
.topsrcdir
, "remote")
391 self
.puppeteer_dir
= os
.path
.join(self
.remotedir
, "test", "puppeteer")
393 def run_test(self
, logger
, *tests
, **params
):
395 Runs Puppeteer unit tests with npm.
397 Possible optional test parameters:
400 Path for the browser binary to use. Defaults to the local
403 Boolean to indicate whether to activate Firefox' headless mode.
405 Dictionary of extra preferences to write to the profile,
406 before invoking npm. Overrides default preferences.
408 Boolean to indicate whether to enable WebRender compositor in Gecko.
410 Path to write the results json file
412 Indicates only a subset of tests are being run, so we should
413 skip the check for missing results
417 binary
= params
.get("binary") or self
.get_binary_path()
418 product
= params
.get("product", "firefox")
421 # Print browser process ouptut
423 # Checked by Puppeteer's custom mocha config
425 # Causes some tests to be skipped due to assumptions about install
426 "PUPPETEER_ALT_INSTALL": "1",
429 for k
, v
in params
.get("extra_launcher_options", {}).items():
430 extra_options
[k
] = json
.loads(v
)
432 # Override upstream defaults: no retries, shorter timeout
435 "./json-mocha-reporter.js",
443 if product
== "firefox":
444 env
["BINARY"] = binary
445 env
["PUPPETEER_PRODUCT"] = "firefox"
447 env
["MOZ_WEBRENDER"] = "%d" % params
.get("enable_webrender", False)
449 command
= ["run", "unit", "--"] + mocha_options
451 env
["HEADLESS"] = str(params
.get("headless", False))
454 for k
, v
in params
.get("extra_prefs", {}).items():
455 prefs
[k
] = mozprofile
.Preferences
.cast(v
)
458 extra_options
["extraPrefsFirefox"] = prefs
461 env
["EXTRA_LAUNCH_OPTIONS"] = json
.dumps(extra_options
)
463 expected_path
= os
.path
.join(
464 os
.path
.dirname(__file__
), "test", "puppeteer-expected.json"
466 if product
== "firefox" and os
.path
.exists(expected_path
):
467 with
open(expected_path
) as f
:
468 expected_data
= json
.load(f
)
472 output_handler
= MochaOutputHandler(logger
, expected_data
)
475 cwd
=self
.puppeteer_dir
,
477 processOutputLine
=output_handler
,
480 output_handler
.proc
= proc
482 # Puppeteer unit tests don't always clean-up child processes in case of
483 # failure, so use an output_timeout as a fallback
484 wait_proc(proc
, "npm", output_timeout
=60, exit_on_fail
=False)
486 output_handler
.after_end(params
.get("subset", False))
488 # Non-zero return codes are non-fatal for now since we have some
489 # issues with unresolved promises that shouldn't otherwise block
491 if proc
.returncode
!= 0:
492 logger
.warning("npm exited with code %s" % proc
.returncode
)
494 if params
["write_results"]:
495 with
open(params
["write_results"], "w") as f
:
497 output_handler
.new_expected(), f
, indent
=2, separators
=(",", ": ")
500 if output_handler
.has_unexpected
:
501 exit(1, "Got unexpected results")
504 def create_parser_puppeteer():
505 p
= argparse
.ArgumentParser()
507 "--product", type=str, default
="firefox", choices
=["chrome", "firefox"]
512 help="Path to browser binary. Defaults to local Firefox build.",
517 help="Flag that indicates that tests run in a CI environment.",
522 help="Enable Fission (site isolation) in Gecko.",
525 "--enable-webrender",
527 help="Enable the WebRender compositor in Gecko.",
530 "-z", "--headless", action
="store_true", help="Run browser in headless mode."
536 metavar
="<pref>=<value>",
537 help="Defines additional user preferences.",
542 dest
="extra_options",
543 metavar
="<option>=<value>",
544 help="Defines additional options for `puppeteer.launch`.",
551 help="Increase remote agent logging verbosity to include "
552 "debug level messages with -v, trace messages with -vv,"
553 "and to not truncate long trace messages with -vvv",
561 os
.path
.dirname(__file__
), "test", "puppeteer-expected.json"
563 help="Path to write updated results to (defaults to the "
564 "expectations file if the argument is provided but "
565 "no path is passed)",
571 help="Indicate that only a subset of the tests are running, "
572 "so checks for missing tests should be skipped",
574 p
.add_argument("tests", nargs
="*")
575 mozlog
.commandline
.add_logging_group(p
)
580 class PuppeteerTest(MachCommandBase
):
584 description
="Run Puppeteer unit tests.",
585 parser
=create_parser_puppeteer
,
592 enable_fission
=False,
593 enable_webrender
=False,
607 logger
= mozlog
.commandline
.setup_logging(
608 "puppeteer-test", kwargs
, {"mach": sys
.stdout
}
611 # moztest calls this programmatically with test objects or manifests
612 if "test_objects" in kwargs
and tests
is not None:
613 logger
.error("Expected either 'test_objects' or 'tests'")
616 if product
!= "firefox" and extra_prefs
is not None:
617 logger
.error("User preferences are not recognized by %s" % product
)
620 if "test_objects" in kwargs
:
622 for test
in kwargs
["test_objects"]:
623 tests
.append(test
["path"])
626 for s
in extra_prefs
or []:
629 logger
.error("syntax error in --setpref={}".format(s
))
631 prefs
[kv
[0]] = kv
[1].strip()
634 for s
in extra_options
or []:
637 logger
.error("syntax error in --setopt={}".format(s
))
639 options
[kv
[0]] = kv
[1].strip()
642 prefs
.update({"fission.autostart": True})
645 prefs
["remote.log.level"] = "Debug"
647 prefs
["remote.log.level"] = "Trace"
649 prefs
["remote.log.truncate"] = False
651 self
.install_puppeteer(product
)
655 "headless": headless
,
656 "enable_webrender": enable_webrender
,
657 "extra_prefs": prefs
,
659 "extra_launcher_options": options
,
660 "write_results": write_results
,
663 puppeteer
= self
._spawn
(PuppeteerRunner
)
665 return puppeteer
.run_test(logger
, *tests
, **params
)
666 except BinaryNotFoundException
as e
:
668 logger
.info(e
.help())
670 except Exception as e
:
673 def install_puppeteer(self
, product
):
676 from mozversioncontrol
import get_repository_object
678 repo
= get_repository_object(self
.topsrcdir
)
679 puppeteer_dir
= os
.path
.join("remote", "test", "puppeteer")
680 changed_files
= False
681 for f
in repo
.get_changed_files():
682 if f
.startswith(puppeteer_dir
) and f
.endswith(".ts"):
686 if product
!= "chrome":
687 env
["PUPPETEER_SKIP_DOWNLOAD"] = "1"
688 lib_dir
= os
.path
.join(self
.topsrcdir
, puppeteer_dir
, "lib")
689 if changed_files
and os
.path
.isdir(lib_dir
):
690 # clobber lib to force `tsc compile` step
691 shutil
.rmtree(lib_dir
)
693 command
= "ci" if self
.ci
else "install"
694 npm(command
, cwd
=os
.path
.join(self
.topsrcdir
, puppeteer_dir
), env
=env
)
697 def exit(code
, error
=None):
698 if error
is not None:
699 if isinstance(error
, Exception):
702 traceback
.print_exc()
704 message
= str(error
).split("\n")[0].strip()
705 print("{}: {}".format(sys
.argv
[0], message
), file=sys
.stderr
)