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/.
19 from mozdevice
import ADBDeviceFactory
, ADBError
, ADBTimeoutError
20 from mozprofile
import DEFAULT_PORTS
, Profile
21 from mozprofile
.cli
import parse_preferences
22 from mozprofile
.permissions
import ServerLocations
23 from runtests
import MochitestDesktop
, update_mozinfo
25 here
= os
.path
.abspath(os
.path
.dirname(__file__
))
28 from mach
.util
import UserError
29 from mozbuild
.base
import MachCommandConditions
as conditions
30 from mozbuild
.base
import MozbuildObject
32 build_obj
= MozbuildObject
.from_environment(cwd
=here
)
39 class JavaTestHarnessException(Exception):
43 class JUnitTestRunner(MochitestDesktop
):
45 A test harness to run geckoview junit tests on a remote device.
48 def __init__(self
, log
, options
):
51 self
.http3Server
= None
52 self
.http2Server
= None
55 options
.log_tbpl_level
== "debug"
56 or options
.log_mach_level
== "debug"
60 self
.device
= ADBDeviceFactory(
61 adb
=options
.adbPath
or "adb",
62 device
=options
.deviceSerial
,
63 test_root
=options
.remoteTestRoot
,
65 run_as_package
=options
.app
,
67 self
.options
= options
68 self
.log
.debug("options=%s" % vars(options
))
70 self
.remote_profile
= posixpath
.join(self
.device
.test_root
, "junit-profile")
71 self
.remote_filter_list
= posixpath
.join(
72 self
.device
.test_root
, "junit-filters.list"
75 if self
.options
.coverage
and not self
.options
.coverage_output_dir
:
77 "--coverage-output-dir is required when using --enable-coverage"
79 if self
.options
.coverage
:
80 self
.remote_coverage_output_file
= posixpath
.join(
81 self
.device
.test_root
, "junit-coverage.ec"
83 self
.coverage_output_file
= os
.path
.join(
84 self
.options
.coverage_output_dir
, "junit-coverage.ec"
90 self
.device
.clear_logcat()
92 self
.startServers(self
.options
, debuggerInfo
=None, public
=True)
93 self
.log
.debug("Servers started")
95 def collectLogcatForCurrentTest(self
):
96 # These are unique start and end markers logged by GeckoSessionTestRule.java
97 START_MARKER
= "1f0befec-3ff2-40ff-89cf-b127eb38b1ec"
98 END_MARKER
= "c5ee677f-bc83-49bd-9e28-2d35f3d0f059"
99 logcat
= self
.device
.get_logcat()
103 if START_MARKER
in l
and self
.test_name
in l
:
106 test_logcat
+= l
+ "\n"
107 if started
and END_MARKER
in l
:
110 def needsWebsocketProcessBridge(self
, options
):
112 Overrides MochitestDesktop.needsWebsocketProcessBridge and always
113 returns False as the junit tests do not use the websocket process
114 bridge. This is needed to satisfy MochitestDesktop.startServers.
118 def server_init(self
):
120 Additional initialization required to satisfy MochitestDesktop.startServers
122 self
._locations
= None
125 self
.websocketProcessBridge
= None
126 self
.SERVER_STARTUP_TIMEOUT
= 180 if mozinfo
.info
.get("debug") else 90
127 if self
.options
.remoteWebServer
is None:
128 self
.options
.remoteWebServer
= moznetwork
.get_ip()
129 self
.options
.webServer
= self
.options
.remoteWebServer
130 self
.options
.webSocketPort
= "9988"
131 self
.options
.httpdPath
= None
132 self
.options
.http3ServerPath
= None
133 self
.options
.http2ServerPath
= None
134 self
.options
.keep_open
= False
135 self
.options
.pidFile
= ""
136 self
.options
.subsuite
= None
137 self
.options
.xrePath
= None
138 self
.options
.useHttp3Server
= False
139 self
.options
.useHttp2Server
= False
140 if build_obj
and "MOZ_HOST_BIN" in os
.environ
:
141 self
.options
.xrePath
= os
.environ
["MOZ_HOST_BIN"]
142 if not self
.options
.utilityPath
:
143 self
.options
.utilityPath
= self
.options
.xrePath
144 if not self
.options
.xrePath
:
145 self
.options
.xrePath
= self
.options
.utilityPath
147 self
.options
.certPath
= os
.path
.join(
148 build_obj
.topsrcdir
, "build", "pgo", "certs"
151 def build_profile(self
):
153 Create a local profile with test prefs and proxy definitions and
154 push it to the remote device.
157 self
.profile
= Profile(locations
=self
.locations
, proxy
=self
.proxy(self
.options
))
158 self
.options
.profilePath
= self
.profile
.profile
161 self
.merge_base_profiles(self
.options
, "geckoview-junit")
163 if self
.options
.web_content_isolation_strategy
is not None:
164 self
.options
.extra_prefs
.append(
165 "fission.webContentIsolationStrategy=%s"
166 % self
.options
.web_content_isolation_strategy
168 self
.options
.extra_prefs
.append("fission.autostart=true")
169 if self
.options
.disable_fission
:
170 self
.options
.extra_prefs
.pop()
171 self
.options
.extra_prefs
.append("fission.autostart=false")
172 prefs
= parse_preferences(self
.options
.extra_prefs
)
173 self
.profile
.set_preferences(prefs
)
175 if self
.fillCertificateDB(self
.options
):
176 self
.log
.error("Certificate integration failed")
178 self
.device
.push(self
.profile
.profile
, self
.remote_profile
)
180 "profile %s -> %s" % (str(self
.profile
.profile
), str(self
.remote_profile
))
186 self
.log
.debug("Servers stopped")
187 self
.device
.stop_application(self
.options
.app
)
188 self
.device
.rm(self
.remote_profile
, force
=True, recursive
=True)
189 if hasattr(self
, "profile"):
191 self
.device
.rm(self
.remote_filter_list
, force
=True)
193 traceback
.print_exc()
194 self
.log
.info("Caught and ignored an exception during cleanup")
196 def build_command_line(self
, test_filters_file
, test_filters
):
198 Construct and return the 'am instrument' command line.
200 cmd
= "am instrument -w -r"
202 cmd
= cmd
+ " -e args '-profile %s'" % self
.remote_profile
204 shards
= self
.options
.totalChunks
205 shard
= self
.options
.thisChunk
206 if shards
is not None and shard
is not None:
207 shard
-= 1 # shard index is 0 based
208 cmd
= cmd
+ " -e numShards %d -e shardIndex %d" % (shards
, shard
)
210 # test filters: limit run to specific test(s)
211 # filter can be class-name or 'class-name#method-name' (single test)
212 # Multiple filters must be specified as a line-separated text file
213 # and then pushed to the device.
214 filter_list_name
= None
216 if test_filters_file
:
217 # We specified a pre-existing file, so use that
218 filter_list_name
= test_filters_file
220 if len(test_filters
) > 1:
221 # Generate the list file from test_filters
222 with tempfile
.NamedTemporaryFile(delete
=False, mode
="w") as filter_list
:
223 for f
in test_filters
:
224 print(f
, file=filter_list
)
225 filter_list_name
= filter_list
.name
227 # A single filter may be directly appended to the command line
228 cmd
= cmd
+ " -e class %s" % test_filters
[0]
231 self
.device
.push(filter_list_name
, self
.remote_filter_list
)
234 # We only remove the filter list if we generated it as a
236 os
.remove(filter_list_name
)
238 cmd
= cmd
+ " -e testFile %s" % self
.remote_filter_list
240 # enable code coverage reports
241 if self
.options
.coverage
:
242 cmd
= cmd
+ " -e coverage true"
243 cmd
= cmd
+ " -e coverageFile %s" % self
.remote_coverage_output_file
246 env
["MOZ_CRASHREPORTER"] = "1"
247 env
["MOZ_CRASHREPORTER_SHUTDOWN"] = "1"
248 env
["XPCOM_DEBUG_BREAK"] = "stack"
249 env
["MOZ_DISABLE_NONLOCAL_CONNECTIONS"] = "1"
250 env
["MOZ_IN_AUTOMATION"] = "1"
251 env
["R_LOG_VERBOSE"] = "1"
252 env
["R_LOG_LEVEL"] = "6"
253 env
["R_LOG_DESTINATION"] = "stderr"
254 # webrender needs gfx.webrender.all=true, gtest doesn't use prefs
255 env
["MOZ_WEBRENDER"] = "1"
256 # FIXME: When android switches to using Fission by default,
257 # MOZ_FORCE_DISABLE_FISSION will need to be configured correctly.
258 if self
.options
.disable_fission
:
259 env
["MOZ_FORCE_DISABLE_FISSION"] = "1"
261 env
["MOZ_FORCE_ENABLE_FISSION"] = "1"
263 # Add additional env variables
264 for [key
, value
] in [p
.split("=", 1) for p
in self
.options
.add_env
]:
267 for (env_count
, (env_key
, env_val
)) in enumerate(six
.iteritems(env
)):
268 cmd
= cmd
+ " -e env%d %s=%s" % (env_count
, env_key
, env_val
)
270 cmd
= cmd
+ " %s/%s" % (self
.options
.app
, self
.options
.runner
)
275 if self
._locations
is not None:
276 return self
._locations
277 locations_file
= os
.path
.join(here
, "server-locations.txt")
278 self
._locations
= ServerLocations(locations_file
)
279 return self
._locations
281 def need_more_runs(self
):
282 if self
.options
.run_until_failure
and (self
.fail_count
== 0):
284 if self
.runs
<= self
.options
.repeat
:
288 def run_tests(self
, test_filters_file
=None, test_filters
=None):
292 if not self
.device
.is_app_installed(self
.options
.app
):
293 raise UserError("%s is not installed" % self
.options
.app
)
294 if self
.device
.process_exist(self
.options
.app
):
296 "%s already running before starting tests" % self
.options
.app
298 # test_filters_file and test_filters must be mutually-exclusive
299 if test_filters_file
and test_filters
:
301 "Test filters may not be specified when test-filters-file is provided"
304 self
.test_started
= False
310 self
.seen_last_test
= False
313 # Output callback: Parse the raw junit log messages, translating into
314 # treeherder-friendly test start/pass/fail messages.
316 line
= six
.ensure_str(line
)
317 self
.log
.process_output(self
.options
.app
, str(line
))
318 # Expect per-test info like: "INSTRUMENTATION_STATUS: class=something"
319 match
= re
.match(r
"INSTRUMENTATION_STATUS:\s*class=(.*)", line
)
321 self
.class_name
= match
.group(1)
322 # Expect per-test info like: "INSTRUMENTATION_STATUS: test=something"
323 match
= re
.match(r
"INSTRUMENTATION_STATUS:\s*test=(.*)", line
)
325 self
.test_name
= match
.group(1)
326 match
= re
.match(r
"INSTRUMENTATION_STATUS:\s*numtests=(.*)", line
)
328 self
.total_count
= int(match
.group(1))
329 match
= re
.match(r
"INSTRUMENTATION_STATUS:\s*current=(.*)", line
)
331 self
.current_test_id
= int(match
.group(1))
332 match
= re
.match(r
"INSTRUMENTATION_STATUS:\s*stack=(.*)", line
)
334 self
.exception_message
= match
.group(1)
336 "org.mozilla.geckoview.test.rule.TestHarnessException"
337 in self
.exception_message
339 # This is actually a problem in the test harness itself
340 raise JavaTestHarnessException(self
.exception_message
)
342 # Expect per-test info like: "INSTRUMENTATION_STATUS_CODE: 0|1|..."
343 match
= re
.match(r
"INSTRUMENTATION_STATUS_CODE:\s*([+-]?\d+)", line
)
345 status
= match
.group(1)
346 full_name
= "%s#%s" % (self
.class_name
, self
.test_name
)
347 if full_name
== self
.current_full_name
:
348 # A crash in the test harness might cause us to ignore tests,
349 # so we double check that we've actually ran all the tests
350 if self
.total_count
== self
.current_test_id
:
351 self
.seen_last_test
= True
359 self
.log
.info("Printing logcat for test:")
360 print(self
.collectLogcatForCurrentTest())
361 elif status
== "-3": # ignored (skipped)
366 elif status
== "-4": # known fail
372 if self
.exception_message
:
373 message
= self
.exception_message
375 message
= "status %s" % status
379 self
.log
.info("Printing logcat for test:")
380 print(self
.collectLogcatForCurrentTest())
381 self
.log
.test_end(full_name
, status
, expected
, message
)
382 self
.test_started
= False
384 if self
.test_started
:
385 # next test started without reporting previous status
390 self
.current_full_name
,
393 "missing test completion status",
395 self
.log
.test_start(full_name
)
396 self
.test_started
= True
397 self
.current_full_name
= full_name
399 # Ideally all test names should be reported to suite_start, but these test
400 # names are not known in advance.
401 self
.log
.suite_start(["geckoview-junit"])
403 self
.device
.grant_runtime_permissions(self
.options
.app
)
404 self
.device
.add_change_device_settings(self
.options
.app
)
405 self
.device
.add_mock_location(self
.options
.app
)
406 cmd
= self
.build_command_line(
407 test_filters_file
=test_filters_file
, test_filters
=test_filters
409 while self
.need_more_runs():
411 self
.exception_message
= ""
413 self
.current_full_name
= ""
414 self
.current_test_id
= 0
416 self
.log
.info("launching %s" % cmd
)
417 p
= self
.device
.shell(
418 cmd
, timeout
=self
.options
.max_time
, stdout_callback
=callback
422 "TEST-UNEXPECTED-TIMEOUT | runjunit.py | "
423 "Timed out after %d seconds" % self
.options
.max_time
425 self
.log
.info("Passed: %d" % self
.pass_count
)
426 self
.log
.info("Failed: %d" % self
.fail_count
)
427 self
.log
.info("Todo: %d" % self
.todo_count
)
428 if not self
.seen_last_test
:
430 "TEST-UNEXPECTED-FAIL | runjunit.py | "
431 "Some tests did not run (probably due to a crash in the harness)"
436 if self
.check_for_crashes():
439 if self
.options
.coverage
:
442 self
.remote_coverage_output_file
, self
.coverage_output_file
445 # Avoid a task retry in case the code coverage file is not found.
447 "No code coverage file (%s) found on remote device"
448 % self
.remote_coverage_output_file
452 return 1 if self
.fail_count
else 0
454 def check_for_crashes(self
):
455 symbols_path
= self
.options
.symbolsPath
457 dump_dir
= tempfile
.mkdtemp()
458 remote_dir
= posixpath
.join(self
.remote_profile
, "minidumps")
459 if not self
.device
.is_dir(remote_dir
):
461 self
.device
.pull(remote_dir
, dump_dir
)
462 crashed
= mozcrash
.log_crashes(
463 self
.log
, dump_dir
, symbols_path
, test
=self
.current_full_name
467 shutil
.rmtree(dump_dir
)
469 self
.log
.warning("unable to remove directory: %s" % dump_dir
)
473 class JunitArgumentParser(argparse
.ArgumentParser
):
475 An argument parser for geckoview-junit.
478 def __init__(self
, **kwargs
):
479 super(JunitArgumentParser
, self
).__init
__(**kwargs
)
486 default
="org.mozilla.geckoview.test",
487 help="Test package name.",
495 help="Path to adb binary.",
502 help="adb serial number of remote device. This is required "
503 "when more than one device is connected to the host. "
504 "Use 'adb devices' to see connected devices. ",
511 help="Set target environment variable, like FOO=BAR",
517 dest
="remoteTestRoot",
518 help="Remote directory to use as test root "
519 "(eg. /data/local/tmp/test_root).",
527 help="Max time in seconds to wait for tests (default 3000s).",
534 default
="androidx.test.runner.AndroidJUnitRunner",
535 help="Test runner name.",
543 help="Path to directory containing breakpad symbols, "
544 "or the URL of a zip file containing symbols.",
552 help="Path to directory containing host utility programs.",
560 help="Total number of chunks to split tests into.",
568 help="If running tests by chunks, the chunk number to run.",
576 help="Verbose output - enable debug log messages",
583 help="Enable code coverage collection.",
586 "--coverage-output-dir",
589 dest
="coverage_output_dir",
591 help="If collecting code coverage, save the report file in this dir.",
596 dest
="disable_fission",
598 help="Run the tests without Fission (site isolation) enabled.",
601 "--web-content-isolation-strategy",
603 dest
="web_content_isolation_strategy",
604 help="Strategy used to determine whether or not a particular site should load into "
605 "a webIsolated content process, see fission.webContentIsolationStrategy.",
611 help="Repeat the tests the given number of times.",
614 "--run-until-failure",
616 dest
="run_until_failure",
618 help="Run tests repeatedly but stop the first time a test fails.",
625 metavar
="PREF=VALUE",
626 help="Defines an extra user preference.",
628 # Additional options for server.
630 "--certificate-path",
635 help="Path to directory containing certificate store.",
642 default
=DEFAULT_PORTS
["http"],
643 help="http port of the remote web server.",
646 "--remote-webserver",
649 dest
="remoteWebServer",
650 help="IP address of the remote web server.",
657 default
=DEFAULT_PORTS
["https"],
658 help="ssl port of the remote web server.",
661 "--test-filters-file",
664 dest
="test_filters_file",
666 help="Line-delimited file containing test filter(s)",
668 # Remaining arguments are test filters.
672 help="Test filter(s): class and/or method names of test(s) to run.",
675 mozlog
.commandline
.add_logging_group(self
)
678 def run_test_harness(parser
, options
):
679 if hasattr(options
, "log"):
682 log
= mozlog
.commandline
.setup_logging(
683 "runjunit", options
, {"tbpl": sys
.stdout
}
685 runner
= JUnitTestRunner(log
, options
)
688 device_exception
= False
689 result
= runner
.run_tests(
690 test_filters_file
=options
.test_filters_file
,
691 test_filters
=options
.test_filters
,
693 except KeyboardInterrupt:
694 log
.info("runjunit.py | Received keyboard interrupt")
696 except JavaTestHarnessException
as e
:
698 "TEST-UNEXPECTED-FAIL | runjunit.py | The previous test failed because "
699 "of an error in the test harness | %s" % (str(e
))
701 except Exception as e
:
702 traceback
.print_exc()
703 log
.error("runjunit.py | Received unexpected exception while running tests")
705 if isinstance(e
, ADBTimeoutError
):
706 device_exception
= True
708 if not device_exception
:
713 def main(args
=sys
.argv
[1:]):
714 parser
= JunitArgumentParser()
715 options
= parser
.parse_args()
716 return run_test_harness(parser
, options
)
719 if __name__
== "__main__":