1/*
2 * Copyright (c) 2010 Google Inc. All rights reserved.
3 *
4 * Redistribution and use in source and binary forms, with or without
5 * modification, are permitted provided that the following conditions are
6 * met:
7 *
8 *     * Redistributions of source code must retain the above copyright
9 * notice, this list of conditions and the following disclaimer.
10 *     * Redistributions in binary form must reproduce the above
11 * copyright notice, this list of conditions and the following disclaimer
12 * in the documentation and/or other materials provided with the
13 * distribution.
14 *     * Neither the name of Google Inc. nor the names of its
15 * contributors may be used to endorse or promote products derived from
16 * this software without specific prior written permission.
17 *
18 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 */
30
31var ALL_DIRECTORY_PATH = '[all]';
32
33var STATE_NEEDS_REBASELINE = 'needs_rebaseline';
34var STATE_REBASELINE_FAILED = 'rebaseline_failed';
35var STATE_REBASELINE_SUCCEEDED = 'rebaseline_succeeded';
36var STATE_IN_QUEUE = 'in_queue';
37var STATE_TO_DISPLAY_STATE = {};
38STATE_TO_DISPLAY_STATE[STATE_NEEDS_REBASELINE] = 'Needs rebaseline';
39STATE_TO_DISPLAY_STATE[STATE_REBASELINE_FAILED] = 'Rebaseline failed';
40STATE_TO_DISPLAY_STATE[STATE_REBASELINE_SUCCEEDED] = 'Rebaseline succeeded';
41STATE_TO_DISPLAY_STATE[STATE_IN_QUEUE] = 'In queue';
42
43var results;
44var testsByFailureType = {};
45var testsByDirectory = {};
46var selectedTests = [];
47var loupe;
48var queue;
49
50function main()
51{
52    $('failure-type-selector').addEventListener('change', selectFailureType);
53    $('directory-selector').addEventListener('change', selectDirectory);
54    $('test-selector').addEventListener('change', selectTest);
55    $('next-test').addEventListener('click', nextTest);
56    $('previous-test').addEventListener('click', previousTest);
57
58    $('toggle-log').addEventListener('click', function() { toggle('log'); });
59
60    loupe = new Loupe();
61    queue = new RebaselineQueue();
62
63    document.addEventListener('keydown', function(event) {
64        if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
65            return;
66        }
67
68        switch (event.keyIdentifier) {
69        case 'Left':
70            event.preventDefault();
71            previousTest();
72            break;
73        case 'Right':
74            event.preventDefault();
75            nextTest();
76            break;
77        case 'U+0051': // q
78            queue.addCurrentTest();
79            break;
80        case 'U+0058': // x
81            queue.removeCurrentTest();
82            break;
83        case 'U+0052': // r
84            queue.rebaseline();
85            break;
86        }
87    });
88
89    loadText('/platforms.json', function(text) {
90        var platforms = JSON.parse(text);
91        platforms.platforms.forEach(function(platform) {
92            var platformOption = document.createElement('option');
93            platformOption.value = platform;
94            platformOption.textContent = platform;
95
96            var targetOption = platformOption.cloneNode(true);
97            targetOption.selected = platform == platforms.defaultPlatform;
98            $('baseline-target').appendChild(targetOption);
99            $('baseline-move-to').appendChild(platformOption.cloneNode(true));
100        });
101    });
102
103    loadText('/results.json', function(text) {
104        results = JSON.parse(text);
105        displayResults();
106    });
107}
108
109/**
110 * Groups test results by failure type.
111 */
112function displayResults()
113{
114    var failureTypeSelector = $('failure-type-selector');
115    var failureTypes = [];
116
117    for (var testName in results.tests) {
118        var test = results.tests[testName];
119        if (test.actual == 'PASS') {
120            continue;
121        }
122        var failureType = test.actual + ' (expected ' + test.expected + ')';
123        if (!(failureType in testsByFailureType)) {
124            testsByFailureType[failureType] = [];
125            failureTypes.push(failureType);
126        }
127        testsByFailureType[failureType].push(testName);
128    }
129
130    // Sort by number of failures
131    failureTypes.sort(function(a, b) {
132        return testsByFailureType[b].length - testsByFailureType[a].length;
133    });
134
135    for (var i = 0, failureType; failureType = failureTypes[i]; i++) {
136        var failureTypeOption = document.createElement('option');
137        failureTypeOption.value = failureType;
138        failureTypeOption.textContent = failureType + ' - ' + testsByFailureType[failureType].length + ' tests';
139        failureTypeSelector.appendChild(failureTypeOption);
140    }
141
142    selectFailureType();
143
144    document.body.className = '';
145}
146
147/**
148 * For a given failure type, gets all the tests and groups them by directory
149 * (populating the directory selector with them).
150 */
151function selectFailureType()
152{
153    var selectedFailureType = getSelectValue('failure-type-selector');
154    var tests = testsByFailureType[selectedFailureType];
155
156    testsByDirectory = {}
157    var displayDirectoryNamesByDirectory = {};
158    var directories = [];
159
160    // Include a special option for all tests
161    testsByDirectory[ALL_DIRECTORY_PATH] = tests;
162    displayDirectoryNamesByDirectory[ALL_DIRECTORY_PATH] = 'all';
163    directories.push(ALL_DIRECTORY_PATH);
164
165    // Roll up tests by ancestor directories
166    tests.forEach(function(test) {
167        var pathPieces = test.split('/');
168        var pathDirectories = pathPieces.slice(0, pathPieces.length -1);
169        var ancestorDirectory = '';
170
171        pathDirectories.forEach(function(pathDirectory, index) {
172            ancestorDirectory += pathDirectory + '/';
173            if (!(ancestorDirectory in testsByDirectory)) {
174                testsByDirectory[ancestorDirectory] = [];
175                var displayDirectoryName = new Array(index * 6).join(' ') + pathDirectory;
176                displayDirectoryNamesByDirectory[ancestorDirectory] = displayDirectoryName;
177                directories.push(ancestorDirectory);
178            }
179
180            testsByDirectory[ancestorDirectory].push(test);
181        });
182    });
183
184    directories.sort();
185
186    var directorySelector = $('directory-selector');
187    directorySelector.innerHTML = '';
188
189    directories.forEach(function(directory) {
190        var directoryOption = document.createElement('option');
191        directoryOption.value = directory;
192        directoryOption.innerHTML =
193            displayDirectoryNamesByDirectory[directory] + ' - ' +
194            testsByDirectory[directory].length + ' tests';
195        directorySelector.appendChild(directoryOption);
196    });
197
198    selectDirectory();
199}
200
201/**
202 * For a given failure type and directory and failure type, gets all the tests
203 * in that directory and populatest the test selector with them.
204 */
205function selectDirectory()
206{
207    var previouslySelectedTest = getSelectedTest();
208
209    var selectedDirectory = getSelectValue('directory-selector');
210    selectedTests = testsByDirectory[selectedDirectory];
211    selectedTests.sort();
212
213    var testsByState = {};
214    selectedTests.forEach(function(testName) {
215        var state = results.tests[testName].state;
216        if (state == STATE_IN_QUEUE) {
217            state = STATE_NEEDS_REBASELINE;
218        }
219        if (!(state in testsByState)) {
220            testsByState[state] = [];
221        }
222        testsByState[state].push(testName);
223    });
224
225    var optionIndexByTest = {};
226
227    var testSelector = $('test-selector');
228    testSelector.innerHTML = '';
229
230    for (var state in testsByState) {
231        var stateOption = document.createElement('option');
232        stateOption.textContent = STATE_TO_DISPLAY_STATE[state];
233        stateOption.disabled = true;
234        testSelector.appendChild(stateOption);
235
236        testsByState[state].forEach(function(testName) {
237            var testOption = document.createElement('option');
238            testOption.value = testName;
239            var testDisplayName = testName;
240            if (testName.lastIndexOf(selectedDirectory) == 0) {
241                testDisplayName = testName.substring(selectedDirectory.length);
242            }
243            testOption.innerHTML = '  ' + testDisplayName;
244            optionIndexByTest[testName] = testSelector.options.length;
245            testSelector.appendChild(testOption);
246        });
247    }
248
249    if (previouslySelectedTest in optionIndexByTest) {
250        testSelector.selectedIndex = optionIndexByTest[previouslySelectedTest];
251    } else if (STATE_NEEDS_REBASELINE in testsByState) {
252        testSelector.selectedIndex =
253            optionIndexByTest[testsByState[STATE_NEEDS_REBASELINE][0]];
254        selectTest();
255    } else {
256        testSelector.selectedIndex = 1;
257        selectTest();
258    }
259
260    selectTest();
261}
262
263function getSelectedTest()
264{
265    return getSelectValue('test-selector');
266}
267
268function selectTest()
269{
270    var selectedTest = getSelectedTest();
271
272    if (results.tests[selectedTest].actual.indexOf('IMAGE') != -1) {
273        $('image-outputs').style.display = '';
274        displayImageResults(selectedTest);
275    } else {
276        $('image-outputs').style.display = 'none';
277    }
278
279    if (results.tests[selectedTest].actual.indexOf('TEXT') != -1) {
280        $('text-outputs').style.display = '';
281        displayTextResults(selectedTest);
282    } else {
283        $('text-outputs').style.display = 'none';
284    }
285
286    var currentBaselines = $('current-baselines');
287    currentBaselines.textContent = '';
288    var baselines = results.tests[selectedTest].baselines;
289    var testName = selectedTest.split('.').slice(0, -1).join('.');
290    getSortedKeys(baselines).forEach(function(platform, i) {
291        if (i != 0) {
292            currentBaselines.appendChild(document.createTextNode('; '));
293        }
294        var platformName = document.createElement('span');
295        platformName.className = 'platform';
296        platformName.textContent = platform;
297        currentBaselines.appendChild(platformName);
298        currentBaselines.appendChild(document.createTextNode(' ('));
299        getSortedKeys(baselines[platform]).forEach(function(extension, j) {
300            if (j != 0) {
301                currentBaselines.appendChild(document.createTextNode(', '));
302            }
303            var link = document.createElement('a');
304            var baselinePath = '';
305            if (platform != 'base') {
306                baselinePath += 'platform/' + platform + '/';
307            }
308            baselinePath += testName + '-expected' + extension;
309            link.href = getTracUrl(baselinePath);
310            if (extension == '.checksum') {
311                link.textContent = 'chk';
312            } else {
313                link.textContent = extension.substring(1);
314            }
315            link.target = '_blank';
316            if (baselines[platform][extension]) {
317                link.className = 'was-used-for-test';
318            }
319            currentBaselines.appendChild(link);
320        });
321        currentBaselines.appendChild(document.createTextNode(')'));
322    });
323
324    updateState();
325    loupe.hide();
326
327    prefetchNextImageTest();
328}
329
330function prefetchNextImageTest()
331{
332    var testSelector = $('test-selector');
333    if (testSelector.selectedIndex == testSelector.options.length - 1) {
334        return;
335    }
336    var nextTest = testSelector.options[testSelector.selectedIndex + 1].value;
337    if (results.tests[nextTest].actual.indexOf('IMAGE') != -1) {
338        new Image().src = getTestResultUrl(nextTest, 'expected-image');
339        new Image().src = getTestResultUrl(nextTest, 'actual-image');
340    }
341}
342
343function updateState()
344{
345    var testName = getSelectedTest();
346    var testIndex = selectedTests.indexOf(testName);
347    var testCount = selectedTests.length
348    $('test-index').textContent = testIndex + 1;
349    $('test-count').textContent = testCount;
350
351    $('next-test').disabled = testIndex == testCount - 1;
352    $('previous-test').disabled = testIndex == 0;
353
354    $('test-link').href = getTracUrl(testName);
355
356    var state = results.tests[testName].state;
357    $('state').className = state;
358    $('state').innerHTML = STATE_TO_DISPLAY_STATE[state];
359
360    queue.updateState();
361}
362
363function getTestResultUrl(testName, mode)
364{
365    return '/test_result?test=' + testName + '&mode=' + mode;
366}
367
368var currentExpectedImageTest;
369var currentActualImageTest;
370
371function displayImageResults(testName)
372{
373    if (currentExpectedImageTest == currentActualImageTest
374        && currentExpectedImageTest == testName) {
375        return;
376    }
377
378    function displayImageResult(mode, callback) {
379        var image = $(mode);
380        image.className = 'loading';
381        image.src = getTestResultUrl(testName, mode);
382        image.onload = function() {
383            image.className = '';
384            callback();
385            updateImageDiff();
386        };
387    }
388
389    displayImageResult(
390        'expected-image',
391        function() { currentExpectedImageTest = testName; });
392    displayImageResult(
393        'actual-image',
394        function() { currentActualImageTest = testName; });
395
396    $('diff-canvas').className = 'loading';
397    $('diff-canvas').style.display = '';
398    $('diff-checksum').style.display = 'none';
399}
400
401/**
402 * Computes a graphical a diff between the expected and actual images by
403 * rendering each to a canvas, getting the image data, and comparing the RGBA
404 * components of each pixel. The output is put into the diff canvas, with
405 * identical pixels appearing at 12.5% opacity and different pixels being
406 * highlighted in red.
407 */
408function updateImageDiff() {
409    if (currentExpectedImageTest != currentActualImageTest)
410        return;
411
412    var expectedImage = $('expected-image');
413    var actualImage = $('actual-image');
414
415    function getImageData(image) {
416        var imageCanvas = document.createElement('canvas');
417        imageCanvas.width = image.width;
418        imageCanvas.height = image.height;
419        imageCanvasContext = imageCanvas.getContext('2d');
420
421        imageCanvasContext.fillStyle = 'rgba(255, 255, 255, 1)';
422        imageCanvasContext.fillRect(
423            0, 0, image.width, image.height);
424
425        imageCanvasContext.drawImage(image, 0, 0);
426        return imageCanvasContext.getImageData(
427            0, 0, image.width, image.height);
428    }
429
430    var expectedImageData = getImageData(expectedImage);
431    var actualImageData = getImageData(actualImage);
432
433    var diffCanvas = $('diff-canvas');
434    var diffCanvasContext = diffCanvas.getContext('2d');
435    var diffImageData =
436        diffCanvasContext.createImageData(diffCanvas.width, diffCanvas.height);
437
438    // Avoiding property lookups for all these during the per-pixel loop below
439    // provides a significant performance benefit.
440    var expectedWidth = expectedImage.width;
441    var expectedHeight = expectedImage.height;
442    var expected = expectedImageData.data;
443
444    var actualWidth = actualImage.width;
445    var actual = actualImageData.data;
446
447    var diffWidth = diffImageData.width;
448    var diff = diffImageData.data;
449
450    var hadDiff = false;
451    for (var x = 0; x < expectedWidth; x++) {
452        for (var y = 0; y < expectedHeight; y++) {
453            var expectedOffset = (y * expectedWidth + x) * 4;
454            var actualOffset = (y * actualWidth + x) * 4;
455            var diffOffset = (y * diffWidth + x) * 4;
456            if (expected[expectedOffset] != actual[actualOffset] ||
457                expected[expectedOffset + 1] != actual[actualOffset + 1] ||
458                expected[expectedOffset + 2] != actual[actualOffset + 2] ||
459                expected[expectedOffset + 3] != actual[actualOffset + 3]) {
460                hadDiff = true;
461                diff[diffOffset] = 255;
462                diff[diffOffset + 1] = 0;
463                diff[diffOffset + 2] = 0;
464                diff[diffOffset + 3] = 255;
465            } else {
466                diff[diffOffset] = expected[expectedOffset];
467                diff[diffOffset + 1] = expected[expectedOffset + 1];
468                diff[diffOffset + 2] = expected[expectedOffset + 2];
469                diff[diffOffset + 3] = 32;
470            }
471        }
472    }
473
474    diffCanvasContext.putImageData(
475        diffImageData,
476        0, 0,
477        0, 0,
478        diffImageData.width, diffImageData.height);
479    diffCanvas.className = '';
480
481    if (!hadDiff) {
482        diffCanvas.style.display = 'none';
483        $('diff-checksum').style.display = '';
484        loadTextResult(currentExpectedImageTest, 'expected-checksum');
485        loadTextResult(currentExpectedImageTest, 'actual-checksum');
486    }
487}
488
489function loadTextResult(testName, mode, responseIsHtml)
490{
491    loadText(getTestResultUrl(testName, mode), function(text) {
492        if (responseIsHtml) {
493            $(mode).innerHTML = text;
494        } else {
495            $(mode).textContent = text;
496        }
497    });
498}
499
500function displayTextResults(testName)
501{
502    loadTextResult(testName, 'expected-text');
503    loadTextResult(testName, 'actual-text');
504    loadTextResult(testName, 'diff-text-pretty', true);
505}
506
507function nextTest()
508{
509    var testSelector = $('test-selector');
510    var nextTestIndex = testSelector.selectedIndex + 1;
511    while (true) {
512        if (nextTestIndex == testSelector.options.length) {
513            return;
514        }
515        if (testSelector.options[nextTestIndex].disabled) {
516            nextTestIndex++;
517        } else {
518            testSelector.selectedIndex = nextTestIndex;
519            selectTest();
520            return;
521        }
522    }
523}
524
525function previousTest()
526{
527    var testSelector = $('test-selector');
528    var previousTestIndex = testSelector.selectedIndex - 1;
529    while (true) {
530        if (previousTestIndex == -1) {
531            return;
532        }
533        if (testSelector.options[previousTestIndex].disabled) {
534            previousTestIndex--;
535        } else {
536            testSelector.selectedIndex = previousTestIndex;
537            selectTest();
538            return
539        }
540    }
541}
542
543window.addEventListener('DOMContentLoaded', main);
544