New flakiness dashboard should hyperlink test names, WebKit revisions, and bubbles
https://bugs.webkit.org/show_bug.cgi?id=123134

Reviewed by Stephanie Lewis.

Copied admin.css, admin-header.php, admin-footer.php, builders.php, repositories.php from WebKit Perf Monitor.
(Unfortunately WebKit Perf Monitor hasn't been committed into WebKit repository just yet.)

Updated various parts of index.html to linkify test names, build numbers, and bubbles (to results page).

* admin/admin.css: Added.
* admin/builders.php: Added.
* admin/index.php: Removed the duplicated code now that it uses admin-header.php.
* admin/repositories.php: Added.
* api/manifest.php: Use camelCase for blame_url and build_url to be consistent with other JSON properties.
Also exported testCategories from config so that we can linkify test names in the dashboard.
* include/admin-footer.php: Added.
* include/admin-header.php: Added.
* include/config.json: Added test categories. This avoids hard-coding the URL to trac in php/js.
* include/init-database.sql: Added name and build_url to builders table and category to tests.
* include/test-results.php: Assume the test category to be LayoutTest for now.

* index.html:
(TestResultsView): Initialize _builders, _slaves, _repositories, _testCategories as dictionaries as intended.
(TestResultsView.setTestCategories): Added.
(TestResultsView._createResultCell): Dynamically resolve URLs of results page and and build page.
(TestResultsView._populateTestPane): Linkify the test name. Unfortunately we don't have a test object anywhere.
We need to figure out a way to find the test object here eventually. For now, hard-coding "LayoutTest" works.
(TestResultsView._linkifiedTestName): Added.
(TestResultsView._createBuildsAndComputeSlownessOfResults): Takes builderId to set "builder" property on each
result object as it's used by Build class.
(TestResultsView._populateBuilderPane):
(fetchManigest):

* js/build.js:
(Build.buildUrl): Support $builderName so that we don't have to keep repeating builder names in the database.
(Build.revision): Don't access [0] if revisions[repositoryId] was undefined.


git-svn-id: http://svn.webkit.org/repository/webkit/trunk@157775 268f45cc-cd09-0410-ab3c-d52691b4dbfc
diff --git a/Websites/test-results/ChangeLog b/Websites/test-results/ChangeLog
index bbc0f20..d45a8b2 100644
--- a/Websites/test-results/ChangeLog
+++ b/Websites/test-results/ChangeLog
@@ -1,5 +1,45 @@
 2013-10-21  Ryosuke Niwa  <rniwa@webkit.org>
 
+        New flakiness dashboard should hyperlink test names, WebKit revisions, and bubbles
+        https://bugs.webkit.org/show_bug.cgi?id=123134
+
+        Reviewed by Stephanie Lewis.
+
+        Copied admin.css, admin-header.php, admin-footer.php, builders.php, repositories.php from WebKit Perf Monitor.
+        (Unfortunately WebKit Perf Monitor hasn't been committed into WebKit repository just yet.)
+
+        Updated various parts of index.html to linkify test names, build numbers, and bubbles (to results page).
+
+        * admin/admin.css: Added.
+        * admin/builders.php: Added.
+        * admin/index.php: Removed the duplicated code now that it uses admin-header.php.
+        * admin/repositories.php: Added.
+        * api/manifest.php: Use camelCase for blame_url and build_url to be consistent with other JSON properties.
+        Also exported testCategories from config so that we can linkify test names in the dashboard.
+        * include/admin-footer.php: Added.
+        * include/admin-header.php: Added.
+        * include/config.json: Added test categories. This avoids hard-coding the URL to trac in php/js.
+        * include/init-database.sql: Added name and build_url to builders table and category to tests.
+        * include/test-results.php: Assume the test category to be LayoutTest for now.
+
+        * index.html:
+        (TestResultsView): Initialize _builders, _slaves, _repositories, _testCategories as dictionaries as intended.
+        (TestResultsView.setTestCategories): Added.
+        (TestResultsView._createResultCell): Dynamically resolve URLs of results page and and build page.
+        (TestResultsView._populateTestPane): Linkify the test name. Unfortunately we don't have a test object anywhere.
+        We need to figure out a way to find the test object here eventually. For now, hard-coding "LayoutTest" works.
+        (TestResultsView._linkifiedTestName): Added.
+        (TestResultsView._createBuildsAndComputeSlownessOfResults): Takes builderId to set "builder" property on each
+        result object as it's used by Build class.
+        (TestResultsView._populateBuilderPane):
+        (fetchManigest):
+
+        * js/build.js:
+        (Build.buildUrl): Support $builderName so that we don't have to keep repeating builder names in the database.
+        (Build.revision): Don't access [0] if revisions[repositoryId] was undefined.
+
+2013-10-21  Ryosuke Niwa  <rniwa@webkit.org>
+
         New flakiness dashboard should align results by revision numbers
         https://bugs.webkit.org/show_bug.cgi?id=123129
 
diff --git a/Websites/test-results/admin/admin.css b/Websites/test-results/admin/admin.css
new file mode 100644
index 0000000..1baa999
--- /dev/null
+++ b/Websites/test-results/admin/admin.css
@@ -0,0 +1,54 @@
+table {
+    font-size: small;
+}
+
+table, td {
+    border-collapse: collapse;
+    border: solid 1px #ccc;
+}
+
+td {
+    padding: 5px;
+}
+
+td pre {
+    max-height: 30em;
+    overflow: scroll;
+    margin: 0;
+    padding: 0;
+}
+
+tbody.odd {
+    background: #f6f6f6;
+}
+
+.action-field, .notice {
+    min-width: 50ex;
+    display: inline-block;
+    margin: 1em 0px;
+    margin-right: 1em;
+    border: solid 1px #ccc;
+    border-radius: 5px;
+    padding: 5px;
+}
+
+.action-field h2 {
+    font-size: 1em;
+    font-weight: normal;
+    padding: 0;
+    margin: 0 0 1em 0;
+}
+
+form {
+    display: inline;
+}
+
+label {
+    display: inline-block;
+}
+
+pre {
+    white-space: pre-wrap;
+    word-wrap: break-word;
+    word-break: break-all;
+}
diff --git a/Websites/test-results/admin/builders.php b/Websites/test-results/admin/builders.php
new file mode 100644
index 0000000..f663563
--- /dev/null
+++ b/Websites/test-results/admin/builders.php
@@ -0,0 +1,32 @@
+<?php
+
+require('../include/admin-header.php');
+
+if ($db) {
+
+    if ($action == 'add') {
+        if ($db->insert_row('builders', NULL, array(
+            'name' => $_POST['name'], 'password_hash' => hash('sha256', $_POST['password']), 'build_url' => array_get($_POST, 'build_url')))) {
+            notice('Inserted the new builder.');
+            regenerate_manifest();
+        } else
+            notice('Could not add the builder.');
+    } else if ($action == 'update') {
+        if (update_field('builders', NULL, 'name') || update_field('builders', NULL, 'build_url'))
+            regenerate_manifest();
+        else
+            notice('Invalid parameters.');
+    }
+
+    $page = new AdministrativePage($db, 'builders', NULL, array(
+        'master' => array(),
+        'name' => array('size' => 50, 'editing_mode' => 'string'),
+        'build_url' => array('label' => 'Build URL', 'size' => 100, 'editing_mode' => 'url'),
+    ));
+
+    $page->render_table('name');
+}
+
+require('../include/admin-footer.php');
+
+?>
diff --git a/Websites/test-results/admin/index.php b/Websites/test-results/admin/index.php
index 76d05c6..a7d8783 100644
--- a/Websites/test-results/admin/index.php
+++ b/Websites/test-results/admin/index.php
@@ -1,34 +1,9 @@
-<!DOCTYPE html>
-<html>
-<head>
-<title>WebKit Test Results</title>
-<link rel="stylesheet" href="/common.css">
-<style type="text/css">
-
-.unfetched {
-    color: gray;
-}
-
-</style>
-</head>
-<body>
-<header id="title">
-<h1><a href="/">WebKit Test Results</a></h1>
-<ul>
-    <li><a href="/admin/update-master">Update Master</a></li>
-</ul>
-</header>
-
-<div id="mainContents">
-<p><strong>FIXME: This page is broken!</strong></p>
 <?php
 
-require_once('../include/db.php');
-require_once('../include/test-results.php');
+include('../include/admin-header.php');
+include('../include/test-results.php');
 
-function notice($message) {
-    echo "<p class='notice'>$message</p>";
-}
+notice("FIXME: This page is broken!");
 
 define('MAX_FETCH_COUNT', 10);
 
diff --git a/Websites/test-results/admin/repositories.php b/Websites/test-results/admin/repositories.php
new file mode 100644
index 0000000..59721dc
--- /dev/null
+++ b/Websites/test-results/admin/repositories.php
@@ -0,0 +1,26 @@
+<?php
+
+include('../include/admin-header.php');
+
+if ($db) {
+    if ($action == 'update') {
+        if (update_field('repositories', NULL, 'name')
+            || update_field('repositories', NULL, 'url')
+            || update_field('repositories', NULL, 'blame_url'))
+            regenerate_manifest();
+        else
+            notice('Invalid parameters.');
+    }
+
+    $page = new AdministrativePage($db, 'repositories', NULL, array(
+        'name' => array('editing_mode' => 'string'),
+        'url' => array('label' => 'Revision URL (At revision $1)', 'editing_mode' => 'url'),
+        'blame_url' => array('label' => 'Blame URL (From revision $1 to revision $2)', 'editing_mode' => 'url')
+    ));
+
+    $page->render_table('name');
+}
+
+include('../include/admin-footer.php');
+
+?>
diff --git a/Websites/test-results/api/manifest.php b/Websites/test-results/api/manifest.php
index 19cf6f9..20fb7df 100644
--- a/Websites/test-results/api/manifest.php
+++ b/Websites/test-results/api/manifest.php
@@ -4,9 +4,17 @@
 
 $db = connect();
 
-exit_with_success(array('tests' => $db->fetch_table('tests'),
-    'builders' => $db->fetch_table('builders'),
-    'slaves' => $db->fetch_table('slaves'),
-    'repositories' => $db->fetch_table('repositories')));
+$repositories = $db->fetch_table('repositories');
+foreach ($repositories as &$value)
+    $value['blameUrl'] = $value['blame_url'];
 
+$builders = $db->fetch_table('builders');
+foreach ($builders as &$value)
+    $value['buildUrl'] = $value['build_url'];
+
+exit_with_success(array('tests' => $db->fetch_table('tests'),
+    'builders' => $builders,
+    'slaves' => $db->fetch_table('slaves'),
+    'repositories' => $repositories,
+    'testCategories' => config('testCategories')));
 ?>
diff --git a/Websites/test-results/include/admin-footer.php b/Websites/test-results/include/admin-footer.php
new file mode 100644
index 0000000..aa5a7c3
--- /dev/null
+++ b/Websites/test-results/include/admin-footer.php
@@ -0,0 +1,4 @@
+</div>
+
+</body>
+</html>
diff --git a/Websites/test-results/include/admin-header.php b/Websites/test-results/include/admin-header.php
new file mode 100644
index 0000000..0399623
--- /dev/null
+++ b/Websites/test-results/include/admin-header.php
@@ -0,0 +1,224 @@
+<?php
+
+require_once('db.php');
+
+?><!DOCTYPE html>
+<html>
+<head>
+<title>WebKit Test Results</title>
+<link rel="stylesheet" href="/common.css">
+<link rel="stylesheet" href="/admin/admin.css">
+</head>
+<body>
+<header id="title">
+<h1><a href="/">WebKit Perf Monitor</a></h1>
+<ul>
+    <li><a href="/admin/">Admin</a></li>
+    <li><a href="/admin/builders">Builders</a></li>
+    <li><a href="/admin/repositories">Repositories</a></li>
+</ul>
+</header>
+
+<div id="mainContents">
+<?php
+
+function regenerate_manifest() {
+    // manifest.php doesn't need to be regenerated but WebKit Perf Monitor generates a static manifest.json.
+}
+
+function notice($message) {
+    echo "<p class='notice'>$message</p>";
+}
+
+$db = new Database;
+if (!$db->connect()) {
+    notice('Failed to connect to the database');
+    $db = NULL;
+} else
+    $action = array_key_exists('action', $_POST) ? $_POST['action'] : NULL;
+
+function execute_query_and_expect_one_row_to_be_affected($query, $params, $success_message, $failure_message) {
+    global $db;
+
+    foreach ($params as &$param) {
+        if ($param == '')
+            $param = NULL;
+    }
+
+    $affected_rows = $db->query_and_get_affected_rows($query, $params);
+    if ($affected_rows) {
+        assert('$affected_rows == 1');
+        notice($success_message);
+        return true;
+    }
+
+    notice($failure_message);
+    return false;
+}
+
+function update_field($table, $prefix, $field_name) {
+    global $db;
+
+    if (!array_key_exists('id', $_POST) || !array_key_exists($field_name, $_POST))
+        return FALSE;
+
+    $id = intval($_POST['id']);
+    $prefixed_field_name = $prefix . $field_name;
+    $id_field_name = $prefix ? $prefix . '_id' : 'id';
+
+    execute_query_and_expect_one_row_to_be_affected("UPDATE $table SET $prefixed_field_name = \$2 WHERE $id_field_name = \$1",
+        array($id, $_POST[$field_name]),
+        "Updated the $prefix $id",
+        "Could not update $prefix $id");
+
+    return TRUE;
+}
+
+class AdministrativePage {
+    private $table;
+    private $prefix;
+    private $column_to_be_ordered_by;
+    private $column_info;
+
+    function __construct($db, $table, $prefix, $column_info) {
+        $this->db = $db;
+        $this->table = $table;
+        $this->prefix = $prefix ? $prefix . '_' : '';
+        $this->column_info = $column_info;
+    }
+
+    private function name_to_titlecase($name) {
+        return ucwords(str_replace('_', ' ', $name));
+    }
+
+    private function column_label($name) {
+        return array_get($this->column_info[$name], 'label', $this->name_to_titlecase($name));
+    }
+
+    private function render_form_control_for_column($editing_mode, $name, $value = '', $show_update_button_if_needed = FALSE, $size = NULL) {
+        if ($editing_mode == 'text') {
+            echo <<< END
+<textarea name="$name" rows="7" cols="50">$value</textarea><br>
+END;
+            if ($show_update_button_if_needed) {
+                echo <<< END
+
+<button type="submit" name="action" value="update">Update</button>
+END;
+            }
+            return;
+        }
+
+        if ($editing_mode == 'url') {
+            if (!$size)
+                $size = 70;
+            echo <<< END
+<input type="text" name="$name" value="$value" size="$size">
+END;
+            return;
+        }
+
+        $sizeIfExits = $size ? " size=\"$size\"" : '';
+        echo <<< END
+<input type="text" name="$name" value="$value"$sizeIfExits>
+END;
+    }
+
+    function render_table($column_to_be_ordered_by) {
+        $column_names = array_keys($this->column_info);
+        $labels = array();
+        foreach ($column_names as $name) {
+            if (array_get($this->column_info[$name], 'pre_insertion'))
+                continue;
+            array_push($labels, htmlspecialchars($this->column_label($name)));
+        }
+
+
+        $headers = join('</td><td>', $labels);
+        echo <<< END
+<table>
+<thead><tr><td>ID</td><td>$headers</td></tr></thead>
+<tbody>
+
+END;
+
+        assert(ctype_alnum_underscore($column_to_be_ordered_by));
+        $rows = $this->db->fetch_table($this->table, $this->prefix . $column_to_be_ordered_by);
+        if ($rows) {
+            foreach ($rows as $row) {
+                $id = intval($row[$this->prefix . 'id']);
+                echo "<tr>\n<td>$id</td>\n";
+                foreach ($column_names as $name) {
+                    if (array_get($this->column_info[$name], 'pre_insertion'))
+                        continue;
+
+                    $custom = array_get($this->column_info[$name], 'custom');
+                    if ($custom) {
+                        echo "<td>";
+                        $custom($row);
+                        echo "</td>\n";
+                        continue;
+                    }
+
+                    $value = htmlspecialchars($row[$this->prefix . $name], ENT_QUOTES);
+                    $editing_mode = array_get($this->column_info[$name], 'editing_mode');
+                    if (!$editing_mode) {
+                        echo "<td>$value</td>\n";
+                        continue;
+                    }
+
+                    echo <<< END
+<td>
+<form method="POST">
+<input type="hidden" name="id" value="$id">
+<input type="hidden" name="action" value="update">
+
+END;
+                    $size = array_get($this->column_info[$name], 'size');
+                    $this->render_form_control_for_column($editing_mode, $name, $value, TRUE, $size);
+                    echo "</form></td>\n";
+
+                }
+                echo "</tr>\n";
+            }
+        }
+        echo <<< END
+</tbody>
+</table>
+END;
+    }
+
+    function render_form_to_add($title = NULL) {
+
+        if (!$title) # Can't use the table name since it needs to be singular.
+            $title = 'New ' . $this->name_to_titlecase($this->prefix);
+
+echo <<< END
+<section class="action-field">
+<h2>$title</h2>
+<form method="POST">
+
+END;
+        foreach (array_keys($this->column_info) as $name) {
+            $editing_mode = array_get($this->column_info[$name], 'editing_mode');
+            if (array_get($this->column_info[$name], 'custom') || !$editing_mode)
+                continue;
+
+            $label = htmlspecialchars($this->column_label($name));
+            echo "<label>$label<br>\n";
+            $this->render_form_control_for_column($editing_mode, $name);
+            echo "</label><br>\n";
+        }
+
+echo <<< END
+
+<button type="submit" name="action" value="add">Add</button>
+</form>
+</section>
+END;
+
+    }
+
+}
+
+?>
diff --git a/Websites/test-results/include/config.json b/Websites/test-results/include/config.json
index a407f46..3f45f02 100644
--- a/Websites/test-results/include/config.json
+++ b/Websites/test-results/include/config.json
@@ -11,5 +11,10 @@
     "masters": [
         "build.webkit.org",
         "build-safari.apple.com"
-    ]
+    ],
+    "testCategories": {
+        "LayoutTest": {
+            "url": "https://trac.webkit.org/browser/trunk/LayoutTests/$testName"
+        }
+    }
 }
