mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-07 20:17:37 +00:00
d3ac719a31
This adds support for web-platform-tests to mach try. It changes the implementation so that instead of passing paths to manifests, the user passes arbitary paths in the source tree, and tests under that path are run, with test discovery mainly left to the harness.
541 lines
23 KiB
Python
541 lines
23 KiB
Python
#!/usr/bin/env python
|
|
# ***** BEGIN LICENSE BLOCK *****
|
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
# You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
# ***** END LICENSE BLOCK *****
|
|
|
|
import copy
|
|
import getpass
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import socket
|
|
|
|
# load modules from parent dir
|
|
sys.path.insert(1, os.path.dirname(sys.path[0]))
|
|
|
|
from mozharness.mozilla.buildbot import TBPL_SUCCESS, BuildbotMixin
|
|
from mozharness.base.errors import BaseErrorList
|
|
from mozharness.base.log import INFO, ERROR, FATAL
|
|
from mozharness.base.vcs.vcsbase import MercurialScript
|
|
from mozharness.mozilla.blob_upload import BlobUploadMixin, blobupload_config_options
|
|
from mozharness.mozilla.mozbase import MozbaseMixin
|
|
from mozharness.mozilla.testing.mozpool import MozpoolMixin
|
|
from mozharness.mozilla.testing.device import SUTDeviceMozdeviceMixin
|
|
from mozharness.mozilla.testing.testbase import TestingMixin, testing_config_options
|
|
|
|
SUITE_CATEGORIES = ['mochitest', 'reftest', 'crashtest', 'jsreftest', 'robocop', 'instrumentation', 'xpcshell', 'jittest', 'cppunittest']
|
|
|
|
|
|
class PandaTest(TestingMixin, MercurialScript, BlobUploadMixin, MozpoolMixin, BuildbotMixin, SUTDeviceMozdeviceMixin, MozbaseMixin):
|
|
test_suites = SUITE_CATEGORIES
|
|
config_options = [
|
|
[["--mozpool-api-url"], {
|
|
"dest": "mozpool_api_url",
|
|
"help": "Override mozpool api url",
|
|
}],
|
|
[["--mozpool-device"], {
|
|
"dest": "mozpool_device",
|
|
"help": "Set Panda device to run tests on",
|
|
}],
|
|
[["--mozpool-assignee"], {
|
|
"dest": "mozpool_assignee",
|
|
"help": "Set mozpool assignee (requestor name, free-form)",
|
|
}],
|
|
[["--total-chunks"], {
|
|
"action": "store",
|
|
"dest": "total_chunks",
|
|
"help": "Number of total chunks",
|
|
}],
|
|
[["--this-chunk"], {
|
|
"action": "store",
|
|
"dest": "this_chunk",
|
|
"help": "Number of this chunk",
|
|
}],
|
|
[["--extra-args"], {
|
|
"action": "store",
|
|
"dest": "extra_args",
|
|
"help": "Extra arguments",
|
|
}],
|
|
[['--mochitest-suite', ], {
|
|
"action": "extend",
|
|
"dest": "specified_mochitest_suites",
|
|
"type": "string",
|
|
"help": "Specify which mochi suite to run. "
|
|
"Suites are defined in the config file.\n"
|
|
"Examples: 'all', 'plain1', 'plain5', 'chrome', or 'a11y'"}
|
|
],
|
|
[['--reftest-suite', ], {
|
|
"action": "extend",
|
|
"dest": "specified_reftest_suites",
|
|
"type": "string",
|
|
"help": "Specify which reftest suite to run. "
|
|
"Suites are defined in the config file.\n"
|
|
"Examples: 'all', 'crashplan', or 'jsreftest'"}
|
|
],
|
|
[['--crashtest-suite', ], {
|
|
"action": "extend",
|
|
"dest": "specified_crashtest_suites",
|
|
"type": "string",
|
|
"help": "Specify which crashtest suite to run. "
|
|
"Suites are defined in the config file\n."
|
|
"Examples: 'crashtest'"}
|
|
],
|
|
[['--jsreftest-suite', ], {
|
|
"action": "extend",
|
|
"dest": "specified_jsreftest_suites",
|
|
"type": "string",
|
|
"help": "Specify which jsreftest suite to run. "
|
|
"Suites are defined in the config file\n."
|
|
"Examples: 'jsreftest'"}
|
|
],
|
|
[['--robocop-suite', ], {
|
|
"action": "extend",
|
|
"dest": "specified_robocop_suites",
|
|
"type": "string",
|
|
"help": "Specify which robocop suite to run. "
|
|
"Suites are defined in the config file\n."
|
|
"Examples: 'robocop'"}
|
|
],
|
|
[['--instrumentation-suite', ], {
|
|
"action": "extend",
|
|
"dest": "specified_instrumentation_suites",
|
|
"type": "string",
|
|
"help": "Specify which instrumentation suite to run. "
|
|
"Suites are defined in the config file\n."
|
|
"Examples: 'browser', 'background'"}
|
|
],
|
|
[['--xpcshell-suite', ], {
|
|
"action": "extend",
|
|
"dest": "specified_xpcshell_suites",
|
|
"type": "string",
|
|
"help": "Specify which xpcshell suite to run. "
|
|
"Suites are defined in the config file\n."
|
|
"Examples: 'xpcshell'"}
|
|
],
|
|
[['--jittest-suite', ], {
|
|
"action": "extend",
|
|
"dest": "specified_jittest_suites",
|
|
"type": "string",
|
|
"help": "Specify which jittest suite to run. "
|
|
"Suites are defined in the config file\n."
|
|
"Examples: 'jittest'"}
|
|
],
|
|
[['--cppunittest-suite', ], {
|
|
"action": "extend",
|
|
"dest": "specified_cppunittest_suites",
|
|
"type": "string",
|
|
"help": "Specify which cpp unittest suite to run. "
|
|
"Suites are defined in the config file\n."
|
|
"Examples: 'cppunittest'"}
|
|
],
|
|
[['--run-all-suites', ], {
|
|
"action": "store_true",
|
|
"dest": "run_all_suites",
|
|
"default": False,
|
|
"help": "This will run all suites that are specified "
|
|
"in the config file. You do not need to specify "
|
|
"any other suites. Beware, this may take a while ;)"}
|
|
],
|
|
] + copy.deepcopy(testing_config_options) + \
|
|
copy.deepcopy(blobupload_config_options)
|
|
|
|
error_list = []
|
|
mozpool_handler = None
|
|
|
|
virtualenv_modules = [
|
|
'mozpoolclient',
|
|
]
|
|
|
|
def __init__(self, require_config_file=False):
|
|
super(PandaTest, self).__init__(
|
|
config_options=self.config_options,
|
|
all_actions=['clobber',
|
|
'read-buildbot-config',
|
|
'download-and-extract',
|
|
'create-virtualenv',
|
|
'request-device',
|
|
'run-test',
|
|
'close-request'],
|
|
default_actions=['clobber',
|
|
'read-buildbot-config',
|
|
'download-and-extract',
|
|
'create-virtualenv',
|
|
'request-device',
|
|
'run-test',
|
|
'close-request'],
|
|
require_config_file=require_config_file,
|
|
config={'virtualenv_modules': self.virtualenv_modules})
|
|
|
|
self.mozpool_assignee = self.config.get('mozpool_assignee', getpass.getuser())
|
|
self.request_url = None
|
|
self.installer_url = self.config.get("installer_url")
|
|
self.test_url = self.config.get("test_url")
|
|
self.mozpool_device = self.config.get("mozpool_device")
|
|
self.symbols_url = self.config.get('symbols_url')
|
|
|
|
def postflight_read_buildbot_config(self):
|
|
super(PandaTest, self).postflight_read_buildbot_config()
|
|
self.mozpool_device = self.config.get('mozpool_device', self.buildbot_config.get('properties')["slavename"])
|
|
dirs = self.query_abs_dirs()
|
|
#touch the shutdown file
|
|
shutdown_file = os.path.join(dirs['shutdown_dir'], 'shutdown.stamp')
|
|
try:
|
|
self.info("*** Touching the shutdown file **")
|
|
open(shutdown_file, 'w').close()
|
|
except Exception, e:
|
|
self.warning("We failed to create the shutdown file: str(%s)" % str(e))
|
|
|
|
def request_device(self):
|
|
self.retrieve_android_device(b2gbase="")
|
|
env = self.query_env()
|
|
cmd = [self.query_exe('python'), self.config.get("verify_path")]
|
|
if self.run_command(cmd, env=env):
|
|
self.critical("Preparing to abort run due to failed verify check.")
|
|
self.close_request()
|
|
self.fatal("Dying due to failing verification")
|
|
else:
|
|
self.info("Successfully verified the device")
|
|
|
|
def _sut_prep_steps(self):
|
|
device_time = self.set_device_epoch_time()
|
|
self.info("Current time on device: %s - %s" %
|
|
(device_time, time.strftime("%x %H:%M:%S", time.gmtime(float(device_time)))))
|
|
|
|
def download_and_extract(self):
|
|
"""
|
|
Provides the target suite categories to TestingMixin.download_
|
|
"""
|
|
if self.config.get('run_all_suites'):
|
|
target_categories = SUITE_CATEGORIES
|
|
else:
|
|
target_categories = [cat for cat in SUITE_CATEGORIES
|
|
if self._query_specified_suites(cat) is not None]
|
|
super(PandaTest, self).download_and_extract(suite_categories=target_categories)
|
|
|
|
def _query_try_flavor(self, category, suite):
|
|
flavors = {
|
|
"mochitest": [("plain.*", "mochitest"),
|
|
("browser-chrome.*", "browser-chrome"),
|
|
("mochitest-devtools-chrome.*", "devtools-chrome"),
|
|
("chrome", "chrome")],
|
|
"xpcshell": [("xpcshell", "xpcshell")],
|
|
"reftest": [("reftest", "reftest"),
|
|
("crashtest", "crashtest")]
|
|
}
|
|
for suite_pattern, flavor in flavors.get(category, []):
|
|
if re.compile(suite_pattern).match(suite):
|
|
return flavor
|
|
|
|
def _run_category_suites(self, suite_category, preflight_run_method=None):
|
|
"""run suite(s) to a specific category"""
|
|
|
|
env = self.query_env(partial_env={'DM_TRANS': "sut", 'TEST_DEVICE': self.mozpool_device})
|
|
self.info("Running tests...")
|
|
|
|
suites = self._query_specified_suites(suite_category)
|
|
level = INFO
|
|
|
|
if preflight_run_method:
|
|
preflight_run_method(suites)
|
|
if suites:
|
|
self.info('#### Running %s suites' % suite_category)
|
|
for suite in suites:
|
|
dirs = self.query_abs_dirs()
|
|
self._download_unzip_hostutils()
|
|
abs_base_cmd = self._query_abs_base_cmd(suite_category, suite)
|
|
|
|
should_install_app = True
|
|
if 'cppunittest' in suite:
|
|
should_install_app = False
|
|
if 'robocop' in suite:
|
|
self._download_robocop_apk()
|
|
if 'jittest' in suite:
|
|
should_install_app = False
|
|
|
|
if should_install_app:
|
|
self._install_app()
|
|
cmd = abs_base_cmd[:]
|
|
|
|
flavor = self._query_try_flavor(suite_category, suite)
|
|
try_options, try_tests = self.try_args(flavor)
|
|
|
|
cmd.extend(self.query_options(suites[suite],
|
|
try_options))
|
|
cmd.extend(self.query_tests_args(try_tests))
|
|
|
|
tests = self.config["suite_definitions"][suite_category].get("tests", [])
|
|
cmd += tests
|
|
|
|
tbpl_status, log_level = None, None
|
|
error_list = BaseErrorList + [{
|
|
'regex': re.compile(r"(?:TEST-UNEXPECTED-FAIL|PROCESS-CRASH) \| .* \| (application crashed|missing output line for total leaks!|negative leaks caught!|\d+ bytes leaked)"),
|
|
'level': ERROR,
|
|
}]
|
|
c = self.config
|
|
if c.get('minidump_stackwalk_path'):
|
|
env['MINIDUMP_STACKWALK'] = c['minidump_stackwalk_path']
|
|
env['MOZ_UPLOAD_DIR'] = self.query_abs_dirs()['abs_blob_upload_dir']
|
|
env['MINIDUMP_SAVE_PATH'] = self.query_abs_dirs()['abs_blob_upload_dir']
|
|
|
|
env = self.query_env(partial_env=env, log_level=INFO)
|
|
if env.has_key('PYTHONPATH'):
|
|
del env['PYTHONPATH']
|
|
|
|
parser = self.get_test_output_parser(suite_category,
|
|
config=self.config,
|
|
error_list=error_list,
|
|
log_obj=self.log_obj)
|
|
|
|
return_code = self.run_command(cmd,
|
|
cwd=dirs['abs_test_install_dir'],
|
|
env=env,
|
|
output_parser=parser)
|
|
|
|
tbpl_status, log_level = parser.evaluate_parser(return_code)
|
|
|
|
if tbpl_status != TBPL_SUCCESS:
|
|
self.info("Output logcat...")
|
|
try:
|
|
lines = self.get_logcat()
|
|
self.info("*** STARTING LOGCAT ***")
|
|
for l in lines:
|
|
self.info(l)
|
|
self.info("*** END LOGCAT ***")
|
|
except Exception, e:
|
|
self.warning("We failed to run logcat: str(%s)" % str(e))
|
|
|
|
parser.append_tinderboxprint_line(suite)
|
|
self.buildbot_status(tbpl_status, level=level)
|
|
|
|
self.log("The %s suite: %s ran with return status: %s" %
|
|
(suite_category, suite, tbpl_status), level=log_level)
|
|
|
|
def _query_specified_suites(self, category):
|
|
# logic goes: if at least one '--{category}-suite' was given,
|
|
# then run only that(those) given suite(s). Elif no suites were
|
|
# specified and the --run-all-suites flag was given,
|
|
# run all {category} suites. Anything else, run no suites.
|
|
c = self.config
|
|
all_suites = c.get('all_%s_suites' % (category))
|
|
specified_suites = c.get('specified_%s_suites' % (category)) # list
|
|
|
|
suites = None
|
|
|
|
if specified_suites:
|
|
if 'all' in specified_suites:
|
|
# useful if you want a quick way of saying run all suites
|
|
# of a specific category.
|
|
suites = all_suites
|
|
else:
|
|
# suites gets a dict of everything from all_suites where a key
|
|
# is also in specified_suites
|
|
suites = dict((key, all_suites.get(key)) for key in
|
|
specified_suites if key in all_suites.keys())
|
|
else:
|
|
if c.get('run_all_suites'): # needed if you dont specify any suites
|
|
suites = all_suites
|
|
|
|
return suites
|
|
|
|
def run_test(self):
|
|
# do we need to set the device time? command doesn't work anyways
|
|
# self._sut_prep_steps()
|
|
env = self.query_env()
|
|
env["DM_TRANS"] = "sut"
|
|
env["TEST_DEVICE"] = self.mozpool_device
|
|
self.mkdir_p(self.abs_dirs['abs_blob_upload_dir'])
|
|
|
|
self._start_logcat()
|
|
|
|
self.info("Running tests...")
|
|
|
|
for category in SUITE_CATEGORIES:
|
|
self._run_category_suites(category)
|
|
|
|
self._stop_logcat()
|
|
|
|
def _start_logcat(self):
|
|
# Start logcat.py as a separate process continuously pulling logcat from
|
|
# the device and writing to a file. Output is written directly to
|
|
# the blobber upload directory so that it is uploaded automatically
|
|
# at the end of the job.
|
|
device_ip = socket.gethostbyname(self.mozpool_device)
|
|
logcat_path = os.path.join(self.abs_dirs['abs_blob_upload_dir'], 'logcat.log')
|
|
logcat_cmd = ['python', '-u', self.config.get("logcat_path"), \
|
|
device_ip, logcat_path, '-v time']
|
|
self.info('Starting logcat: %s' % str(logcat_cmd))
|
|
self.logcat_proc = subprocess.Popen(logcat_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
|
|
def _stop_logcat(self):
|
|
# Signal logcat.py so that it can cleanup (kill the device logcat process)
|
|
self.logcat_proc.send_signal(signal.SIGINT)
|
|
self.logcat_proc.kill()
|
|
out, err = self.logcat_proc.communicate()
|
|
self.info("logcat.py output:\n%s\n%s\n" % (out, err))
|
|
|
|
def _download_unzip_hostutils(self):
|
|
c = self.config
|
|
dirs = self.query_abs_dirs()
|
|
self.host_utils_url = c['hostutils_url']
|
|
#create the hostutils dir, get the zip and extract it
|
|
self.mkdir_p(dirs['abs_hostutils_dir'])
|
|
self._download_unzip(self.host_utils_url, dirs['abs_hostutils_dir'])
|
|
|
|
def _install_app(self):
|
|
c = self.config
|
|
base_work_dir = c['base_work_dir']
|
|
cmd = ['python', self.config.get("install_app_path"), self.device_ip, 'build/' + str(self.filename_apk), self.app_name]
|
|
self.run_command(cmd, cwd=base_work_dir, halt_on_failure=True, fatal_exit_code=3)
|
|
|
|
def _download_robocop_apk(self):
|
|
dirs = self.query_abs_dirs()
|
|
self.apk_url = self.installer_url[:self.installer_url.rfind('/')]
|
|
robocop_url = self.apk_url + '/robocop.apk'
|
|
self.info("Downloading robocop...")
|
|
self.download_file(robocop_url, 'robocop.apk', dirs['abs_work_dir'], error_level=FATAL)
|
|
|
|
def query_abs_dirs(self):
|
|
if self.abs_dirs:
|
|
return self.abs_dirs
|
|
abs_dirs = super(PandaTest, self).query_abs_dirs()
|
|
dirs = {}
|
|
dirs['abs_test_install_dir'] = os.path.join(
|
|
abs_dirs['abs_work_dir'], 'tests')
|
|
dirs['abs_test_bin_dir'] = os.path.join(dirs['abs_test_install_dir'], 'bin')
|
|
dirs['abs_mochitest_dir'] = os.path.join(
|
|
dirs['abs_test_install_dir'], 'mochitest')
|
|
dirs['abs_reftest_dir'] = os.path.join(
|
|
dirs['abs_test_install_dir'], 'reftest')
|
|
dirs['abs_crashtest_dir'] = os.path.join(
|
|
dirs['abs_test_install_dir'], 'reftest')
|
|
dirs['abs_jsreftest_dir'] = os.path.join(
|
|
dirs['abs_test_install_dir'], 'reftest')
|
|
dirs['abs_xpcshell_dir'] = os.path.join(
|
|
dirs['abs_test_install_dir'], 'xpcshell')
|
|
dirs['abs_xre_dir'] = os.path.join(
|
|
abs_dirs['abs_work_dir'], 'xre')
|
|
dirs['abs_utility_path'] = os.path.join(
|
|
abs_dirs['abs_work_dir'], 'bin')
|
|
dirs['abs_certificate_path'] = os.path.join(
|
|
abs_dirs['abs_work_dir'], 'certs')
|
|
dirs['abs_hostutils_dir'] = os.path.join(
|
|
abs_dirs['abs_work_dir'], 'hostutils')
|
|
dirs['abs_robocop_dir'] = os.path.join(
|
|
dirs['abs_test_install_dir'], 'mochitest')
|
|
dirs['abs_instrumentation_dir'] = os.path.join(
|
|
dirs['abs_test_install_dir'], 'instrumentation')
|
|
dirs['abs_blob_upload_dir'] = os.path.join(abs_dirs['abs_work_dir'], 'blobber_upload_dir')
|
|
dirs['abs_jittest_dir'] = os.path.join(dirs['abs_test_install_dir'], "jit-test", "jit-test")
|
|
dirs['shutdown_dir'] = abs_dirs['abs_work_dir'].rsplit("/", 2)[0]
|
|
dirs['abs_cppunittest_dir'] = os.path.join(
|
|
dirs['abs_test_install_dir'], 'cppunittest')
|
|
for key in dirs.keys():
|
|
if key not in abs_dirs:
|
|
abs_dirs[key] = dirs[key]
|
|
self.abs_dirs = abs_dirs
|
|
return self.abs_dirs
|
|
|
|
def _query_symbols_url(self):
|
|
"""query the full symbols URL based upon binary URL"""
|
|
# may break with name convention changes but is one less 'input' for script
|
|
if self.symbols_url:
|
|
return self.symbols_url
|
|
|
|
def _query_abs_base_cmd(self, suite_category, suite):
|
|
#check for apk first with if ?
|
|
c = self.config
|
|
dirs = self.query_abs_dirs()
|
|
options = []
|
|
run_file = c["suite_definitions"][suite_category]["run_filename"]
|
|
base_cmd = ['python', '-u']
|
|
base_cmd.append(os.path.join((dirs["abs_%s_dir" % suite_category]), run_file))
|
|
self.device_ip = socket.gethostbyname(self.mozpool_device)
|
|
#applies to mochitest, reftest, jsreftest
|
|
# TestingMixin._download_and_extract_symbols() will set
|
|
# self.symbols_path when downloading/extracting.
|
|
hostnumber = 0
|
|
mozpool_device_list = self.mozpool_device.split('-')
|
|
if len(mozpool_device_list) == 2:
|
|
hostnumber = int(mozpool_device_list[1])
|
|
http_port = '30%03i' % hostnumber
|
|
ssl_port = '31%03i' % hostnumber
|
|
#get filename from installer_url
|
|
self.filename_apk = self.installer_url.split('/')[-1]
|
|
#find appname from package-name.txt - assumes download-and-extract has completed successfully
|
|
apk_dir = self.abs_dirs['abs_work_dir']
|
|
self.apk_path = os.path.join(apk_dir, self.filename_apk)
|
|
unzip = self.query_exe("unzip")
|
|
package_path = os.path.join(apk_dir, 'package-name.txt')
|
|
unzip_cmd = [unzip, '-q', '-o', self.apk_path]
|
|
self.run_command(unzip_cmd, cwd=apk_dir, halt_on_failure=True, fatal_exit_code=3)
|
|
self.app_name = str(self.read_from_file(package_path, verbose=True)).rstrip()
|
|
|
|
raw_log_file = os.path.join(dirs['abs_blob_upload_dir'],
|
|
'%s_raw.log' % suite)
|
|
error_summary_file = os.path.join(dirs['abs_blob_upload_dir'],
|
|
'%s_errorsummary.log' % suite)
|
|
str_format_values = {
|
|
'device_ip': self.device_ip,
|
|
'hostname': self.mozpool_device,
|
|
'symbols_path': self._query_symbols_url(),
|
|
'http_port': http_port,
|
|
'ssl_port': ssl_port,
|
|
'app_name': self.app_name,
|
|
'apk_name': self.filename_apk,
|
|
'apk_path': self.apk_path,
|
|
'raw_log_file': raw_log_file,
|
|
'error_summary_file': error_summary_file,
|
|
}
|
|
if "suite_definitions" in c and \
|
|
suite_category in c["suite_definitions"]: # new in-tree format
|
|
for option in c["suite_definitions"][suite_category]["options"]:
|
|
options.append(option % str_format_values)
|
|
abs_base_cmd = base_cmd + options
|
|
return abs_base_cmd
|
|
else:
|
|
self.warning("Suite options for %s could not be determined."
|
|
"\nIf you meant to have options for this suite, "
|
|
"please make sure they are specified in your "
|
|
"config." % suite_category)
|
|
|
|
###### helper methods
|
|
def _pre_config_lock(self, rw_config):
|
|
super(PandaTest, self)._pre_config_lock(rw_config)
|
|
c = self.config
|
|
if not c.get('run_all_suites'):
|
|
return # configs are valid
|
|
for category in SUITE_CATEGORIES:
|
|
specific_suites = c.get('specified_%s_suites' % (category))
|
|
if specific_suites:
|
|
if specific_suites != 'all':
|
|
self.fatal("Config options are not valid. Please ensure"
|
|
" that if the '--run-all-suites' flag was enabled,"
|
|
" then do not specify to run only specific suites "
|
|
"like:\n '--mochitest-suite browser-chrome'")
|
|
|
|
def close_request(self):
|
|
if self.request_url:
|
|
mph = self.query_mozpool_handler(self.mozpool_device)
|
|
mph.close_request(self.request_url)
|
|
self.info("Request '%s' deleted on cleanup" % self.request_url)
|
|
self.request_url = None
|
|
else:
|
|
self.info("request_url doesn't exist. Already closed?")
|
|
|
|
def _build_arg(self, option, value):
|
|
"""
|
|
Build a command line argument
|
|
"""
|
|
if not value:
|
|
return []
|
|
return [str(option), str(value)]
|
|
|
|
if __name__ == '__main__':
|
|
pandaTest = PandaTest()
|
|
pandaTest.run_and_exit()
|