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