<!DOCTYPE html>
<html>
<head>
<style>
:root {
    color-scheme: dark light;
    --clickable-link-color: blue;
    --item-highlight-color: red;
    --button-background-color: white;
    --panel-background-color: rgba(255, 255, 255, 0.9);
}

@media (prefers-color-scheme: dark) {
    :root {
        --item-highlight-color: #F8490A;
        --button-background-color: #666;
        --panel-background-color: rgba(50, 50, 50, 0.9);
        --clickable-link-color: #9e9eff;
    }
    /* Remove when webkit.org/b/209851 is fixed. */
    a:link { color: #9e9eff; }
    a:visited { color: #d0adf0; }
    a:active { color: red; }
}

body {
    margin: 0;
    font-family: Helvetica, sans-serif;
    font-size: 11pt;
}

body > * {
    margin-left: 4px;
    margin-top: 4px;
}

h1 {
    font-size: 14pt;
    margin-top: 1.5em;
}

p {
    margin-bottom: 0.3em;
}

a.clickable {
    color: var(--clickable-link-color);
    cursor: pointer;
    margin-left: 0.2em;
}

tr:not(.results-row) td {
    white-space: nowrap;
}

tr:not(.results-row) td:first-of-type {
    white-space: normal;
}

td:not(:first-of-type) {
    text-transform: lowercase;
}

th, td {
    padding: 1px 4px;
    vertical-align: top;
}

td:nth-child(1) {  
    min-width: 35em;
}

th:empty, td:empty {
    padding: 0;
}

th {
    -webkit-user-select: none;
    -moz-user-select: none;
}

dt > sup {
    vertical-align:text-top;
    font-size:75%;
}

sup > a {
    text-decoration: none;
}

.content-container {
    min-height: 0;
}

.note {
    color: gray;
    font-size: smaller;
}

.results-row iframe, .results-row img {
    width: 800px;
    height: 600px;
}

.results-row[data-expanded="false"] {
    display: none;
}

#toolbar {
    position: fixed;
    top: 2px;
    right: 2px;
    text-align: right;
}

.floating-panel {
    padding: 6px;
    background-color: var(--panel-background-color);
    border: 1px solid silver;
    border-radius: 4px;
}

.expand-button {
    background-color: var(--button-background-color);
    width: 11px;
    height: 12px;
    border: 1px solid gray;
    display: inline-block;
    margin: 0 3px 0 0;
    position: relative;
    cursor: default;
}

.current {
    color: var(--item-highlight-color);
}

.current .expand-button {
    border-color: var(--item-highlight-color);
}

.expand-button-text {
    position: absolute;
    top: -0.3em;
    left: 1px;
}

tbody .flag {
    display: none;
}

tbody.flagged .flag {
    display: inline;
}

.stopped-running-early-message {
    border: 3px solid #d00;
    font-weight: bold;
    display: inline-block;
    padding: 3px;
}

.result-container {
    display: inline-block;
    border: 1px solid gray;
    margin: 4px;
}

.result-container iframe, .result-container img {
    border: 0;
    vertical-align: top;
}

.leaks > table {
    margin: 4px;
}
.leaks > td {
    padding-left: 20px;
}

#options {
    background-color: white;
}

#options-menu {
    border: 1px solid gray;
    border-radius: 4px;
    margin-top: 1px;
    padding: 2px 4px;
    box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.6);
    transition: opacity .2s;
    text-align: left;
    position: absolute;
    right: 4px;
    background-color: var(--panel-background-color);
}

#options-menu label {
    display: block;
}

.hidden-menu {
    pointer-events: none;
    opacity: 0;
}

.label {
    padding-left: 3px;
    font-weight: bold;
    font-size: small;
    background-color: silver;
}

.pixel-zoom-container {
    position: fixed;
    top: 0;
    left: 0;
    width: 96%;
    margin: 10px;
    padding: 10px;
    display: -webkit-box;
    display: -moz-box;
    pointer-events: none;
    background-color: silver;
    border-radius: 20px;
    border: 1px solid gray;
    box-shadow: 0 0 5px rgba(0, 0, 0, 0.75);
}

.pixel-zoom-container > * {
    -webkit-box-flex: 1;
    -moz-box-flex: 1;
    border: 1px solid black;
    margin: 4px;
    overflow: hidden;
    background-color: white;
}

.pixel-zoom-container .scaled-image-container {
    position: relative;
    overflow: hidden;
    width: 100%;
    height: 400px;
}

.scaled-image-container > img {
    position: absolute;
    top: 0;
    left: 0;
    image-rendering: -webkit-optimize-contrast;
}

#flagged-test-container {
    position: fixed;
    bottom: 4px;
    right: 4px;
    width: 50%;
    min-width: 400px;
    background-color: var(--panel-background-color);
}

#flagged-test-container > h2 {
    margin: 0 0 4px 0;
}

#flagged-tests {
    padding: 0 5px;
    margin: 0;
    height: 7em;
    overflow-y: scroll;
}
</style>
<style id="unexpected-style"></style>

<script>
if (window.testRunner)
    testRunner.dumpAsText();

class Utils
{
    static matchesSelector(node, selector)
    {
        if (node.matches)
            return node.matches(selector);

        if (node.webkitMatchesSelector)
            return node.webkitMatchesSelector(selector);

        if (node.mozMatchesSelector)
            return node.mozMatchesSelector(selector);
    }

    static parentOfType(node, selector)
    {
        while (node = node.parentNode) {
            if (Utils.matchesSelector(node, selector))
                return node;
        }
        return null;
    }

    static testPrefix(testName)
    {
        let variant = '';
        let variantSeparatorIndex = testName.indexOf('?');
        if (variantSeparatorIndex >= 0) {
            variant = testName.substring(variantSeparatorIndex + 1);
            testName = testName.substring(0, variantSeparatorIndex);
        }
        variantSeparatorIndex = testName.indexOf('#');
        if (variantSeparatorIndex >= 0) {
            variant = testName.substring(variantSeparatorIndex + 1);
            testName = testName.substring(0, variantSeparatorIndex);
        }

        let prefix = Utils.splitExtension(testName)[0];
        if (variant.length)
            prefix += '_' + variant.replace(/[\|\* <>:]/g, '_');
        return prefix;
    }

    static splitExtension(testName)
    {
        // Splits 'foo/bar/boo.html?blah' to ['foo/bar/boo', '.html', '?blah']. Last item can be
        // null if `testName` had no URL fragment or query separator.
        let filenameIndex = testName.lastIndexOf("/");
        let buffer = testName.substr(filenameIndex + 1);
        buffer = buffer.split('?')[0];
        buffer = buffer.split('#')[0];
        let parts = buffer.split('.');
        let extensionIndex = buffer.lastIndexOf(".");
        let filename = buffer.substr(0, extensionIndex);
        // Test names that are acutally process names are treated like they don't have any extension
        if (filename.match(/com\.apple\..+/) || filename.match(/org\.WebKit\..+/)) {
            return [testName, '', ''];
        }
        let ext = buffer.substr(extensionIndex);
        let remainsIndex = testName.lastIndexOf(ext);
        let remains = testName.substr(remainsIndex + ext.length);
        return [testName.substr(0, filenameIndex) + '/' + filename, ext, remains];
    }

    static forEach(nodeList, handler)
    {
        Array.prototype.forEach.call(nodeList, handler);
    }

    static toArray(nodeList)
    {
        return Array.prototype.slice.call(nodeList);
    }

