1# Copyright 2013 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import collections
6
7from metrics import Metric
8from telemetry.core import bitmap
9from telemetry.value import scalar
10
11
12class SpeedIndexMetric(Metric):
13  """The speed index metric is one way of measuring page load speed.
14
15  It is meant to approximate user perception of page load speed, and it
16  is based on the amount of time that it takes to paint to the visual
17  portion of the screen. It includes paint events that occur after the
18  onload event, and it doesn't include time loading things off-screen.
19
20  This speed index metric is based on WebPageTest.org (WPT).
21  For more info see: http://goo.gl/e7AH5l
22  """
23  def __init__(self):
24    super(SpeedIndexMetric, self).__init__()
25    self._impl = None
26
27  @classmethod
28  def CustomizeBrowserOptions(cls, options):
29    options.AppendExtraBrowserArgs('--disable-infobars')
30
31  def Start(self, _, tab):
32    """Start recording events.
33
34    This method should be called in the WillNavigateToPage method of
35    a PageTest, so that all the events can be captured. If it's called
36    in DidNavigateToPage, that will be too late.
37    """
38    self._impl = (VideoSpeedIndexImpl() if tab.video_capture_supported else
39                  PaintRectSpeedIndexImpl())
40    self._impl.Start(tab)
41
42  def Stop(self, _, tab):
43    """Stop timeline recording."""
44    assert self._impl, 'Must call Start() before Stop()'
45    assert self.IsFinished(tab), 'Must wait for IsFinished() before Stop()'
46    self._impl.Stop(tab)
47
48  # Optional argument chart_name is not in base class Metric.
49  # pylint: disable=W0221
50  def AddResults(self, tab, results, chart_name=None):
51    """Calculate the speed index and add it to the results."""
52    index = self._impl.CalculateSpeedIndex(tab)
53    # Release the tab so that it can be disconnected.
54    self._impl = None
55    results.AddValue(scalar.ScalarValue(
56        results.current_page, '%s.speed_index' % chart_name, 'ms', index))
57
58  def IsFinished(self, tab):
59    """Decide whether the timeline recording should be stopped.
60
61    When the timeline recording is stopped determines which paint events
62    are used in the speed index metric calculation. In general, the recording
63    should continue if there has just been some data received, because
64    this suggests that painting may continue.
65
66    A page may repeatedly request resources in an infinite loop; a timeout
67    should be placed in any measurement that uses this metric, e.g.:
68      def IsDone():
69        return self._speedindex.IsFinished(tab)
70      util.WaitFor(IsDone, 60)
71
72    Returns:
73      True if 2 seconds have passed since last resource received, false
74      otherwise.
75    """
76    return tab.HasReachedQuiescence()
77
78
79class SpeedIndexImpl(object):
80
81  def Start(self, tab):
82    raise NotImplementedError()
83
84  def Stop(self, tab):
85    raise NotImplementedError()
86
87  def GetTimeCompletenessList(self, tab):
88    """Returns a list of time to visual completeness tuples.
89
90    In the WPT PHP implementation, this is also called 'visual progress'.
91    """
92    raise NotImplementedError()
93
94  def CalculateSpeedIndex(self, tab):
95    """Calculate the speed index.
96
97    The speed index number conceptually represents the number of milliseconds
98    that the page was "visually incomplete". If the page were 0% complete for
99    1000 ms, then the score would be 1000; if it were 0% complete for 100 ms
100    then 90% complete (ie 10% incomplete) for 900 ms, then the score would be
101    1.0*100 + 0.1*900 = 190.
102
103    Returns:
104      A single number, milliseconds of visual incompleteness.
105    """
106    time_completeness_list = self.GetTimeCompletenessList(tab)
107    prev_completeness = 0.0
108    speed_index = 0.0
109    prev_time = time_completeness_list[0][0]
110    for time, completeness in time_completeness_list:
111      # Add the incemental value for the interval just before this event.
112      elapsed_time = time - prev_time
113      incompleteness = (1.0 - prev_completeness)
114      speed_index += elapsed_time * incompleteness
115
116      # Update variables for next iteration.
117      prev_completeness = completeness
118      prev_time = time
119    return int(speed_index)
120
121
122class VideoSpeedIndexImpl(SpeedIndexImpl):
123
124  def __init__(self):
125    super(VideoSpeedIndexImpl, self).__init__()
126    self._time_completeness_list = None
127
128  def Start(self, tab):
129    assert tab.video_capture_supported
130    # Blank out the current page so it doesn't count towards the new page's
131    # completeness.
132    tab.Highlight(bitmap.WHITE)
133    # TODO(tonyg): Bitrate is arbitrary here. Experiment with screen capture
134    # overhead vs. speed index accuracy and set the bitrate appropriately.
135    tab.StartVideoCapture(min_bitrate_mbps=4)
136
137  def Stop(self, tab):
138    # Ignore white because Chrome may blank out the page during load and we want
139    # that to count as 0% complete. Relying on this fact, we also blank out the
140    # previous page to white. The tolerance of 8 experimentally does well with
141    # video capture at 4mbps. We should keep this as low as possible with
142    # supported video compression settings.
143    video_capture = tab.StopVideoCapture()
144    histograms = [(time, bmp.ColorHistogram(ignore_color=bitmap.WHITE,
145                                            tolerance=8))
146                  for time, bmp in video_capture.GetVideoFrameIter()]
147
148    start_histogram = histograms[0][1]
149    final_histogram = histograms[-1][1]
150    total_distance = start_histogram.Distance(final_histogram)
151
152    def FrameProgress(histogram):
153      if total_distance == 0:
154        if histogram.Distance(final_histogram) == 0:
155          return 1.0
156        else:
157          return 0.0
158      return 1 - histogram.Distance(final_histogram) / total_distance
159
160    self._time_completeness_list = [(time, FrameProgress(hist))
161                                    for time, hist in histograms]
162
163  def GetTimeCompletenessList(self, tab):
164    assert self._time_completeness_list, 'Must call Stop() first.'
165    return self._time_completeness_list
166
167
168class PaintRectSpeedIndexImpl(SpeedIndexImpl):
169
170  def __init__(self):
171    super(PaintRectSpeedIndexImpl, self).__init__()
172
173  def Start(self, tab):
174    tab.StartTimelineRecording()
175
176  def Stop(self, tab):
177    tab.StopTimelineRecording()
178
179  def GetTimeCompletenessList(self, tab):
180    events = tab.timeline_model.GetAllEvents()
181    viewport = self._GetViewportSize(tab)
182    paint_events = self._IncludedPaintEvents(events)
183    time_area_dict = self._TimeAreaDict(paint_events, viewport)
184    total_area = sum(time_area_dict.values())
185    assert total_area > 0.0, 'Total paint event area must be greater than 0.'
186    completeness = 0.0
187    time_completeness_list = []
188
189    # TODO(tonyg): This sets the start time to the start of the first paint
190    # event. That can't be correct. The start time should be navigationStart.
191    # Since the previous screen is not cleared at navigationStart, we should
192    # probably assume the completeness is 0 until the first paint and add the
193    # time of navigationStart as the start. We need to confirm what WPT does.
194    time_completeness_list.append(
195        (tab.timeline_model.GetAllEvents()[0].start, completeness))
196
197    for time, area in sorted(time_area_dict.items()):
198      completeness += float(area) / total_area
199      # Visual progress is rounded to the nearest percentage point as in WPT.
200      time_completeness_list.append((time, round(completeness, 2)))
201    return time_completeness_list
202
203  def _GetViewportSize(self, tab):
204    """Returns dimensions of the viewport."""
205    return tab.EvaluateJavaScript('[ window.innerWidth, window.innerHeight ]')
206
207  def _IncludedPaintEvents(self, events):
208    """Get all events that are counted in the calculation of the speed index.
209
210    There's one category of paint event that's filtered out: paint events
211    that occur before the first 'ResourceReceiveResponse' and 'Layout' events.
212
213    Previously in the WPT speed index, paint events that contain children paint
214    events were also filtered out.
215    """
216    def FirstLayoutTime(events):
217      """Get the start time of the first layout after a resource received."""
218      has_received_response = False
219      for event in events:
220        if event.name == 'ResourceReceiveResponse':
221          has_received_response = True
222        elif has_received_response and event.name == 'Layout':
223          return event.start
224      assert False, 'There were no layout events after resource receive events.'
225
226    first_layout_time = FirstLayoutTime(events)
227    paint_events = [e for e in events
228                    if e.start >= first_layout_time and e.name == 'Paint']
229    return paint_events
230
231  def _TimeAreaDict(self, paint_events, viewport):
232    """Make a dict from time to adjusted area value for events at that time.
233
234    The adjusted area value of each paint event is determined by how many paint
235    events cover the same rectangle, and whether it's a full-window paint event.
236    "Adjusted area" can also be thought of as "points" of visual completeness --
237    each rectangle has a certain number of points and these points are
238    distributed amongst the paint events that paint that rectangle.
239
240    Args:
241      paint_events: A list of paint events
242      viewport: A tuple (width, height) of the window.
243
244    Returns:
245      A dictionary of times of each paint event (in milliseconds) to the
246      adjusted area that the paint event is worth.
247    """
248    width, height = viewport
249    fullscreen_area = width * height
250
251    def ClippedArea(rectangle):
252      """Returns rectangle area clipped to viewport size."""
253      _, x0, y0, x1, y1 = rectangle
254      clipped_width = max(0, min(width, x1) - max(0, x0))
255      clipped_height = max(0, min(height, y1) - max(0, y0))
256      return clipped_width * clipped_height
257
258    grouped = self._GroupEventByRectangle(paint_events)
259    event_area_dict = collections.defaultdict(int)
260
261    for rectangle, events in grouped.items():
262      # The area points for each rectangle are divided up among the paint
263      # events in that rectangle.
264      area = ClippedArea(rectangle)
265      update_count = len(events)
266      adjusted_area = float(area) / update_count
267
268      # Paint events for the largest-area rectangle are counted as 50%.
269      if area == fullscreen_area:
270        adjusted_area /= 2
271
272      for event in events:
273        # The end time for an event is used for that event's time.
274        event_time = event.end
275        event_area_dict[event_time] += adjusted_area
276
277    return event_area_dict
278
279  def _GetRectangle(self, paint_event):
280    """Get the specific rectangle on the screen for a paint event.
281
282    Each paint event belongs to a frame (as in html <frame> or <iframe>).
283    This, together with location and dimensions, comprises a rectangle.
284    In the WPT source, this 'rectangle' is also called a 'region'.
285    """
286    def GetBox(quad):
287      """Gets top-left and bottom-right coordinates from paint event.
288
289      In the timeline data from devtools, paint rectangle dimensions are
290      represented x-y coordinates of four corners, clockwise from the top-left.
291      See: function WebInspector.TimelinePresentationModel.quadFromRectData
292      in file src/out/Debug/obj/gen/devtools/TimelinePanel.js.
293      """
294      x0, y0, _, _, x1, y1, _, _ = quad
295      return (x0, y0, x1, y1)
296
297    assert paint_event.name == 'Paint'
298    frame = paint_event.args['frameId']
299    return (frame,) + GetBox(paint_event.args['data']['clip'])
300
301  def _GroupEventByRectangle(self, paint_events):
302    """Group all paint events according to the rectangle that they update."""
303    result = collections.defaultdict(list)
304    for event in paint_events:
305      assert event.name == 'Paint'
306      result[self._GetRectangle(event)].append(event)
307    return result
308