summaryrefslogtreecommitdiffstats
path: root/testing/firefox-ui/harness/firefox_ui_harness/testcases.py
blob: abcbd65550cc6262ffb4df7211fddb4609a12913 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
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.')