    static trim(string)
    {
        return string.replace(/^[\s\xa0]+|[\s\xa0]+$/g, '');
    }

    static async(func, args)
    {
        setTimeout(() => { func.apply(null, args); }, 50);
    }

    static appendHTML(node, html)
    {
        if (node.insertAdjacentHTML)
            node.insertAdjacentHTML('beforeEnd', html);
        else
            node.innerHTML += html;
    }};

class TestResult
{
    constructor(info, name)
    {
        this.name = name;
        this.info = info; // FIXME: make this private.
    }

    isFailureExpected()
    {
        let actual = this.info.actual;    
        let expected = this.info.expected || 'PASS';

        if (actual != 'SKIP') {
            let expectedArray = expected.split(' ');
            let actualArray = actual.split(' ');
            for (let actualValue of actualArray) {
                if (expectedArray.indexOf(actualValue) == -1 && (expectedArray.indexOf('FAIL') == -1 || (actualValue != 'TEXT' && actualValue != 'IMAGE+TEXT' && actualValue != 'AUDIO')))
                    return false;
            }
        }
        return true;
    }
    
    isMissingAllResults()
    {
        return this.info.actual == 'MISSING';
    }
    
    hasMissingResult()
    {
        return this.info.actual.indexOf('MISSING') != -1;
    }
    
    isFlakey(pixelTestsEnabled)
    {
        let actualTokens = this.info.actual.split(' ');
        let passedWithImageOnlyFailureInRetry = actualTokens[0] == 'TEXT' && actualTokens[1] == 'IMAGE';
        if (actualTokens[1] && this.info.actual.indexOf('PASS') != -1 || (!pixelTestsEnabled && passedWithImageOnlyFailureInRetry))
            return true;
        
        return false;
    }
    
    isPass()
    {
        return this.info.actual == 'PASS';
    }

    isTextFailure()
    {
        return this.info.actual.indexOf('TEXT') != -1;
    }

    isImageFailure()
    {
        return this.info.actual.indexOf('IMAGE') != -1;
    }

    isAudioFailure()
    {
        return this.info.actual.indexOf('AUDIO') != -1;
    }

    hasLeak()
    {
        return this.info.actual.indexOf('LEAK') != -1;
    }

    isCrash()
    {
        return this.info.actual == 'CRASH';
    }
    
    isTimeout()
    {
        return this.info.actual == 'TIMEOUT';
    }
    
    isUnexpectedPass(pixelTestsEnabled)
    {
        if (this.info.actual == 'PASS' && this.info.expected != 'PASS') {
            if (this.info.expected != 'IMAGE' || (pixelTestsEnabled || this.isRefTest()))
                return true;
        }
        
        return false;
    }
    
    isRefTest()
    {
        return !!this.info.reftest_type;
    }

    isMismatchRefTest()
    {
        return this.isRefTest() && this.info.reftest_type.indexOf('!=') != -1;
    }

    isMatchRefTest()
    {
        return this.isRefTest() && this.info.reftest_type.indexOf('==') != -1;
    }
    
    isMissingText()
    {
        return this.info.is_missing_text;
    }

    isMissingImage()
    {
        return this.info.is_missing_image;
    }

    isMissingAudio()
    {
        return this.info.is_missing_audio;
    }
    
    hasStdErr()
    {
        return this.info.has_stderr;
    }
};

class TestResults
{
    constructor(results)
    {
        this._results = results;

        this.crashTests = [];
        this.crashOther = [];
        this.missingResults = [];
        this.failingTests = [];
        this.testsWithStderr = [];
        this.timeoutTests = [];
        this.unexpectedPassTests = [];
        this.flakyPassTests = [];

        this.hasHttpTests = false;
        this.hasImageFailures = false;
        this.hasTextFailures = false;

        this._testsByName = new Map;

        this._forEachTest(this._results.tests, '');
        this._forOtherCrashes(this._results.other_crashes);
    }
    
    date()
    {
        return this._results.date;
    }

    layoutTestsDir()
    {
        return this._results.layout_tests_dir;
    }
    
    usesExpectationsFile()
    {
        return this._results.uses_expectations_file;
    }
    
    resultForTest(testName)
    {
        return this._resultsByTest[testName];
    }
    
    wasInterrupted()
    {
        return this._results.interrupted;
    }

    hasPrettyPatch()
    {
        return this._results.has_pretty_patch;
    }
    
    hasWDiff()
    {
        return this._results.has_wdiff;
    }
    
    testWithName(testName)
    {
        return this._testsByName.get(testName);
    }

    _processResultForTest(testResult)
    {
        this._testsByName.set(testResult.name, testResult);

        let test = testResult.name;
        if (testResult.hasStdErr())
            this.testsWithStderr.push(testResult);

        this.hasHttpTests |= test.indexOf('http/') == 0;

        if (this.usesExpectationsFile())
            testResult.isExpected = testResult.isFailureExpected();
        
        if (testResult.isTextFailure())
            this.hasTextFailures = true;

        if (testResult.isImageFailure())
            this.hasImageFailures = true;

        if (testResult.isMissingAllResults()) {
            // FIXME: make sure that new-run-webkit-tests spits out an -actual.txt file for tests with MISSING results.
            this.missingResults.push(testResult);
            return;
        }

        if (testResult.isFlakey(this._results.pixel_tests_enabled)) {
            this.flakyPassTests.push(testResult);
            return;
        }

        if (testResult.isPass()) {
            if (testResult.isUnexpectedPass(this._results.pixel_tests_enabled))
                this.unexpectedPassTests.push(testResult);
            return;
        }

        if (testResult.isCrash()) {
            this.crashTests.push(testResult);
            return;
        }

        if (testResult.isTimeout()) {
            this.timeoutTests.push(testResult);
            return;
        }
    
        this.failingTests.push(testResult);
    }
    
    _forEachTest(tree, prefix)
    {
        for (let key in tree) {
            let newPrefix = prefix ? (prefix + '/' + key) : key;
            if ('actual' in tree[key]) {
                let testObject = new TestResult(tree[key], newPrefix);
                this._processResultForTest(testObject);
            } else
                this._forEachTest(tree[key], newPrefix);
        }
    }

    _forOtherCrashes(tree)
    {
        for (let key in tree) {
            let testObject = new TestResult(tree[key], key);
            this.crashOther.push(testObject);
        }
    }
    
    static sortByName(tests)
    {
        tests.sort(function (a, b) { return a.name.localeCompare(b.name) });
    }

    static hasUnexpectedResult(tests)
    {
        return tests.some(function (test) { return !test.isExpected; });
    }
};

class TestResultsController
{        
    constructor(containerElement, testResults)
    {
        this.containerElement = containerElement;
        this.testResults = testResults;

        this.shouldToggleImages = true;
        this._togglingImageInterval = null;
        
        this._updatePageTitle();

        this.buildResultsTables();
        this.hideNonApplicableUI();
        this.setupSorting();
        this.setupOptions();
    }
    
