import FIREFOX_52_6_0esr_RELEASE from mozilla-esr52 hg repo

This commit is contained in:
Roy Tam 2018-01-19 03:59:58 +08:00
commit dcd9973243
150858 changed files with 23884658 additions and 0 deletions

View file

@ -0,0 +1,2 @@
exclude MANIFEST.in
include requirements.txt

View file

@ -0,0 +1,8 @@
# 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/.
__version__ = '1.4.0'
import cli_functional
import cli_update

View file

@ -0,0 +1,6 @@
# 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/.
from firefox_ui_harness.arguments.base import FirefoxUIArguments
from firefox_ui_harness.arguments.update import UpdateArguments

View file

@ -0,0 +1,18 @@
# 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/.
from marionette_harness import BaseMarionetteArguments
class FirefoxUIBaseArguments(object):
name = 'Firefox UI Tests'
args = []
class FirefoxUIArguments(BaseMarionetteArguments):
def __init__(self, **kwargs):
super(FirefoxUIArguments, self).__init__(**kwargs)
self.register_argument_container(FirefoxUIBaseArguments())

View file

@ -0,0 +1,65 @@
# 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/.
from base import FirefoxUIArguments
class UpdateBaseArguments(object):
name = 'Firefox UI Update Tests'
args = [
[['--update-allow-mar-channel'], {
'dest': 'update_mar_channels',
'default': [],
'action': 'append',
'metavar': 'MAR_CHANNEL',
'help': 'Additional MAR channel to be allowed for updates, '
'e.g. "firefox-mozilla-beta" for updating a release '
'build to the latest beta build.'
}],
[['--update-channel'], {
'dest': 'update_channel',
'metavar': 'CHANNEL',
'help': 'Channel to use for the update check.'
}],
[['--update-direct-only'], {
'dest': 'update_direct_only',
'default': False,
'action': 'store_true',
'help': 'Only perform a direct update'
}],
[['--update-fallback-only'], {
'dest': 'update_fallback_only',
'default': False,
'action': 'store_true',
'help': 'Only perform a fallback update'
}],
[['--update-override-url'], {
'dest': 'update_override_url',
'metavar': 'URL',
'help': 'Force specified URL to use for update checks.'
}],
[['--update-target-version'], {
'dest': 'update_target_version',
'metavar': 'VERSION',
'help': 'Version of the updated build.'
}],
[['--update-target-buildid'], {
'dest': 'update_target_buildid',
'metavar': 'BUILD_ID',
'help': 'Build ID of the updated build.'
}],
]
def verify_usage_handler(self, args):
if args.update_direct_only and args.update_fallback_only:
raise ValueError('Arguments --update-direct-only and --update-fallback-only '
'are mutually exclusive.')
class UpdateArguments(FirefoxUIArguments):
def __init__(self, **kwargs):
super(UpdateArguments, self).__init__(**kwargs)
self.register_argument_container(UpdateBaseArguments())

View file

@ -0,0 +1,21 @@
#!/usr/bin/env python
# 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/.
from marionette_harness.runtests import cli as mn_cli
from firefox_ui_harness.arguments import FirefoxUIArguments
from firefox_ui_harness.runners import FirefoxUITestRunner
def cli(args=None):
mn_cli(runner_class=FirefoxUITestRunner,
parser_class=FirefoxUIArguments,
args=args,
)
if __name__ == '__main__':
cli()

View file

@ -0,0 +1,21 @@
#!/usr/bin/env python
# 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/.
from marionette_harness.runtests import cli as mn_cli
from firefox_ui_harness.arguments import UpdateArguments
from firefox_ui_harness.runners import UpdateTestRunner
def cli(args=None):
mn_cli(runner_class=UpdateTestRunner,
parser_class=UpdateArguments,
args=args,
)
if __name__ == '__main__':
cli()

View file

@ -0,0 +1,6 @@
# 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/.
from firefox_ui_harness.runners.base import FirefoxUITestRunner
from firefox_ui_harness.runners.update import UpdateTestRunner

