2420bb1a6ed73775b273f18d88fd873745491f68
[WebKit-https.git] / Tools / TestResultServer / static-dashboards / dashboard_base.js
1 // Copyright (C) 2012 Google Inc. All rights reserved.
2 //
3 // Redistribution and use in source and binary forms, with or without
4 // modification, are permitted provided that the following conditions are
5 // met:
6 //
7 //         * Redistributions of source code must retain the above copyright
8 // notice, this list of conditions and the following disclaimer.
9 //         * Redistributions in binary form must reproduce the above
10 // copyright notice, this list of conditions and the following disclaimer
11 // in the documentation and/or other materials provided with the
12 // distribution.
13 //         * Neither the name of Google Inc. nor the names of its
14 // contributors may be used to endorse or promote products derived from
15 // this software without specific prior written permission.
16 //
17 // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
29 // @fileoverview Base JS file for pages that want to parse the results JSON
30 // from the testing bots. This deals with generic utility functions, visible
31 // history, popups and appending the script elements for the JSON files.
32 //
33 // The calling page is expected to implement the following "abstract"
34 // functions/objects:
35 var g_pageLoadStartTime = Date.now();
36 var g_resourceLoader;
37
38 // Generates the contents of the dashboard. The page should override this with
39 // a function that generates the page assuming all resources have loaded.
40 function generatePage() {}
41
42 // Takes a key and a value and sets the g_currentState[key] = value iff key is
43 // a valid hash parameter and the value is a valid value for that key.
44 //
45 // @return {boolean} Whether the key what inserted into the g_currentState.
46 function handleValidHashParameter(key, value)
47 {
48     return false;
49 }
50
51 // Default hash parameters for the page. The page should override this to create
52 // default states.
53 var g_defaultDashboardSpecificStateValues = {};
54
55
56 // The page should override this to modify page state due to
57 // changing query parameters.
58 // @param {Object} params New or modified query params as key: value.
59 // @return {boolean} Whether changing this parameter should cause generatePage to be called.
60 function handleQueryParameterChange(params)
61 {
62     return true;
63 }
64
65 //////////////////////////////////////////////////////////////////////////////
66 // CONSTANTS
67 //////////////////////////////////////////////////////////////////////////////
68 var GTEST_EXPECTATIONS_MAP_ = {
69     'P': 'PASS',
70     'F': 'FAIL',
71     'N': 'NO DATA',
72     'X': 'SKIPPED'
73 };
74
75 var LAYOUT_TEST_EXPECTATIONS_MAP_ = {
76     'P': 'PASS',
77     'N': 'NO DATA',
78     'X': 'SKIP',
79     'T': 'TIMEOUT',
80     'F': 'TEXT',
81     'C': 'CRASH',
82     'I': 'IMAGE',
83     'Z': 'IMAGE+TEXT',
84     // We used to glob a bunch of expectations into "O" as OTHER. Expectations
85     // are more precise now though and it just means MISSING.
86     'O': 'MISSING'
87 };
88
89 var FAILURE_EXPECTATIONS_ = {
90     'T': 1,
91     'F': 1,
92     'C': 1,
93     'I': 1,
94     'Z': 1
95 };
96
97 // Keys in the JSON files.
98 var WONTFIX_COUNTS_KEY = 'wontfixCounts';
99 var FIXABLE_COUNTS_KEY = 'fixableCounts';
100 var DEFERRED_COUNTS_KEY = 'deferredCounts';
101 var WONTFIX_DESCRIPTION = 'Tests never to be fixed (WONTFIX)';
102 var FIXABLE_DESCRIPTION = 'All tests for this release';
103 var DEFERRED_DESCRIPTION = 'All deferred tests (DEFER)';
104 var FIXABLE_COUNT_KEY = 'fixableCount';
105 var ALL_FIXABLE_COUNT_KEY = 'allFixableCount';
106 var CHROME_REVISIONS_KEY = 'chromeRevision';
107 var WEBKIT_REVISIONS_KEY = 'webkitRevision';
108 var TIMESTAMPS_KEY = 'secondsSinceEpoch';
109 var BUILD_NUMBERS_KEY = 'buildNumbers';
110 var TESTS_KEY = 'tests';
111 var ONE_DAY_SECONDS = 60 * 60 * 24;
112 var ONE_WEEK_SECONDS = ONE_DAY_SECONDS * 7;
113
114 // These should match the testtype uploaded to test-results.appspot.com.
115 // See http://test-results.appspot.com/testfile.
116 var TEST_TYPES = [
117     'base_unittests',
118     'browser_tests',
119     'cacheinvalidation_unittests',
120     'compositor_unittests',
121     'content_browsertests',
122     'content_unittests',
123     'courgette_unittests',
124     'crypto_unittests',
125     'googleurl_unittests',
126     'gfx_unittests',
127     'gl_tests',
128     'gpu_tests',
129     'gpu_unittests',
130     'installer_util_unittests',
131     'interactive_ui_tests',
132     'ipc_tests',
133     'jingle_unittests',
134     'layout-tests',
135     'media_unittests',
136     'mini_installer_test',
137     'net_unittests',
138     'printing_unittests',
139     'remoting_unittests',
140     'safe_browsing_tests',
141     'sql_unittests',
142     'sync_unit_tests',
143     'sync_integration_tests',
144     'test_shell_tests',
145     'ui_tests',
146     'unit_tests',
147     'views_unittests',
148     'webkit_unit_tests',
149 ];
150
151 var RELOAD_REQUIRING_PARAMETERS = ['showAllRuns', 'group', 'testType'];
152
153 // Enum for indexing into the run-length encoded results in the JSON files.
154 // 0 is where the count is length is stored. 1 is the value.
155 var RLE = {
156     LENGTH: 0,
157     VALUE: 1
158 }
159
160 function isFailingResult(value)
161 {
162     return 'FSTOCIZ'.indexOf(value) != -1;
163 }
164
165 // Takes a key and a value and sets the g_currentState[key] = value iff key is
166 // a valid hash parameter and the value is a valid value for that key. Handles
167 // cross-dashboard parameters then falls back to calling
168 // handleValidHashParameter for dashboard-specific parameters.
169 //
170 // @return {boolean} Whether the key what inserted into the g_currentState.
171 function handleValidHashParameterWrapper(key, value)
172 {
173     switch(key) {
174     case 'testType':
175         validateParameter(g_crossDashboardState, key, value,
176             function() { return TEST_TYPES.indexOf(value) != -1; });
177         return true;
178
179     case 'group':
180         validateParameter(g_crossDashboardState, key, value,
181             function() {
182               return value in LAYOUT_TESTS_BUILDER_GROUPS ||
183                   value in CHROMIUM_GPU_TESTS_BUILDER_GROUPS ||
184                   value in CHROMIUM_GTESTS_BUILDER_GROUPS;
185             });
186         return true;
187
188     // FIXME: This should probably be stored on g_crossDashboardState like everything else in this function.
189     case 'builder':
190         validateParameter(g_currentState, key, value,
191             function() { return value in currentBuilders(); });
192         return true;
193
194     case 'useTestData':
195     case 'showAllRuns':
196         g_crossDashboardState[key] = value == 'true';
197         return true;
198
199     default:
200         return handleValidHashParameter(key, value);
201     }
202 }
203
204 var g_defaultCrossDashboardStateValues = {
205     group: '@ToT - chromium.org',
206     showAllRuns: false,
207     testType: 'layout-tests',
208     useTestData: false,
209 }
210
211 // Generic utility functions.
212 function $(id)
213 {
214     return document.getElementById(id);
215 }
216
217 function stringContains(a, b)
218 {
219     return a.indexOf(b) != -1;
220 }
221
222 function caseInsensitiveContains(a, b)
223 {
224     return a.match(new RegExp(b, 'i'));
225 }
226
227 function startsWith(a, b)
228 {
229     return a.indexOf(b) == 0;
230 }
231
232 function endsWith(a, b)
233 {
234     return a.lastIndexOf(b) == a.length - b.length;
235 }
236
237 function isValidName(str)
238 {
239     return str.match(/[A-Za-z0-9\-\_,]/);
240 }
241
242 function trimString(str)
243 {
244     return str.replace(/^\s+|\s+$/g, '');
245 }
246
247 function collapseWhitespace(str)
248 {
249     return str.replace(/\s+/g, ' ');
250 }
251
252 function validateParameter(state, key, value, validateFn)
253 {
254     if (validateFn())
255         state[key] = value;
256     else
257         console.log(key + ' value is not valid: ' + value);
258 }
259
260 function queryHashAsMap()
261 {
262     var hash = window.location.hash;
263     var paramsList = hash ? hash.substring(1).split('&') : [];
264     var paramsMap = {};
265     var invalidKeys = [];
266     for (var i = 0; i < paramsList.length; i++) {
267         var thisParam = paramsList[i].split('=');
268         if (thisParam.length != 2) {
269             console.log('Invalid query parameter: ' + paramsList[i]);
270             continue;
271         }
272
273         paramsMap[thisParam[0]] = decodeURIComponent(thisParam[1]);
274     }
275
276     // FIXME: remove support for mapping from the master parameter to the group
277     // one once the waterfall starts to pass in the builder name instead.
278     if (paramsMap.master) {
279         paramsMap.group = LEGACY_BUILDER_MASTERS_TO_GROUPS[paramsMap.master];
280         if (!paramsMap.group)
281             console.log('ERROR: Unknown master name: ' + paramsMap.master);
282         window.location.hash = window.location.hash.replace('master=' + paramsMap.master, 'group=' + paramsMap.group);
283         delete paramsMap.master;
284     }
285
286     return paramsMap;
287 }
288
289 function parseParameter(parameters, key)
290 {
291     if (!(key in parameters))
292         return;
293     var value = parameters[key];
294     if (!handleValidHashParameterWrapper(key, value))
295         console.log("Invalid query parameter: " + key + '=' + value);
296 }
297
298 function parseCrossDashboardParameters()
299 {
300     g_crossDashboardState = {};
301     var parameters = queryHashAsMap();
302     for (parameterName in g_defaultCrossDashboardStateValues)
303         parseParameter(parameters, parameterName);
304
305     fillMissingValues(g_crossDashboardState, g_defaultCrossDashboardStateValues);
306     if (currentBuilderGroup() === undefined)
307         g_crossDashboardState.group = g_defaultCrossDashboardStateValues.group;
308 }
309
310 function parseDashboardSpecificParameters()
311 {
312     g_currentState = {};
313     var parameters = queryHashAsMap();
314     for (parameterName in g_defaultDashboardSpecificStateValues)
315         parseParameter(parameters, parameterName);
316 }
317
318 function parseParameters()
319 {
320     var oldCrossDashboardState = g_crossDashboardState;
321     var oldDashboardSpecificState = g_currentState;
322
323     parseCrossDashboardParameters();
324     parseDashboardSpecificParameters();
325     parseParameter(queryHashAsMap(), 'builder');
326
327     var crossDashboardDiffState = diffStates(oldCrossDashboardState, g_crossDashboardState);
328     var dashboardSpecificDiffState = diffStates(oldDashboardSpecificState, g_currentState);
329
330     fillMissingValues(g_currentState, g_defaultDashboardSpecificStateValues);
331
332     if (!g_crossDashboardState.useTestData)
333         fillMissingValues(g_currentState, {'builder': currentBuilderGroup().defaultBuilder()});
334
335     // FIXME: dashboard_base shouldn't know anything about specific dashboard specific keys.
336     if (dashboardSpecificDiffState.builder)
337         delete g_currentState.tests;
338     if (g_currentState.tests)
339         delete g_currentState.builder;
340
341     // Some parameters require loading different JSON files when the value changes. Do a reload.
342     if (Object.keys(oldCrossDashboardState).length) {
343         for (var key in g_crossDashboardState) {
344             if (oldCrossDashboardState[key] != g_crossDashboardState[key] && RELOAD_REQUIRING_PARAMETERS.indexOf(key) != -1)
345                 window.location.reload();
346         }
347     }
348
349     return dashboardSpecificDiffState;
350 }
351
352 function diffStates(oldState, newState)
353 {
354     // If there is no old state, everything in the current state is new.
355     if (!oldState)
356         return newState;
357
358     var changedParams = {};
359     for (curKey in newState) {
360         var oldVal = oldState[curKey];
361         var newVal = newState[curKey];
362         // Add new keys or changed values.
363         if (!oldVal || oldVal != newVal)
364             changedParams[curKey] = newVal;
365     }
366     return changedParams;
367 }
368
369 function defaultValue(key)
370 {
371     if (key in g_defaultDashboardSpecificStateValues)
372         return g_defaultDashboardSpecificStateValues[key];
373     return g_defaultCrossDashboardStateValues[key];
374 }
375
376 function fillMissingValues(to, from)
377 {
378     for (var state in from) {
379         if (!(state in to))
380             to[state] = from[state];
381     }
382 }
383
384 // FIXME: Rename this to g_dashboardSpecificState;
385 var g_currentState = {};
386 var g_crossDashboardState = {};
387 parseCrossDashboardParameters();
388
389 function isLayoutTestResults()
390 {
391     return g_crossDashboardState.testType == 'layout-tests';
392 }
393
394 function isGPUTestResults()
395 {
396     return g_crossDashboardState.testType == 'gpu_tests';
397 }
398
399 function currentBuilderGroupCategory()
400 {
401     switch (g_crossDashboardState.testType) {
402     case 'gl_tests':
403     case 'gpu_tests':
404         return CHROMIUM_GPU_TESTS_BUILDER_GROUPS;
405     case 'layout-tests':
406         return LAYOUT_TESTS_BUILDER_GROUPS;
407     case 'test_shell_tests':
408     case 'webkit_unit_tests':
409         return TEST_SHELL_TESTS_BUILDER_GROUPS;
410     default:
411         return CHROMIUM_GTESTS_BUILDER_GROUPS;
412     }
413 }
414
415 function currentBuilderGroup()
416 {
417     return currentBuilderGroupCategory()[g_crossDashboardState.group]
418 }
419
420 function currentBuilders()
421 {
422     return currentBuilderGroup().builders;
423 }
424
425 function isTipOfTreeWebKitBuilder()
426 {
427     return currentBuilderGroup().isToTWebKit;
428 }
429
430 var g_resultsByBuilder = {};
431 var g_expectationsByPlatform = {};
432 var g_staleBuilders = [];
433 var g_buildersThatFailedToLoad = [];
434
435 // TODO(aboxhall): figure out whether this is a performance bottleneck and
436 // change calling code to understand the trie structure instead if necessary.
437 function flattenTrie(trie, prefix)
438 {
439     var result = {};
440     for (var name in trie) {
441         var fullName = prefix ? prefix + "/" + name : name;
442         var data = trie[name];
443         if ("results" in data)
444             result[fullName] = data;
445         else {
446             var partialResult = flattenTrie(data, fullName);
447             for (var key in partialResult) {
448                 result[key] = partialResult[key];
449             }
450         }
451     }
452     return result;
453 }
454
455 function isTreeMap()
456 {
457     return endsWith(window.location.pathname, 'treemap.html');
458 }
459
460 function isFlakinessDashboard()
461 {
462     return endsWith(window.location.pathname, 'flakiness_dashboard.html');
463 }
464
465 // String of error messages to display to the user.
466 var g_errorMessages = '';
467
468 // Record a new error message.
469 // @param {string} errorMsg The message to show to the user.
470 function addError(errorMsg)
471 {
472     g_errorMessages += errorMsg + '<br>';
473 }
474
475 // Clear out error and warning messages.
476 function clearErrors()
477 {
478     g_errorMessages = '';
479 }
480
481 // If there are errors, show big and red UI for errors so as to be noticed.
482 function showErrors()
483 {
484     var errors = $('errors');
485
486     if (!g_errorMessages) {
487         if (errors)
488             errors.parentNode.removeChild(errors);
489         return;
490     }
491
492     if (!errors) {
493         errors = document.createElement('H2');
494         errors.style.color = 'red';
495         errors.id = 'errors';
496         document.body.appendChild(errors);
497     }
498
499     errors.innerHTML = g_errorMessages;
500 }
501
502 function addBuilderLoadErrors()
503 {
504     if (g_buildersThatFailedToLoad.length)
505         addError('ERROR: Failed to get data from ' + g_buildersThatFailedToLoad.toString() + '.');
506
507     if (g_staleBuilders.length)
508         addError('ERROR: Data from ' + g_staleBuilders.toString() + ' is more than 1 day stale.');
509 }
510
511 function resourceLoadingComplete()
512 {
513     g_resourceLoader = null;
514     addBuilderLoadErrors();
515     handleLocationChange();
516 }
517
518 function handleLocationChange()
519 {
520     if (g_resourceLoader)
521         return;
522
523     var params = parseParameters();
524     var shouldGeneratePage = true;
525     if (Object.keys(params).length)
526         shouldGeneratePage = handleQueryParameterChange(params);
527
528     var newHash = permaLinkURLHash();
529     var winHash = window.location.hash || "#";
530     // Make sure the location is the same as the state we are using internally.
531     // These get out of sync if processQueryParamChange changed state.
532     if (newHash != winHash) {
533         // This will cause another hashchange, and when we loop
534         // back through here next time, we'll go through generatePage.
535         window.location.hash = newHash;
536     } else if (shouldGeneratePage)
537         generatePage();
538 }
539
540 window.onhashchange = handleLocationChange;
541
542 function combinedDashboardState()
543 {
544     var combinedState = Object.create(g_currentState);
545     for (var key in g_crossDashboardState)
546         combinedState[key] = g_crossDashboardState[key];
547     return combinedState;    
548 }
549
550 // Sets the page state. Takes varargs of key, value pairs.
551 function setQueryParameter(var_args)
552 {
553     var state = combinedDashboardState();
554     for (var i = 0; i < arguments.length; i += 2) {
555         var key = arguments[i];
556         state[key] = arguments[i + 1];
557     }
558     // Note: We use window.location.hash rather that window.location.replace
559     // because of bugs in Chrome where extra entries were getting created
560     // when back button was pressed and full page navigation was occuring.
561     // FIXME: file those bugs.
562     window.location.hash = permaLinkURLHash(state);
563 }
564
565 function permaLinkURLHash(opt_state)
566 {
567     var state = opt_state || combinedDashboardState();
568     return '#' + joinParameters(state);
569 }
570
571 function joinParameters(stateObject)
572 {
573     var state = [];
574     for (var key in stateObject) {
575         var value = stateObject[key];
576         if (value != defaultValue(key))
577             state.push(key + '=' + encodeURIComponent(value));
578     }
579     return state.join('&');
580 }
581
582 function logTime(msg, startTime)
583 {
584     console.log(msg + ': ' + (Date.now() - startTime));
585 }
586
587 function hidePopup()
588 {
589     var popup = $('popup');
590     if (popup)
591         popup.parentNode.removeChild(popup);
592 }
593
594 function showPopup(target, html)
595 {
596     var popup = $('popup');
597     if (!popup) {
598         popup = document.createElement('div');
599         popup.id = 'popup';
600         document.body.appendChild(popup);
601     }
602
603     // Set html first so that we can get accurate size metrics on the popup.
604     popup.innerHTML = html;
605
606     var targetRect = target.getBoundingClientRect();
607
608     var x = Math.min(targetRect.left - 10, document.documentElement.clientWidth - popup.offsetWidth);
609     x = Math.max(0, x);
610     popup.style.left = x + document.body.scrollLeft + 'px';
611
612     var y = targetRect.top + targetRect.height;
613     if (y + popup.offsetHeight > document.documentElement.clientHeight)
614         y = targetRect.top - popup.offsetHeight;
615     y = Math.max(0, y);
616     popup.style.top = y + document.body.scrollTop + 'px';
617 }
618
619 // Create a new function with some of its arguements
620 // pre-filled.
621 // Taken from goog.partial in the Closure library.
622 // @param {Function} fn A function to partially apply.
623 // @param {...*} var_args Additional arguments that are partially
624 //         applied to fn.
625 // @return {!Function} A partially-applied form of the function bind() was
626 //         invoked as a method of.
627 function partial(fn, var_args)
628 {
629     var args = Array.prototype.slice.call(arguments, 1);
630     return function() {
631         // Prepend the bound arguments to the current arguments.
632         var newArgs = Array.prototype.slice.call(arguments);
633         newArgs.unshift.apply(newArgs, args);
634         return fn.apply(this, newArgs);
635     };
636 };
637
638 // Returns the appropriate expectatiosn map for the current testType.
639 function expectationsMap()
640 {
641     return isLayoutTestResults() ? LAYOUT_TEST_EXPECTATIONS_MAP_ : GTEST_EXPECTATIONS_MAP_;
642 }
643
644 function toggleQueryParameter(param)
645 {
646     setQueryParameter(param, !queryParameterValue(param));
647 }
648
649 function queryParameterValue(parameter)
650 {
651     return g_currentState[parameter] || g_crossDashboardState[parameter];
652 }
653
654 function checkboxHTML(queryParameter, label, isChecked, opt_extraJavaScript)
655 {
656     var js = opt_extraJavaScript || '';
657     return '<label style="padding-left: 2em">' +
658         '<input type="checkbox" onchange="toggleQueryParameter(\'' + queryParameter + '\');' + js + '" ' +
659             (isChecked ? 'checked' : '') + '>' + label +
660         '</label> ';
661 }
662
663 function selectHTML(label, queryParameter, options)
664 {
665     var html = '<label style="padding-left: 2em">' + label + ': ' +
666         '<select onchange="setQueryParameter(\'' + queryParameter + '\', this[this.selectedIndex].value)">';
667
668     for (var i = 0; i < options.length; i++) {
669         var value = options[i];
670         html += '<option value="' + value + '" ' +
671             (queryParameterValue(queryParameter) == value ? 'selected' : '') +
672             '>' + value + '</option>'
673     }
674     html += '</select></label> ';
675     return html;
676 }
677
678 // Returns the HTML for the select element to switch to different testTypes.
679 function htmlForTestTypeSwitcher(opt_noBuilderMenu, opt_extraHtml, opt_includeNoneBuilder)
680 {
681     var html = '<div style="border-bottom:1px dashed">';
682     html += '' +
683         htmlForDashboardLink('Stats', 'aggregate_results.html') +
684         htmlForDashboardLink('Timeline', 'timeline_explorer.html') +
685         htmlForDashboardLink('Results', 'flakiness_dashboard.html') +
686         htmlForDashboardLink('Treemap', 'treemap.html');
687
688     html += selectHTML('Test type', 'testType', TEST_TYPES);
689
690     if (!opt_noBuilderMenu) {
691         var buildersForMenu = Object.keys(currentBuilders());
692         if (opt_includeNoneBuilder)
693             buildersForMenu.unshift('--------------');
694         html += selectHTML('Builder', 'builder', buildersForMenu);
695     }
696
697     html += selectHTML('Group', 'group',
698         Object.keys(currentBuilderGroupCategory()));
699
700     if (!isTreeMap())
701         html += checkboxHTML('showAllRuns', 'Show all runs', g_crossDashboardState.showAllRuns);
702
703     if (opt_extraHtml)
704         html += opt_extraHtml;
705     return html + '</div>';
706 }
707
708 function selectBuilder(builder)
709 {
710     setQueryParameter('builder', builder);
711 }
712
713 function loadDashboard(fileName)
714 {
715     var pathName = window.location.pathname;
716     pathName = pathName.substring(0, pathName.lastIndexOf('/') + 1);
717     window.location = pathName + fileName + window.location.hash;
718 }
719
720 function htmlForTopLink(html, onClick, isSelected)
721 {
722     var cssText = isSelected ? 'font-weight: bold;' : 'color:blue;text-decoration:underline;cursor:pointer;';
723     cssText += 'margin: 0 5px;';
724     return '<span style="' + cssText + '" onclick="' + onClick + '">' + html + '</span>';
725 }
726
727 function htmlForDashboardLink(html, fileName)
728 {
729     var pathName = window.location.pathname;
730     var currentFileName = pathName.substring(pathName.lastIndexOf('/') + 1);
731     var isSelected = currentFileName == fileName;
732     var onClick = 'loadDashboard(\'' + fileName + '\')';
733     return htmlForTopLink(html, onClick, isSelected);
734 }
735
736 function revisionLink(results, index, key, singleUrlTemplate, rangeUrlTemplate)
737 {
738     var currentRevision = parseInt(results[key][index], 10);
739     var previousRevision = parseInt(results[key][index + 1], 10);
740
741     function singleUrl()
742     {
743         return singleUrlTemplate.replace('<rev>', currentRevision);
744     }
745
746     function rangeUrl()
747     {
748         return rangeUrlTemplate.replace('<rev1>', currentRevision).replace('<rev2>', previousRevision + 1);
749     }
750
751     if (currentRevision == previousRevision)
752         return 'At <a href="' + singleUrl() + '">r' + currentRevision    + '</a>';
753     else if (currentRevision - previousRevision == 1)
754         return '<a href="' + singleUrl() + '">r' + currentRevision    + '</a>';
755     else
756         return '<a href="' + rangeUrl() + '">r' + (previousRevision + 1) + ' to r' + currentRevision + '</a>';
757 }
758
759 function chromiumRevisionLink(results, index)
760 {
761     return revisionLink(
762         results,
763         index,
764         CHROME_REVISIONS_KEY,
765         'http://src.chromium.org/viewvc/chrome?view=rev&revision=<rev>',
766         'http://build.chromium.org/f/chromium/perf/dashboard/ui/changelog.html?url=/trunk/src&range=<rev2>:<rev1>&mode=html');
767 }
768
769 function webKitRevisionLink(results, index)
770 {
771     return revisionLink(
772         results,
773         index,
774         WEBKIT_REVISIONS_KEY,
775         'http://trac.webkit.org/changeset/<rev>',
776         'http://trac.webkit.org/log/trunk/?rev=<rev1>&stop_rev=<rev2>&limit=100&verbose=on');
777 }
778
779 // "Decompresses" the RLE-encoding of test results so that we can query it
780 // by build index and test name.
781 //
782 // @param {Object} results results for the current builder
783 // @return Object with these properties:
784 //     - testNames: array mapping test index to test names.
785 //     - resultsByBuild: array of builds, for each build a (sparse) array of test results by test index.
786 //     - flakyTests: array with the boolean value true at test indices that are considered flaky (more than one single-build failure).
787 //     - flakyDeltasByBuild: array of builds, for each build a count of flaky test results by expectation, as well as a total.
788 function decompressResults(builderResults)
789 {
790     var builderTestResults = builderResults[TESTS_KEY];
791     var buildCount = builderResults[FIXABLE_COUNTS_KEY].length;
792     var resultsByBuild = new Array(buildCount);
793     var flakyDeltasByBuild = new Array(buildCount);
794
795     // Pre-sizing the test result arrays for each build saves us ~250ms
796     var testCount = 0;
797     for (var testName in builderTestResults)
798         testCount++;
799     for (var i = 0; i < buildCount; i++) {
800         resultsByBuild[i] = new Array(testCount);
801         resultsByBuild[i][testCount - 1] = undefined;
802         flakyDeltasByBuild[i] = {};
803     }
804
805     // Using indices instead of the full test names for each build saves us
806     // ~1500ms
807     var testIndex = 0;
808     var testNames = new Array(testCount);
809     var flakyTests = new Array(testCount);
810
811     // Decompress and "invert" test results (by build instead of by test) and
812     // determine which are flaky.
813     for (var testName in builderTestResults) {
814         var oneBuildFailureCount = 0;
815
816         testNames[testIndex] = testName;
817         var testResults = builderTestResults[testName].results;
818         for (var i = 0, rleResult, currentBuildIndex = 0; (rleResult = testResults[i]) && currentBuildIndex < buildCount; i++) {
819             var count = rleResult[RLE.LENGTH];
820             var value = rleResult[RLE.VALUE];
821
822             if (count == 1 && value in FAILURE_EXPECTATIONS_)
823                 oneBuildFailureCount++;
824
825             for (var j = 0; j < count; j++) {
826                 resultsByBuild[currentBuildIndex++][testIndex] = value;
827                 if (currentBuildIndex == buildCount)
828                     break;
829             }
830         }
831
832         if (oneBuildFailureCount > 2)
833             flakyTests[testIndex] = true;
834
835         testIndex++;
836     }
837
838     // Now that we know which tests are flaky, count the test results that are
839     // from flaky tests for each build.
840     testIndex = 0;
841     for (var testName in builderTestResults) {
842         if (!flakyTests[testIndex++])
843             continue;
844
845         var testResults = builderTestResults[testName].results;
846         for (var i = 0, rleResult, currentBuildIndex = 0; (rleResult = testResults[i]) && currentBuildIndex < buildCount; i++) {
847             var count = rleResult[RLE.LENGTH];
848             var value = rleResult[RLE.VALUE];
849
850             for (var j = 0; j < count; j++) {
851                 var buildTestResults = flakyDeltasByBuild[currentBuildIndex++];
852                 function addFlakyDelta(key)
853                 {
854                     if (!(key in buildTestResults))
855                         buildTestResults[key] = 0;
856                     buildTestResults[key]++;
857                 }
858                 addFlakyDelta(value);
859                 if (value != 'P' && value != 'N')
860                     addFlakyDelta('total');
861                 if (currentBuildIndex == buildCount)
862                     break;
863             }
864         }
865     }
866
867     return {
868         testNames: testNames,
869         resultsByBuild: resultsByBuild,
870         flakyTests: flakyTests,
871         flakyDeltasByBuild: flakyDeltasByBuild
872     };
873 }
874
875 document.addEventListener('mousedown', function(e) {
876     // Clear the open popup, unless the click was inside the popup.
877     var popup = $('popup');
878     if (popup && e.target != popup && !(popup.compareDocumentPosition(e.target) & 16))
879         hidePopup();
880 }, false);
881
882 window.addEventListener('load', function() {
883     // This doesn't seem totally accurate as there is a race between
884     // onload firing and the last script tag being executed.
885     logTime('Time to load JS', g_pageLoadStartTime);
886     g_resourceLoader = new loader.Loader();
887     g_resourceLoader.load();
888 }, false);