    buildResultsTables()
    {
        if (this.testResults.wasInterrupted()) {
            let interruptionMessage = document.createElement('p');
            interruptionMessage.textContent = 'Testing exited early';
            interruptionMessage.classList.add('stopped-running-early-message');
            this.containerElement.appendChild(interruptionMessage);
        }

        if (this.testResults.crashTests.length)
            this.containerElement.appendChild(this.buildOneSection(this.testResults.crashTests, 'crash-tests-table'));

        if (this.testResults.crashOther.length)
            this.containerElement.appendChild(this.buildOneSection(this.testResults.crashOther, 'other-crash-tests-table'));

        if (this.testResults.failingTests.length)
            this.containerElement.appendChild(this.buildOneSection(this.testResults.failingTests, 'results-table'));

        if (this.testResults.missingResults.length)
            this.containerElement.appendChild(this.buildOneSection(this.testResults.missingResults, 'missing-table'));

        if (this.testResults.timeoutTests.length)
            this.containerElement.appendChild(this.buildOneSection(this.testResults.timeoutTests, 'timeout-tests-table'));

        if (this.testResults.testsWithStderr.length)
            this.containerElement.appendChild(this.buildOneSection(this.testResults.testsWithStderr, 'stderr-table'));

        if (this.testResults.flakyPassTests.length)
            this.containerElement.appendChild(this.buildOneSection(this.testResults.flakyPassTests, 'flaky-tests-table'));

        if (this.testResults.usesExpectationsFile() && this.testResults.unexpectedPassTests.length)
            this.containerElement.appendChild(this.buildOneSection(this.testResults.unexpectedPassTests, 'passes-table'));

        this.containerElement.appendChild(this.buildMiscLinksSection());

        this.updateTestlistCounts();
    }

    static sectionBuilderClassForTableID(tableID)
    {
        const idToBuilderClassMap = {
            'crash-tests-table' : CrashingTestsSectionBuilder,
            'other-crash-tests-table' : OtherCrashesSectionBuilder,
            'results-table' : FailingTestsSectionBuilder,
            'missing-table' : TestsWithMissingResultsSectionBuilder,
            'timeout-tests-table' : TimedOutTestsSectionBuilder,
            'stderr-table' : TestsWithStdErrSectionBuilder,
            'flaky-tests-table' : FlakyPassTestsSectionBuilder,
            'passes-table' : UnexpectedPassTestsSectionBuilder,
        };
        return idToBuilderClassMap[tableID];
    }
    
    setupSorting()
    {
        let resultsTable = document.getElementById('results-table');
        if (!resultsTable)
            return;
        
        // FIXME: Make all the tables sortable. Maybe SectionBuilder should put a TableSorter on each table.
        resultsTable.addEventListener('click', TableSorter.handleClick, false);
        TableSorter.sortColumn(0);
    }
    
    hideNonApplicableUI()
    {
        // FIXME: do this all through body classnames.
        if (!this.testResults.hasTextFailures) {
            let textResultsHeader = document.getElementById('text-results-header');
            if (textResultsHeader)
                textResultsHeader.textContent = '';
        }

        if (!this.testResults.hasImageFailures) {
            let imageResultsHeader = document.getElementById('image-results-header');
            if (imageResultsHeader)
                imageResultsHeader.textContent = '';

            Utils.parentOfType(document.getElementById('toggle-images'), 'label').style.display = 'none';
        }
    }
    
    setupOptions()
    {
        // FIXME: do this all through body classnames.
        if (!this.testResults.usesExpectationsFile())
            Utils.parentOfType(document.getElementById('unexpected-results'), 'label').style.display = 'none';
    }

    buildOneSection(tests, tableID)
    {
        TestResults.sortByName(tests);
        
        let sectionBuilderClass = TestResultsController.sectionBuilderClassForTableID(tableID);
        let sectionBuilder = new sectionBuilderClass(tests, tableID, this);
        return sectionBuilder.build();
    }

    buildMiscLinksSection()
    {
        let section = document.createElement('section');
        let header = document.createElement('h1');
        header.textContent = 'Additional test data';
        section.appendChild(header);

        if (this.testResults.hasHttpTests) {
            let httpdAccessLogLink = document.createElement('p');
            httpdAccessLogLink.innerHTML = '<a href="access_log.txt">httpd access log</a>';

            let httpdErrorLogLink = document.createElement('p');
            httpdErrorLogLink.innerHTML = '<a href="error_log.txt">httpd error log</a>';
            
            section.appendChild(httpdAccessLogLink);
            section.appendChild(httpdErrorLogLink);
        }

        let perfTreeMapLink = document.createElement('p');
        perfTreeMapLink.innerHTML = '<a href="test-duration-treemap.html">Test durations</a>';
        section.appendChild(perfTreeMapLink);

        return section;
    }

    updateTestlistCounts()
    {
        // FIXME: do this through the data model, not through the DOM.
        let onlyShowUnexpectedFailures = this.onlyShowUnexpectedFailures();
        Utils.forEach(document.querySelectorAll('.test-list-count'), count => {
            let container = Utils.parentOfType(count, 'section');
            let testContainers;
            if (onlyShowUnexpectedFailures)
                testContainers = container.querySelectorAll('tbody:not(.expected)');
            else
                testContainers = container.querySelectorAll('tbody');

            count.textContent = testContainers.length;
        })
    }
    
    flagAll(headerLink)
    {
        let tests = this.visibleTests(Utils.parentOfType(headerLink, 'section'));
        Utils.forEach(tests, tests => {
            let shouldFlag = true;
            testNavigator.flagTest(tests, shouldFlag);
        })
    }

    unflag(flag)
    {
        const shouldFlag = false;
        testNavigator.flagTest(Utils.parentOfType(flag, 'tbody'), shouldFlag);
    }

    visibleTests(opt_container)
    {
        let container = opt_container || document;
        if (this.onlyShowUnexpectedFailures())
            return container.querySelectorAll('tbody:not(.expected)');
        else
            return container.querySelectorAll('tbody');
    }

    // FIXME: this is confusing. Flip the sense around.
    onlyShowUnexpectedFailures()
    {
        return document.getElementById('unexpected-results').checked;
    }

    static _testListHeader(title)
    {
        let header = document.createElement('h1');
        header.innerHTML = title + ' (<span class=test-list-count></span>): <a href="#" class=flag-all onclick="controller.flagAll(this)">flag all</a>';
        return header;
    }

    testToURL(testResult, layoutTestsPath)
    {
        const mappings = {
            "http/tests/ssl/": "https://127.0.0.1:8443/ssl/",
            "http/tests/": "http://127.0.0.1:8000/",
            "http/wpt/": "http://localhost:8800/WebKit/",
            "imported/w3c/web-platform-tests/": "http://localhost:8800/"
        };

        for (let key in mappings) {
            if (testResult.name.startsWith(key))
                return mappings[key] + testResult.name.substring(key.length);

        }
        return "file://" + layoutTestsPath + "/" + testResult.name;
    }

    layoutTestURL(testResult)
    {
        if (this.shouldUseGithubLinks())
            return this.layoutTestsBasePath() + testResult.name;

        return this.testToURL(testResult, this.layoutTestsBasePath());
    }

    layoutTestsBasePath()
    {
        let basePath;
        if (this.shouldUseGithubLinks())
            basePath = 'https://github.com/WebKit/WebKit/blob/main/LayoutTests/';
        else
            basePath = this.testResults.layoutTestsDir() + '/';

        return basePath;
    }

    convertToLayoutTestBaseRelativeURL(fullURL)
    {
        if (fullURL.startsWith('file://')) {
            let urlPrefix = 'file://' + this.layoutTestsBasePath();
            if (fullURL.startsWith(urlPrefix))
                return fullURL.substring(urlPrefix.length);
        }
        
        return fullURL;
    }

    shouldUseGithubLinks()
    {
        return !this.testResults.layoutTestsDir() || !location.toString().indexOf('file://') == 0;
    }

