More results.html cleanup
[WebKit-https.git] / LayoutTests / fast / harness / results.html
1 <!DOCTYPE html>
2 <style>
3 body {
4     margin: 0;
5     font-family: Helvetica, sans-serif;
6     font-size: 11pt;
7 }
8
9 body > * {
10     margin-left: 4px;
11     margin-top: 4px;
12 }
13
14 h1 {
15     font-size: 14pt;
16     margin-top: 1.5em;
17 }
18
19 p {
20     margin-bottom: 0.3em;
21 }
22
23 a.clickable {
24     color: blue;
25     cursor: pointer;
26     margin-left: 0.2em;
27 }
28
29 tr:not(.results-row) td {
30     white-space: nowrap;
31 }
32
33 tr:not(.results-row) td:first-of-type {
34     white-space: normal;
35 }
36
37 td:not(:first-of-type) {
38     text-transform: lowercase;
39 }
40
41 th, td {
42     padding: 1px 4px;
43 }
44
45 th:empty, td:empty {
46     padding: 0;
47 }
48
49 th {
50     -webkit-user-select: none;
51     -moz-user-select: none;
52 }
53
54 .content-container {
55     min-height: 0;
56 }
57
58 .note {
59     color: gray;
60     font-size: smaller;
61 }
62
63 .results-row {
64     background-color: white;
65 }
66
67 .results-row iframe, .results-row img {
68     width: 800px;
69     height: 600px;
70 }
71
72 .results-row[data-expanded="false"] {
73     display: none;
74 }
75
76 #toolbar {
77     position: fixed;
78     top: 2px;
79     right: 2px;
80     text-align: right;
81 }
82
83 .floating-panel {
84     padding: 6px;
85     background-color: rgba(255, 255, 255, 0.9);
86     border: 1px solid silver;
87     border-radius: 4px;
88 }
89
90 .expand-button {
91     background-color: white;
92     width: 11px;
93     height: 12px;
94     border: 1px solid gray;
95     display: inline-block;
96     margin: 0 3px 0 0;
97     position: relative;
98     cursor: default;
99 }
100
101 .current {
102     color: red;
103 }
104
105 .current .expand-button {
106     border-color: red;
107 }
108
109 .expand-button-text {
110     position: absolute;
111     top: -0.3em;
112     left: 1px;
113 }
114
115 tbody .flag {
116     display: none;
117 }
118
119 tbody.flagged .flag {
120     display: inline;
121 }
122
123 .stopped-running-early-message {
124     border: 3px solid #d00;
125     font-weight: bold;
126     display: inline-block;
127     padding: 3px;
128 }
129
130 .result-container {
131     display: inline-block;
132     border: 1px solid gray;
133     margin: 4px;
134 }
135
136 .result-container iframe, .result-container img {
137     border: 0;
138     vertical-align: top;
139 }
140
141 #options {
142     background-color: white;
143 }
144
145 #options-menu {
146     border: 1px solid gray;
147     border-radius: 4px;
148     margin-top: 1px;
149     padding: 2px 4px;
150     box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.6);
151     transition: opacity .2s;
152     text-align: left;
153     position: absolute;
154     right: 4px;
155     background-color: white;
156 }
157
158 #options-menu label {
159     display: block;
160 }
161
162 .hidden-menu {
163     pointer-events: none;
164     opacity: 0;
165 }
166
167 .label {
168     padding-left: 3px;
169     font-weight: bold;
170     font-size: small;
171     background-color: silver;
172 }
173
174 .pixel-zoom-container {
175     position: fixed;
176     top: 0;
177     left: 0;
178     width: 96%;
179     margin: 10px;
180     padding: 10px;
181     display: -webkit-box;
182     display: -moz-box;
183     pointer-events: none;
184     background-color: silver;
185     border-radius: 20px;
186     border: 1px solid gray;
187     box-shadow: 0 0 5px rgba(0, 0, 0, 0.75);
188 }
189
190 .pixel-zoom-container > * {
191     -webkit-box-flex: 1;
192     -moz-box-flex: 1;
193     border: 1px solid black;
194     margin: 4px;
195     overflow: hidden;
196     background-color: white;
197 }
198
199 .pixel-zoom-container .scaled-image-container {
200     position: relative;
201     overflow: hidden;
202     width: 100%;
203     height: 400px;
204 }
205
206 .scaled-image-container > img {
207     position: absolute;
208     top: 0;
209     left: 0;
210     image-rendering: -webkit-optimize-contrast;
211 }
212
213 #flagged-test-container {
214     position: fixed;
215     bottom: 4px;
216     right: 4px;
217     width: 50%;
218     min-width: 400px;
219     background-color: rgba(255, 255, 255, 0.75);
220 }
221
222 #flagged-test-container > h2 {
223     margin: 0 0 4px 0;
224 }
225
226 #flagged-tests {
227     padding: 0 5px;
228     margin: 0;
229     height: 7em;
230     overflow-y: scroll;
231 }
232 </style>
233 <style id="unexpected-style"></style>
234
235 <script>
236 if (window.testRunner)
237     testRunner.dumpAsText();
238
239 class Utils
240 {
241     static matchesSelector(node, selector)
242     {
243         if (node.matches)
244             return node.matches(selector);
245
246         if (node.webkitMatchesSelector)
247             return node.webkitMatchesSelector(selector);
248
249         if (node.mozMatchesSelector)
250             return node.mozMatchesSelector(selector);
251     }
252
253     static parentOfType(node, selector)
254     {
255         while (node = node.parentNode) {
256             if (Utils.matchesSelector(node, selector))
257                 return node;
258         }
259         return null;
260     }
261
262     static stripExtension(testName)
263     {
264         // Temporary fix, also in Tools/Scripts/webkitpy/layout_tests/constrollers/test_result_writer.py, line 95.
265         // FIXME: Refactor to avoid confusing reference to both test and process names.
266         if (Utils.splitExtension(testName)[1].length > 5)
267             return testName;
268         return Utils.splitExtension(testName)[0];
269     }
270
271     static splitExtension(testName)
272     {
273         let index = testName.lastIndexOf('.');
274         if (index == -1) {
275             return [testName, ''];
276         }
277         return [testName.substring(0, index), testName.substring(index + 1)];
278     }
279
280     static forEach(nodeList, handler)
281     {
282         Array.prototype.forEach.call(nodeList, handler);
283     }
284
285     static toArray(nodeList)
286     {
287         return Array.prototype.slice.call(nodeList);
288     }
289
290     static trim(string)
291     {
292         return string.replace(/^[\s\xa0]+|[\s\xa0]+$/g, '');
293     }
294
295     static async(func, args)
296     {
297         setTimeout(() => { func.apply(null, args); }, 50);
298     }
299
300     static appendHTML(node, html)
301     {
302         if (node.insertAdjacentHTML)
303             node.insertAdjacentHTML('beforeEnd', html);
304         else
305             node.innerHTML += html;
306     }};
307
308 class TestResult
309 {
310     constructor(info, name)
311     {
312         this.name = name;
313         this.info = info; // FIXME: make this private.
314     }
315
316     isFailureExpected()
317     {
318         let actual = this.info.actual;    
319         let expected = this.info.expected || 'PASS';
320
321         if (actual != 'SKIP') {
322             let expectedArray = expected.split(' ');
323             let actualArray = actual.split(' ');
324             for (let actualValue of actualArray) {
325                 if (expectedArray.indexOf(actualValue) == -1 && (expectedArray.indexOf('FAIL') == -1 || (actualValue != 'TEXT' && actualValue != 'IMAGE+TEXT' && actualValue != 'AUDIO')))
326                     return false;
327             }
328         }
329         return true;
330     }
331     
332     isMissingAllResults()
333     {
334         return this.info.actual == 'MISSING';
335     }
336     
337     hasMissingResult()
338     {
339         return this.info.actual.indexOf('MISSING') != -1;
340     }
341     
342     isFlakey(pixelTestsEnabled)
343     {
344         let actualTokens = this.info.actual.split(' ');
345         let passedWithImageOnlyFailureInRetry = actualTokens[0] == 'TEXT' && actualTokens[1] == 'IMAGE';
346         if (actualTokens[1] && this.info.actual.indexOf('PASS') != -1 || (!pixelTestsEnabled && passedWithImageOnlyFailureInRetry))
347             return true;
348         
349         return false;
350     }
351     
352     isPass()
353     {
354         return this.info.actual == 'PASS';
355     }
356
357     isTextFailure()
358     {
359         return this.info.actual.indexOf('TEXT') != -1;
360     }
361
362     isImageFailure()
363     {
364         return this.info.actual.indexOf('IMAGE') != -1;
365     }
366
367     isAudioFailure()
368     {
369         return this.info.actual.indexOf('AUDIO') != -1;
370     }
371
372     isCrash()
373     {
374         return this.info.actual == 'CRASH';
375     }
376     
377     isTimeout()
378     {
379         return this.info.actual == 'TIMEOUT';
380     }
381     
382     isUnexpectedPass(pixelTestsEnabled)
383     {
384         if (this.info.actual == 'PASS' && this.info.expected != 'PASS') {
385             if (this.info.expected != 'IMAGE' || (pixelTestsEnabled || this.isRefTest()))
386                 return true;
387         }
388         
389         return false;
390     }
391     
392     isRefTest()
393     {
394         return !!this.info.reftest_type;
395     }
396
397     isMismatchRefTest()
398     {
399         return this.isRefTest() && this.info.reftest_type.indexOf('!=') != -1;
400     }
401
402     isMatchRefTest()
403     {
404         return this.isRefTest() && this.info.reftest_type.indexOf('==') != -1;
405     }
406     
407     isMissingText()
408     {
409         return this.info.is_missing_text;
410     }
411
412     isMissingImage()
413     {
414         return this.info.is_missing_image;
415     }
416
417     isMissingAudio()
418     {
419         return this.info.is_missing_audio;
420     }
421     
422     hasStdErr()
423     {
424         return this.info.has_stderr;
425     }
426 };
427
428 class TestResults
429 {
430     constructor(results)
431     {
432         this._results = results;
433
434         this.crashTests = [];
435         this.crashOther = [];
436         this.missingResults = [];
437         this.failingTests = [];
438         this.testsWithStderr = [];
439         this.timeoutTests = [];
440         this.unexpectedPassTests = [];
441         this.flakyPassTests = [];
442
443         this.hasHttpTests = false;
444         this.hasImageFailures = false;
445         this.hasTextFailures = false;
446
447         this._testsByName = new Map;
448
449         this._forEachTest(this._results.tests, '');
450         this._forOtherCrashes(this._results.other_crashes);
451     }
452     
453     date()
454     {
455         return this._results.date;
456     }
457
458     layoutTestsDir()
459     {
460         return this._results.layout_tests_dir;
461     }
462     
463     usesExpectationsFile()
464     {
465         return this._results.uses_expectations_file;
466     }
467     
468     resultForTest(testName)
469     {
470         return this._resultsByTest[testName];
471     }
472     
473     wasInterrupted()
474     {
475         return this._results.interrupted;
476     }
477
478     hasPrettyPatch()
479     {
480         return this._results.has_pretty_patch;
481     }
482     
483     hasWDiff()
484     {
485         return this._results.has_wdiff;
486     }
487     
488     testWithName(testName)
489     {
490         return this._testsByName.get(testName);
491     }
492
493     _processResultForTest(testResult)
494     {
495         this._testsByName.set(testResult.name, testResult);
496
497         let test = testResult.name;
498         if (testResult.hasStdErr())
499             this.testsWithStderr.push(testResult);
500
501         this.hasHttpTests |= test.indexOf('http/') == 0;
502
503         if (this.usesExpectationsFile())
504             testResult.isExpected = testResult.isFailureExpected();
505         
506         if (testResult.isTextFailure())
507             this.hasTextFailures = true;
508
509         if (testResult.isImageFailure())
510             this.hasImageFailures = true;
511
512         if (testResult.isMissingAllResults()) {
513             // FIXME: make sure that new-run-webkit-tests spits out an -actual.txt file for tests with MISSING results.
514             this.missingResults.push(testResult);
515             return;
516         }
517
518         if (testResult.isFlakey(this._results.pixel_tests_enabled)) {
519             this.flakyPassTests.push(testResult);
520             return;
521         }
522
523         if (testResult.isPass()) {
524             if (testResult.isUnexpectedPass(this._results.pixel_tests_enabled))
525                 this.unexpectedPassTests.push(testResult);
526             return;
527         }
528
529         if (testResult.isCrash()) {
530             this.crashTests.push(testResult);
531             return;
532         }
533
534         if (testResult.isTimeout()) {
535             this.timeoutTests.push(testResult);
536             return;
537         }
538     
539         this.failingTests.push(testResult);
540     }
541     
542     _forEachTest(tree, prefix)
543     {
544         for (let key in tree) {
545             let newPrefix = prefix ? (prefix + '/' + key) : key;
546             if ('actual' in tree[key]) {
547                 let testObject = new TestResult(tree[key], newPrefix);
548                 this._processResultForTest(testObject);
549             } else
550                 this._forEachTest(tree[key], newPrefix);
551         }
552     }
553
554     _forOtherCrashes(tree)
555     {
556         for (let key in tree) {
557             let testObject = new TestResult(tree[key], key);
558             this.crashOther.push(testObject);
559         }
560     }
561     
562     static sortByName(tests)
563     {
564         tests.sort(function (a, b) { return a.name.localeCompare(b.name) });
565     }
566
567     static hasUnexpectedResult(tests)
568     {
569         return tests.some(function (test) { return !test.isExpected; });
570     }
571 };
572
573 class TestResultsController
574 {        
575     constructor(containerElement, testResults)
576     {
577         this.containerElement = containerElement;
578         this.testResults = testResults;
579
580         this.shouldToggleImages = true;
581         this._togglingImageInterval = null;
582         
583         this._updatePageTitle();
584
585         this.buildResultsTables();
586         this.hideNonApplicableUI();
587         this.setupSorting();
588         this.setupOptions();
589     }
590     
591     buildResultsTables()
592     {
593         if (this.testResults.wasInterrupted()) {
594             let interruptionMessage = document.createElement('p');
595             interruptionMessage.textContent = 'Testing exited early';
596             interruptionMessage.classList.add('stopped-running-early-message');
597             this.containerElement.appendChild(interruptionMessage);
598         }
599
600         if (this.testResults.crashTests.length)
601             this.containerElement.appendChild(this.buildOneSection(this.testResults.crashTests, 'crash-tests-table'));
602
603         if (this.testResults.crashOther.length)
604             this.containerElement.appendChild(this.buildOneSection(this.testResults.crashOther, 'other-crash-tests-table'));
605
606         if (this.testResults.failingTests.length)
607             this.containerElement.appendChild(this.buildOneSection(this.testResults.failingTests, 'results-table'));
608
609         if (this.testResults.missingResults.length)
610             this.containerElement.appendChild(this.buildOneSection(this.testResults.missingResults, 'missing-table'));
611
612         if (this.testResults.timeoutTests.length)
613             this.containerElement.appendChild(this.buildOneSection(this.testResults.timeoutTests, 'timeout-tests-table'));
614
615         if (this.testResults.testsWithStderr.length)
616             this.containerElement.appendChild(this.buildOneSection(this.testResults.testsWithStderr, 'stderr-table'));
617
618         if (this.testResults.flakyPassTests.length)
619             this.containerElement.appendChild(this.buildOneSection(this.testResults.flakyPassTests, 'flaky-tests-table'));
620
621         if (this.testResults.usesExpectationsFile() && this.testResults.unexpectedPassTests.length)
622             this.containerElement.appendChild(this.buildOneSection(this.testResults.unexpectedPassTests, 'passes-table'));
623
624         if (this.testResults.hasHttpTests) {
625             let httpdAccessLogLink = document.createElement('p');
626             httpdAccessLogLink.innerHTML = 'httpd access log: <a href="access_log.txt">access_log.txt</a>';
627
628             let httpdErrorLogLink = document.createElement('p');
629             httpdErrorLogLink.innerHTML = 'httpd error log: <a href="error_log.txt">error_log.txt</a>';
630             
631             this.containerElement.appendChild(httpdAccessLogLink);
632             this.containerElement.appendChild(httpdErrorLogLink);
633         }
634         
635         this.updateTestlistCounts();
636     }
637
638     static sectionBuilderClassForTableID(tableID)
639     {
640         const idToBuilderClassMap = {
641             'crash-tests-table' : CrashingTestsSectionBuilder,
642             'other-crash-tests-table' : OtherCrashesSectionBuilder,
643             'results-table' : FailingTestsSectionBuilder,
644             'missing-table' : TestsWithMissingResultsSectionBuilder,
645             'timeout-tests-table' : TimedOutTestsSectionBuilder,
646             'stderr-table' : TestsWithStdErrSectionBuilder,
647             'flaky-tests-table' : FlakyPassTestsSectionBuilder,
648             'passes-table' : UnexpectedPassTestsSectionBuilder,
649         };
650         return idToBuilderClassMap[tableID];
651     }
652     
653     setupSorting()
654     {
655         let resultsTable = document.getElementById('results-table');
656         if (!resultsTable)
657             return;
658         
659         // FIXME: Make all the tables sortable. Maybe SectionBuilder should put a TableSorter on each table.
660         resultsTable.addEventListener('click', TableSorter.handleClick, false);
661         TableSorter.sortColumn(0);
662     }
663     
664     hideNonApplicableUI()
665     {
666         // FIXME: do this all through body classnames.
667         if (!this.testResults.hasTextFailures) {
668             let textResultsHeader = document.getElementById('text-results-header');
669             if (textResultsHeader)
670                 textResultsHeader.textContent = '';
671         }
672
673         if (!this.testResults.hasImageFailures) {
674             let imageResultsHeader = document.getElementById('image-results-header');
675             if (imageResultsHeader)
676                 imageResultsHeader.textContent = '';
677
678             Utils.parentOfType(document.getElementById('toggle-images'), 'label').style.display = 'none';
679         }
680     }
681     
682     setupOptions()
683     {
684         // FIXME: do this all through body classnames.
685         if (!this.testResults.usesExpectationsFile())
686             Utils.parentOfType(document.getElementById('unexpected-results'), 'label').style.display = 'none';
687     }
688
689     buildOneSection(tests, tableID)
690     {
691         TestResults.sortByName(tests);
692         
693         let sectionBuilderClass = TestResultsController.sectionBuilderClassForTableID(tableID);
694         let sectionBuilder = new sectionBuilderClass(tests, tableID, this);
695         return sectionBuilder.build();
696     }
697
698     updateTestlistCounts()
699     {
700         // FIXME: do this through the data model, not through the DOM.
701         let onlyShowUnexpectedFailures = this.onlyShowUnexpectedFailures();
702         Utils.forEach(document.querySelectorAll('.test-list-count'), count => {
703             let container = Utils.parentOfType(count, 'section');
704             let testContainers;
705             if (onlyShowUnexpectedFailures)
706                 testContainers = container.querySelectorAll('tbody:not(.expected)');
707             else
708                 testContainers = container.querySelectorAll('tbody');
709
710             count.textContent = testContainers.length;
711         })
712     }
713     
714     flagAll(headerLink)
715     {
716         let tests = this.visibleTests(Utils.parentOfType(headerLink, 'section'));
717         Utils.forEach(tests, tests => {
718             let shouldFlag = true;
719             testNavigator.flagTest(tests, shouldFlag);
720         })
721     }
722
723     unflag(flag)
724     {
725         const shouldFlag = false;
726         testNavigator.flagTest(Utils.parentOfType(flag, 'tbody'), shouldFlag);
727     }
728
729     visibleTests(opt_container)
730     {
731         let container = opt_container || document;
732         if (this.onlyShowUnexpectedFailures())
733             return container.querySelectorAll('tbody:not(.expected)');
734         else
735             return container.querySelectorAll('tbody');
736     }
737
738     // FIXME: this is confusing. Flip the sense around.
739     onlyShowUnexpectedFailures()
740     {
741         return document.getElementById('unexpected-results').checked;
742     }
743
744     static _testListHeader(title)
745     {
746         let header = document.createElement('h1');
747         header.innerHTML = title + ' (<span class=test-list-count></span>): <a href="#" class=flag-all onclick="controller.flagAll(this)">flag all</a>';
748         return header;
749     }
750
751     testToURL(testResult, layoutTestsPath)
752     {
753         const mappings = {
754             "http/tests/ssl/": "https://127.0.0.1:8443/ssl/",
755             "http/tests/": "http://127.0.0.1:8000/",
756             "http/wpt/": "http://localhost:8800/WebKit/",
757             "imported/w3c/web-platform-tests/": "http://localhost:8800/"
758         };
759
760         for (let key in mappings) {
761             if (testResult.name.startsWith(key))
762                 return mappings[key] + testResult.name.substring(key.length);
763
764         }
765         return "file://" + layoutTestsPath + "/" + testResult.name;
766     }
767
768     layoutTestURL(testResult)
769     {
770         if (this.shouldUseTracLinks())
771             return this.layoutTestsBasePath() + testResult.name;
772
773         return this.testToURL(testResult, this.layoutTestsBasePath());
774     }
775
776     layoutTestsBasePath()
777     {
778         let basePath;
779         if (this.shouldUseTracLinks()) {
780             let revision = this.testResults.revision;
781             basePath = 'http://trac.webkit.org';
782             basePath += revision ? ('/export/' + revision) : '/browser';
783             basePath += '/trunk/LayoutTests/';
784         } else
785             basePath = this.testResults.layoutTestsDir() + '/';
786
787         return basePath;
788     }
789
790     shouldUseTracLinks()
791     {
792         return !this.testResults.layoutTestsDir() || !location.toString().indexOf('file://') == 0;
793     }
794
795     checkServerIsRunning(event)
796     {
797         if (this.shouldUseTracLinks())
798             return;
799
800         let url = event.target.href;
801         if (url.startsWith("file://"))
802             return;
803
804         event.preventDefault();
805         fetch(url, { mode: "no-cors" }).then(() => {
806             window.location = url;
807         }, () => {
808             alert("HTTP server does not seem to be running, please use the run-webkit-httpd script");
809         });
810     }
811
812     testLink(testResult)
813     {
814         return '<a class=test-link onclick="controller.checkServerIsRunning(event)" href="' + this.layoutTestURL(testResult) + '">' + testResult.name + '</a><span class=flag onclick="controller.unflag(this)"> \u2691</span>';
815     }
816     
817     expandButtonSpan()
818     {
819         return '<span class=expand-button onclick="controller.toggleExpectations(this)"><span class=expand-button-text>+</span></span>';
820     }
821     
822     static resultLink(testPrefix, suffix, contents)
823     {
824         return '<a class=result-link href="' + testPrefix + suffix + '" data-prefix="' + testPrefix + '">' + contents + '</a> ';
825     }
826
827     textResultLinks(prefix)
828     {
829         let html = TestResultsController.resultLink(prefix, '-expected.txt', 'expected') +
830             TestResultsController.resultLink(prefix, '-actual.txt', 'actual') +
831             TestResultsController.resultLink(prefix, '-diff.txt', 'diff');
832
833         if (this.testResults.hasPrettyPatch())
834             html += TestResultsController.resultLink(prefix, '-pretty-diff.html', 'pretty diff');
835
836         if (this.testResults.hasWDiff())
837             html += TestResultsController.resultLink(prefix, '-wdiff.html', 'wdiff');
838
839         return html;
840     }
841
842     flakinessDashboardURLForTests(testObjects)
843     {
844         // FIXME: just map and join here.
845         let testList = '';
846         for (let i = 0; i < testObjects.length; ++i) {
847             testList += testObjects[i].name;
848
849             if (i != testObjects.length - 1)
850                 testList += ',';
851         }
852
853         return 'http://webkit-test-results.webkit.org/dashboards/flakiness_dashboard.html#showAllRuns=true&tests=' + encodeURIComponent(testList);
854     }
855
856     _updatePageTitle()
857     {
858         let dateString = this.testResults.date();
859         let title = document.createElement('title');
860         title.textContent = 'Layout Test Results from ' + dateString;
861         document.head.appendChild(title);
862     }
863     
864     // Options handling. FIXME: move to a separate class?
865     updateAllOptions()
866     {
867         Utils.forEach(document.querySelectorAll('#options-menu input'), input => { input.onchange() });
868     }
869
870     toggleOptionsMenu()
871     {
872         let menu = document.getElementById('options-menu');
873         menu.className = (menu.className == 'hidden-menu') ? '' : 'hidden-menu';
874     }
875
876     handleToggleUseNewlines()
877     {
878         OptionWriter.save();
879         testNavigator.updateFlaggedTests();
880     }
881
882     handleUnexpectedResultsChange()
883     {
884         OptionWriter.save();
885         this._updateExpectedFailures();
886     }
887
888     expandAllExpectations()
889     {
890         let expandLinks = this._visibleExpandLinks();
891         for (let link of expandLinks)
892             Utils.async(link => { controller.expandExpectations(link) }, [ link ]);
893     }
894
895     collapseAllExpectations()
896     {
897         let expandLinks = this._visibleExpandLinks();
898         for (let link of expandLinks)
899             Utils.async(link => { controller.collapseExpectations(link) }, [ link ]);
900     }
901
902     expandExpectations(expandLink)
903     {
904         let row = Utils.parentOfType(expandLink, 'tr');
905         let parentTbody = row.parentNode;
906         let existingResultsRow = parentTbody.querySelector('.results-row');
907     
908         const enDash = '\u2013';
909         expandLink.textContent = enDash;
910         if (existingResultsRow) {
911             this._updateExpandedState(existingResultsRow, true);
912             return;
913         }
914
915         let testName = row.getAttribute('data-test-name');
916         let testResult = this.testResults.testWithName(testName);
917
918         let newRow = TestResultsController._buildExpandedRowForTest(testResult, row);
919         parentTbody.appendChild(newRow);
920
921         this._updateExpandedState(newRow, true);
922
923         this._updateImageTogglingTimer();
924     }
925
926     collapseExpectations(expandLink)
927     {
928         expandLink.textContent = '+';
929         let existingResultsRow = Utils.parentOfType(expandLink, 'tbody').querySelector('.results-row');
930         if (existingResultsRow)
931             this._updateExpandedState(existingResultsRow, false);
932     }
933
934     toggleExpectations(element)
935     {
936         let expandLink = element;
937         if (expandLink.className != 'expand-button-text')
938             expandLink = expandLink.querySelector('.expand-button-text');
939
940         if (expandLink.textContent == '+')
941             this.expandExpectations(expandLink);
942         else
943             this.collapseExpectations(expandLink);
944     }
945
946     _updateExpandedState(row, isExpanded)
947     {
948         row.setAttribute('data-expanded', isExpanded);
949         this._updateImageTogglingTimer();
950     }
951
952     handleToggleImagesChange()
953     {
954         OptionWriter.save();
955         this._updateTogglingImages();
956     }
957
958     _visibleExpandLinks()
959     {
960         if (this.onlyShowUnexpectedFailures())
961             return document.querySelectorAll('tbody:not(.expected) .expand-button-text');
962         else
963             return document.querySelectorAll('.expand-button-text');
964     }
965
966     static _togglingImage(prefix)
967     {
968         return '<div class=result-container><div class="label imageText"></div><img class=animatedImage data-prefix="' + prefix + '"></img></div>';
969     }
970
971     _updateTogglingImages()
972     {
973         this.shouldToggleImages = document.getElementById('toggle-images').checked;
974
975         // FIXME: this is all pretty confusing. Simplify.
976         if (this.shouldToggleImages) {
977             Utils.forEach(document.querySelectorAll('table:not(#missing-table) tbody:not([mismatchreftest]) a[href$=".png"]'), TestResultsController._convertToTogglingHandler(function(prefix) {
978                 return TestResultsController.resultLink(prefix, '-diffs.html', 'images');
979             }));
980             Utils.forEach(document.querySelectorAll('table:not(#missing-table) tbody:not([mismatchreftest]) img[src$=".png"]'), TestResultsController._convertToTogglingHandler(TestResultsController._togglingImage));
981         } else {
982             Utils.forEach(document.querySelectorAll('a[href$="-diffs.html"]'), element => {
983                 TestResultsController._convertToNonTogglingHandler(element);
984             });
985             Utils.forEach(document.querySelectorAll('.animatedImage'), TestResultsController._convertToNonTogglingHandler(function (absolutePrefix, suffix) {
986                 return TestResultsController._resultIframe(absolutePrefix + suffix);
987             }));
988         }
989
990         this._updateImageTogglingTimer();
991     }
992
993     _updateExpectedFailures()
994     {
995         // Gross to do this by setting stylesheet text. Use a body class!
996         document.getElementById('unexpected-style').textContent = this.onlyShowUnexpectedFailures() ? '.expected { display: none; }' : '';
997
998         this.updateTestlistCounts();
999         testNavigator.onlyShowUnexpectedFailuresChanged();
1000     }
1001
1002     static _buildExpandedRowForTest(testResult, row)
1003     {
1004         let newRow = document.createElement('tr');
1005         newRow.className = 'results-row';
1006         let newCell = document.createElement('td');
1007         newCell.colSpan = row.querySelectorAll('td').length;
1008
1009         // FIXME: migrate more of code to using testResult for building the expanded content.
1010         let resultLinks = row.querySelectorAll('.result-link');
1011         let hasTogglingImages = false;
1012         for (let link of resultLinks) {
1013             let result;
1014             if (link.textContent == 'images') {
1015                 hasTogglingImages = true;
1016                 result = TestResultsController._togglingImage(link.getAttribute('data-prefix'));
1017             } else
1018                 result = TestResultsController._resultIframe(link.href);
1019
1020             Utils.appendHTML(newCell, result);    
1021         }
1022
1023         newRow.appendChild(newCell);
1024         return newRow;
1025     }
1026
1027     static _resultIframe(src)
1028     {
1029         // FIXME: use audio tags for AUDIO tests?
1030         let layoutTestsIndex = src.indexOf('LayoutTests');
1031         let name;
1032         if (layoutTestsIndex != -1) {
1033             let hasTrac = src.indexOf('trac.webkit.org') != -1;
1034             let prefix = hasTrac ? 'trac.webkit.org/.../' : '';
1035             name = prefix + src.substring(layoutTestsIndex + 'LayoutTests/'.length);
1036         } else {
1037             let lastDashIndex = src.lastIndexOf('-pretty');
1038             if (lastDashIndex == -1)
1039                 lastDashIndex = src.lastIndexOf('-');
1040             name = src.substring(lastDashIndex + 1);
1041         }
1042
1043         let tagName = (src.lastIndexOf('.png') == -1) ? 'iframe' : 'img';
1044
1045         if (tagName != 'img')
1046             src += '?format=txt';
1047         return '<div class=result-container><div class=label>' + name + '</div><' + tagName + ' src="' + src + '"></' + tagName + '></div>';
1048     }
1049
1050
1051     static _toggleImages()
1052     {
1053         let images = document.querySelectorAll('.animatedImage');
1054         let imageTexts = document.querySelectorAll('.imageText');
1055         for (let i = 0, len = images.length; i < len; i++) {
1056             let image = images[i];
1057             let text = imageTexts[i];
1058             if (text.textContent == 'Expected Image') {
1059                 text.textContent = 'Actual Image';
1060                 image.src = image.getAttribute('data-prefix') + '-actual.png';
1061             } else {
1062                 text.textContent = 'Expected Image';
1063                 image.src = image.getAttribute('data-prefix') + '-expected.png';
1064             }
1065         }
1066     }
1067
1068     _updateImageTogglingTimer()
1069     {
1070         let hasVisibleAnimatedImage = document.querySelector('.results-row[data-expanded="true"] .animatedImage');
1071         if (!hasVisibleAnimatedImage) {
1072             clearInterval(this._togglingImageInterval);
1073             this._togglingImageInterval = null;
1074             return;
1075         }
1076
1077         if (!this._togglingImageInterval) {
1078             TestResultsController._toggleImages();
1079             this._togglingImageInterval = setInterval(TestResultsController._toggleImages, 2000);
1080         }
1081     }
1082     
1083     static _getResultContainer(node)
1084     {
1085         return (node.tagName == 'IMG') ? Utils.parentOfType(node, '.result-container') : node;
1086     }
1087
1088     static _convertToTogglingHandler(togglingImageFunction)
1089     {
1090         return function(node) {
1091             let url = (node.tagName == 'IMG') ? node.src : node.href;
1092             if (url.match('-expected.png$'))
1093                 TestResultsController._getResultContainer(node).remove();
1094             else if (url.match('-actual.png$')) {
1095                 let name = Utils.parentOfType(node, 'tbody').querySelector('.test-link').textContent;
1096                 TestResultsController._getResultContainer(node).outerHTML = togglingImageFunction(Utils.stripExtension(name));
1097             }
1098         }
1099     }
1100     
1101     static _convertToNonTogglingHandler(resultFunction)
1102     {
1103         return function(node) {
1104             let prefix = node.getAttribute('data-prefix');
1105             TestResultsController._getResultContainer(node).outerHTML = resultFunction(prefix, '-expected.png', 'expected') + resultFunction(prefix, '-actual.png', 'actual');
1106         }
1107     }
1108 };
1109
1110 class SectionBuilder {
1111     
1112     constructor(tests, tableID, resultsController)
1113     {
1114         this._tests = tests;
1115         this._table = null;
1116         this._resultsController = resultsController;
1117         this._tableID = tableID;
1118     }
1119
1120     build()
1121     {
1122         TestResults.sortByName(this._tests);
1123         
1124         let section = document.createElement('section');
1125         section.appendChild(TestResultsController._testListHeader(this.sectionTitle()));
1126         if (this.hideWhenShowingUnexpectedResultsOnly())
1127             section.classList.add('expected');
1128
1129         this._table = document.createElement('table');
1130         this._table.id = this.tableID();
1131         this.addTableHeader();
1132
1133         let visibleResultsCount = 0;
1134         for (let testResult of this._tests) {
1135             let tbody = this.createTableRow(testResult);
1136             this._table.appendChild(tbody);
1137             
1138             if (!this._resultsController.onlyShowUnexpectedFailures() || testResult.isExpected)
1139                 ++visibleResultsCount;
1140         }
1141         
1142         section.querySelector('.test-list-count').textContent = visibleResultsCount;
1143         section.appendChild(this._table);
1144         return section;
1145     }
1146
1147     createTableRow(testResult)
1148     {
1149         let tbody = document.createElement('tbody');
1150         if (testResult.isExpected)
1151             tbody.classList.add('expected');
1152         
1153         let row = document.createElement('tr');
1154         row.setAttribute('data-test-name', testResult.name);
1155         tbody.appendChild(row);
1156
1157         let testNameCell = document.createElement('td');
1158         this.fillTestCell(testResult, testNameCell);
1159         row.appendChild(testNameCell);
1160
1161         let resultCell = document.createElement('td');
1162         this.fillTestResultCell(testResult, resultCell);
1163         row.appendChild(resultCell);
1164
1165         let historyCell = this.createHistoryCell(testResult);
1166         if (historyCell)
1167             row.appendChild(historyCell);
1168
1169         return tbody;
1170     }
1171     
1172     hideWhenShowingUnexpectedResultsOnly()
1173     {
1174         return !TestResults.hasUnexpectedResult(this._tests);
1175     }
1176     
1177     addTableHeader()
1178     {
1179     }
1180     
1181     fillTestCell(testResult, cell)
1182     {
1183         let testLink = this.linkifyTestName() ? this._resultsController.testLink(testResult) : testResult.name;
1184         if (this.rowsAreExpandable()) {
1185             cell.innerHTML = this._resultsController.expandButtonSpan() + testLink;
1186             return;
1187         }
1188
1189         cell.innerHTML = testLink;
1190     }
1191
1192     fillTestResultCell(testResult, cell)
1193     {
1194     }
1195     
1196     createHistoryCell(testResult)
1197     {
1198         let historyCell = document.createElement('td');
1199         historyCell.innerHTML = '<a href="' + this._resultsController.flakinessDashboardURLForTests([testResult]) + '">history</a>'
1200         return historyCell;
1201     }
1202     
1203     tableID()
1204     {
1205         return this._tableID;
1206     }
1207     
1208     rowsAreExpandable()
1209     {
1210         return true;
1211     }
1212     
1213     linkifyTestName()
1214     {
1215         return true;
1216     }
1217
1218     sectionTitle() { return ''; }
1219 };
1220
1221 class FailuresSectionBuilder extends SectionBuilder {
1222     
1223     addTableHeader()
1224     {
1225         let header = document.createElement('thead');
1226         let html = '<th>test</th><th id="text-results-header">results</th><th id="image-results-header">image results</th>';
1227
1228         if (this._resultsController.testResults.usesExpectationsFile())
1229             html += '<th>actual failure</th><th>expected failure</th>';
1230
1231         html += '<th><a href="' + this._resultsController.flakinessDashboardURLForTests(this._tests) + '">history</a></th>';
1232
1233         if (this.tableID() == 'flaky-tests-table') // FIXME: use the classes, Luke!
1234             html += '<th>failures</th>';
1235
1236         header.innerHTML = html;
1237         this._table.appendChild(header);
1238     }
1239     
1240     createTableRow(testResult)
1241     {
1242         let tbody = document.createElement('tbody');
1243         if (testResult.isExpected)
1244             tbody.classList.add('expected');
1245         
1246         if (testResult.isMismatchRefTest())
1247             tbody.setAttribute('mismatchreftest', 'true');
1248
1249         let row = document.createElement('tr');
1250         row.setAttribute('data-test-name', testResult.name);
1251         tbody.appendChild(row);
1252         
1253         let testNameCell = document.createElement('td');
1254         this.fillTestCell(testResult, testNameCell);
1255         row.appendChild(testNameCell);
1256
1257         let resultCell = document.createElement('td');
1258         this.fillTestResultCell(testResult, resultCell);
1259         row.appendChild(resultCell);
1260
1261         if (testResult.isTextFailure())
1262             this.appendTextFailureLinks(testResult, resultCell);
1263
1264         if (testResult.isAudioFailure())
1265             this.appendAudioFailureLinks(testResult, resultCell);
1266             
1267         if (testResult.hasMissingResult())
1268             this.appendActualOnlyLinks(testResult, resultCell);
1269
1270         let actualTokens = testResult.info.actual.split(/\s+/);
1271
1272         let testPrefix = Utils.stripExtension(testResult.name);
1273         let imageResults = this.imageResultLinks(testResult, testPrefix, actualTokens[0]);
1274         if (!imageResults && actualTokens.length > 1)
1275             imageResults = this.imageResultLinks(testResult, 'retries/' + testPrefix, actualTokens[1]);
1276
1277         let imageResultsCell = document.createElement('td');
1278         imageResultsCell.innerHTML = imageResults;
1279         row.appendChild(imageResultsCell);
1280
1281         if (this._resultsController.testResults.usesExpectationsFile() || actualTokens.length) {
1282             let actualCell = document.createElement('td');
1283             actualCell.textContent = testResult.info.actual;
1284             row.appendChild(actualCell);
1285         }
1286
1287         if (this._resultsController.testResults.usesExpectationsFile()) {
1288             let expectedCell = document.createElement('td');
1289             expectedCell.textContent = testResult.hasMissingResult() ? '' : testResult.info.expected;
1290             row.appendChild(expectedCell);
1291         }
1292
1293         let historyCell = this.createHistoryCell(testResult);
1294         if (historyCell)
1295             row.appendChild(historyCell);
1296
1297         return tbody;
1298     }
1299
1300     appendTextFailureLinks(testResult, cell)
1301     {
1302         cell.innerHTML += this._resultsController.textResultLinks(Utils.stripExtension(testResult.name));
1303     }
1304     
1305     appendAudioFailureLinks(testResult, cell)
1306     {
1307         let prefix = Utils.stripExtension(testResult.name);
1308         cell.innerHTML += TestResultsController.resultLink(prefix, '-expected.wav', 'expected audio')
1309             + TestResultsController.resultLink(prefix, '-actual.wav', 'actual audio')
1310             + TestResultsController.resultLink(prefix, '-diff.txt', 'textual diff');
1311     }
1312     
1313     appendActualOnlyLinks(testResult, cell)
1314     {
1315         let prefix = Utils.stripExtension(testResult.name);
1316         if (testResult.isMissingAudio())
1317             cell.innerHTML += TestResultsController.resultLink(prefix, '-actual.wav', 'audio result');
1318
1319         if (testResult.isMissingText())
1320             cell.innerHTML += TestResultsController.resultLink(prefix, '-actual.txt', 'result');
1321     }
1322
1323     imageResultLinks(testResult, testPrefix, resultToken)
1324     {
1325         let result = '';
1326         if (resultToken.indexOf('IMAGE') != -1) {
1327             let testExtension = Utils.splitExtension(testResult.name)[1];
1328
1329             if (testResult.isMismatchRefTest()) {
1330                 result += TestResultsController.resultLink(this._resultsController.layoutTestsBasePath() + testPrefix, '-expected-mismatch.' + testExtension, 'ref mismatch');
1331                 result += TestResultsController.resultLink(testPrefix, '-actual.png', 'actual');
1332             } else {
1333                 if (testResult.isMatchRefTest())
1334                     result += TestResultsController.resultLink(this._resultsController.layoutTestsBasePath() + testPrefix, '-expected.' + testExtension, 'reference');
1335
1336                 if (this._resultsController.shouldToggleImages)
1337                     result += TestResultsController.resultLink(testPrefix, '-diffs.html', 'images');
1338                 else {
1339                     result += TestResultsController.resultLink(testPrefix, '-expected.png', 'expected');
1340                     result += TestResultsController.resultLink(testPrefix, '-actual.png', 'actual');
1341                 }
1342
1343                 let diff = testResult.info.image_diff_percent;
1344                 result += TestResultsController.resultLink(testPrefix, '-diff.png', 'diff (' + diff + '%)');
1345             }
1346         }
1347         
1348         if (testResult.hasMissingResult() && testResult.isMissingImage())
1349             result += TestResultsController.resultLink(testPrefix, '-actual.png', 'png result');
1350         
1351         return result;
1352     }
1353 };
1354
1355 class FailingTestsSectionBuilder extends FailuresSectionBuilder {
1356     sectionTitle() { return 'Tests that failed text/pixel/audio diff'; }
1357 };
1358
1359 class TestsWithMissingResultsSectionBuilder extends FailuresSectionBuilder {
1360     sectionTitle() { return 'Tests that had no expected results (probably new)'; }
1361
1362     rowsAreExpandable()
1363     {
1364         return false;
1365     }
1366 };
1367
1368 class FlakyPassTestsSectionBuilder extends FailuresSectionBuilder {
1369     sectionTitle() { return 'Flaky tests (failed the first run and passed on retry)'; }
1370 };
1371
1372 class UnexpectedPassTestsSectionBuilder extends SectionBuilder {
1373     sectionTitle() { return 'Tests expected to fail but passed'; }
1374
1375     addTableHeader()
1376     {
1377         let header = document.createElement('thead');
1378         header.innerHTML = '<th>test</th><th>expected failure</th><th>history</th>';
1379         this._table.appendChild(header);
1380     }
1381     
1382     fillTestResultCell(testResult, cell)
1383     {
1384         cell.innerHTML = testResult.info.expected;
1385     }
1386
1387     rowsAreExpandable()
1388     {
1389         return false;
1390     }
1391 };
1392
1393 class TestsWithStdErrSectionBuilder extends SectionBuilder {
1394     sectionTitle() { return 'Tests that had stderr output'; }
1395     hideWhenShowingUnexpectedResultsOnly() { return false; }
1396
1397     fillTestResultCell(testResult, cell)
1398     {
1399         cell.innerHTML = TestResultsController.resultLink(Utils.stripExtension(testResult.name), '-stderr.txt', 'stderr');
1400     }
1401 };
1402
1403 class TimedOutTestsSectionBuilder extends SectionBuilder {
1404     sectionTitle() { return 'Tests that timed out'; }
1405
1406     fillTestResultCell(testResult, cell)
1407     {
1408         // FIXME: only include timeout actual/diff results here if we actually spit out results for timeout tests.
1409         cell.innerHTML = this._resultsController.textResultLinks(Utils.stripExtension(testResult.name));
1410     }
1411 };
1412
1413 class CrashingTestsSectionBuilder extends SectionBuilder {
1414     sectionTitle() { return 'Tests that crashed'; }
1415
1416     fillTestResultCell(testResult, cell)
1417     {
1418         cell.innerHTML = TestResultsController.resultLink(Utils.stripExtension(testResult.name), '-crash-log.txt', 'crash log')
1419                        + TestResultsController.resultLink(Utils.stripExtension(testResult.name), '-sample.txt', 'sample');
1420     }
1421 };
1422
1423 class OtherCrashesSectionBuilder extends SectionBuilder {
1424     sectionTitle() { return 'Other crashes'; }
1425     fillTestResultCell(testResult, cell)
1426     {
1427         cell.innerHTML = TestResultsController.resultLink(Utils.stripExtension(testResult.name), '-crash-log.txt', 'crash log');
1428     }
1429
1430     createHistoryCell(testResult)
1431     {
1432         return null;
1433     }
1434
1435     linkifyTestName()
1436     {
1437         return false;
1438     }
1439 };
1440
1441 class PixelZoomer {
1442     constructor()
1443     {
1444         this.showOnDelay = true;
1445         this._zoomFactor = 6;
1446
1447         this._resultWidth = 800;
1448         this._resultHeight = 600;
1449         
1450         this._percentX = 0;
1451         this._percentY = 0;
1452
1453         document.addEventListener('mousemove', this, false);
1454         document.addEventListener('mouseout', this, false);
1455     }
1456
1457     _zoomedResultWidth()
1458     {
1459         return this._resultWidth * this._zoomFactor;
1460     }
1461     
1462     _zoomedResultHeight()
1463     {
1464         return this._resultHeight * this._zoomFactor;
1465     }
1466     
1467     _zoomImageContainer(url)
1468     {
1469         let container = document.createElement('div');
1470         container.className = 'zoom-image-container';
1471
1472         let title = url.match(/\-([^\-]*)\.png/)[1];
1473     
1474         let label = document.createElement('div');
1475         label.className = 'label';
1476         label.appendChild(document.createTextNode(title));
1477         container.appendChild(label);
1478     
1479         let imageContainer = document.createElement('div');
1480         imageContainer.className = 'scaled-image-container';
1481     
1482         let image = new Image();
1483         image.src = url;
1484         image.style.width = this._zoomedResultWidth() + 'px';
1485         image.style.height = this._zoomedResultHeight() + 'px';
1486         image.style.border = '1px solid black';
1487         imageContainer.appendChild(image);
1488         container.appendChild(imageContainer);
1489     
1490         return container;
1491     }
1492
1493     _createContainer(e)
1494     {
1495         let tbody = Utils.parentOfType(e.target, 'tbody');
1496         let row = tbody.querySelector('tr');
1497         let imageDiffLinks = row.querySelectorAll('a[href$=".png"]');
1498     
1499         let container = document.createElement('div');
1500         container.className = 'pixel-zoom-container';
1501     
1502         let html = '';
1503     
1504         let togglingImageLink = row.querySelector('a[href$="-diffs.html"]');
1505         if (togglingImageLink) {
1506             let prefix = togglingImageLink.getAttribute('data-prefix');
1507             container.appendChild(this._zoomImageContainer(prefix + '-expected.png'));
1508             container.appendChild(this._zoomImageContainer(prefix + '-actual.png'));
1509         }
1510     
1511         for (let link of imageDiffLinks)
1512             container.appendChild(this._zoomImageContainer(link.href));
1513
1514         document.body.appendChild(container);
1515         this._drawAll();
1516     }
1517
1518     _draw(imageContainer)
1519     {
1520         let image = imageContainer.querySelector('img');
1521         let containerBounds = imageContainer.getBoundingClientRect();
1522         image.style.left = (containerBounds.width / 2 - this._percentX * this._zoomedResultWidth()) + 'px';
1523         image.style.top = (containerBounds.height / 2 - this._percentY * this._zoomedResultHeight()) + 'px';
1524     }
1525
1526     _drawAll()
1527     {
1528         Utils.forEach(document.querySelectorAll('.pixel-zoom-container .scaled-image-container'), element => { this._draw(element) });
1529     }
1530     
1531     handleEvent(event)
1532     {
1533         if (event.type == 'mousemove') {
1534             this._handleMouseMove(event);
1535             return;
1536         }
1537
1538         if (event.type == 'mouseout') {
1539             this._handleMouseOut(event);
1540             return;
1541         }
1542     }
1543
1544     _handleMouseOut(event)
1545     {
1546         if (event.relatedTarget && event.relatedTarget.tagName != 'IFRAME')
1547             return;
1548
1549         // If e.relatedTarget is null, we've moused out of the document.
1550         let container = document.querySelector('.pixel-zoom-container');
1551         if (container)
1552             container.remove();
1553     }
1554
1555     _handleMouseMove(event)
1556     {
1557         if (this._mouseMoveTimeout) {
1558             clearTimeout(this._mouseMoveTimeout);
1559             this._mouseMoveTimeout = 0;
1560         }
1561
1562         if (Utils.parentOfType(event.target, '.pixel-zoom-container'))
1563             return;
1564
1565         let container = document.querySelector('.pixel-zoom-container');
1566     
1567         let resultContainer = (event.target.className == 'result-container') ? event.target : Utils.parentOfType(event.target, '.result-container');
1568         if (!resultContainer || !resultContainer.querySelector('img')) {
1569             if (container)
1570                 container.remove();
1571             return;
1572         }
1573
1574         let targetLocation = event.target.getBoundingClientRect();
1575         this._percentX = (event.clientX - targetLocation.left) / targetLocation.width;
1576         this._percentY = (event.clientY - targetLocation.top) / targetLocation.height;
1577
1578         if (!container) {
1579             if (this.showOnDelay) {
1580                 this._mouseMoveTimeout = setTimeout(() => {
1581                     this._createContainer(event);
1582                 }, 400);
1583                 return;
1584             }
1585
1586             this._createContainer(event);
1587             return;
1588         }
1589     
1590         this._drawAll();
1591     }
1592 };
1593
1594 class TableSorter
1595 {
1596     static _forwardArrow()
1597     {
1598         return '<svg style="width:10px;height:10px"><polygon points="0,0 10,0 5,10" style="fill:#ccc"></svg>';
1599     }
1600
1601     static _backwardArrow()
1602     {
1603         return '<svg style="width:10px;height:10px"><polygon points="0,10 10,10 5,0" style="fill:#ccc"></svg>';
1604     }
1605
1606     static _sortedContents(header, arrow)
1607     {
1608         return arrow + ' ' + Utils.trim(header.textContent) + ' ' + arrow;
1609     }
1610
1611     static _updateHeaderClassNames(newHeader)
1612     {
1613         let sortHeader = document.querySelector('.sortHeader');
1614         if (sortHeader) {
1615             if (sortHeader == newHeader) {
1616                 let isAlreadyReversed = sortHeader.classList.contains('reversed');
1617                 if (isAlreadyReversed)
1618                     sortHeader.classList.remove('reversed');
1619                 else
1620                     sortHeader.classList.add('reversed');
1621             } else {
1622                 sortHeader.textContent = sortHeader.textContent;
1623                 sortHeader.classList.remove('sortHeader');
1624                 sortHeader.classList.remove('reversed');
1625             }
1626         }
1627
1628         newHeader.classList.add('sortHeader');
1629     }
1630
1631     static _textContent(tbodyRow, column)
1632     {
1633         return tbodyRow.querySelectorAll('td')[column].textContent;
1634     }
1635
1636     static _sortRows(newHeader, reversed)
1637     {
1638         let testsTable = document.getElementById('results-table');
1639         let headers = Utils.toArray(testsTable.querySelectorAll('th'));
1640         let sortColumn = headers.indexOf(newHeader);
1641
1642         let rows = Utils.toArray(testsTable.querySelectorAll('tbody'));
1643
1644         rows.sort(function(a, b) {
1645             // Only need to support lexicographic sort for now.
1646             let aText = TableSorter._textContent(a, sortColumn);
1647             let bText = TableSorter._textContent(b, sortColumn);
1648         
1649             // Forward sort equal values by test name.
1650             if (sortColumn && aText == bText) {
1651                 let aTestName = TableSorter._textContent(a, 0);
1652                 let bTestName = TableSorter._textContent(b, 0);
1653                 if (aTestName == bTestName)
1654                     return 0;
1655                 return aTestName < bTestName ? -1 : 1;
1656             }
1657
1658             if (reversed)
1659                 return aText < bText ? 1 : -1;
1660             else
1661                 return aText < bText ? -1 : 1;
1662         });
1663
1664         for (let row of rows)
1665             testsTable.appendChild(row);
1666     }
1667
1668     static sortColumn(columnNumber)
1669     {
1670         let newHeader = document.getElementById('results-table').querySelectorAll('th')[columnNumber];
1671         TableSorter._sort(newHeader);
1672     }
1673
1674     static handleClick(e)
1675     {
1676         let newHeader = e.target;
1677         if (newHeader.localName != 'th')
1678             return;
1679         TableSorter._sort(newHeader);
1680     }
1681
1682     static _sort(newHeader)
1683     {
1684         TableSorter._updateHeaderClassNames(newHeader);
1685     
1686         let reversed = newHeader.classList.contains('reversed');
1687         let sortArrow = reversed ? TableSorter._backwardArrow() : TableSorter._forwardArrow();
1688         newHeader.innerHTML = TableSorter._sortedContents(newHeader, sortArrow);
1689     
1690         TableSorter._sortRows(newHeader, reversed);
1691     }    
1692 };
1693
1694 class OptionWriter {
1695     static save()
1696     {
1697         let options = document.querySelectorAll('label input');
1698         let data = {};
1699         for (let option of options)
1700             data[option.id] = option.checked;
1701
1702         try {
1703             localStorage.setItem(OptionWriter._key, JSON.stringify(data));
1704         } catch (err) {
1705             if (err.name != "SecurityError")
1706                 throw err;
1707         }
1708     }
1709
1710     static apply()
1711     {
1712         let json;
1713         try {
1714             json = localStorage.getItem(OptionWriter._key);
1715         } catch (err) {
1716            if (err.name != "SecurityError")
1717               throw err;
1718         }
1719
1720         if (!json) {
1721             controller.updateAllOptions();
1722             return;
1723         }
1724
1725         let data = JSON.parse(json);
1726         for (let id in data) {
1727             let input = document.getElementById(id);
1728             if (input)
1729                 input.checked = data[id];
1730         }
1731         controller.updateAllOptions();
1732     }
1733
1734     static get _key()
1735     {
1736         return 'run-webkit-tests-options';
1737     }
1738 };
1739
1740 let testResults;
1741 function ADD_RESULTS(input)
1742 {
1743     testResults = new TestResults(input);
1744 }
1745 </script>
1746
1747 <script src="full_results.json"></script>
1748
1749 <script>
1750
1751 class TestNavigator
1752 {
1753     constructor() {
1754         this.currentTestIndex = -1;
1755         this.flaggedTests = {};
1756         document.addEventListener('keypress', this, false);
1757     }
1758     
1759     handleEvent(event)
1760     {
1761         if (event.type == 'keypress') {
1762             this.handleKeyEvent(event);
1763             return;
1764         }
1765     }
1766
1767     handleKeyEvent(event)
1768     {
1769         if (event.metaKey || event.shiftKey || event.ctrlKey)
1770             return;
1771
1772         switch (String.fromCharCode(event.charCode)) {
1773             case 'i':
1774                 this._scrollToFirstTest();
1775                 break;
1776             case 'j':
1777                 this._scrollToNextTest();
1778                 break;
1779             case 'k':
1780                 this._scrollToPreviousTest();
1781                 break;
1782             case 'l':
1783                 this._scrollToLastTest();
1784                 break;
1785             case 'e':
1786                 this._expandCurrentTest();
1787                 break;
1788             case 'c':
1789                 this._collapseCurrentTest();
1790                 break;
1791             case 't':
1792                 this._toggleCurrentTest();
1793                 break;
1794             case 'f':
1795                 this._toggleCurrentTestFlagged();
1796                 break;
1797         }
1798     }
1799
1800     _scrollToFirstTest()
1801     {
1802         if (this._setCurrentTest(0))
1803             this._scrollToCurrentTest();
1804     }
1805
1806     _scrollToLastTest()
1807     {
1808         let links = controller.visibleTests();
1809         if (this._setCurrentTest(links.length - 1))
1810             this._scrollToCurrentTest();
1811     }
1812
1813     _scrollToNextTest()
1814     {
1815         if (this.currentTestIndex == -1)
1816             this._scrollToFirstTest();
1817         else if (this._setCurrentTest(this.currentTestIndex + 1))
1818             this._scrollToCurrentTest();
1819     }
1820
1821     _scrollToPreviousTest()
1822     {
1823         if (this.currentTestIndex == -1)
1824             this._scrollToLastTest();
1825         else if (this._setCurrentTest(this.currentTestIndex - 1))
1826             this._scrollToCurrentTest();
1827     }
1828
1829     _currentTestLink()
1830     {
1831         let links = controller.visibleTests();
1832         return links[this.currentTestIndex];
1833     }
1834
1835     _currentTestExpandLink()
1836     {
1837         return this._currentTestLink().querySelector('.expand-button-text');
1838     }
1839
1840     _expandCurrentTest()
1841     {
1842         controller.expandExpectations(this._currentTestExpandLink());
1843     }
1844
1845     _collapseCurrentTest()
1846     {
1847         controller.collapseExpectations(this._currentTestExpandLink());
1848     }
1849
1850     _toggleCurrentTest()
1851     {
1852         controller.toggleExpectations(this._currentTestExpandLink());
1853     }
1854
1855     _toggleCurrentTestFlagged()
1856     {
1857         let testLink = this._currentTestLink();
1858         this.flagTest(testLink, !testLink.classList.contains('flagged'));
1859     }
1860
1861     // FIXME: Test navigator shouldn't know anything about flagging. It should probably call out to TestFlagger or something.
1862     // FIXME: Batch flagging (avoid updateFlaggedTests on each test).
1863     flagTest(testTbody, shouldFlag)
1864     {
1865         let testName = testTbody.querySelector('.test-link').innerText;
1866     
1867         if (shouldFlag) {
1868             testTbody.classList.add('flagged');
1869             this.flaggedTests[testName] = 1;
1870         } else {
1871             testTbody.classList.remove('flagged');
1872             delete this.flaggedTests[testName];
1873         }
1874
1875         this.updateFlaggedTests();
1876     }
1877
1878     updateFlaggedTests()
1879     {
1880         let flaggedTestTextbox = document.getElementById('flagged-tests');
1881         if (!flaggedTestTextbox) {
1882             let flaggedTestContainer = document.createElement('div');
1883             flaggedTestContainer.id = 'flagged-test-container';
1884             flaggedTestContainer.className = 'floating-panel';
1885             flaggedTestContainer.innerHTML = '<h2>Flagged Tests</h2><pre id="flagged-tests" contentEditable></pre>';
1886             document.body.appendChild(flaggedTestContainer);
1887
1888             flaggedTestTextbox = document.getElementById('flagged-tests');
1889         }
1890
1891         let flaggedTests = Object.keys(this.flaggedTests);
1892         flaggedTests.sort();
1893         let separator = document.getElementById('use-newlines').checked ? '\n' : ' ';
1894         flaggedTestTextbox.innerHTML = flaggedTests.join(separator);
1895         document.getElementById('flagged-test-container').style.display = flaggedTests.length ? '' : 'none';
1896     }
1897
1898     _setCurrentTest(testIndex)
1899     {
1900         let links = controller.visibleTests();
1901         if (testIndex < 0 || testIndex >= links.length)
1902             return false;
1903
1904         let currentTest = links[this.currentTestIndex];
1905         if (currentTest)
1906             currentTest.classList.remove('current');
1907
1908         this.currentTestIndex = testIndex;
1909
1910         currentTest = links[this.currentTestIndex];
1911         currentTest.classList.add('current');
1912
1913         return true;
1914     }
1915
1916     _scrollToCurrentTest()
1917     {
1918         let targetLink = this._currentTestLink();
1919         if (!targetLink)
1920             return;
1921
1922         let rowRect = targetLink.getBoundingClientRect();
1923         // rowRect is in client coords (i.e. relative to viewport), so we just want to add its top to the current scroll position.
1924         document.body.scrollTop += rowRect.top;
1925     }
1926
1927     onlyShowUnexpectedFailuresChanged()
1928     {
1929         let currentTest = document.querySelector('.current');
1930         if (!currentTest)
1931             return;
1932
1933         // If our currentTest became hidden, reset the currentTestIndex.
1934         if (controller.onlyShowUnexpectedFailures() && currentTest.classList.contains('expected'))
1935             this._scrollToFirstTest();
1936         else {
1937             // Recompute this.currentTestIndex
1938             let links = controller.visibleTests();
1939             this.currentTestIndex = links.indexOf(currentTest);
1940         }
1941     }
1942 };
1943
1944 function handleMouseDown(e)
1945 {
1946     if (!Utils.parentOfType(e.target, '#options-menu') && e.target.id != 'options-link')
1947         document.getElementById('options-menu').className = 'hidden-menu';
1948 }
1949
1950 document.addEventListener('mousedown', handleMouseDown, false);
1951
1952 let controller;
1953 let pixelZoomer;
1954 let testNavigator;
1955
1956 function generatePage()
1957 {
1958     let container = document.getElementById('main-content');
1959
1960     controller = new TestResultsController(container, testResults);
1961     pixelZoomer = new PixelZoomer();
1962     testNavigator = new TestNavigator();
1963
1964     OptionWriter.apply();
1965 }
1966
1967 window.addEventListener('load', generatePage, false);
1968
1969 </script>
1970 <body>
1971     
1972     <div class="content-container">
1973         <div id="toolbar" class="floating-panel">
1974         <div class="note">Use the i, j, k and l keys to navigate, e, c to expand and collapse, and f to flag</div>
1975         <a class="clickable" onclick="controller.expandAllExpectations()">expand all</a>
1976         <a class="clickable" onclick="controller.collapseAllExpectations()">collapse all</a>
1977         <a class="clickable" id=options-link onclick="controller.toggleOptionsMenu()">options</a>
1978         <div id="options-menu" class="hidden-menu">
1979             <label><input id="unexpected-results" type="checkbox" checked onchange="controller.handleUnexpectedResultsChange()">Only unexpected results</label>
1980             <label><input id="toggle-images" type="checkbox" checked onchange="controller.handleToggleImagesChange()">Toggle images</label>
1981             <label title="Use newlines instead of spaces to separate flagged tests"><input id="use-newlines" type="checkbox" checked onchange="controller.handleToggleUseNewlines()">Use newlines in flagged list</label>
1982         </div>
1983     </div>
1984
1985 <div id="main-content"></div>
1986
1987 </body>