New flakiness dashboard should align results by revision numbers
[WebKit-https.git] / Websites / test-results / index.html
1 <!DOCTYPE html>
2 <html>
3 <head>
4 <title>WebKit Test Results</title>
5 <link rel="stylesheet" href="common.css">
6 <link rel="stylesheet" href="main.css">
7 <script src="js/autocompleter.js"></script>
8 <script src="js/build.js"></script>
9 <script src="js/dom.js"></script>
10 </head>
11 <body>
12
13 <header id="title">
14 <h1><a href="/">WebKit Test Results</a></h1>
15 <ul>
16     <li><a href="http://build.webkit.org/waterfall">Waterfall</a></li>
17 </ul>
18 </header>
19
20 <form onsubmit="TestResultsView.fetchTest(this['testName'].value);
21     TestResultsView.updateLocationHash();
22     return false;">
23 <input id="testName" type="text" size="150" onpaste="pasteHelper(this, event)"
24     placeholder="Type in a test name, or copy and paste test names on results.html or NRWT stdout (including junks)"></form>
25 <div id="testView"></div>
26
27 <form>
28 Show
29 <label>tests <select id="builderFailureTypeView"><option>failing</option><option>flaky</option><option value="wrongexpectations">has wrong expectations</option></select></label>
30 <label>on <select id="builderListView"><option value="">Select builder</option></select></label>
31 <label for="builderDaysView">in the last <select id="builderDaysView"><option>5</option><option>15</option><option>30</option></select> days</label>
32 </form>
33 <div id="builderFailingTestsView"></div>
34
35 <div id="tooltipContainer"></div>
36
37 <script>
38
39 var TestResultsView = new (function () {
40     var tooltipContainer = document.getElementById('tooltipContainer');
41
42     tooltipContainer.onclick = function (event) { event.insideTooltip = true; }
43     document.addEventListener('click', function (event) {
44         if (!event.insideTooltip)
45             tooltipContainer.style.display = 'none';
46     });
47
48     window.addEventListener('hashchange', function (event) {
49         TestResultsView.locationHashChanged();
50     });
51
52     this._tooltipContainer = tooltipContainer;
53     this._tests = [];
54     this._currentBuilder = null;
55     this._currentBuilderFailureType = null;
56     this._currentBuilderDays = null;
57     this._oldHash = null;
58     this._builders = [];
59     this._slaves = [];
60     this._repositories = [];
61 });
62
63 TestResultsView.setAvailableTests = function (availableTests) {
64     this._availableTests = availableTests;
65 }
66
67 TestResultsView.setBuilders = function (builders) {
68     this._builders = builders;
69 }
70
71 TestResultsView.setSlaves = function (slaves) {
72     this._slaves = slaves;
73 }
74
75 TestResultsView.setRepositories = function (repositories) {
76     this._repositories = repositories;
77 }
78
79 TestResultsView.showTooltip = function (anchor, contentElement) {
80     var tooltipContainer = this._tooltipContainer;
81     tooltipContainer.style.display = null;
82
83     while (tooltipContainer.firstChild)
84         tooltipContainer.removeChild(tooltipContainer.firstChild);
85     tooltipContainer.appendChild(contentElement);
86
87     var position = {x: 0, y: 0};
88     var currentNode = anchor;
89     while (currentNode) {
90         position.x += currentNode.offsetLeft;
91         position.y += currentNode.offsetTop;
92         currentNode = currentNode.offsetParent;
93     }
94     tooltipContainer.style.left = (position.x - contentElement.offsetWidth / 2 + anchor.offsetWidth / 2) + 'px';
95     tooltipContainer.style.top = (position.y - contentElement.clientHeight - 5) + 'px';
96 }
97
98 TestResultsView._urlFromBuilder = function (urlType, master, builder, revision, build) {
99     // FIXME: We should probably make this configurable or fetch from buildbot configuraration.
100     return {
101         "build": "http://$master/builders/$builder/builds/$build",
102         "result": "http://$master/results/$builder/r$revision%20($build)/results.html"
103     }[urlType].replace(/\$master/g, master).replace(/\$builder/g, builder)
104         .replace(/\$revision/g, revision).replace(/\$build/g, build);
105 }
106
107 TestResultsView._createResultCell = function (master, builder, result, previousResult) {
108     var buildTime = result['buildTime'];
109     var revision = result['revision'];
110     var slave = result['slave'];
111     var build = result['buildNumber'];
112     var actual = result['actual'];
113     var expected = result['expected'];
114     var timeIfSlow = result.isSlow ? result.roundedTime : '';
115     var anchor = element('a', {'href': this._urlFromBuilder('result', master, builder, revision, build)}, [timeIfSlow]);
116     anchor.onmouseenter = function () {
117         var repositoryById = TestResultsView._repositories;
118         var formattedRevisions = result.build.formattedRevisions(previousResult ? previousResult.build : null);
119         var revisionDescription = '';
120         for (var repositoryName in formattedRevisions) {
121             var revision = formattedRevisions[repositoryName];
122             if (revisionDescription)
123                 revisionDescription += ', ';
124             if (revision.url)
125                 revisionDescription += repositoryName + ': ' + revision.url;
126             else
127                 revisionDescription += revision.label;
128         }
129
130         TestResultsView.showTooltip(anchor, element('div', {'class': 'tooltip'}, [
131             element('ul', [
132                 element('li', ['Build Time: ' + result.build.formattedBuildTime()]),
133                 element('li', ['Revision: ' +  revisionDescription]),
134                 element('li', ['Build: ', element('a', {'href': TestResultsView._urlFromBuilder('build', master, builder, revision, build)}, [build])]),
135                 element('li', ['Actual: ' + actual]),
136                 element('li', ['Expected: ' + expected]),
137             ])
138         ]));
139     }
140     var cell = element('span', {
141         'class': actual + ' resultCell',
142     }, [anchor]);
143     return cell;
144 }
145
146 TestResultsView._populateTestPane = function(testName, results, section) {
147     var table = element('table', {'class': 'resultsTable'}, [element('caption', [testName])]);
148     table.appendChild(this._createTestResultHeader('Builder'));
149
150     var resultsByBuilder = results['builders'];
151     var buildTimes = new Array();
152     for (var builderId in resultsByBuilder) {
153         var results = resultsByBuilder[builderId];
154         this._createBuildsAndComputeSlownessOfResults(results);
155         for (var i = 0; i < results.length; i++) {
156             var time = results[i].build.time();
157             if (buildTimes.indexOf(time) < 0)
158                 buildTimes.push(time);
159         }
160     }
161     buildTimes.sort(function (a, b) { return b - a; });
162
163     for (var builderId in resultsByBuilder) {
164         var builder = this._builders[builderId];
165         // FIXME: Add a master name if there is more than one.
166         table.appendChild(this._createTestResultRow(builder.name, resultsByBuilder[builderId], builder, buildTimes));
167     }
168     section.appendChild(table);
169 }
170
171 TestResultsView._createTestResultHeader = function (labelForFirstColumn) {
172     return element('thead', [element('tr', [
173         element('th', [labelForFirstColumn]),
174         element('th', ['Bug']),
175         element('th', ['Expectations']),
176         element('th', ['Slowest'])])]);
177 }
178
179 TestResultsView._createBuildsAndComputeSlownessOfResults = function (results) {
180     for (var i = 0; i < results.length; i++) {
181         var result = results[i];
182         result.build = new TestBuild(this._repositories, this._builders, result);
183         result.roundedTime = result.time > 10000 ? Math.round(result.time / 1000) : Math.round(result.time / 100) / 10;
184         result.isSlow = result.time > 1000;
185     }
186 }
187
188 TestResultsView._createTestResultRow = function (title, results, builder, buildTimes) {
189     var sortedResults = results.sort(function (result1, result2) { return result2.build.time() - result1.build.time(); });
190     var cells = new Array();
191
192     function addEmptyCell() { cells.push(element('span', {'class': 'resultCell'}, [element('a')])); }
193
194     var buildTimeIndex = 0;
195     for (var i = 0; i < sortedResults.length; i++) {
196         var result = sortedResults[i];
197         if (buildTimes) {
198             while (buildTimes[buildTimeIndex] > result.build.time()) {
199                 addEmptyCell();
200                 buildTimeIndex++;
201             }
202             if (buildTimes[buildTimeIndex] == result.build.time())
203                 buildTimeIndex++;
204         }
205         cells.push(this._createResultCell(builder.master, builder.name, result, sortedResults[i - 1]));
206     }
207     if (buildTimes) {
208         while (buildTimeIndex < buildTimes.length) {
209             addEmptyCell();
210             buildTimeIndex++;
211         }
212     }
213
214     var formattedModifiers = sortedResults[0].modifiers.split(' ').map(function (modifier) {
215         if (modifier.indexOf('/') > 0)
216             return element('a', {'href': (modifier.indexOf('http') == 0 ? '' : 'http://') + modifier}, [modifier]);
217         return modifier;
218     });
219
220     var slowestTime = Math.max.apply(Math, results.map(function (result) { return result.roundedTime; }));
221     if (slowestTime >= 1)
222         slowestTime += 's';
223     else
224         slowestTime = '';
225
226     return element('tr', [
227         element('th', ['' + title]),
228         element('td', {'class': 'modifiers'}, formattedModifiers),
229         element('td', {'class': 'expected'}, [sortedResults[0].expected]),
230         element('td', {'class': 'slowestTime'}, [slowestTime]),
231         element('td', cells)]);
232 }
233
234 TestResultsView.fetchTest = function (testName) {
235     if (this._tests.indexOf(testName) >= 0)
236         return;
237
238     var self = this;
239
240     var closeButton = element('div', {'class': 'closeButton'});
241     closeButton.innerHTML = '<svg viewBox="0 0 100 100"><g stroke-width="10">'
242         + '<circle cx="50" cy="50" r="45" fill="transparent"></circle><polygon points="30,30 70,70"></polygon>'
243         + '<polygon points="30,70 70,30"></polygon></g></svg>';
244     closeButton.addEventListener('click', function (event) {
245             self._removeTest(testName);
246             section.parentNode.removeChild(section);
247             event.preventDefault();
248         });
249     var section = element('section', {'id': testName, 'class': 'testResults'}, [closeButton, 'Loading...']);
250
251     document.getElementById('testView').appendChild(section);
252
253     var xhr = new XMLHttpRequest();
254     xhr.open("GET", 'api/results.php?test=' + testName, true);  
255     xhr.onload = function(event) {
256         var response = JSON.parse(xhr.response);
257         section.removeChild(section.lastChild); // Remove Loading...
258         if (response['status'] != 'OK') {
259             section.appendChild(text('Failed to load results for ' + testName + ': ' + response['status']));
260             return;
261         }
262         self._populateTestPane(testName, response, section);
263     }
264     xhr.send();
265     this._tests.push(testName);
266 }
267
268 TestResultsView._removeTest = function (testName) {
269     var index = this._tests.indexOf(testName);
270     if (index < 0)
271         return;
272     this._tests.splice(index, 1);
273     this.updateLocationHash();
274 }
275
276 TestResultsView.fetchTests = function (testNames, doNotUpdateHash) {
277     for (var i = 0; i < testNames.length; i++)
278         this.fetchTest(testNames[i], doNotUpdateHash);
279     if (!doNotUpdateHash)
280         this.updateLocationHash();
281 }
282
283 TestResultsView._matchesFailureType = function (results, failureType, tn) {
284     if (!results.length)
285         return false;
286     var latestActualResult = results[0].actual;
287     var latestExpectedResult = results[0].expected;
288     switch (failureType) {
289     case 'failing':
290         return results[0].actual != 'PASS';
291     case 'flaky':
292         var offOneChangeCount = 0;
293         for (var i = 1; i + 1 < results.length; i++) {
294             var previousActual = results[i - 1].actual;
295             var nextActual = results[i + 1].actual;
296             if (previousActual == nextActual && results[i].actual != previousActual)
297                 offOneChangeCount++;
298         }
299         return offOneChangeCount; // Heuristics.
300     case 'wrongexpectations':
301         if (latestExpectedResult == latestActualResult)
302             return false;
303         var expectedTokens = latestExpectedResult.split(' ');
304         if (expectedTokens.indexOf(latestActualResult) >= 0)
305             return false;
306         if (latestActualResult == 'TEXT' || latestActualResult == 'TEXT+IMAGE' && expectedTokens.indexOf('FAIL') >= 0)
307             return false;
308         return true;
309     }
310
311     return false;
312 }
313
314 TestResultsView._populateBuilderPane = function(builderName, failureType, results, section) {
315     var table = element('table', {'class': 'resultsTable'}, [element('caption', [builderName])]);
316     var resultsByBuilder = results['builders'];
317     var resultsByTests;
318     var builderId;
319     for (var currentBuilderId in resultsByBuilder) {
320         builderId = currentBuilderId;
321         resultsByTests = resultsByBuilder[builderId];
322     }
323     if (!resultsByTests)
324         return;
325
326     table.appendChild(this._createTestResultHeader('Test'));
327
328     var builder = this._builders[builderId];
329     for (var testId in resultsByTests) {
330         var results = resultsByTests[testId];
331         if (!results.length || !this._matchesFailureType(results, failureType, this._availableTests[testId].name))
332             continue;
333         this._createBuildsAndComputeSlownessOfResults(resultsByTests[testId]);
334         table.appendChild(this._createTestResultRow(this._availableTests[testId].name, resultsByTests[testId], builder));
335     }
336     section.appendChild(table);
337 }
338
339 TestResultsView.fetchFailingTestsForBuilder = function (builderName, numberOfDays, failureType, doNotUpdateHash) {
340     var section = element('section', {'class': 'testResults'}, ['Loading...']);
341
342     var container = document.getElementById('builderFailingTestsView');
343     container.innerHTML = '';
344     container.appendChild(section);
345
346     var self = this;
347     var xhr = new XMLHttpRequest();
348     xhr.open("GET", 'api/failing-tests.php?builder=' + escape(builderName) + '&days=' + numberOfDays, true);  
349     xhr.onload = function(event) {
350         var response = JSON.parse(xhr.response);
351         section.innerHTML = '';
352         if (response['status'] != 'OK') {
353             section.appendChild(text('Failed to load results for ' + builderName + ': ' + response['status']));
354             return;
355         }
356         // FIXME: Normalize failureType.
357         self._currentBuilder = builderName;
358         self._currentBuilderFailureType = failureType;
359         self._currentBuilderDays = numberOfDays;
360         self._populateBuilderPane(builderName, failureType, response, section);
361         if (!doNotUpdateHash)
362             self.updateLocationHash();
363     }
364     xhr.send();
365 }
366
367 TestResultsView.updateLocationHash = function () {
368     var params = {
369         'tests': this._tests.join(','),
370         'builder': this._currentBuilder,
371         'builderFailureType': this._currentBuilderFailureType,
372         'builderDays': this._currentBuilderDays,
373     };
374     var hash = '';
375     for (var key in params) {
376         var value = params[key];
377         if (value === null || value === undefined)
378             continue;
379         hash += '&' + decodeURIComponent(key) + '=' + decodeURIComponent(value);
380     }
381     location.hash = hash;
382     this._oldHash = location.hash;
383 }
384
385 TestResultsView.locationHashChanged = function () {
386     var newHash = location.hash;
387     if (newHash == this._oldHash)
388         return;
389     this._oldHash = newHash;
390     this._tests = [];
391     this.loadTestsFromLocationHash();
392 }
393
394 TestResultsView.loadTestsFromLocationHash = function () {
395     var parsed = {};
396     location.hash.substr(1).split('&').forEach(function (component) {
397         var equalPosition = component.indexOf('=');
398         if (equalPosition < 0)
399             return;
400         var name = decodeURIComponent(component.substr(0, equalPosition));
401         parsed[name] = decodeURIComponent(component.substr(equalPosition + 1));
402     });
403     var doNotUpdateHash = true;
404     if (parsed['tests']) {
405         document.getElementById('testView').innerHTML = '';
406         this.fetchTests(parsed['tests'].split(','), doNotUpdateHash);
407     }
408     if (parsed['builder']) {
409         this.fetchFailingTestsForBuilder(
410             parsed['builder'],
411             parseInt(parsed['builderDays']) || 5,
412             parsed['builderFailureType'] || 'failing', doNotUpdateHash);
413     }
414     return parsed;
415 }
416
417 function fetchManifest(callback) {
418     var xhr = new XMLHttpRequest();
419     xhr.open("GET", 'api/manifest.php', true);  
420     xhr.onload = function(event) {
421         var response = JSON.parse(xhr.response);
422         if (response['status'] != 'OK') {
423             alert('Failed to load manifest:' + response['status']);
424             console.log(response);
425             return;
426         }
427         callback(response);
428     }  
429     xhr.send();
430 }
431
432 fetchManifest(function (response) {
433     var testNames = response['tests'].map(function (test) { return test['name']; })
434     var input = document.getElementById('testName');
435     input.autocompleter = new Autocompleter(input, testNames);
436
437     var builderListView = document.getElementById('builderListView');
438     for (var builderId in response['builders'])
439         builderListView.appendChild(element('option', [text(response['builders'][builderId].name)]));
440
441     var builderDaysView = document.getElementById('builderDaysView');
442     var builderFailureTypeView = document.getElementById('builderFailureTypeView');
443
444     function updateBuilderView() {
445         if (builderListView.value)
446             TestResultsView.fetchFailingTestsForBuilder(builderListView.value, builderDaysView.value, builderFailureTypeView.value);
447     }
448
449     builderListView.addEventListener('change', updateBuilderView);
450     builderDaysView.addEventListener('change', updateBuilderView);
451     builderFailureTypeView.addEventListener('change', updateBuilderView);
452
453     function mapById(items) {
454         var results = {};
455         for (var i = 0; i < items.length; i++)
456             results[items[i].id] = items[i];
457         return results;
458     }
459
460     TestResultsView.setAvailableTests(mapById(response['tests']));
461     TestResultsView.setBuilders(mapById(response['builders']));
462     TestResultsView.setSlaves(mapById(response['slaves']));
463     TestResultsView.setRepositories(mapById(response['repositories']));
464     // FIXME: Updating location.href shouldn't be TestResultsView's responsibility.
465     var parsedStates = TestResultsView.loadTestsFromLocationHash();
466     if (parsedStates['builder']) {
467         builderListView.value = parsedStates['builder'];
468         builderDaysView.value = parsedStates['builderDays'];
469         builderFailureTypeView.value = parsedStates['builderFailureType'];
470     }
471 });
472
473 function pasteHelper(input, event) {
474     function removeJunkFromNRWTStdout(input) {
475         return input.replace(/(\[[\w ]+\])|(.+\:.+)/g, '').replace(/^[ \t]+|[ \t]+$/gm, '');
476     }
477
478     function removeJunkFromResultsPage(input) {
479         return input.replace(/(^[^\/]+$)|(^\+)/gm, '').replace(/\([^)]+\)/g, '').replace(/\s+[A-Za-z]+(\s+[A-Za-z]+)*\s*$/gm, '');
480     }
481
482     var text = event.clipboardData.getData('text/plain');
483     if (text.indexOf('\n') < 0)
484         return;
485
486     var urls = removeJunkFromResultsPage(removeJunkFromNRWTStdout(text)).split('\n');
487     TestResultsView.fetchTests(urls.filter(function (url) { return url.length; }));
488     event.preventDefault();
489 }
490
491 </script>
492 </body>
493 </html>