    checkServerIsRunning(event)
    {
        if (this.shouldUseGithubLinks())
            return;

        let url = event.target.href;
        if (url.startsWith("file://"))
            return;

        event.preventDefault();
        fetch(url, { mode: "no-cors" }).then(() => {
            window.location = url;
        }, () => {
            alert("HTTP server does not seem to be running, please use the run-webkit-httpd script");
        });
    }

    testLink(testResult)
    {
        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>';
    }
    
    expandButtonSpan()
    {
        return '<span class=expand-button onclick="controller.toggleExpectations(this)"><span class=expand-button-text>+</span></span>';
    }
    
    static resultLink(testPrefix, suffix, contents)
    {
        return '<a class=result-link href="' + testPrefix + suffix + '" data-prefix="' + testPrefix + '">' + contents + '</a> ';
    }

    textResultLinks(prefix)
    {
        let html = TestResultsController.resultLink(prefix, '-expected.txt', 'expected') +
            TestResultsController.resultLink(prefix, '-actual.txt', 'actual') +
            TestResultsController.resultLink(prefix, '-diff.txt', 'diff');

        if (this.testResults.hasPrettyPatch())
            html += TestResultsController.resultLink(prefix, '-pretty-diff.html', 'pretty diff');

        if (this.testResults.hasWDiff())
            html += TestResultsController.resultLink(prefix, '-wdiff.html', 'wdiff');

        return html;
    }

    flakinessDashboardURLForTests(testObjects)
    {
        // FIXME: just map and join here.
        let testList = '';
        for (let i = 0; i < testObjects.length; ++i) {
            testList += testObjects[i].name;

            if (i != testObjects.length - 1)
                testList += ',';
        }

        return 'https://results.webkit.org/?suite=layout-tests&test=' + encodeURIComponent(testList);
    }

    _updatePageTitle()
    {
        let dateString = this.testResults.date();
        let title = document.createElement('title');
        title.textContent = 'Layout Test Results from ' + dateString;
        document.head.appendChild(title);
    }
    
    // Options handling. FIXME: move to a separate class?
    updateAllOptions()
    {
        Utils.forEach(document.querySelectorAll('#options-menu input'), input => { input.onchange() });
    }

    toggleOptionsMenu()
    {
        let menu = document.getElementById('options-menu');
        menu.className = (menu.className == 'hidden-menu') ? '' : 'hidden-menu';
    }

    handleToggleUseNewlines()
    {
        OptionWriter.save();
        testNavigator.updateFlaggedTests();
    }

    handleUnexpectedResultsChange()
    {
        OptionWriter.save();
        this._updateExpectedFailures();
    }

    expandAllExpectations()
    {
        let expandLinks = this._visibleExpandLinks();
        for (let link of expandLinks)
            Utils.async(link => { controller.expandExpectations(link) }, [ link ]);
    }

    collapseAllExpectations()
    {
        let expandLinks = this._visibleExpandLinks();
        for (let link of expandLinks)
            Utils.async(link => { controller.collapseExpectations(link) }, [ link ]);
    }

    expandExpectations(expandLink)
    {
        let row = Utils.parentOfType(expandLink, 'tr');
        let parentTbody = row.parentNode;
        let existingResultsRow = parentTbody.querySelector('.results-row');
    
        const enDash = '\u2013';
        expandLink.textContent = enDash;
        if (existingResultsRow) {
            this._updateExpandedState(existingResultsRow, true);
            return;
        }

        let testName = row.getAttribute('data-test-name');
        let testResult = this.testResults.testWithName(testName);

        let newRow = TestResultsController._buildExpandedRowForTest(testResult, row);
        parentTbody.appendChild(newRow);

        this._updateExpandedState(newRow, true);

        this._updateImageTogglingTimer();
    }

    collapseExpectations(expandLink)
    {
        expandLink.textContent = '+';
        let existingResultsRow = Utils.parentOfType(expandLink, 'tbody').querySelector('.results-row');
        if (existingResultsRow)
            this._updateExpandedState(existingResultsRow, false);
    }

    toggleExpectations(element)
    {
        let expandLink = element;
        if (expandLink.className != 'expand-button-text')
            expandLink = expandLink.querySelector('.expand-button-text');

        if (expandLink.textContent == '+')
            this.expandExpectations(expandLink);
        else
            this.collapseExpectations(expandLink);
    }

    _updateExpandedState(row, isExpanded)
    {
        row.setAttribute('data-expanded', isExpanded);
        this._updateImageTogglingTimer();
    }

    handleToggleImagesChange()
    {
        OptionWriter.save();
        this._updateTogglingImages();
    }

    _visibleExpandLinks()
    {
        if (this.onlyShowUnexpectedFailures())
            return document.querySelectorAll('tbody:not(.expected) .expand-button-text');
        else
            return document.querySelectorAll('.expand-button-text');
    }

    static _togglingImage(prefix)
    {
        return '<div class=result-container><div class="label imageText"></div><img class=animatedImage data-prefix="' + prefix + '"></img></div>';
    }

    _updateTogglingImages()
    {
        this.shouldToggleImages = document.getElementById('toggle-images').checked;

        // FIXME: this is all pretty confusing. Simplify.
        if (this.shouldToggleImages) {
            Utils.forEach(document.querySelectorAll('table:not(#missing-table) tbody:not([mismatchreftest]) a[href$=".png"]'), TestResultsController._convertToTogglingHandler(function(prefix) {
                return TestResultsController.resultLink(prefix, '-diffs.html', 'images');
            }));
            Utils.forEach(document.querySelectorAll('table:not(#missing-table) tbody:not([mismatchreftest]) img[src$=".png"]'), TestResultsController._convertToTogglingHandler(TestResultsController._togglingImage));
        } else {
            Utils.forEach(document.querySelectorAll('a[href$="-diffs.html"]'), element => {
                TestResultsController._convertToNonTogglingHandler(element);
            });
            Utils.forEach(document.querySelectorAll('.animatedImage'), TestResultsController._convertToNonTogglingHandler(function (absolutePrefix, suffix) {
                return TestResultsController._resultIframe(absolutePrefix + suffix);
            }));
        }

        this._updateImageTogglingTimer();
    }

    _updateExpectedFailures()
    {
        // Gross to do this by setting stylesheet text. Use a body class!
        document.getElementById('unexpected-style').textContent = this.onlyShowUnexpectedFailures() ? '.expected { display: none; }' : '';

        this.updateTestlistCounts();
        testNavigator.onlyShowUnexpectedFailuresChanged();
    }

    static _buildExpandedRowForTest(testResult, row)
    {
        let newRow = document.createElement('tr');
        newRow.className = 'results-row';
        let newCell = document.createElement('td');
        newCell.colSpan = row.querySelectorAll('td').length;

        // FIXME: migrate more of code to using testResult for building the expanded content.
        let resultLinks = row.querySelectorAll('.result-link');
        let hasTogglingImages = false;
        for (let link of resultLinks) {
            let result;
            if (link.textContent == 'images') {
                hasTogglingImages = true;
                result = TestResultsController._togglingImage(link.getAttribute('data-prefix'));
            } else
                result = TestResultsController._resultIframe(link.href);

            Utils.appendHTML(newCell, result);    
        }

        if (testResult.hasLeak())
            newCell.appendChild(TestResultsController._makeLeaksCell(testResult));

        newRow.appendChild(newCell);

        return newRow;
    }
    