View file

@ -0,0 +1,45 @@
# 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/.
import os
import shutil
import tempfile
import mozfile
import mozinfo
from marionette_harness import BaseMarionetteTestRunner, MarionetteTestCase
class FirefoxUITestRunner(BaseMarionetteTestRunner):
def __init__(self, **kwargs):
super(FirefoxUITestRunner, self).__init__(**kwargs)
# select the appropriate GeckoInstance
self.app = 'fxdesktop'
self.test_handlers = [MarionetteTestCase]
def duplicate_application(self, application_folder):
"""Creates a copy of the specified binary."""
if self.workspace:
target_folder = os.path.join(self.workspace_path, 'application.copy')
else:
target_folder = tempfile.mkdtemp('.application.copy')
self.logger.info('Creating a copy of the application at "%s".' % target_folder)
mozfile.remove(target_folder)
shutil.copytree(application_folder, target_folder)
return target_folder
def get_application_folder(self, binary):
"""Returns the directory of the application."""
if mozinfo.isMac:
end_index = binary.find('.app') + 4
return binary[:end_index]
else:
return os.path.dirname(binary)

View file

@ -0,0 +1,101 @@
# 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/.
import sys
import mozfile
import mozinstall
from firefox_ui_harness.runners import FirefoxUITestRunner
from firefox_ui_harness.testcases import UpdateTestCase
DEFAULT_PREFS = {
# Bug 1355026: Re-enable when support for the new simplified UI update is available
'app.update.doorhanger': False,
'app.update.log': True,
'startup.homepage_override_url': 'about:blank',
}
class UpdateTestRunner(FirefoxUITestRunner):
def __init__(self, **kwargs):
super(UpdateTestRunner, self).__init__(**kwargs)
self.original_bin = self.bin
self.prefs.update(DEFAULT_PREFS)
# In case of overriding the update URL, set the appropriate preference
override_url = kwargs.pop('update_override_url', None)
if override_url:
self.prefs.update({'app.update.url.override': override_url})
self.run_direct_update = not kwargs.pop('update_fallback_only', False)
self.run_fallback_update = not kwargs.pop('update_direct_only', False)
self.test_handlers = [UpdateTestCase]
def run_tests(self, tests):
# Used to store the last occurred exception because we execute
# run_tests() multiple times
self.exc_info = None
failed = 0
source_folder = self.get_application_folder(self.original_bin)
results = {}
def _run_tests(tags):
application_folder = None
try:
# Backup current tags
test_tags = self.test_tags
application_folder = self.duplicate_application(source_folder)
self.bin = mozinstall.get_binary(application_folder, 'Firefox')
self.test_tags = tags
super(UpdateTestRunner, self).run_tests(tests)
except Exception:
self.exc_info = sys.exc_info()
self.logger.error('Failure during execution of the update test.',
exc_info=self.exc_info)
finally:
self.test_tags = test_tags
self.logger.info('Removing copy of the application at "%s"' % application_folder)
try:
mozfile.remove(application_folder)
except IOError as e:
self.logger.error('Cannot remove copy of application: "%s"' % str(e))
# Run direct update tests if wanted
if self.run_direct_update:
_run_tests(tags=['direct'])
failed += self.failed
results['Direct'] = False if self.failed else True
# Run fallback update tests if wanted
if self.run_fallback_update:
_run_tests(tags=['fallback'])
failed += self.failed
results['Fallback'] = False if self.failed else True
self.logger.info("Summary of update tests:")
for test_type, result in results.iteritems():
self.logger.info("\t%s update test ran and %s" %
(test_type, 'PASSED' if result else 'FAILED'))
# Combine failed tests for all run_test() executions
self.failed = failed
# If exceptions happened, re-throw the last one
if self.exc_info:
ex_type, exception, tb = self.exc_info
raise ex_type, exception, tb

View file

