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
):
57 def remotedir(self
, command_context
):
58 return os
.path
.join(command_context
.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 command_context
._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(
92 self
.remotedir(command_context
), "test", "puppeteer"
95 # Preserve our custom mocha reporter
97 os
.path
.join(puppeteer_dir
, "json-mocha-reporter.js"),
98 self
.remotedir(command_context
),
100 shutil
.rmtree(puppeteer_dir
, ignore_errors
=True)
101 os
.makedirs(puppeteer_dir
)
102 with
TemporaryDirectory() as tmpdir
:
103 git("clone", "-q", repository
, tmpdir
)
104 git("checkout", commitish
, worktree
=tmpdir
)
110 "{}/".format(puppeteer_dir
),
114 # remove files which may interfere with git checkout of central
116 os
.remove(os
.path
.join(puppeteer_dir
, ".gitattributes"))
117 os
.remove(os
.path
.join(puppeteer_dir
, ".gitignore"))
121 unwanted_dirs
= ["experimental", "docs"]
123 for dir in unwanted_dirs
:
124 dir_path
= os
.path
.join(puppeteer_dir
, dir)
125 if os
.path
.isdir(dir_path
):
126 shutil
.rmtree(dir_path
)
129 os
.path
.join(self
.remotedir(command_context
), "json-mocha-reporter.js"),
138 "product": "Remote Protocol",
139 "component": "Agent",
143 "description": "Headless Chrome Node API",
145 "license": "Apache-2.0",
146 "release": commitish
,
149 with
open(os
.path
.join(puppeteer_dir
, "moz.yaml"), "w") as fh
:
153 default_flow_style
=False,
159 env
= {"PUPPETEER_SKIP_DOWNLOAD": "1"}
162 cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
),
167 def git(*args
, **kwargs
):
169 if kwargs
.get("worktree"):
170 cmd
+= ("-C", kwargs
["worktree"])
173 pipe
= kwargs
.get("pipe")
174 git_p
= subprocess
.Popen(
176 env
={"GIT_CONFIG_NOSYSTEM": "1"},
177 stdout
=subprocess
.PIPE
,
178 stderr
=subprocess
.PIPE
,
182 pipe_p
= subprocess
.Popen(pipe
, stdin
=git_p
.stdout
, stderr
=subprocess
.PIPE
)
185 _
, pipe_err
= pipe_p
.communicate()
186 out
, git_err
= git_p
.communicate()
188 # use error from first program that failed
189 if git_p
.returncode
> 0:
190 exit(EX_SOFTWARE
, git_err
)
191 if pipe
and pipe_p
.returncode
> 0:
192 exit(EX_SOFTWARE
, pipe_err
)
197 def npm(*args
, **kwargs
):
198 from mozprocess
import processhandler
201 if kwargs
.get("env"):
202 env
= os
.environ
.copy()
203 env
.update(kwargs
["env"])
206 if "processOutputLine" in kwargs
:
207 proc_kwargs
["processOutputLine"] = kwargs
["processOutputLine"]
209 p
= processhandler
.ProcessHandler(
212 cwd
=kwargs
.get("cwd"),
214 universal_newlines
=True,
217 if not kwargs
.get("wait", True):
220 wait_proc(p
, cmd
="npm", exit_on_fail
=kwargs
.get("exit_on_fail", True))
225 def wait_proc(p
, cmd
=None, exit_on_fail
=True, output_timeout
=None):
227 p
.run(outputTimeout
=output_timeout
)
230 # In some cases, we wait longer for a mocha timeout
231 print("Timed out after {} seconds of no output".format(output_timeout
))
234 if exit_on_fail
and p
.returncode
> 0:
236 "%s: exit code %s" % (cmd
, p
.returncode
)
238 else "exit code %s" % p
.returncode
240 exit(p
.returncode
, msg
)
243 class MochaOutputHandler(object):
244 def __init__(self
, logger
, expected
):
245 self
.hook_re
= re
.compile('"before\b?.*" hook|"after\b?.*" hook')
249 self
.test_results
= OrderedDict()
250 self
.expected
= expected
251 self
.unexpected_skips
= set()
253 self
.has_unexpected
= False
254 self
.logger
.suite_start([], name
="puppeteer-tests")
258 "TERMINATED": "CRASH",
266 return self
.proc
and self
.proc
.pid
268 def __call__(self
, line
):
271 if line
.startswith("[") and line
.endswith("]"):
272 event
= json
.loads(line
)
273 self
.process_event(event
)
277 self
.logger
.process_output(self
.pid
, line
, command
="npm")
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_name
= test_info
.get("fullTitle", "")
287 test_path
= test_info
.get("file", "")
288 test_err
= test_info
.get("err")
289 if status
== "FAIL" and test_err
:
290 if "timeout" in test_err
.lower():
292 if test_name
and test_path
:
293 test_name
= "{} ({})".format(test_name
, os
.path
.basename(test_path
))
294 # mocha hook failures are not tracked in metadata
295 if status
!= "PASS" and self
.hook_re
.search(test_name
):
296 self
.logger
.error("TEST-UNEXPECTED-ERROR %s" % (test_name
,))
299 self
.logger
.test_start(test_name
)
301 expected
= self
.expected
.get(test_name
, ["PASS"])
302 # mozlog doesn't really allow unexpected skip,
303 # so if a test is disabled just expect that and note the unexpected skip
304 # Also, mocha doesn't log test-start for skipped tests
306 self
.logger
.test_start(test_name
)
307 if self
.expected
and status
not in expected
:
308 self
.unexpected_skips
.add(test_name
)
310 known_intermittent
= expected
[1:]
311 expected_status
= expected
[0]
313 # check if we've seen a result for this test before this log line
314 result_recorded
= self
.test_results
.get(test_name
)
317 "Received a second status for {}: "
318 "first {}, now {}".format(test_name
, result_recorded
, status
)
320 # mocha intermittently logs an additional test result after the
321 # test has already timed out. Avoid recording this second status.
322 if result_recorded
!= "TIMEOUT":
323 self
.test_results
[test_name
] = status
324 if status
not in expected
:
325 self
.has_unexpected
= True
326 self
.logger
.test_end(
329 expected
=expected_status
,
330 known_intermittent
=known_intermittent
,
333 def new_expected(self
):
334 new_expected
= OrderedDict()
335 for test_name
, status
in iteritems(self
.test_results
):
336 if test_name
not in self
.expected
:
337 new_status
= [status
]
339 if status
in self
.expected
[test_name
]:
340 new_status
= self
.expected
[test_name
]
342 new_status
= [status
]
343 new_expected
[test_name
] = new_status
346 def after_end(self
, subset
=False):
348 missing
= set(self
.expected
) - set(self
.test_results
)
349 extra
= set(self
.test_results
) - set(self
.expected
)
351 self
.has_unexpected
= True
352 for test_name
in missing
:
353 self
.logger
.error("TEST-UNEXPECTED-MISSING %s" % (test_name
,))
354 if self
.expected
and extra
:
355 self
.has_unexpected
= True
356 for test_name
in extra
:
358 "TEST-UNEXPECTED-MISSING Unknown new test %s" % (test_name
,)
361 if self
.unexpected_skips
:
362 self
.has_unexpected
= True
363 for test_name
in self
.unexpected_skips
:
365 "TEST-UNEXPECTED-MISSING Unexpected skipped %s" % (test_name
,)
367 self
.logger
.suite_end()
370 # tempfile.TemporaryDirectory missing from Python 2.7
371 class TemporaryDirectory(object):
373 self
.path
= tempfile
.mkdtemp()
377 return "<{} {!r}>".format(self
.__class
__.__name
__, self
.path
)
382 def __exit__(self
, exc
, value
, tb
):
389 if self
.path
and not self
._closed
:
390 shutil
.rmtree(self
.path
)
394 class PuppeteerRunner(MozbuildObject
):
395 def __init__(self
, *args
, **kwargs
):
396 super(PuppeteerRunner
, self
).__init
__(*args
, **kwargs
)
398 self
.remotedir
= os
.path
.join(self
.topsrcdir
, "remote")
399 self
.puppeteer_dir
= os
.path
.join(self
.remotedir
, "test", "puppeteer")
401 def run_test(self
, logger
, *tests
, **params
):
403 Runs Puppeteer unit tests with npm.
405 Possible optional test parameters:
408 Path for the browser binary to use. Defaults to the local
411 Boolean to indicate whether to activate Firefox' headless mode.
413 Dictionary of extra preferences to write to the profile,
414 before invoking npm. Overrides default preferences.
416 Boolean to indicate whether to enable WebRender compositor in Gecko.
418 Path to write the results json file
420 Indicates only a subset of tests are being run, so we should
421 skip the check for missing results
425 binary
= params
.get("binary") or self
.get_binary_path()
426 product
= params
.get("product", "firefox")
429 # Print browser process ouptut
431 # Checked by Puppeteer's custom mocha config
433 # Causes some tests to be skipped due to assumptions about install
434 "PUPPETEER_ALT_INSTALL": "1",
437 for k
, v
in params
.get("extra_launcher_options", {}).items():
438 extra_options
[k
] = json
.loads(v
)
440 # Override upstream defaults: no retries, shorter timeout
443 "./json-mocha-reporter.js",
451 if product
== "firefox":
452 env
["BINARY"] = binary
453 env
["PUPPETEER_PRODUCT"] = "firefox"
455 env
["MOZ_WEBRENDER"] = "%d" % params
.get("enable_webrender", False)
457 command
= ["run", "unit", "--"] + mocha_options
459 env
["HEADLESS"] = str(params
.get("headless", False))
462 for k
, v
in params
.get("extra_prefs", {}).items():
463 prefs
[k
] = mozprofile
.Preferences
.cast(v
)
466 extra_options
["extraPrefsFirefox"] = prefs
469 env
["EXTRA_LAUNCH_OPTIONS"] = json
.dumps(extra_options
)
471 expected_path
= os
.path
.join(
472 os
.path
.dirname(__file__
), "test", "puppeteer-expected.json"
474 if product
== "firefox" and os
.path
.exists(expected_path
):
475 with
open(expected_path
) as f
:
476 expected_data
= json
.load(f
)
480 output_handler
= MochaOutputHandler(logger
, expected_data
)
483 cwd
=self
.puppeteer_dir
,
485 processOutputLine
=output_handler
,
488 output_handler
.proc
= proc
490 # Puppeteer unit tests don't always clean-up child processes in case of
491 # failure, so use an output_timeout as a fallback
492 wait_proc(proc
, "npm", output_timeout
=60, exit_on_fail
=False)
494 output_handler
.after_end(params
.get("subset", False))
496 # Non-zero return codes are non-fatal for now since we have some
497 # issues with unresolved promises that shouldn't otherwise block
499 if proc
.returncode
!= 0:
500 logger
.warning("npm exited with code %s" % proc
.returncode
)
502 if params
["write_results"]:
503 with
open(params
["write_results"], "w") as f
:
505 output_handler
.new_expected(), f
, indent
=2, separators
=(",", ": ")
508 if output_handler
.has_unexpected
:
509 exit(1, "Got unexpected results")
512 def create_parser_puppeteer():
513 p
= argparse
.ArgumentParser()
515 "--product", type=str, default
="firefox", choices
=["chrome", "firefox"]
520 help="Path to browser binary. Defaults to local Firefox build.",
525 help="Flag that indicates that tests run in a CI environment.",
530 help="Enable Fission (site isolation) in Gecko.",
533 "--enable-webrender",
535 help="Enable the WebRender compositor in Gecko.",
538 "-z", "--headless", action
="store_true", help="Run browser in headless mode."
544 metavar
="<pref>=<value>",
545 help="Defines additional user preferences.",
550 dest
="extra_options",
551 metavar
="<option>=<value>",
552 help="Defines additional options for `puppeteer.launch`.",
559 help="Increase remote agent logging verbosity to include "
560 "debug level messages with -v, trace messages with -vv,"
561 "and to not truncate long trace messages with -vvv",
569 os
.path
.dirname(__file__
), "test", "puppeteer-expected.json"
571 help="Path to write updated results to (defaults to the "
572 "expectations file if the argument is provided but "
573 "no path is passed)",
579 help="Indicate that only a subset of the tests are running, "
580 "so checks for missing tests should be skipped",
582 p
.add_argument("tests", nargs
="*")
583 mozlog
.commandline
.add_logging_group(p
)
588 class PuppeteerTest(MachCommandBase
):
592 description
="Run Puppeteer unit tests.",
593 parser
=create_parser_puppeteer
,
600 enable_fission
=False,
601 enable_webrender
=False,
613 logger
= mozlog
.commandline
.setup_logging(
614 "puppeteer-test", kwargs
, {"mach": sys
.stdout
}
617 # moztest calls this programmatically with test objects or manifests
618 if "test_objects" in kwargs
and tests
is not None:
619 logger
.error("Expected either 'test_objects' or 'tests'")
622 if product
!= "firefox" and extra_prefs
is not None:
623 logger
.error("User preferences are not recognized by %s" % product
)
626 if "test_objects" in kwargs
:
628 for test
in kwargs
["test_objects"]:
629 tests
.append(test
["path"])
632 for s
in extra_prefs
or []:
635 logger
.error("syntax error in --setpref={}".format(s
))
637 prefs
[kv
[0]] = kv
[1].strip()
640 for s
in extra_options
or []:
643 logger
.error("syntax error in --setopt={}".format(s
))
645 options
[kv
[0]] = kv
[1].strip()
648 prefs
.update({"fission.autostart": True})
651 prefs
["remote.log.level"] = "Debug"
653 prefs
["remote.log.level"] = "Trace"
655 prefs
["remote.log.truncate"] = False
657 self
.install_puppeteer(command_context
, product
, ci
)
661 "headless": headless
,
662 "enable_webrender": enable_webrender
,
663 "extra_prefs": prefs
,
665 "extra_launcher_options": options
,
666 "write_results": write_results
,
669 puppeteer
= command_context
._spawn
(PuppeteerRunner
)
671 return puppeteer
.run_test(logger
, *tests
, **params
)
672 except BinaryNotFoundException
as e
:
674 logger
.info(e
.help())
676 except Exception as e
:
679 def install_puppeteer(self
, command_context
, product
, ci
):
682 from mozversioncontrol
import get_repository_object
684 repo
= get_repository_object(command_context
.topsrcdir
)
685 puppeteer_dir
= os
.path
.join("remote", "test", "puppeteer")
686 changed_files
= False
687 for f
in repo
.get_changed_files():
688 if f
.startswith(puppeteer_dir
) and f
.endswith(".ts"):
692 if product
!= "chrome":
693 env
["PUPPETEER_SKIP_DOWNLOAD"] = "1"
694 lib_dir
= os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
, "lib")
695 if changed_files
and os
.path
.isdir(lib_dir
):
696 # clobber lib to force `tsc compile` step
697 shutil
.rmtree(lib_dir
)
699 command
= "ci" if ci
else "install"
701 command
, cwd
=os
.path
.join(command_context
.topsrcdir
, puppeteer_dir
), env
=env
705 def exit(code
, error
=None):
706 if error
is not None:
707 if isinstance(error
, Exception):
710 traceback
.print_exc()
712 message
= str(error
).split("\n")[0].strip()
713 print("{}: {}".format(sys
.argv
[0], message
), file=sys
.stderr
)