New flakiness dashboard show test time, modifiers, and flaky tests
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Mon, 21 Oct 2013 22:25:06 +0000 (22:25 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Mon, 21 Oct 2013 22:25:06 +0000 (22:25 +0000)
https://bugs.webkit.org/show_bug.cgi?id=123119

Reviewed by Tim Horton.

* api/failing-tests.php: Manually serialize each row in the results to avoid hitting the memory limit.
* include/db.php:
(Database::query): Added.
(Database::fetch_next_row): Added.
* include/init-database.sql: Added modifiers and time columns to results table.
* include/test-results.php:
(store_test_results): Update start_time and end_time to the union of the new interval and the existing interval.
(recursively_add_test_results): Handle empty $full_name to eliminate the loop over tests in store_test_results.
Also verify that each test name, expected and actual results conform to the specific format to prevent XSS.
Also use insert_row instead of select_or_insert_row to avoid issuing an unnecessary SQL query.
(format_result): Extracted from format_result_rows. Used in failing-tests.php.
* index.html:
(TestResultsView): Added _currentBuilderFailureType and _currentBuilderDays.
(TestResultsView._createResultCell): Show the test time and the expected result.
(TestResultsView._createTestResultRow): Compute the slowest run and also round time to tenth of second for time
less than 10s or second if it's more than 10s so that the test time will always be shown in two digits.
Also show the bug number and the latest expected result on the left columns after linkifying the bug numbers.
(TestResultsView._matchesFailureType): Added. Determines whether results is of a particular failure type.
(TestResultsView._populateBuilderPane):
(TestResultsView.fetchFailingTestsForBuilder): Store the failure type such as flaky, wrongtestexpectations.
(TestResultsView.updateLocationHash):
(TestResultsView.loadTestsFromLocationHash):
(fetchManifest):
* js/dom.js:
(element): appendChild if an item is a Node. Otherwise, e.g. integer, create a text node out of toString() call.
* main.css: Updated styles.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@157751 268f45cc-cd09-0410-ab3c-d52691b4dbfc

Websites/test-results/ChangeLog
Websites/test-results/api/failing-tests.php
Websites/test-results/include/db.php
Websites/test-results/include/init-database.sql
Websites/test-results/include/test-results.php
Websites/test-results/index.html
Websites/test-results/js/dom.js
Websites/test-results/main.css

index 8172a02..15aabd3 100644 (file)
@@ -1,3 +1,37 @@
+2013-10-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        New flakiness dashboard show test time, modifiers, and flaky tests
+        https://bugs.webkit.org/show_bug.cgi?id=123119
+
+        Reviewed by Tim Horton.
+
+        * api/failing-tests.php: Manually serialize each row in the results to avoid hitting the memory limit.
+        * include/db.php:
+        (Database::query): Added.
+        (Database::fetch_next_row): Added.
+        * include/init-database.sql: Added modifiers and time columns to results table.
+        * include/test-results.php:
+        (store_test_results): Update start_time and end_time to the union of the new interval and the existing interval.
+        (recursively_add_test_results): Handle empty $full_name to eliminate the loop over tests in store_test_results.
+        Also verify that each test name, expected and actual results conform to the specific format to prevent XSS.
+        Also use insert_row instead of select_or_insert_row to avoid issuing an unnecessary SQL query.
+        (format_result): Extracted from format_result_rows. Used in failing-tests.php.
+        * index.html:
+        (TestResultsView): Added _currentBuilderFailureType and _currentBuilderDays.
+        (TestResultsView._createResultCell): Show the test time and the expected result.
+        (TestResultsView._createTestResultRow): Compute the slowest run and also round time to tenth of second for time
+        less than 10s or second if it's more than 10s so that the test time will always be shown in two digits.
+        Also show the bug number and the latest expected result on the left columns after linkifying the bug numbers. 
+        (TestResultsView._matchesFailureType): Added. Determines whether results is of a particular failure type.
+        (TestResultsView._populateBuilderPane):
+        (TestResultsView.fetchFailingTestsForBuilder): Store the failure type such as flaky, wrongtestexpectations.
+        (TestResultsView.updateLocationHash):
+        (TestResultsView.loadTestsFromLocationHash):
+        (fetchManifest):
+        * js/dom.js:
+        (element): appendChild if an item is a Node. Otherwise, e.g. integer, create a text node out of toString() call.
+        * main.css: Updated styles.
+
 2013-10-18  Ryosuke Niwa  <rniwa@webkit.org>
 
         New flakiness dashboard should support showing the failing tests per builder
index 8cf2b5b..c57129f 100644 (file)
@@ -19,15 +19,32 @@ if (!$builder_row)
     exit_with_error('BuilderNotFound');
 $builder_id = $builder_row['id'];
 
-$result_rows = $db->query_and_fetch_all(
-'SELECT results.*, builds.*, array_agg((build_revisions.repository, build_revisions.value, build_revisions.time)) AS revisions
+$all_results = $db->query(
+"SELECT results.*, builds.*, array_agg((build_revisions.repository, build_revisions.value, build_revisions.time)) AS revisions
     FROM results, builds, build_revisions
     WHERE build_revisions.build = builds.id AND results.build = builds.id AND builds.builder = $1
-    AND results.actual != $2 AND builds.start_time > now() - interval \'' . $number_of_days . ' days\'
-    GROUP BY results.id, builds.id', array($builder_id, 'PASS'));
-if (!$result_rows)
+    AND builds.start_time > now() - interval '$number_of_days days'
+    GROUP BY results.id, builds.id ORDER BY results.test, max(build_revisions.time) DESC", array($builder_id));
+
+if (!$all_results)
     exit_with_error('ResultsNotFound');
 
-exit_with_success(format_result_rows($result_rows));
+// To conserve memory, we serialize tests at a time.
+echo "{\"status\": \"OK\", \"builders\": {\"$builder_id\":{";
+$currentTest = NULL;
+$i = 0;
+while ($result = $db->fetch_next_row($all_results)) {
+    if ($result['test'] != $currentTest) {
+        if ($currentTest)
+            echo '],';
+        $currentTest = $result['test'];
+        echo "\"$currentTest\": [";
+    } else
+        echo ',';
+    echo json_encode(format_result($result), true);
+}
+if ($currentTest)
+    echo ']';
+echo '}}}';
 
 ?>
index f042acd..e9968f8 100644 (file)
@@ -161,6 +161,16 @@ class Database
         return pg_fetch_all($result);
     }
 
+    function query($query, $params = array()) {
+        if (!$this->connection)
+            return FALSE;
+        return pg_query_params($this->connection, $query, $params);
+    }
+
+    function fetch_next_row($result) {
+        return pg_fetch_assoc($result);
+    }
+
     function fetch_table($table_name, $column_to_be_ordered_by = null) {
         if (!$this->connection || !ctype_alnum_underscore($table_name) || ($column_to_be_ordered_by && !ctype_alnum_underscore($column_to_be_ordered_by)))
             return false;
index 06d87e7..8a011eb 100644 (file)
@@ -1,9 +1,9 @@
 DROP TABLE results CASCADE;
 DROP TABLE tests CASCADE;
-DROP TABLE build_revision_map CASCADE;
-DROP TABLE revisions CASCADE;
+DROP TABLE build_revisions CASCADE;
 DROP TABLE builds CASCADE;
 DROP TABLE slaves CASCADE;
+DROP TABLE repositories CASCADE;
 DROP TABLE builders CASCADE;
 
 CREATE TABLE builders (
@@ -28,7 +28,6 @@ CREATE TABLE builds (
     start_time timestamp,
     end_time timestamp,
     slave integer REFERENCES slaves ON DELETE CASCADE,
-    fetched bool NOT NULL DEFAULT FALSE,
     CONSTRAINT builder_and_build_number_must_be_unique UNIQUE(builder, number));
 CREATE INDEX build_builder_index ON builds(builder);
 CREATE INDEX build_slave_index ON builds(slave);
@@ -52,6 +51,8 @@ CREATE TABLE results (
     test integer REFERENCES tests ON DELETE CASCADE,
     build integer REFERENCES builds ON DELETE CASCADE,
     expected varchar(64) NOT NULL,
-    actual varchar(64) NOT NULL);
+    actual varchar(64) NOT NULL,
+    modifiers varchar(64) NOT NULL,
+    time integer);
 CREATE INDEX results_test ON results(test);
 CREATE INDEX results_build ON results(build);
index af92be1..f24921e 100644 (file)
@@ -38,9 +38,10 @@ function store_test_results($db, $test_results, $build_id, $start_time, $end_tim
     $db->begin_transaction();
 
     try {
-        foreach ($test_results['tests'] as $name => $subtests)
-            recursively_add_test_results($db, $build_id, $subtests, $name);
-        $db->query_and_get_affected_rows('UPDATE builds SET (start_time, end_time, slave, fetched) = ($1, $2, $3, TRUE) WHERE id = $4',
+        recursively_add_test_results($db, $build_id, $test_results['tests'], '');
+
+        $db->query_and_get_affected_rows(
+            'UPDATE builds SET (start_time, end_time, slave) = (least($1, start_time), greatest($2, end_time), $3) WHERE id = $4',
             array($start_time->format('Y-m-d H:i:s.u'), $end_time->format('Y-m-d H:i:s.u'), $slave_id, $build_id));
         $db->commit_transaction();
     } catch (Exception $e) {
@@ -53,17 +54,29 @@ function store_test_results($db, $test_results, $build_id, $start_time, $end_tim
 
 function recursively_add_test_results($db, $build_id, $tests, $full_name) {
     if (!array_key_exists('expected', $tests) and !array_key_exists('actual', $tests)) {
-        foreach ($tests as $name => $subtests)
-            recursively_add_test_results($db, $build_id, $subtests, $full_name . '/' . $name);
+        $prefix = $full_name ? $full_name . '/' : '';
+        foreach ($tests as $name => $subtests) {
+            require_format('test_name', $name, '/^[A-Za-z0-9 +_\-\.]+$/');
+            recursively_add_test_results($db, $build_id, $subtests, $prefix . $name);
+        }
         return;
     }
 
+    require_format('expected_result', $tests['expected'], '/^[A-Za-z ]+$/');
+    require_format('actual_result', $tests['actual'], '/^[A-Za-z ]+$/');
+    require_format('test_time', $tests['time'], '/^\d*$/');
+    $modifiers = array_get($tests, 'modifiers');
+    if ($modifiers)
+        require_format('test_modifiers', $modifiers, '/^[A-Za-z0-9 \.\/]+$/');
+    else
+        $modifiers = NULL;
+
     $test_id = $db->select_or_insert_row('tests', NULL,
         array('name' => $full_name), array('name' => $full_name, 'reftest_type' => json_encode(array_get($tests, 'reftest_type'))));
 
-    // FIXME: Either use a transaction or check the return value.
-    $db->select_or_insert_row('results', NULL, array('test' => $test_id, 'build' => $build_id,
-        'expected' => $tests['expected'], 'actual' => $tests['actual']));
+    $db->insert_row('results', NULL, array('test' => $test_id, 'build' => $build_id,
+        'expected' => $tests['expected'], 'actual' => $tests['actual'],
+        'time' => $tests['time'], 'modifiers' => $tests['modifiers']));
 }
 
 date_default_timezone_set('UTC');
@@ -79,16 +92,22 @@ function parse_revisions_array($postgres_array) {
     return $revisions;
 }
 
+function format_result($result) {
+    return array('buildTime' => strtotime($result['start_time']) * 1000,
+        'revisions' => parse_revisions_array($result['revisions']),
+        'slave' => $result['slave'],
+        'buildNumber' => $result['number'],
+        'actual' => $result['actual'],
+        'expected' => $result['expected'],
+        'time' => $result['time'],
+        'modifiers' => $result['modifiers']);
+}
+
 function format_result_rows($result_rows) {
     $builders = array();
     foreach ($result_rows as $result) {
         array_push(array_ensure_item_has_array(array_ensure_item_has_array($builders, $result['builder']), $result['test']),
-            array('buildTime' => strtotime($result['start_time']) * 1000,
-            'revisions' => parse_revisions_array($result['revisions']),
-            'slave' => $result['slave'],
-            'buildNumber' => $result['number'],
-            'actual' => $result['actual'],
-            'expected' => $result['expected']));
+            format_result($result));
     }
     return array('builders' => $builders);
 }
index 01f1ab6..48e20a5 100644 (file)
 <div id="testView"></div>
 
 <form>
-<label>Show failing tests for</label>
-<select id="builderListView"><option value="">Select builder</option></select>
-<select id="builderDaysView"><option>5</option><option>15</option><option>30</option></select>
+Show
+<label>tests <select id="builderFailureTypeView"><option>failing</option><option>flaky</option><option value="wrongexpectations">has wrong expectations</option></select></label>
+<label>on <select id="builderListView"><option value="">Select builder</option></select></label>
+<label for="builderDaysView">in the last <select id="builderDaysView"><option>5</option><option>15</option><option>30</option></select> days</label>
 </form>
 <div id="builderFailingTestsView"></div>
 
@@ -51,6 +52,8 @@ var TestResultsView = new (function () {
     this._tooltipContainer = tooltipContainer;
     this._tests = [];
     this._currentBuilder = null;
+    this._currentBuilderFailureType = null;
+    this._currentBuilderDays = null;
     this._oldHash = null;
     this._builders = [];
     this._slaves = [];
@@ -108,7 +111,8 @@ TestResultsView._createResultCell = function (master, builder, result, previousR
     var build = result['buildNumber'];
     var actual = result['actual'];
     var expected = result['expected'];
-    var anchor = element('a', {'href': this._urlFromBuilder('result', master, builder, revision, build)});
+    var timeIfSlow = result.isSlow ? result.roundedTime : '-';
+    var anchor = element('a', {'href': this._urlFromBuilder('result', master, builder, revision, build)}, [timeIfSlow]);
     anchor.onmouseenter = function () {
         var repositoryById = TestResultsView._repositories;
         var formattedRevisions = result.build.formattedRevisions(previousResult ? previousResult.build : null);
@@ -128,7 +132,8 @@ TestResultsView._createResultCell = function (master, builder, result, previousR
                 element('li', ['Build Time: ' + result.build.formattedBuildTime()]),
                 element('li', ['Revision: ' +  revisionDescription]),
                 element('li', ['Build: ', element('a', {'href': TestResultsView._urlFromBuilder('build', master, builder, revision, build)}, [build])]),
-                element('li', ['Result: ' + actual]),
+                element('li', ['Actual: ' + actual]),
+                element('li', ['Expected: ' + expected]),
             ])
         ]));
     }
@@ -157,17 +162,37 @@ TestResultsView._populateTestPane = function(testName, results, section) {
 }
 
 TestResultsView._createTestResultRow = function (title, results, builder) {
-    for (var i = 0; i < results.length; i++)
-        results[i].build = new TestBuild(this._repositories, this._builders, results[i]);
+    var slowestTime = 0;
+    for (var i = 0; i < results.length; i++) {
+        var result = results[i];
+        result.build = new TestBuild(this._repositories, this._builders, result);
+        result.roundedTime = result.time > 10000 ? Math.round(result.time / 1000) : Math.round(result.time / 100) / 10;
+        result.isSlow = result.time > 1000;
+        if (result.isSlow)
+            slowestTime = Math.max(slowestTime, result.roundedTime);
+    }
+    if (slowestTime)
+        slowestTime += 's';
+    else
+        slowestTime = '';
 
-    var sortedResults = results.sort(function (result1, result2) { return result1.build.time() - result2.build.time(); });
+    var sortedResults = results.sort(function (result1, result2) { return result2.build.time() - result1.build.time(); });
     var cells = new Array(sortedResults.length);
     for (var i = 0; i < sortedResults.length; i++)
         cells[i] = this._createResultCell(builder.master, builder.name, sortedResults[i], sortedResults[i - 1]);
 
-    var passCount = cells.filter(function (cell) { return cell.className == 'PASS'; }).length;
-    var passingRate = Math.round(passCount / cells.length * 100) + '%';
-    return element('tr', [element('th', ['' + title]), element('td', {'class': 'passingRate'}, [passingRate]), element('td', cells)]);
+    var formattedModifiers = sortedResults[0].modifiers.split(' ').map(function (modifier) {
+        if (modifier.indexOf('/') > 0)
+            return element('a', {'href': (modifier.indexOf('http') == 0 ? '' : 'http://') + modifier}, [modifier]);
+        return modifier;
+    });
+
+    return element('tr', [
+        element('th', ['' + title]),
+        element('td', {'class': 'modifiers'}, formattedModifiers),
+        element('td', {'class': 'expected'}, [sortedResults[0].expected]),
+        element('td', {'class': 'slowestTime'}, [slowestTime]),
+        element('td', cells)]);
 }
 
 TestResultsView.fetchTest = function (testName) {
@@ -219,7 +244,38 @@ TestResultsView.fetchTests = function (testNames, doNotUpdateHash) {
         this.updateLocationHash();
 }
 
-TestResultsView._populateBuilderPane = function(builderName, results, section) {
+TestResultsView._matchesFailureType = function (results, failureType, tn) {
+    if (!results.length)
+        return false;
+    var latestActualResult = results[0].actual;
+    var latestExpectedResult = results[0].expected;
+    switch (failureType) {
+    case 'failing':
+        return results[0].actual != 'PASS';
+    case 'flaky':
+        var offOneChangeCount = 0;
+        for (var i = 1; i + 1 < results.length; i++) {
+            var previousActual = results[i - 1].actual;
+            var nextActual = results[i + 1].actual;
+            if (previousActual == nextActual && results[i].actual != previousActual)
+                offOneChangeCount++;
+        }
+        return offOneChangeCount; // Heuristics.
+    case 'wrongexpectations':
+        if (latestExpectedResult == latestActualResult)
+            return false;
+        var expectedTokens = latestExpectedResult.split(' ');
+        if (expectedTokens.indexOf(latestActualResult) >= 0)
+            return false;
+        if (latestActualResult == 'TEXT' || latestActualResult == 'TEXT+IMAGE' && expectedTokens.indexOf('FAIL') >= 0)
+            return false;
+        return true;
+    }
+
+    return false;
+}
+
+TestResultsView._populateBuilderPane = function(builderName, failureType, results, section) {
     var table = element('table', {'class': 'resultsTable'}, [element('caption', [builderName])]);
     var resultsByBuilder = results['builders'];
     var resultsByTests;
@@ -232,12 +288,16 @@ TestResultsView._populateBuilderPane = function(builderName, results, section) {
         return;
 
     var builder = this._builders[builderId];
-    for (var testId in resultsByTests)
+    for (var testId in resultsByTests) {
+        var results = resultsByTests[testId];
+        if (!results.length || !this._matchesFailureType(results, failureType, this._availableTests[testId].name))
+            continue;
         table.appendChild(this._createTestResultRow(this._availableTests[testId].name, resultsByTests[testId], builder));
+    }
     section.appendChild(table);
 }
 
-TestResultsView.fetchFailingTestsForBuilder = function (builderName, numberOfDays, doNotUpdateHash) {
+TestResultsView.fetchFailingTestsForBuilder = function (builderName, numberOfDays, failureType, doNotUpdateHash) {
     var section = element('section', {'class': 'testResults'}, ['Loading...']);
 
     var container = document.getElementById('builderFailingTestsView');
@@ -246,7 +306,7 @@ TestResultsView.fetchFailingTestsForBuilder = function (builderName, numberOfDay
 
     var self = this;
     var xhr = new XMLHttpRequest();
-    xhr.open("GET", 'api/failing-tests.php?builder=' + escape(builderName) + '&' + 'days=' + numberOfDays, true);  
+    xhr.open("GET", 'api/failing-tests.php?builder=' + escape(builderName) + '&days=' + numberOfDays, true);  
     xhr.onload = function(event) {
         var response = JSON.parse(xhr.response);
         section.innerHTML = '';
@@ -254,9 +314,11 @@ TestResultsView.fetchFailingTestsForBuilder = function (builderName, numberOfDay
             section.appendChild(text('Failed to load results for ' + builderName + ': ' + response['status']));
             return;
         }
+        // FIXME: Normalize failureType.
         self._currentBuilder = builderName;
+        self._currentBuilderFailureType = failureType;
         self._currentBuilderDays = numberOfDays;
-        self._populateBuilderPane(builderName, response, section);
+        self._populateBuilderPane(builderName, failureType, response, section);
         if (!doNotUpdateHash)
             self.updateLocationHash();
     }
@@ -267,6 +329,7 @@ TestResultsView.updateLocationHash = function () {
     var params = {
         'tests': this._tests.join(','),
         'builder': this._currentBuilder,
+        'builderFailureType': this._currentBuilderFailureType,
         'builderDays': this._currentBuilderDays,
     };
     var hash = '';
@@ -303,8 +366,13 @@ TestResultsView.loadTestsFromLocationHash = function () {
         document.getElementById('testView').innerHTML = '';
         this.fetchTests(parsed['tests'].split(','), doNotUpdateHash);
     }
-    if (parsed['builder'])
-        this.fetchFailingTestsForBuilder(parsed['builder'], parsed['builderDays'] || 5, doNotUpdateHash);
+    if (parsed['builder']) {
+        this.fetchFailingTestsForBuilder(
+            parsed['builder'],
+            parseInt(parsed['builderDays']) || 5,
+            parsed['builderFailureType'] || 'failing', doNotUpdateHash);
+    }
+    return parsed;
 }
 
 function fetchManifest(callback) {
@@ -332,14 +400,16 @@ fetchManifest(function (response) {
         builderListView.appendChild(element('option', [text(response['builders'][builderId].name)]));
 
     var builderDaysView = document.getElementById('builderDaysView');
+    var builderFailureTypeView = document.getElementById('builderFailureTypeView');
 
     function updateBuilderView() {
         if (builderListView.value)
-            TestResultsView.fetchFailingTestsForBuilder(builderListView.value, builderDaysView.value);
+            TestResultsView.fetchFailingTestsForBuilder(builderListView.value, builderDaysView.value, builderFailureTypeView.value);
     }
 
     builderListView.addEventListener('change', updateBuilderView);
     builderDaysView.addEventListener('change', updateBuilderView);
+    builderFailureTypeView.addEventListener('change', updateBuilderView);
 
     function mapById(items) {
         var results = {};
@@ -352,7 +422,13 @@ fetchManifest(function (response) {
     TestResultsView.setBuilders(mapById(response['builders']));
     TestResultsView.setSlaves(mapById(response['slaves']));
     TestResultsView.setRepositories(mapById(response['repositories']));
-    TestResultsView.loadTestsFromLocationHash();
+    // FIXME: Updating location.href shouldn't be TestResultsView's responsibility.
+    var parsedStates = TestResultsView.loadTestsFromLocationHash();
+    if (parsedStates['builder']) {
+        builderListView.value = parsedStates['builder'];
+        builderDaysView.value = parsedStates['builderDays'];
+        builderFailureTypeView.value = parsedStates['builderFailureType'];
+    }
 });
 
 function pasteHelper(input, event) {
index ac00842..3928391 100644 (file)
@@ -14,10 +14,10 @@ function element(elementName, attributesOrChildNodes, childNodes) {
 
     if (childNodes) {
         for (var i = 0; i < childNodes.length; i++) {
-            if (typeof(childNodes[i]) === 'string')
-                element.appendChild(document.createTextNode(childNodes[i]));
-            else
+            if (childNodes[i] instanceof Node)
                 element.appendChild(childNodes[i]);
+            else
+                element.appendChild(document.createTextNode(childNodes[i]));
         }
     }
     return element;
index 3ff1663..84aabdb 100644 (file)
 .resultsTable {
     font-size: small;
     border-collapse: collapse;
-    border: 2px solid #fff;
+    border: 0px solid #fff;
     padding: 0;
     margin: 0;
+    width: 100%;
 }
 
 .resultsTable caption {
 
 .resultsTable td,
 .resultsTable th {
-    border: 2px solid #fff;
-    padding: 0;
+    white-space: pre;
+    border: 0px solid #fff;
+    padding: 0 0.5em;
     margin: 0;
 }
 
 .resultsTable th,
 .resultsTable .passingRate {
+    text-align: left;
     font-weight: normal;
     padding-right: 10px;
 }
     padding: 0.2em 0.2em;
 }
 
+.resultsTable tr:hover,
+.resultsTable tr:hover {
+    background-color: #eee;
+}
+
 .resultsTable a {
+    color: #00f;
+    text-shadow: none;
+}
+
+.resultsTable a:visited {
+    color: #006;
+}
+
+.resultsTable span a {
     display: block;
-    width: 1em;
-    height: 1.5em;
+    width: 1.4em;
+    height: 2em;
     border-radius: 3px;
+    font-size: 0.5em;
+    padding: 0.3em;
+    text-align: center;
+    line-height: 1.9em;
+    color: inherit;
 }
 
 .resultsTable .PASS a {
 }
 
 .resultsTable .IMAGE a {
-    background-color: #3cf;
+    background-color: #fc3;
 }
 
 .resultsTable .TEXT.PASS a {
     background-color: #f00;
 }
 
+.resultsTable .TIMEOUT a {
+    background-color: #000;
+    color: #fff;
+}
+
 .candidateWindow {
     z-index: 999;
     position: absolute;