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/.
4
5from collections import namedtuple
6from time import clock, sleep
7
8from marionette_driver import By, expected, Wait
9from marionette_harness import Marionette
10
11from external_media_tests.utils import verbose_until
12
13
14# Adapted from
15# https://github.com/gavinsharp/aboutmedia/blob/master/chrome/content/aboutmedia.xhtml
16debug_script = """
17var mainWindow = window.QueryInterface(Components.interfaces.nsIInterfaceRequestor)
18    .getInterface(Components.interfaces.nsIWebNavigation)
19    .QueryInterface(Components.interfaces.nsIDocShellTreeItem)
20    .rootTreeItem
21    .QueryInterface(Components.interfaces.nsIInterfaceRequestor)
22    .getInterface(Components.interfaces.nsIDOMWindow);
23var tabbrowser = mainWindow.gBrowser;
24for (var i=0; i < tabbrowser.browsers.length; ++i) {
25  var b = tabbrowser.getBrowserAtIndex(i);
26  var media = b.contentDocumentAsCPOW.getElementsByTagName('video');
27  for (var j=0; j < media.length; ++j) {
28     var ms = media[j].mozMediaSourceObject;
29     if (ms) {
30       debugLines = ms.mozDebugReaderData.split(\"\\n\");
31       return debugLines;
32     }
33  }
34}"""
35
36
37class VideoPuppeteer(object):
38    """
39    Wrapper to control and introspect HTML5 video elements.
40
41    A note about properties like current_time and duration:
42    These describe whatever stream is playing when the state is checked.
43    It is possible that many different streams are dynamically spliced
44    together, so the video stream that is currently playing might be the main
45    video or it might be something else, like an ad, for example.
46
47    :param marionette: The marionette instance this runs in.
48    :param url: the URL of the page containing the video element.
49    :param video_selector: the selector of the element that we want to watch.
50     This is set by default to 'video', which is what most sites use, but
51     others should work.
52    :param interval: The polling interval that is used to check progress.
53    :param set_duration: When set to >0, the polling and checking will stop at
54     the number of seconds specified. Otherwise, this will stop at the end
55     of the video.
56    :param stall_wait_time: The amount of time to wait to see if a stall has
57     cleared. If 0, do not check for stalls.
58    :param timeout: The amount of time to wait until the video starts.
59    """
60
61    _video_var_script = (
62        'var video = arguments[0];'
63        'var baseURI = arguments[0].baseURI;'
64        'var currentTime = video.wrappedJSObject.currentTime;'
65        'var duration = video.wrappedJSObject.duration;'
66        'var buffered = video.wrappedJSObject.buffered;'
67        'var bufferedRanges = [];'
68        'for (var i = 0; i < buffered.length; i++) {'
69        'bufferedRanges.push([buffered.start(i), buffered.end(i)]);'
70        '}'
71        'var played = video.wrappedJSObject.played;'
72        'var playedRanges = [];'
73        'for (var i = 0; i < played.length; i++) {'
74        'playedRanges.push([played.start(i), played.end(i)]);'
75        '}'
76        'var totalFrames = '
77        'video.getVideoPlaybackQuality()["totalVideoFrames"];'
78        'var droppedFrames = '
79        'video.getVideoPlaybackQuality()["droppedVideoFrames"];'
80        'var corruptedFrames = '
81        'video.getVideoPlaybackQuality()["corruptedVideoFrames"];'
82    )
83    """
84    A string containing JS that assigns video state to variables.
85    The purpose of this string script is to be appended to by this and
86    any inheriting classes to return these and other variables. In the case
87    of an inheriting class the script can be added to in order to fetch
88    further relevant variables -- keep in mind we only want one script
89    execution to prevent races, so it wouldn't do to have child classes
90    run this script then their own, as there is potential for lag in
91    between.
92
93    This script assigns a subset of the vars used later by the
94    `_video_state_named_tuple` function. Please see that function's
95    documentation for further information on these variables.
96    """
97
98    def __init__(self, marionette, url, video_selector='video', interval=1,
99                 set_duration=0, stall_wait_time=0, timeout=60,
100                 autostart=True):
101        self.marionette = marionette
102        self.test_url = url
103        self.interval = interval
104        self.stall_wait_time = stall_wait_time
105        self.timeout = timeout
106        self._set_duration = set_duration
107        self.video = None
108        self.expected_duration = 0
109        self._first_seen_time = 0
110        self._first_seen_wall_time = 0
111        self._fetch_state_script_string = None
112        self._last_seen_video_state = None
113        wait = Wait(self.marionette, timeout=self.timeout)
114        with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
115            self.marionette.navigate(self.test_url)
116            self.marionette.execute_script("""
117                log('URL: {0}');""".format(self.test_url))
118            verbose_until(wait, self,
119                          expected.element_present(By.TAG_NAME, 'video'))
120            videos_found = self.marionette.find_elements(By.CSS_SELECTOR,
121                                                         video_selector)
122            if len(videos_found) > 1:
123                self.marionette.log(type(self).__name__ + ': multiple video '
124                                                          'elements found. '
125                                                          'Using first.')
126            if len(videos_found) <= 0:
127                self.marionette.log(type(self).__name__ + ': no video '
128                                                          'elements found.')
129                return
130            self.video = videos_found[0]
131            self.marionette.execute_script("log('video element obtained');")
132            if autostart:
133                self.start()
134
135    def start(self):
136        # To get an accurate expected_duration, playback must have started
137        self._refresh_state()
138        wait = Wait(self, timeout=self.timeout)
139        verbose_until(wait, self, VideoPuppeteer.playback_started,
140                      "Check if video has played some range")
141        self._first_seen_time = self._last_seen_video_state.current_time
142        self._first_seen_wall_time = clock()
143        self._update_expected_duration()
144
145    def get_debug_lines(self):
146        """
147        Get Firefox internal debugging for the video element.
148
149        :return: A text string that has Firefox-internal debugging information.
150        """
151        with self.marionette.using_context('chrome'):
152            debug_lines = self.marionette.execute_script(debug_script)
153        return debug_lines
154
155    def play(self):
156        """
157        Tell the video element to Play.
158        """
159        self._execute_video_script('arguments[0].wrappedJSObject.play();')
160
161    def pause(self):
162        """
163        Tell the video element to Pause.
164        """
165        self._execute_video_script('arguments[0].wrappedJSObject.pause();')
166
167    def playback_started(self):
168        """
169        Determine if video has started
170
171        :param self: The VideoPuppeteer instance that we are interested in.
172
173        :return: True if is playing; False otherwise
174        """
175        self._refresh_state()
176        try:
177            played_ranges = self._last_seen_video_state.played
178            return (
179                played_ranges.length > 0 and
180                played_ranges.start(0) < played_ranges.end(0) and
181                played_ranges.end(0) > 0.0
182            )
183        except Exception as e:
184            print ('Got exception {}'.format(e))
185            return False
186
187    def playback_done(self):
188        """
189        If we are near the end and there is still a video element, then
190        we are essentially done. If this happens to be last time we are polled
191        before the video ends, we won't get another chance.
192
193        :param self: The VideoPuppeteer instance that we are interested in.
194
195        :return: True if we are close enough to the end of playback; False
196            otherwise.
197        """
198        self._refresh_state()
199
200        if self._last_seen_video_state.remaining_time < self.interval:
201            return True
202
203        # Check to see if the video has stalled. Accumulate the amount of lag
204        # since the video started, and if it is too high, then raise.
205        if (self.stall_wait_time and
206                self._last_seen_video_state.lag > self.stall_wait_time):
207            raise VideoException('Video {} stalled.\n{}'
208                                 .format(self._last_seen_video_state.video_uri,
209                                         self))
210
211        # We are cruising, so we are not done.
212        return False
213
214    def _update_expected_duration(self):
215        """
216        Update the duration of the target video at self.test_url (in seconds).
217        This is based on the last seen state, so the state should be,
218        refreshed at least once before this is called.
219
220        expected_duration represents the following: how long do we expect
221        playback to last before we consider the video to be 'complete'?
222        If we only want to play the first n seconds of the video,
223        expected_duration is set to n.
224        """
225
226        # self._last_seen_video_state.duration is the duration of whatever was
227        # playing when the state was checked. In this case, we assume the video
228        # element always shows the same stream throughout playback (i.e. the
229        # are no ads spliced into the main video, for example), so
230        # self._last_seen_video_state.duration is the duration of the main
231        # video.
232        video_duration = self._last_seen_video_state.duration
233        # Do our best to figure out where the video started playing
234        played_ranges = self._last_seen_video_state.played
235        if played_ranges.length > 0:
236            # If we have a range we should only have on continuous range
237            assert played_ranges.length == 1
238            start_position = played_ranges.start(0)
239        else:
240            # If we don't have a range we should have a current time
241            start_position = self._first_seen_time
242        # In case video starts at t > 0, adjust target time partial playback
243        remaining_video = video_duration - start_position
244        if 0 < self._set_duration < remaining_video:
245            self.expected_duration = self._set_duration
246        else:
247            self.expected_duration = remaining_video
248
249    @staticmethod
250    def _video_state_named_tuple():
251        """
252        Create a named tuple class that can be used to store state snapshots
253        of the wrapped element. The fields in the tuple should be used as
254        follows:
255
256        base_uri: the baseURI attribute of the wrapped element.
257        current_time: the current time of the wrapped element.
258        duration: the duration of the wrapped element.
259        buffered: the buffered ranges of the wrapped element. In its raw form
260        this is as a list where the first element is the length and the second
261        element is a list of 2 item lists, where each two items are a buffered
262        range. Once assigned to the tuple this data should be wrapped in the
263        TimeRanges class.
264        played: the played ranges of the wrapped element. In its raw form this
265        is as a list where the first element is the length and the second
266        element is a list of 2 item lists, where each two items are a played
267        range. Once assigned to the tuple this data should be wrapped in the
268        TimeRanges class.
269        lag: the difference in real world time and wrapped element time.
270        Calculated as real world time passed - element time passed.
271        totalFrames: number of total frames for the wrapped element
272        droppedFrames: number of dropped frames for the wrapped element.
273        corruptedFrames: number of corrupted frames for the wrapped.
274        video_src: the src attribute of the wrapped element.
275
276        :return: A 'video_state_info' named tuple class.
277        """
278        return namedtuple('video_state_info',
279                          ['base_uri',
280                           'current_time',
281                           'duration',
282                           'remaining_time',
283                           'buffered',
284                           'played',
285                           'lag',
286                           'total_frames',
287                           'dropped_frames',
288                           'corrupted_frames',
289                           'video_src'])
290
291    def _create_video_state_info(self, **video_state_info_kwargs):
292        """
293        Create an instance of the video_state_info named tuple. This function
294        expects a dictionary populated with the following keys: current_time,
295        duration, raw_played_ranges, total_frames, dropped_frames, and
296        corrupted_frames.
297
298        Aside from raw_played_ranges, see `_video_state_named_tuple` for more
299        information on the above keys and values. For raw_played_ranges a
300        list is expected that can be consumed to make a TimeRanges object.
301
302        :return: A named tuple 'video_state_info' derived from arguments and
303        state information from the puppeteer.
304        """
305        raw_buffered_ranges = video_state_info_kwargs['raw_buffered_ranges']
306        raw_played_ranges = video_state_info_kwargs['raw_played_ranges']
307        # Remove raw ranges from dict as they are not used in the final named
308        # tuple and will provide an unexpected kwarg if kept.
309        del video_state_info_kwargs['raw_buffered_ranges']
310        del video_state_info_kwargs['raw_played_ranges']
311        # Create buffered ranges
312        video_state_info_kwargs['buffered'] = (
313            TimeRanges(raw_buffered_ranges[0], raw_buffered_ranges[1]))
314        # Create played ranges
315        video_state_info_kwargs['played'] = (
316            TimeRanges(raw_played_ranges[0], raw_played_ranges[1]))
317        # Calculate elapsed times
318        elapsed_current_time = (video_state_info_kwargs['current_time'] -
319                                self._first_seen_time)
320        elapsed_wall_time = clock() - self._first_seen_wall_time
321        # Calculate lag
322        video_state_info_kwargs['lag'] = (
323            elapsed_wall_time - elapsed_current_time)
324        # Calculate remaining time
325        if video_state_info_kwargs['played'].length > 0:
326            played_duration = (video_state_info_kwargs['played'].end(0) -
327                               video_state_info_kwargs['played'].start(0))
328            video_state_info_kwargs['remaining_time'] = (
329                self.expected_duration - played_duration)
330        else:
331            # No playback has happened yet, remaining time is duration
332            video_state_info_kwargs['remaining_time'] = self.expected_duration
333        # Fetch non time critical source information
334        video_state_info_kwargs['video_src'] = self.video.get_attribute('src')
335        # Create video state snapshot
336        state_info = self._video_state_named_tuple()
337        return state_info(**video_state_info_kwargs)
338
339    @property
340    def _fetch_state_script(self):
341        if not self._fetch_state_script_string:
342            self._fetch_state_script_string = (
343                self._video_var_script +
344                'return ['
345                'baseURI,'
346                'currentTime,'
347                'duration,'
348                '[buffered.length, bufferedRanges],'
349                '[played.length, playedRanges],'
350                'totalFrames,'
351                'droppedFrames,'
352                'corruptedFrames];')
353        return self._fetch_state_script_string
354
355    def _refresh_state(self):
356        """
357        Refresh the snapshot of the underlying video state. We do this all
358        in one so that the state doesn't change in between queries.
359
360        We also store information that can be derived from the snapshotted
361        information, such as lag. This is stored in the last seen state to
362        stress that it's based on the snapshot.
363        """
364        keys = ['base_uri', 'current_time', 'duration', 'raw_buffered_ranges',
365                'raw_played_ranges', 'total_frames', 'dropped_frames',
366                'corrupted_frames']
367        values = self._execute_video_script(self._fetch_state_script)
368        self._last_seen_video_state = (
369            self._create_video_state_info(**dict(zip(keys, values))))
370
371    def _measure_progress(self):
372        self._refresh_state()
373        initial = self._last_seen_video_state.current_time
374        sleep(1)
375        self._refresh_state()
376        return self._last_seen_video_state.current_time - initial
377
378    def _execute_video_script(self, script):
379        """
380        Execute JS script in content context with access  to video element.
381
382        :param script: script to be executed
383        :return: value returned by script
384        """
385        with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
386            return self.marionette.execute_script(script,
387                                                  script_args=[self.video])
388
389    def __str__(self):
390        messages = ['{} - test url: {}: '
391                    .format(type(self).__name__, self.test_url)]
392        if not self.video:
393            messages += ['\tvideo: None']
394            return '\n'.join(messages)
395        if not self._last_seen_video_state:
396            messages += ['\tvideo: No last seen state']
397            return '\n'.join(messages)
398        # Have video and state info
399        messages += [
400            '{',
401            '\t(video)'
402        ]
403        messages += ['\tinterval: {}'.format(self.interval)]
404        messages += ['\texpected duration: {}'.format(self.expected_duration)]
405        messages += ['\tstall wait time: {}'.format(self.stall_wait_time)]
406        messages += ['\ttimeout: {}'.format(self.timeout)]
407        # Print each field on its own line
408        for field in self._last_seen_video_state._fields:
409            # For compatibility with different test environments we force ascii
410            field_ascii = (
411                unicode(getattr(self._last_seen_video_state, field))
412                .encode('ascii','replace'))
413            messages += [('\t{}: {}'.format(field, field_ascii))]
414        messages += '}'
415        return '\n'.join(messages)
416
417
418class VideoException(Exception):
419    """
420    Exception class to use for video-specific error processing.
421    """
422    pass
423
424
425class TimeRanges:
426    """
427    Class to represent the TimeRanges data returned by played(). Exposes a
428    similar interface to the JavaScript TimeRanges object.
429    """
430    def __init__(self, length, ranges):
431        # These should be the same,. Theoretically we don't need the length,
432        # but since this should be used to consume data coming back from
433        # JS exec, this is a valid sanity check.
434        assert length == len(ranges)
435        self.length = length
436        self.ranges = [(pair[0], pair[1]) for pair in ranges]
437
438    def __repr__(self):
439        return (
440            'TimeRanges: length: {}, ranges: {}'
441            .format(self.length, self.ranges)
442        )
443
444    def start(self, index):
445        return self.ranges[index][0]
446
447    def end(self, index):
448        return self.ranges[index][1]
449