    static _makeLeaksCell(testResult)
    {
        let container = document.createElement('div');
        container.className = 'result-container leaks';
        
        let label = document.createElement('div');
        label.className = 'label';
        label.textContent = "Leaks";
        container.appendChild(label);

        let leaksTable = document.createElement('table');
        
        for (let leak of testResult.info.leaks) {
            let leakRow = document.createElement('tr');

            for (let leakedObjectType in leak) {
                let th = document.createElement('th');
                th.textContent = leakedObjectType;
                let td = document.createElement('td');
                
                let url = leak[leakedObjectType]; // FIXME: when we track leaks other than documents, this might not be a URL.
                td.textContent = controller.convertToLayoutTestBaseRelativeURL(url)
                
                leakRow.appendChild(th);
                leakRow.appendChild(td);
            }
            
            leaksTable.appendChild(leakRow);
        }
        
        container.appendChild(leaksTable);
        return container;
    }

    static _resultIframe(src)
    {
        // FIXME: use audio tags for AUDIO tests?
        let layoutTestsIndex = src.indexOf('LayoutTests');
        let name;
        if (layoutTestsIndex != -1) {
            let hasGithub = src.indexOf('github.com/WebKit/WebKit/') != -1;
            let prefix = hasGithub ? 'github.com/WebKit/WebKit/.../' : '';
            name = prefix + src.substring(layoutTestsIndex + 'LayoutTests/'.length);
        } else {
            let lastDashIndex = src.lastIndexOf('-pretty');
            if (lastDashIndex == -1)
                lastDashIndex = src.lastIndexOf('-');
            name = src.substring(lastDashIndex + 1);
        }

        let tagName = (src.lastIndexOf('.png') == -1) ? 'iframe' : 'img';

        if (tagName != 'img')
            src += '?format=txt';
        return '<div class=result-container><div class=label>' + name + '</div><' + tagName + ' src="' + src + '"></' + tagName + '></div>';
    }


    static _toggleImages()
    {
        let images = document.querySelectorAll('.animatedImage');
        let imageTexts = document.querySelectorAll('.imageText');
        for (let i = 0, len = images.length; i < len; i++) {
            let image = images[i];
            let text = imageTexts[i];
            if (text.textContent == 'Expected Image') {
                text.textContent = 'Actual Image';
                image.src = image.getAttribute('data-prefix') + '-actual.png';
            } else {
                text.textContent = 'Expected Image';
                image.src = image.getAttribute('data-prefix') + '-expected.png';
            }
        }
    }

    _updateImageTogglingTimer()
    {
        let hasVisibleAnimatedImage = document.querySelector('.results-row[data-expanded="true"] .animatedImage');
        if (!hasVisibleAnimatedImage) {
            clearInterval(this._togglingImageInterval);
            this._togglingImageInterval = null;
            return;
        }

        if (!this._togglingImageInterval) {
            TestResultsController._toggleImages();
            this._togglingImageInterval = setInterval(TestResultsController._toggleImages, 2000);
        }
    }
    
    static _getResultContainer(node)
    {
        return (node.tagName == 'IMG') ? Utils.parentOfType(node, '.result-container') : node;
    }

    static _convertToTogglingHandler(togglingImageFunction)
    {
        return function(node) {
            let url = (node.tagName == 'IMG') ? node.src : node.href;
            if (url.match('-expected.png$'))
                TestResultsController._getResultContainer(node).remove();
            else if (url.match('-actual.png$')) {
                let name = Utils.parentOfType(node, 'tbody').querySelector('.test-link').textContent;
                TestResultsController._getResultContainer(node).outerHTML = togglingImageFunction(Utils.testPrefix(name));
            }
        }
    }
    
    static _convertToNonTogglingHandler(resultFunction)
    {
        return function(node) {
            let prefix = node.getAttribute('data-prefix');
            TestResultsController._getResultContainer(node).outerHTML = resultFunction(prefix, '-expected.png', 'expected') + resultFunction(prefix, '-actual.png', 'actual');
        }
    }
};

class SectionBuilder {
    
    constructor(tests, tableID, resultsController)
    {
        this._tests = tests;
        this._table = null;
        this._resultsController = resultsController;
        this._tableID = tableID;
    }

    build()
    {
        TestResults.sortByName(this._tests);
        
        let section = document.createElement('section');
        section.appendChild(TestResultsController._testListHeader(this.sectionTitle()));
        if (this.hideWhenShowingUnexpectedResultsOnly())
            section.classList.add('expected');

        this._table = document.createElement('table');
        this._table.id = this.tableID();
        this.addTableHeader();

        let visibleResultsCount = 0;
        for (let testResult of this._tests) {
            let tbody = this.createTableRow(testResult);
            this._table.appendChild(tbody);
            
            if (!this._resultsController.onlyShowUnexpectedFailures() || testResult.isExpected)
                ++visibleResultsCount;
        }
        
        section.querySelector('.test-list-count').textContent = visibleResultsCount;
        section.appendChild(this._table);
        return section;
    }

    createTableRow(testResult)
    {
        let tbody = document.createElement('tbody');
        if (testResult.isExpected)
            tbody.classList.add('expected');
        
        let row = document.createElement('tr');
        row.setAttribute('data-test-name', testResult.name);
        tbody.appendChild(row);

        let testNameCell = document.createElement('td');
        this.fillTestCell(testResult, testNameCell);
        row.appendChild(testNameCell);

        let resultCell = document.createElement('td');
        this.fillTestResultCell(testResult, resultCell);
        row.appendChild(resultCell);

        let historyCell = this.createHistoryCell(testResult);
        if (historyCell)
            row.appendChild(historyCell);

        return tbody;
    }
    
    hideWhenShowingUnexpectedResultsOnly()
    {
        return !TestResults.hasUnexpectedResult(this._tests);
    }
    
    addTableHeader()
    {
    }
    
    fillTestCell(testResult, cell)
    {
        let testLink = this.linkifyTestName() ? this._resultsController.testLink(testResult) : testResult.name;
        if (this.rowsAreExpandable()) {
            cell.innerHTML = this._resultsController.expandButtonSpan() + testLink;
            return;
        }

        cell.innerHTML = testLink;
    }

    fillTestResultCell(testResult, cell)
    {
    }
    
    createHistoryCell(testResult)
    {
        let historyCell = document.createElement('td');
        historyCell.innerHTML = '<a href="' + this._resultsController.flakinessDashboardURLForTests([testResult]) + '">history</a>'
        return historyCell;
    }
    
    tableID()
    {
        return this._tableID;
    }
    
    rowsAreExpandable()
    {
        return true;
    }
    
    linkifyTestName()
    {
        return true;
    }

    sectionTitle() { return ''; }
};

class FailuresSectionBuilder extends SectionBuilder {
    
    addTableHeader()
    {
        let header = document.createElement('thead');
        let html = '<th>test</th><th id="text-results-header">results</th><th id="image-results-header">image results</th>';

        if (this._resultsController.testResults.usesExpectationsFile())
            html += '<th>actual failure</th><th>expected failure</th>';

        html += '<th>history</th>';

        if (this.tableID() == 'flaky-tests-table') // FIXME: use the classes, Luke!
            html += '<th>failures</th>';

        header.innerHTML = html;
        this._table.appendChild(header);
    }
    
