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 8172a02fdd29b8782d703729ac0855e5306b69d6..15aabd327f06e2aed5cb4d7112e01fd9c4e430c9 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 8cf2b5bdfb738fbeff7e263f2be5f2b457bf30d9..c57129f8ec4eec208920fd001ac4c0414498af45 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 f042acd7353b54bd975ae73efc47a5950cb4ae9e..e9968f87a72dd3101e0faf4492101c79b94d05f4 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 06d87e7e89c3f5e286c03b8eeb77366716d204aa..8a011ebda46088648e5012c71b88818b8d0b7b7c 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 af92be1afb1f0031bd2d54823e78d4149e7a5f0b..f24921e34d3811df6eb33addf9de32975ae6b029 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 01f1ab6fd521eb4804044355efc7f3f9340a0f07..48e20a54f57d0cb2f72b8caf9aeea1a0820dc1cb 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 ac0084241048c2d3a603b55206157a8c31b700c3..392839109dba22a893d4fe8ae4e632e766062fcb 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 3ff16638a1c10021837bcdb9478ee6b15132d0ba..84aabdb179cf0e4095328045ee124dd791993850 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;