@ -0,0 +1,420 @@
# 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/.
import os
import pprint
from datetime import datetime
import mozfile
from firefox_puppeteer import PuppeteerMixin
from firefox_puppeteer.api.software_update import SoftwareUpdate
from firefox_puppeteer.ui.update_wizard import UpdateWizardDialog
from marionette_driver import Wait
from marionette_driver.errors import NoSuchWindowException
from marionette_harness import MarionetteTestCase
class UpdateTestCase(PuppeteerMixin, MarionetteTestCase):
TIMEOUT_UPDATE_APPLY = 300
TIMEOUT_UPDATE_CHECK = 30
TIMEOUT_UPDATE_DOWNLOAD = 720
# For the old update wizard, the errors are displayed inside the dialog. For the
# handling of updates in the about window the errors are displayed in new dialogs.
# When the old wizard is open we have to set the preference, so the errors will be
# shown as expected, otherwise we would have unhandled modal dialogs when errors are
# raised. See:
# http://mxr.mozilla.org/mozilla-central/source/toolkit/mozapps/update/nsUpdateService.js?rev=a9240b1eb2fb#4813
# http://mxr.mozilla.org/mozilla-central/source/toolkit/mozapps/update/nsUpdateService.js?rev=a9240b1eb2fb#4756
PREF_APP_UPDATE_ALTWINDOWTYPE = 'app.update.altwindowtype'
def __init__(self, *args, **kwargs):
super(UpdateTestCase, self).__init__(*args, **kwargs)
self.update_channel = kwargs.pop('update_channel')
self.update_mar_channels = set(kwargs.pop('update_mar_channels'))
self.target_buildid = kwargs.pop('update_target_buildid')
self.target_version = kwargs.pop('update_target_version')
def setUp(self, is_fallback=False):
super(UpdateTestCase, self).setUp()
self.software_update = SoftwareUpdate(self.marionette)
self.download_duration = None
# If a custom update channel has to be set, force a restart of
# Firefox to actually get it applied as a default pref. Use the clean
# option to force a non in_app restart, which would allow Firefox to
# dump the logs to the console.
if self.update_channel:
self.software_update.update_channel = self.update_channel
self.restart(clean=True)
self.assertEqual(self.software_update.update_channel, self.update_channel)
# If requested modify the list of allowed MAR channels
if self.update_mar_channels:
self.software_update.mar_channels.add_channels(self.update_mar_channels)
self.assertTrue(self.update_mar_channels.issubset(
self.software_update.mar_channels.channels),
'Allowed MAR channels have been set: expected "{}" in "{}"'.format(
', '.join(self.update_mar_channels),
', '.join(self.software_update.mar_channels.channels)))
# Ensure that there exists no already partially downloaded update
self.remove_downloaded_update()
# Dictionary which holds the information for each update
self.update_status = {
'build_pre': self.software_update.build_info,
'build_post': None,
'fallback': is_fallback,
'patch': {},
'success': False,
}
# Check if the user has permissions to run the update
self.assertTrue(self.software_update.allowed,
'Current user has permissions to update the application.')
def tearDown(self):
try:
self.browser.tabbar.close_all_tabs([self.browser.tabbar.selected_tab])
# Add content of the update log file for detailed failures when applying an update
self.update_status['update_log'] = self.read_update_log()
# Print results for now until we have treeherder integration
output = pprint.pformat(self.update_status)
self.logger.info('Update test results: \n{}'.format(output))
finally:
super(UpdateTestCase, self).tearDown()
# Ensure that no trace of an partially downloaded update remain
self.remove_downloaded_update()
@property
def patch_info(self):
""" Returns information about the active update in the queue.
:returns: A dictionary with information about the active patch
"""
patch = self.software_update.patch_info
patch['download_duration'] = self.download_duration
return patch
def check_for_updates(self, about_window, timeout=TIMEOUT_UPDATE_CHECK):
"""Clicks on "Check for Updates" button, and waits for check to complete.
:param about_window: Instance of :class:`AboutWindow`.
:param timeout: How long to wait for the update check to finish. Optional,
defaults to 60s.
:returns: True, if an update is available.
"""
self.assertEqual(about_window.deck.selected_panel,
about_window.deck.check_for_updates)
about_window.deck.check_for_updates.button.click()
Wait(self.marionette, timeout=self.TIMEOUT_UPDATE_CHECK).until(
lambda _: about_window.deck.selected_panel not in
(about_window.deck.check_for_updates, about_window.deck.checking_for_updates),
message='Check for updates has been finished.')
return about_window.deck.selected_panel != about_window.deck.no_updates_found
def check_update_applied(self):
"""Check that the update has been applied correctly"""
self.update_status['build_post'] = self.software_update.build_info
# Ensure that the target version is the same or higher. No downgrade
# should have happened.
version_check = self.marionette.execute_script("""
Components.utils.import("resource://gre/modules/Services.jsm");
return Services.vc.compare(arguments[0], arguments[1]);
""", script_args=(self.update_status['build_post']['version'],
self.update_status['build_pre']['version']))
self.assertGreaterEqual(version_check, 0,
'A downgrade from version {} to {} is not allowed'.format(
self.update_status['build_pre']['version'],
self.update_status['build_post']['version']))
self.assertNotEqual(self.update_status['build_post']['buildid'],
self.update_status['build_pre']['buildid'],
'The staged update to buildid {} has not been applied'.format(
self.update_status['patch']['buildid']))
self.assertEqual(self.update_status['build_post']['buildid'],
self.update_status['patch']['buildid'],
'Unexpected target buildid after applying the patch, {} != {}'.format(
self.update_status['build_post']['buildid'],
self.update_status['patch']['buildid']))
self.assertEqual(self.update_status['build_post']['locale'],
self.update_status['build_pre']['locale'],
'Unexpected change of the locale from {} to {}'.format(
self.update_status['build_pre']['locale'],
self.update_status['build_post']['locale']))
self.assertEqual(self.update_status['build_post']['disabled_addons'],
self.update_status['build_pre']['disabled_addons'],
'Application-wide addons have been unexpectedly disabled: {}'.format(
', '.join(set(self.update_status['build_pre']['locale']) -
set(self.update_status['build_post']['locale']))
))
if self.target_version:
self.assertEqual(self.update_status['build_post']['version'],
self.target_version,
'Current target version {} does not match expected version {}'.format(
self.update_status['build_post']['version'], self.target_version))
if self.target_buildid:
self.assertEqual(self.update_status['build_post']['buildid'],
self.target_buildid,
'Current target buildid {} does not match expected buildid {}'.format(
self.update_status['build_post']['buildid'], self.target_buildid))
self.update_status['success'] = True
def check_update_not_applied(self):
"""Check that the update has not been applied due to a forced invalidation of the patch"""
build_info = self.software_update.build_info
# Ensure that the version has not been changed
version_check = self.marionette.execute_script("""
Components.utils.import("resource://gre/modules/Services.jsm");
return Services.vc.compare(arguments[0], arguments[1]);
""", script_args=(build_info['version'],
self.update_status['build_pre']['version']))
self.assertEqual(version_check, 0,
'An update from version {} to {} has been unexpectedly applied'.format(
self.update_status['build_pre']['version'],
build_info['version']))
# Check that the build id of the source build and the current build are identical
self.assertEqual(build_info['buildid'],
self.update_status['build_pre']['buildid'],
'The build id has been unexpectedly changed from {} to {}'.format(
self.update_status['build_pre']['buildid'], build_info['buildid']))
def download_update(self, window, wait_for_finish=True, timeout=TIMEOUT_UPDATE_DOWNLOAD):
""" Download the update patch.
:param window: Instance of :class:`AboutWindow` or :class:`UpdateWizardDialog`.
:param wait_for_finish: If True the function has to wait for the download to be finished.
Optional, default to `True`.
:param timeout: How long to wait for the download to finish. Optional, default to 360s.
"""
def download_via_update_wizard(dialog):
""" Download the update via the old update wizard dialog.
:param dialog: Instance of :class:`UpdateWizardDialog`.
"""
self.marionette.set_pref(self.PREF_APP_UPDATE_ALTWINDOWTYPE, dialog.window_type)
try:
# If updates have already been found, proceed to download
if dialog.wizard.selected_panel in [dialog.wizard.updates_found_basic,
dialog.wizard.error_patching,
]:
dialog.select_next_page()
# If incompatible add-on are installed, skip over the wizard page
# TODO: Remove once we no longer support version Firefox 45.0ESR
if self.puppeteer.utils.compare_version(self.puppeteer.appinfo.version,
'49.0a1') == -1:
if dialog.wizard.selected_panel == dialog.wizard.incompatible_list:
dialog.select_next_page()
# Updates were stored in the cache, so no download is necessary
if dialog.wizard.selected_panel in [dialog.wizard.finished,
dialog.wizard.finished_background,
]:
pass
# Download the update
elif dialog.wizard.selected_panel == dialog.wizard.downloading:
if wait_for_finish:
start_time = datetime.now()
self.wait_for_download_finished(dialog, timeout)
self.download_duration = (datetime.now() - start_time).total_seconds()
Wait(self.marionette).until(lambda _: (
dialog.wizard.selected_panel in [dialog.wizard.finished,
dialog.wizard.finished_background,
]),
message='Final wizard page has been selected.')
else:
raise Exception('Invalid wizard page for downloading an update: {}'.format(
dialog.wizard.selected_panel))
finally:
self.marionette.clear_pref(self.PREF_APP_UPDATE_ALTWINDOWTYPE)
# The old update wizard dialog has to be handled differently. It's necessary
# for fallback updates and invalid add-on versions.
if isinstance(window, UpdateWizardDialog):
download_via_update_wizard(window)
return
if window.deck.selected_panel == window.deck.download_and_install:
window.deck.download_and_install.button.click()
# Wait for the download to start
Wait(self.marionette).until(lambda _: (
window.deck.selected_panel != window.deck.download_and_install),
message='Download of the update has been started.')
if wait_for_finish:
start_time = datetime.now()
self.wait_for_download_finished(window, timeout)
self.download_duration = (datetime.now() - start_time).total_seconds()
def download_and_apply_available_update(self, force_fallback=False):
"""Checks, downloads, and applies an available update.
:param force_fallback: Optional, if `True` invalidate current update status.
Defaults to `False`.
"""
# Open the about window and check for updates
about_window = self.browser.open_about_window()
try:
update_available = self.check_for_updates(about_window)
self.assertTrue(update_available,
"Available update has been found")
# Download update and wait until it has been applied
self.download_update(about_window)
self.wait_for_update_applied(about_window)
finally:
self.update_status['patch'] = self.patch_info
if force_fallback:
# Set the downloaded update into failed state
self.software_update.force_fallback()
# Restart Firefox to apply the downloaded update
self.restart()
def download_and_apply_forced_update(self):
self.check_update_not_applied()
# The update wizard dialog opens automatically after the restart but with a short delay
dialog = Wait(self.marionette, ignored_exceptions=[NoSuchWindowException]).until(
lambda _: self.puppeteer.windows.switch_to(lambda win: type(win) is UpdateWizardDialog)
)
# In case of a broken complete update the about window has to be used
if self.update_status['patch']['is_complete']:
about_window = None
try:
self.assertEqual(dialog.wizard.selected_panel,
dialog.wizard.error)
dialog.close()
# Open the about window and check for updates
about_window = self.browser.open_about_window()
update_available = self.check_for_updates(about_window)
self.assertTrue(update_available,
'Available update has been found')
# Download update and wait until it has been applied
self.download_update(about_window)
self.wait_for_update_applied(about_window)
finally:
if about_window:
self.update_status['patch'] = self.patch_info
else:
try:
self.assertEqual(dialog.wizard.selected_panel,
dialog.wizard.error_patching)
# Start downloading the fallback update
self.download_update(dialog)
finally:
self.update_status['patch'] = self.patch_info
# Restart Firefox to apply the update
self.restart()
def read_update_log(self):
"""Read the content of the update log file for the last update attempt."""
path = os.path.join(os.path.dirname(self.software_update.staging_directory),
'last-update.log')
try:
with open(path, 'rb') as f:
return f.read().splitlines()
except IOError as exc:
self.logger.warning(str(exc))
return None
def remove_downloaded_update(self):
"""Remove an already downloaded update from the update staging directory.
Hereby not only remove the update subdir but everything below 'updates'.
"""
path = os.path.dirname(self.software_update.staging_directory)
self.logger.info('Clean-up update staging directory: {}'.format(path))
mozfile.remove(path)
def wait_for_download_finished(self, window, timeout=TIMEOUT_UPDATE_DOWNLOAD):
""" Waits until download is completed.
:param window: Instance of :class:`AboutWindow` or :class:`UpdateWizardDialog`.
:param timeout: How long to wait for the download to finish. Optional,
default to 360 seconds.
"""
# The old update wizard dialog has to be handled differently. It's necessary
# for fallback updates and invalid add-on versions.
if isinstance(window, UpdateWizardDialog):
Wait(self.marionette, timeout=timeout).until(
lambda _: window.wizard.selected_panel != window.wizard.downloading,
message='Download has been completed.')
self.assertNotIn(window.wizard.selected_panel,
[window.wizard.error, window.wizard.error_extra])
return
Wait(self.marionette, timeout=timeout).until(
lambda _: window.deck.selected_panel not in
(window.deck.download_and_install, window.deck.downloading),
message='Download has been completed.')
self.assertNotEqual(window.deck.selected_panel,
window.deck.download_failed)
def wait_for_update_applied(self, about_window, timeout=TIMEOUT_UPDATE_APPLY):
""" Waits until the downloaded update has been applied.
:param about_window: Instance of :class:`AboutWindow`.
:param timeout: How long to wait for the update to apply. Optional,
default to 300 seconds
"""
Wait(self.marionette, timeout=timeout).until(
lambda _: about_window.deck.selected_panel == about_window.deck.apply,
message='Final wizard page has been selected.')
# Wait for update to be staged because for update tests we modify the update
# status file to enforce the fallback update. If we modify the file before
# Firefox does, Firefox will override our change and we will have no fallback update.
Wait(self.marionette, timeout=timeout).until(
lambda _: 'applied' in self.software_update.active_update.state,
message='Update has been applied.')