    createTableRow(testResult)
    {
        let tbody = document.createElement('tbody');
        if (testResult.isExpected)
            tbody.classList.add('expected');
        
        if (testResult.isMismatchRefTest())
            tbody.setAttribute('mismatchreftest', 'true');

        let row = document.createElement('tr');
        row.setAttribute('data-test-name', testResult.name);
        tbody.appendChild(row);
        
        let testNameCell = document.createElement('td');
        this.fillTestCell(testResult, testNameCell);
        row.appendChild(testNameCell);

        let resultCell = document.createElement('td');
        this.fillTestResultCell(testResult, resultCell);
        row.appendChild(resultCell);

        if (testResult.isTextFailure())
            this.appendTextFailureLinks(testResult, resultCell);

        if (testResult.isAudioFailure())
            this.appendAudioFailureLinks(testResult, resultCell);
            
        if (testResult.hasMissingResult())
            this.appendActualOnlyLinks(testResult, resultCell);

        let actualTokens = testResult.info.actual.split(/\s+/);

        let testPrefix = Utils.testPrefix(testResult.name);
        let imageResults = this.imageResultLinks(testResult, testPrefix, actualTokens[0]);
        if (!imageResults && actualTokens.length > 1)
            imageResults = this.imageResultLinks(testResult, 'retries/' + testPrefix, actualTokens[1]);

        let imageResultsCell = document.createElement('td');
        imageResultsCell.innerHTML = imageResults;
        row.appendChild(imageResultsCell);

        if (this._resultsController.testResults.usesExpectationsFile() || actualTokens.length) {
            let actualCell = document.createElement('td');
            actualCell.textContent = testResult.info.actual;
            row.appendChild(actualCell);
        }

        if (this._resultsController.testResults.usesExpectationsFile()) {
            let expectedCell = document.createElement('td');
            expectedCell.textContent = testResult.hasMissingResult() ? '' : testResult.info.expected;
            row.appendChild(expectedCell);
        }

        let historyCell = this.createHistoryCell(testResult);
        if (historyCell)
            row.appendChild(historyCell);

        return tbody;
    }

    appendTextFailureLinks(testResult, cell)
    {
        cell.innerHTML += this._resultsController.textResultLinks(Utils.testPrefix(testResult.name));
    }
    
    appendAudioFailureLinks(testResult, cell)
    {
        let prefix = Utils.testPrefix(testResult.name);
        cell.innerHTML += TestResultsController.resultLink(prefix, '-expected.wav', 'expected audio')
            + TestResultsController.resultLink(prefix, '-actual.wav', 'actual audio')
            + TestResultsController.resultLink(prefix, '-diff.txt', 'textual diff');
    }
    
    appendActualOnlyLinks(testResult, cell)
    {
        let prefix = Utils.testPrefix(testResult.name);
        if (testResult.isMissingAudio())
            cell.innerHTML += TestResultsController.resultLink(prefix, '-actual.wav', 'audio result');

        if (testResult.isMissingText())
            cell.innerHTML += TestResultsController.resultLink(prefix, '-actual.txt', 'result');
    }

    imageResultLinks(testResult, testPrefix, resultToken)
    {
        let result = '';
        if (resultToken.indexOf('IMAGE') != -1) {
            let testExtension = Utils.splitExtension(testResult.name)[1];

            if (testResult.isMismatchRefTest()) {
                result += TestResultsController.resultLink(this._resultsController.layoutTestsBasePath() + testPrefix, '-expected-mismatch' + testExtension, 'ref mismatch');
                result += TestResultsController.resultLink(testPrefix, '-actual.png', 'actual');
            } else {
                if (testResult.isMatchRefTest())
                    result += TestResultsController.resultLink(this._resultsController.layoutTestsBasePath() + testPrefix, '-expected' + testExtension, 'reference');

                if (this._resultsController.shouldToggleImages)
                    result += TestResultsController.resultLink(testPrefix, '-diffs.html', 'images');
                else {
                    result += TestResultsController.resultLink(testPrefix, '-expected.png', 'expected');
                    result += TestResultsController.resultLink(testPrefix, '-actual.png', 'actual');
                }

                const diff = Math.max(testResult.info.image_diff_percent, 0.01).toFixed(2);
                result += TestResultsController.resultLink(testPrefix, '-diff.png', `diff (${diff}%)`);
            }
        }
        
        if (testResult.hasMissingResult() && testResult.isMissingImage())
            result += TestResultsController.resultLink(testPrefix, '-actual.png', 'png result');
        
        return result;
    }
};

class FailingTestsSectionBuilder extends FailuresSectionBuilder {
    sectionTitle() { return 'Tests that failed text/pixel/audio diff'; }
};

class TestsWithMissingResultsSectionBuilder extends FailuresSectionBuilder {
    sectionTitle() { return 'Tests that had no expected results (probably new)'; }

    rowsAreExpandable()
    {
        return false;
    }
};

class FlakyPassTestsSectionBuilder extends FailuresSectionBuilder {
    sectionTitle() { return 'Flaky tests (failed the first run and passed on retry)'; }
};

class UnexpectedPassTestsSectionBuilder extends SectionBuilder {
    sectionTitle() { return 'Tests expected to fail but passed'; }

    addTableHeader()
    {
        let header = document.createElement('thead');
        header.innerHTML = '<th>test</th><th>expected failure</th><th>history</th>';
        this._table.appendChild(header);
    }
    
    fillTestResultCell(testResult, cell)
    {
        cell.innerHTML = testResult.info.expected;
    }

    rowsAreExpandable()
    {
        return false;
    }
};

class TestsWithStdErrSectionBuilder extends SectionBuilder {
    sectionTitle() { return 'Tests that had stderr output'; }
    hideWhenShowingUnexpectedResultsOnly() { return false; }

    fillTestResultCell(testResult, cell)
    {
        cell.innerHTML = TestResultsController.resultLink(Utils.testPrefix(testResult.name), '-stderr.txt', 'stderr');
    }
};

class TimedOutTestsSectionBuilder extends SectionBuilder {
    sectionTitle() { return 'Tests that timed out'; }

    fillTestResultCell(testResult, cell)
    {
        // FIXME: only include timeout actual/diff results here if we actually spit out results for timeout tests.
        cell.innerHTML = this._resultsController.textResultLinks(Utils.testPrefix(testResult.name));
    }
};

class CrashingTestsSectionBuilder extends SectionBuilder {
    sectionTitle() { return 'Tests that crashed'; }

    fillTestResultCell(testResult, cell)
    {
        cell.innerHTML = TestResultsController.resultLink(Utils.testPrefix(testResult.name), '-crash-log.txt', 'crash log')
                       + TestResultsController.resultLink(Utils.testPrefix(testResult.name), '-sample.txt', 'sample');
    }
};

class OtherCrashesSectionBuilder extends SectionBuilder {
    sectionTitle() { return 'Other crashes'; }
    fillTestResultCell(testResult, cell)
    {
        cell.innerHTML = TestResultsController.resultLink(Utils.testPrefix(testResult.name), '-crash-log.txt', 'crash log');
    }

    createHistoryCell(testResult)
    {
        return null;
    }

    linkifyTestName()
    {
        return false;
    }
};

class PixelZoomer {
    constructor()
    {
        this.showOnDelay = true;
        this._zoomFactor = 6;

        this._resultWidth = 800;
        this._resultHeight = 600;
        
        this._percentX = 0;
        this._percentY = 0;

        document.addEventListener('mousemove', this, false);
        document.addEventListener('mouseout', this, false);
    }

    _zoomedResultWidth()
    {
        return this._resultWidth * this._zoomFactor;
    }
    