diff --git a/Websites/test-results/include/init-database.sql b/Websites/test-results/include/init-database.sql
index 8a011eb..fca78f3 100644
--- a/Websites/test-results/include/init-database.sql
+++ b/Websites/test-results/include/init-database.sql
@@ -9,7 +9,8 @@
 CREATE TABLE builders (
     id serial PRIMARY KEY,
     master varchar(64) NOT NULL,
-    name varchar(64) NOT NULL UNIQUE);
+    name varchar(64) NOT NULL UNIQUE,
+    build_url varchar(1024));
 
 CREATE TABLE repositories (
     id serial PRIMARY KEY,
@@ -44,6 +45,7 @@
 CREATE TABLE tests (
     id serial PRIMARY KEY,
     name varchar(1024) NOT NULL UNIQUE,
+    category varchar(64) NOT NULL,
     reftest_type varchar(64));
 
 CREATE TABLE results (
diff --git a/Websites/test-results/include/test-results.php b/Websites/test-results/include/test-results.php
index f24921e..423d21c 100644
--- a/Websites/test-results/include/test-results.php
+++ b/Websites/test-results/include/test-results.php
@@ -70,9 +70,11 @@
         require_format('test_modifiers', $modifiers, '/^[A-Za-z0-9 \.\/]+$/');
     else
         $modifiers = NULL;
+    $category = 'LayoutTest'; // FIXME: Support other test categories.
 
     $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'))));
+        array('name' => $full_name),
+        array('name' => $full_name, 'reftest_type' => json_encode(array_get($tests, 'reftest_type')), 'category' => $category));
 
     $db->insert_row('results', NULL, array('test' => $test_id, 'build' => $build_id,
         'expected' => $tests['expected'], 'actual' => $tests['actual'],
diff --git a/Websites/test-results/index.html b/Websites/test-results/index.html
index 2dd0429..d14c8f8 100644
--- a/Websites/test-results/index.html
+++ b/Websites/test-results/index.html
@@ -55,9 +55,10 @@
     this._currentBuilderFailureType = null;
     this._currentBuilderDays = null;
     this._oldHash = null;
-    this._builders = [];
-    this._slaves = [];
-    this._repositories = [];
+    this._builders = {};
+    this._slaves = {};
+    this._repositories = {};
+    this._testCategories = {};
 });
 
 TestResultsView.setAvailableTests = function (availableTests) {
@@ -76,6 +77,10 @@
     this._repositories = repositories;
 }
 
+TestResultsView.setTestCategories = function (testCategories) {
+    this._testCategories = testCategories;
+}
+
 TestResultsView.showTooltip = function (anchor, contentElement) {
     var tooltipContainer = this._tooltipContainer;
     tooltipContainer.style.display = null;
@@ -95,43 +100,44 @@
     tooltipContainer.style.top = (position.y - contentElement.clientHeight - 5) + 'px';
 }
 
-TestResultsView._urlFromBuilder = function (urlType, master, builder, revision, build) {
-    // FIXME: We should probably make this configurable or fetch from buildbot configuraration.
-    return {
-        "build": "http://$master/builders/$builder/builds/$build",
-        "result": "http://$master/results/$builder/r$revision%20($build)/results.html"
-    }[urlType].replace(/\$master/g, master).replace(/\$builder/g, builder)
-        .replace(/\$revision/g, revision).replace(/\$build/g, build);
-}
-
 TestResultsView._createResultCell = function (master, builder, result, previousResult) {
     var buildTime = result['buildTime'];
-    var revision = result['revision'];
+    var revisions = result['revisions'];
     var slave = result['slave'];
-    var build = result['buildNumber'];
+    var buildNumber = result['buildNumber'];
     var actual = result['actual'];
     var expected = result['expected'];
     var timeIfSlow = result.isSlow ? result.roundedTime : '';
-    var anchor = element('a', {'href': this._urlFromBuilder('result', master, builder, revision, build)}, [timeIfSlow]);
+
+    // FIXME: We shouldn't be hard-coding WebKit revisions here.
+    var webkitRepositoryId;
+    for (var repositoryId in TestResultsView._repositories) {
+        if (TestResultsView._repositories[repositoryId].name == 'WebKit')
+            webkitRepositoryId = repositoryId;
+    }
+    var webkitRevision = result.build.revision(webkitRepositoryId);
+    var resultsPage = webkitRevision ? "http://" + master + "/results/" + builder + "/r" + webkitRevision + "%20(" + buildNumber + ")/results.html"
+        : 'javascript:alert("Could no resolve WebKit revision")';
+
+    var anchor = element('a', {'href': resultsPage }, [timeIfSlow]);
     anchor.onmouseenter = function () {
-        var repositoryById = TestResultsView._repositories;
         var formattedRevisions = result.build.formattedRevisions(previousResult ? previousResult.build : null);
-        var revisionDescription = '';
+        var revisionDescription = [];
         for (var repositoryName in formattedRevisions) {
             var revision = formattedRevisions[repositoryName];
-            if (revisionDescription)
-                revisionDescription += ', ';
+            if (revisionDescription.length)
+                revisionDescription.push(', ');
             if (revision.url)
-                revisionDescription += repositoryName + ': ' + revision.url;
+                revisionDescription.push(element('a', {'href': revision.url}, [revision.label]));
             else
-                revisionDescription += revision.label;
+                revisionDescription.push(revision.label);
         }
 
         TestResultsView.showTooltip(anchor, element('div', {'class': 'tooltip'}, [
             element('ul', [
                 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', ['Revision: '].concat(revisionDescription)),
+                element('li', ['Build: ', element('a', {'href': result.build.buildUrl()}, [buildNumber])]),
                 element('li', ['Actual: ' + actual]),
                 element('li', ['Expected: ' + expected]),
             ])
@@ -144,14 +150,15 @@
 }
 
 TestResultsView._populateTestPane = function(testName, results, section) {
-    var table = element('table', {'class': 'resultsTable'}, [element('caption', [testName])]);
+    var test = {name: testName, category: 'LayoutTest'}; // FIXME: Use the real test object.
+    var table = element('table', {'class': 'resultsTable'}, [element('caption', [this._linkifiedTestName(test)])]);
     table.appendChild(this._createTestResultHeader('Builder'));
 
     var resultsByBuilder = results['builders'];
     var buildTimes = new Array();
     for (var builderId in resultsByBuilder) {
         var results = resultsByBuilder[builderId];
-        this._createBuildsAndComputeSlownessOfResults(results);
+        this._createBuildsAndComputeSlownessOfResults(builderId, results);
         for (var i = 0; i < results.length; i++) {
             var time = results[i].build.time();
             if (buildTimes.indexOf(time) < 0)
@@ -168,6 +175,14 @@
     section.appendChild(table);
 }
 
+TestResultsView._linkifiedTestName = function (test) {
+    var category = this._testCategories[test.category];
+    if (!category)
+        return test.name;
+
+    return element('a', {'href': category.url.replace(/\$testName/g, test.name)}, [test.name]);
+}
+
 TestResultsView._createTestResultHeader = function (labelForFirstColumn) {
     return element('thead', [element('tr', [
         element('th', [labelForFirstColumn]),
@@ -176,9 +191,10 @@
         element('th', ['Slowest'])])]);
 }
 
-TestResultsView._createBuildsAndComputeSlownessOfResults = function (results) {
+TestResultsView._createBuildsAndComputeSlownessOfResults = function (builderId, results) {
     for (var i = 0; i < results.length; i++) {
         var result = results[i];
+        result.builder = builderId;
         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;
@@ -224,7 +240,7 @@
         slowestTime = '';
 
     return element('tr', [
-        element('th', ['' + title]),
+        element('th', [title]),
         element('td', {'class': 'modifiers'}, formattedModifiers),
         element('td', {'class': 'expected'}, [sortedResults[0].expected]),
         element('td', {'class': 'slowestTime'}, [slowestTime]),
@@ -330,8 +346,8 @@
         var results = resultsByTests[testId];
         if (!results.length || !this._matchesFailureType(results, failureType, this._availableTests[testId].name))
             continue;
-        this._createBuildsAndComputeSlownessOfResults(resultsByTests[testId]);
-        table.appendChild(this._createTestResultRow(this._availableTests[testId].name, resultsByTests[testId], builder));
+        this._createBuildsAndComputeSlownessOfResults(builderId, resultsByTests[testId]);
+        table.appendChild(this._createTestResultRow(this._linkifiedTestName(this._availableTests[testId]), resultsByTests[testId], builder));
     }
     section.appendChild(table);
 }
@@ -461,6 +477,7 @@
     TestResultsView.setBuilders(mapById(response['builders']));
     TestResultsView.setSlaves(mapById(response['slaves']));
     TestResultsView.setRepositories(mapById(response['repositories']));
+    TestResultsView.setTestCategories(response['testCategories']);
     // FIXME: Updating location.href shouldn't be TestResultsView's responsibility.
     var parsedStates = TestResultsView.loadTestsFromLocationHash();
     if (parsedStates['builder']) {
diff --git a/Websites/test-results/js/build.js b/Websites/test-results/js/build.js
index d2be2ff..29a81dd3 100644
--- a/Websites/test-results/js/build.js
+++ b/Websites/test-results/js/build.js
@@ -18,10 +18,16 @@
     this.builder = function () { return builders[rawRun.builder].name; }
     this.buildNumber = function () { return rawRun.buildNumber; }
     this.buildUrl = function () {
-        var template = builders[rawRun.builder].buildUrl;
-        return template ? template.replace(/\$buildNumber/g, this.buildNumber()) : null;
+        var builderData = builders[rawRun.builder];
+        var template = builderData.buildUrl;
+        if (!template)
+            return null;
+        return template.replace(/\$builderName/g, builderData.name).replace(/\$buildNumber/g, this.buildNumber());
     }
-    this.revision = function(repositoryId) { return revisions[repositoryId][0]; }
+    this.revision = function(repositoryId) {
+        var repository = revisions[repositoryId];
+        return repository ? repository[0] : null;
+    }
     this.formattedRevisions = function (previousBuild) {
         var result = {};
         for (var repositoryId in revisions) {