View file

@ -0,0 +1,5 @@
firefox-puppeteer >= 52.1.0, <53.0.0
marionette-harness >= 4.0.0
mozfile >= 1.2
mozinfo >= 0.8
mozinstall >= 1.12

View file

@ -0,0 +1,44 @@
# 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/.
import os
import re
from setuptools import setup, find_packages
THIS_DIR = os.path.dirname(os.path.realpath(__name__))
def read(*parts):
with open(os.path.join(THIS_DIR, *parts)) as f:
return f.read()
def get_version():
return re.findall("__version__ = '([\d\.]+)'",
read('firefox_ui_harness', '__init__.py'), re.M)[0]
long_description = """Custom Marionette runner classes and entry scripts for Firefox Desktop
specific Marionette tests.
"""
setup(name='firefox-ui-harness',
version=get_version(),
description="Firefox UI Harness",
long_description=long_description,
classifiers=[], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers
keywords='mozilla',
author='Auto-tools',
author_email='tools-marionette@lists.mozilla.org',
url='https://wiki.mozilla.org/Auto-tools/Projects/Marionette/Harnesses/FirefoxUI',
license='MPL',
packages=find_packages(),
include_package_data=True,
zip_safe=False,
install_requires=read('requirements.txt').splitlines(),
entry_points="""
[console_scripts]
firefox-ui-functional = firefox_ui_harness.cli_functional:cli
firefox-ui-update = firefox_ui_harness.cli_update:cli
""",
)