    _zoomedResultHeight()
    {
        return this._resultHeight * this._zoomFactor;
    }
    
    _zoomImageContainer(url)
    {
        let container = document.createElement('div');
        container.className = 'zoom-image-container';

        let title = url.match(/\-([^\-]*)\.png/)[1];
    
        let label = document.createElement('div');
        label.className = 'label';
        label.appendChild(document.createTextNode(title));
        container.appendChild(label);
    
        let imageContainer = document.createElement('div');
        imageContainer.className = 'scaled-image-container';
    
        let image = new Image();
        image.src = url;
        image.style.width = this._zoomedResultWidth() + 'px';
        image.style.height = this._zoomedResultHeight() + 'px';
        image.style.border = '1px solid black';
        imageContainer.appendChild(image);
        container.appendChild(imageContainer);
    
        return container;
    }

    _createContainer(e)
    {
        let tbody = Utils.parentOfType(e.target, 'tbody');
        let row = tbody.querySelector('tr');
        let imageDiffLinks = row.querySelectorAll('a[href$=".png"]');
    
        let container = document.createElement('div');
        container.className = 'pixel-zoom-container';
    
        let html = '';
    
        let togglingImageLink = row.querySelector('a[href$="-diffs.html"]');
        if (togglingImageLink) {
            let prefix = togglingImageLink.getAttribute('data-prefix');
            container.appendChild(this._zoomImageContainer(prefix + '-expected.png'));
            container.appendChild(this._zoomImageContainer(prefix + '-actual.png'));
        }
    
        for (let link of imageDiffLinks)
            container.appendChild(this._zoomImageContainer(link.href));

        document.body.appendChild(container);
        this._drawAll();
    }

    _draw(imageContainer)
    {
        let image = imageContainer.querySelector('img');
        let containerBounds = imageContainer.getBoundingClientRect();
        image.style.left = (containerBounds.width / 2 - this._percentX * this._zoomedResultWidth()) + 'px';
        image.style.top = (containerBounds.height / 2 - this._percentY * this._zoomedResultHeight()) + 'px';
    }

    _drawAll()
    {
        Utils.forEach(document.querySelectorAll('.pixel-zoom-container .scaled-image-container'), element => { this._draw(element) });
    }
    
    handleEvent(event)
    {
        if (event.type == 'mousemove') {
            this._handleMouseMove(event);
            return;
        }

        if (event.type == 'mouseout') {
            this._handleMouseOut(event);
            return;
        }
    }

    _handleMouseOut(event)
    {
        if (event.relatedTarget && event.relatedTarget.tagName != 'IFRAME')
            return;

        // If e.relatedTarget is null, we've moused out of the document.
        let container = document.querySelector('.pixel-zoom-container');
        if (container)
            container.remove();
    }

    _handleMouseMove(event)
    {
        if (this._mouseMoveTimeout) {
            clearTimeout(this._mouseMoveTimeout);
            this._mouseMoveTimeout = 0;
        }

        if (Utils.parentOfType(event.target, '.pixel-zoom-container'))
            return;

        let container = document.querySelector('.pixel-zoom-container');
    
        let resultContainer = (event.target.className == 'result-container') ? event.target : Utils.parentOfType(event.target, '.result-container');
        if (!resultContainer || !resultContainer.querySelector('img')) {
            if (container)
                container.remove();
            return;
        }

        let targetLocation = event.target.getBoundingClientRect();
        this._percentX = (event.clientX - targetLocation.left) / targetLocation.width;
        this._percentY = (event.clientY - targetLocation.top) / targetLocation.height;

        if (!container) {
            if (this.showOnDelay) {
                this._mouseMoveTimeout = setTimeout(() => {
                    this._createContainer(event);
                }, 400);
                return;
            }

            this._createContainer(event);
            return;
        }
    
        this._drawAll();
    }
};

class TableSorter
{
    static _forwardArrow()
    {
        return '<svg style="width:10px;height:10px"><polygon points="0,0 10,0 5,10" style="fill:#ccc"></svg>';
    }

    static _backwardArrow()
    {
        return '<svg style="width:10px;height:10px"><polygon points="0,10 10,10 5,0" style="fill:#ccc"></svg>';
    }

    static _sortedContents(header, arrow)
    {
        return arrow + ' ' + Utils.trim(header.textContent) + ' ' + arrow;
    }

    static _updateHeaderClassNames(newHeader)
    {
        let sortHeader = document.querySelector('.sortHeader');
        if (sortHeader) {
            if (sortHeader == newHeader) {
                let isAlreadyReversed = sortHeader.classList.contains('reversed');
                if (isAlreadyReversed)
                    sortHeader.classList.remove('reversed');
                else
                    sortHeader.classList.add('reversed');
            } else {
                sortHeader.textContent = sortHeader.textContent;
                sortHeader.classList.remove('sortHeader');
                sortHeader.classList.remove('reversed');
            }
        }

        newHeader.classList.add('sortHeader');
    }

    static _textContent(tbodyRow, column)
    {
        return tbodyRow.querySelectorAll('td')[column].textContent;
    }

    static _sortRows(newHeader, reversed)
    {
        let testsTable = document.getElementById('results-table');
        let headers = Utils.toArray(testsTable.querySelectorAll('th'));
        let sortColumn = headers.indexOf(newHeader);

        let rows = Utils.toArray(testsTable.querySelectorAll('tbody'));

        const LT = reversed ? 1 : -1;
        const GT = reversed ? -1 : 1;

        rows.sort(function(a, b) {
            // Only need to support lexicographic sort for now.
            let aText = TableSorter._textContent(a, sortColumn);
            let bText = TableSorter._textContent(b, sortColumn);
        
            // First, sort numerically by percentage (for the "image results" column).
            let aPercentage = (/([0-9.]+)%/.exec(aText) || [, 0])[1];
            let bPercentage = (/([0-9.]+)%/.exec(bText) || [, 0])[1];
            if (aPercentage != bPercentage)
                return +aPercentage < +bPercentage ? LT : GT;

            // Second, sort by the column text.
            if (aText != bText)
                return aText < bText ? LT : GT;

            // Finally, forward sort by the test name.
            let aTestName = TableSorter._textContent(a, 0);
            let bTestName = TableSorter._textContent(b, 0);
            if (aTestName != bTestName)
                return aTestName < bTestName ? -1 : 1;
            return 0;
        });

        for (let row of rows)
            testsTable.appendChild(row);
    }

    static sortColumn(columnNumber)
    {
        let newHeader = document.getElementById('results-table').querySelectorAll('th')[columnNumber];
        TableSorter._sort(newHeader);
    }

    static handleClick(e)
    {
        let newHeader = e.target;
        if (newHeader.localName != 'th')
            return;
        TableSorter._sort(newHeader);
    }

    static _sort(newHeader)
    {
        TableSorter._updateHeaderClassNames(newHeader);
    
        let reversed = newHeader.classList.contains('reversed');
        let sortArrow = reversed ? TableSorter._backwardArrow() : TableSorter._forwardArrow();
        newHeader.innerHTML = TableSorter._sortedContents(newHeader, sortArrow);
    
        TableSorter._sortRows(newHeader, reversed);
    }    
};

class OptionWriter {
    static save()
    {
        let options = document.querySelectorAll('label input');
        let data = {};
        for (let option of options)
            data[option.id] = option.checked;

        try {
            localStorage.setItem(OptionWriter._key, JSON.stringify(data));
        } catch (err) {
            if (err.name != "SecurityError")
                throw err;
        }
    }

    static apply()
    {
        let json;
        try {
            json = localStorage.getItem(OptionWriter._key);
        } catch (err) {
           if (err.name != "SecurityError")
              throw err;
        }

        if (!json) {
            controller.updateAllOptions();
            return;
        }

        let data = JSON.parse(json);
        for (let id in data) {
            let input = document.getElementById(id);
            if (input)
                input.checked = data[id];
        }
        controller.updateAllOptions();
    }

    static get _key()
    {
        return 'run-webkit-tests-options';
    }
};

let testResults;
function ADD_RESULTS(input)
{
    testResults = new TestResults(input);
}
</script>

<script src="full_results.json"></script>

<script>

class TestNavigator
{
    constructor() {
        this.currentTestIndex = -1;
        this.flaggedTests = {};
        document.addEventListener('keypress', this, false);
    }
    
    handleEvent(event)
    {
        if (event.type == 'keypress') {
            this.handleKeyEvent(event);
            return;
        }
    }

    handleKeyEvent(event)
    {
        if (event.metaKey || event.shiftKey || event.ctrlKey)
            return;

        switch (String.fromCharCode(event.charCode)) {
            case 'i':
                this._scrollToFirstTest();
                break;
            case 'j':
                this._scrollToNextTest();
                break;
            case 'k':
                this._scrollToPreviousTest();
                break;
            case 'l':
                this._scrollToLastTest();
                break;
            case 'e':
                this._expandCurrentTest();
                break;
            case 'c':
                this._collapseCurrentTest();
                break;
            case 't':
                this._toggleCurrentTest();
                break;
            case 'f':
                this._toggleCurrentTestFlagged();
                break;
        }
    }

    _scrollToFirstTest()
    {
        if (this._setCurrentTest(0))
            this._scrollToCurrentTest();
    }

    _scrollToLastTest()
    {
        let links = controller.visibleTests();
        if (this._setCurrentTest(links.length - 1))
            this._scrollToCurrentTest();
    }

    _scrollToNextTest()
    {
        if (this.currentTestIndex == -1)
            this._scrollToFirstTest();
        else if (this._setCurrentTest(this.currentTestIndex + 1))
            this._scrollToCurrentTest();
    }

    _scrollToPreviousTest()
    {
        if (this.currentTestIndex == -1)
            this._scrollToLastTest();
        else if (this._setCurrentTest(this.currentTestIndex - 1))
            this._scrollToCurrentTest();
    }

    _currentTestLink()
    {
        let links = controller.visibleTests();
        return links[this.currentTestIndex];
    }

    _currentTestExpandLink()
    {
        return this._currentTestLink().querySelector('.expand-button-text');
    }

    _expandCurrentTest()
    {
        controller.expandExpectations(this._currentTestExpandLink());
    }

    _collapseCurrentTest()
    {
        controller.collapseExpectations(this._currentTestExpandLink());
    }

    _toggleCurrentTest()
    {
        controller.toggleExpectations(this._currentTestExpandLink());
    }

    _toggleCurrentTestFlagged()
    {
        let testLink = this._currentTestLink();
        this.flagTest(testLink, !testLink.classList.contains('flagged'));
    }

    // FIXME: Test navigator shouldn't know anything about flagging. It should probably call out to TestFlagger or something.
    // FIXME: Batch flagging (avoid updateFlaggedTests on each test).
    flagTest(testTbody, shouldFlag)
    {
        let testName = testTbody.querySelector('.test-link').innerText;
    
        if (shouldFlag) {
            testTbody.classList.add('flagged');
            this.flaggedTests[testName] = 1;
        } else {
            testTbody.classList.remove('flagged');
            delete this.flaggedTests[testName];
        }

        this.updateFlaggedTests();
    }

    updateFlaggedTests()
    {
        let flaggedTestTextbox = document.getElementById('flagged-tests');
        if (!flaggedTestTextbox) {
            let flaggedTestContainer = document.createElement('div');
            flaggedTestContainer.id = 'flagged-test-container';
            flaggedTestContainer.className = 'floating-panel';
            flaggedTestContainer.innerHTML = '<h2>Flagged Tests</h2><pre id="flagged-tests" contentEditable></pre>';
            document.body.appendChild(flaggedTestContainer);

            flaggedTestTextbox = document.getElementById('flagged-tests');
        }

        let flaggedTests = Object.keys(this.flaggedTests);
        flaggedTests.sort();
        let separator = document.getElementById('use-newlines').checked ? '\n' : ' ';
        flaggedTestTextbox.innerHTML = flaggedTests.join(separator);
        document.getElementById('flagged-test-container').style.display = flaggedTests.length ? '' : 'none';
    }

    _setCurrentTest(testIndex)
    {
        let links = controller.visibleTests();
        if (testIndex < 0 || testIndex >= links.length)
            return false;

        let currentTest = links[this.currentTestIndex];
        if (currentTest)
            currentTest.classList.remove('current');

        this.currentTestIndex = testIndex;

        currentTest = links[this.currentTestIndex];
        currentTest.classList.add('current');

        return true;
    }

    _scrollToCurrentTest()
    {
        let targetLink = this._currentTestLink();
        if (!targetLink)
            return;

        let rowRect = targetLink.getBoundingClientRect();
        // rowRect is in client coords (i.e. relative to viewport), so we just want to add its top to the current scroll position.
        document.scrollingElement.scrollTop += rowRect.top;
    }

    onlyShowUnexpectedFailuresChanged()
    {
        let currentTest = document.querySelector('.current');
        if (!currentTest)
            return;

        // If our currentTest became hidden, reset the currentTestIndex.
        if (controller.onlyShowUnexpectedFailures() && currentTest.classList.contains('expected'))
            this._scrollToFirstTest();
        else {
            // Recompute this.currentTestIndex
            let links = controller.visibleTests();
            this.currentTestIndex = links.indexOf(currentTest);
        }
    }
};

function handleMouseDown(e)
{
    if (!Utils.parentOfType(e.target, '#options-menu') && e.target.id != 'options-link')
        document.getElementById('options-menu').className = 'hidden-menu';
}

document.addEventListener('mousedown', handleMouseDown, false);

let controller;
let pixelZoomer;
let testNavigator;

function generatePage()
{
    let container = document.getElementById('main-content');

    controller = new TestResultsController(container, testResults);
    pixelZoomer = new PixelZoomer();
    testNavigator = new TestNavigator();

    OptionWriter.apply();
}

window.addEventListener('load', generatePage, false);

</script>
</head>
<body>
    
    <div class="content-container">
        <div id="toolbar" class="floating-panel">
        <div class="note">Use the i, j, k and l keys to navigate, e, c to expand and collapse, and f to flag</div>
        <a class="clickable" onclick="controller.expandAllExpectations()">expand all</a>
        <a class="clickable" onclick="controller.collapseAllExpectations()">collapse all</a>
        <a class="clickable" id=options-link onclick="controller.toggleOptionsMenu()">options</a>
        <div id="options-menu" class="hidden-menu">
            <label><input id="unexpected-results" type="checkbox" checked onchange="controller.handleUnexpectedResultsChange()">Only unexpected results</label>
            <label><input id="toggle-images" type="checkbox" checked onchange="controller.handleToggleImagesChange()">Toggle images</label>
            <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>
        </div>
    </div>

<div id="main-content"></div>

</body>
</html>
