Add a new flakiness dashboard clone
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 17 Oct 2013 03:10:47 +0000 (03:10 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 17 Oct 2013 03:10:47 +0000 (03:10 +0000)
https://bugs.webkit.org/show_bug.cgi?id=122936

Reviewed by Anders Carlsson.

Added the initial prototype.

* Websites/test-results: Added.
* Websites/test-results/.htaccess: Added.
* Websites/test-results/admin: Added.
* Websites/test-results/admin/index.php: Added.
* Websites/test-results/api: Added.
* Websites/test-results/api/manifest.php: Added.
* Websites/test-results/api/report.php: Added.
* Websites/test-results/api/results.php: Added.
* Websites/test-results/include: Added.
* Websites/test-results/include/config.json: Added.
* Websites/test-results/include/db.php: Added.
* Websites/test-results/include/init-database.sql: Added.
* Websites/test-results/include/json-shared.php: Added.
* Websites/test-results/include/test-results.php: Added.
* Websites/test-results/index.html: Added.
* Websites/test-results/js: Added.
* Websites/test-results/js/autocompleter.js: Added.
* Websites/test-results/js/build.js: Added.
* Websites/test-results/js/dom.js: Added.

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

15 files changed:
ChangeLog
Websites/test-results/.htaccess [new file with mode: 0644]
Websites/test-results/admin/index.php [new file with mode: 0644]
Websites/test-results/api/manifest.php [new file with mode: 0644]
Websites/test-results/api/report.php [new file with mode: 0644]
Websites/test-results/api/results.php [new file with mode: 0644]
Websites/test-results/include/config.json [new file with mode: 0644]
Websites/test-results/include/db.php [new file with mode: 0644]
Websites/test-results/include/init-database.sql [new file with mode: 0644]
Websites/test-results/include/json-shared.php [new file with mode: 0644]
Websites/test-results/include/test-results.php [new file with mode: 0644]
Websites/test-results/index.html [new file with mode: 0644]
Websites/test-results/js/autocompleter.js [new file with mode: 0644]
Websites/test-results/js/build.js [new file with mode: 0644]
Websites/test-results/js/dom.js [new file with mode: 0644]

index 345372f..7fa939e 100644 (file)
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,32 @@
+2013-10-16  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Add a new flakiness dashboard clone
+        https://bugs.webkit.org/show_bug.cgi?id=122936
+
+        Reviewed by Anders Carlsson.
+
+        Added the initial prototype.
+
+        * Websites/test-results: Added.
+        * Websites/test-results/.htaccess: Added.
+        * Websites/test-results/admin: Added.
+        * Websites/test-results/admin/index.php: Added.
+        * Websites/test-results/api: Added.
+        * Websites/test-results/api/manifest.php: Added.
+        * Websites/test-results/api/report.php: Added.
+        * Websites/test-results/api/results.php: Added.
+        * Websites/test-results/include: Added.
+        * Websites/test-results/include/config.json: Added.
+        * Websites/test-results/include/db.php: Added.
+        * Websites/test-results/include/init-database.sql: Added.
+        * Websites/test-results/include/json-shared.php: Added.
+        * Websites/test-results/include/test-results.php: Added.
+        * Websites/test-results/index.html: Added.
+        * Websites/test-results/js: Added.
+        * Websites/test-results/js/autocompleter.js: Added.
+        * Websites/test-results/js/build.js: Added.
+        * Websites/test-results/js/dom.js: Added.
+
 2013-10-16  Csaba Osztrogon√°c  <ossy@webkit.org>
 
         [WK2][Efl][CMake] Add support for ENABLE_NETWORK_PROCESS to the build system
diff --git a/Websites/test-results/.htaccess b/Websites/test-results/.htaccess
new file mode 100644 (file)
index 0000000..817aa47
--- /dev/null
@@ -0,0 +1,8 @@
+php_value post_max_size 20M
+
+<IfModule mod_php5.c>
+php_value upload_max_filesize 100000000
+php_value post_max_size 110000000
+php_value memory_limit 120000000
+php_value max_input_time 60
+</IfModule>
diff --git a/Websites/test-results/admin/index.php b/Websites/test-results/admin/index.php
new file mode 100644 (file)
index 0000000..76d05c6
--- /dev/null
@@ -0,0 +1,122 @@
+<!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');
+
+function notice($message) {
+    echo "<p class='notice'>$message</p>";
+}
+
+define('MAX_FETCH_COUNT', 10);
+
+function fetch_builders($db, $master) {
+    flush();
+    $builders_json = fetch_and_parse_test_results_json("http://$master/json/builders/");
+    if (!$builders_json)
+        return notice("Failed to fetch or decode /json/builders from $master");
+
+    echo "<h2>Fetching builds from $master</h2>\n";
+    echo "<ul>\n";
+
+    foreach ($builders_json as $builder_name => $builder_data) {
+        if (!stristr($builder_name, 'Test') || stristr($builder_name, 'Apple Win'))
+            continue;
+        $builder_id = $db->select_or_insert_row('builders', NULL, array('master' => $master, 'name' => $builder_name));
+        $escaped_builder_name = htmlspecialchars($builder_name);
+        echo "<li><em>$escaped_builder_name</em> (id: $builder_id) - ";
+
+        $fetchCount = 0;
+        $builds = $builder_data['cachedBuilds'];
+        foreach (array_reverse($builds) as $build_number) {
+            $build_number = intval($build_number);
+            $build = $db->select_or_insert_row('builds', NULL, array('builder' => $builder_id, 'number' => $build_number), NULL, '*');
+            if ($db->is_true($build['fetched']))
+                $class = 'fetched';
+            else if ($fetchCount >= MAX_FETCH_COUNT)
+                $class = 'unfetched';
+            else {
+                $class = fetch_build($db, $master, $builder_name, $build['id'], $build_number) ? 'fetched' : 'unfetched';
+                $fetchCount++;
+            }
+            echo "<span class=\"$class\">$build_number</a> ";
+        }
+        echo "</li>\n";
+    }
+    echo "<ul>\n";
+
+    return TRUE;
+}
+
+function urlencode_without_plus($url) {
+    return str_replace('+', '%20', urlencode($url));
+}
+
+function fetch_build($db, $master, $builder_name, $build_id, $build_number) {
+    flush();
+    $builder_name = urlencode_without_plus($builder_name);
+    $build_json = fetch_and_parse_test_results_json("http://$master/json/builders/$builder_name/builds/$build_number");
+    if (!$build_json || !array_key_exists('times', $build_json) || count($build_json['times']) != 2)
+        return FALSE;
+
+    $revision = NULL;
+    $slavename = NULL;
+    foreach ($build_json['properties'] as $property) {
+        if ($property[0] == 'got_revision')
+            $revision = $property[1];
+        if ($property[0] == 'slavename')
+            $slavename = $property[2];
+    }
+    if (!$revision || !$slavename)
+        return FALSE;
+
+    $start_time = float_to_time($build_json['times'][0]);
+    $end_time = float_to_time($build_json['times'][1]);
+
+    flush();
+    $full_results = fetch_and_parse_test_results_json("http://$master/results/$builder_name/r$revision%20($build_number)/full_results.json", TRUE);
+    return store_test_results($db, $full_results, $build_id, $revision, $start_time, $end_time, $slavename);
+}
+
+$db = new Database;
+if (!$db->connect())
+    notice('Failed to connect to the database');
+else if (array_key_exists('master', $_GET)) {
+    $master = htmlspecialchars($_GET['master']); // Okay since hostname shoudln't contain any HTML special characters.
+    if (in_array($master, config('masters')))
+        fetch_builders($db, $master);
+    else
+        notice("The master $master not found");
+} else {
+    echo "<ul>\n";
+    foreach (config('masters') as $master)
+        echo "<li><a href=\"?master=$master\">$master</a></li>\n";
+    echo "</ul>\n";
+}
+
+?></div>
+
+</body>
+</html>
diff --git a/Websites/test-results/api/manifest.php b/Websites/test-results/api/manifest.php
new file mode 100644 (file)
index 0000000..672b78a
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+require_once('../include/json-shared.php');
+
+$db = connect();
+$tests = $db->fetch_table('tests');
+
+function fetch_table_and_create_map_by_id($table_name) {
+    global $db;
+
+    $rows = $db->fetch_table($table_name);
+    if (!$rows)
+        return array();
+
+    $results = array();
+    foreach ($rows as $row) {
+        $results[$row['id']] = $row;
+    }
+    return $results;
+}
+
+exit_with_success(array('tests' => $tests,
+    'builders' => fetch_table_and_create_map_by_id('builders'),
+    'slaves' => fetch_table_and_create_map_by_id('slaves'),
+    'repositories' => fetch_table_and_create_map_by_id('repositories')));
+
+?>
diff --git a/Websites/test-results/api/report.php b/Websites/test-results/api/report.php
new file mode 100644 (file)
index 0000000..db6ba6c
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+require_once('../include/json-shared.php');
+require_once('../include/test-results.php');
+
+$db = connect();
+
+require_existence_of($_POST, array(
+    'master' => '/[A-Za-z0-9\.]+/',
+    'builder_name' => '/^[A-Za-z0-9 \(\)\-_]+$/',
+    'build_number' => '/^[0-9]+?$/',
+    'build_slave' => '/^[A-Za-z0-9\-_]+$/',
+    'revisions' => '/^.+?$/',
+    'start_time' => '/^[0-9]+(\.[0-9]+)?$/',
+    'end_time' => '/^[0-9]+(\.[0-9]+)?$/'));
+
+if (!array_key_exists('file', $_FILES) or !array_key_exists('tmp_name', $_FILES['file']) or count($_FILES['file']['tmp_name']) <= 0)
+    exit_with_error('ResultsJSONNotIncluded');
+
+$revisions = json_decode($_POST['revisions'], true);
+foreach ($revisions as $repository_name => $revision_data) {
+    require_format('repository_name', $repository_name, '/^\w+$/');
+    require_existence_of($revision_data, array(
+        'revision' => '/^[a-z0-9]+$/',
+        'timestamp' => '/^[a-z0-9\-\.:TZ]+$/',
+    ), 'revision');
+}
+
+$test_results = fetch_and_parse_test_results_json($_FILES['file']['tmp_name']);
+if (!$test_results)
+    exit_with_error('InvalidResultsJSON');
+
+$start_time = float_to_time($_POST['start_time']);
+$end_time = float_to_time($_POST['end_time']);
+
+$build_id = add_build($db, $_POST['master'], $_POST['builder_name'], intval($_POST['build_number']));
+if (!$build_id)
+    exit_with_error('FailedToInsertBuild', array('master' => $_POST['master'], 'builderName' => $_POST['builder_name'], 'buildNumber' => $_POST['build_number']));
+
+foreach ($revisions as $repository_name => $revision_data) {
+    $repository_id = $db->select_or_insert_row('repositories', NULL, array('name' => $repository_name));
+    if (!$repository_id)
+        exit_with_error('FailedToInsertRepository', array('name' => $repository_name));
+
+    $revision_data = array(
+        'repository' => $repository_id,
+        'build' => $build_id,
+        'value' => $revision_data['revision'],
+        'time' => array_get($revision_data, 'timestamp'));
+    $db->select_or_insert_row('build_revisions', NULL, array('repository' => $repository_id, 'build' => $build_id), $revision_data, 'value')
+        or exit_with_error('FailedToInsertRevision', array('name' => $repository_name, 'data' => $revision_data));
+}
+
+$slave_id = add_slave($db, $_POST['build_slave']);
+if (!store_test_results($db, $test_results, $build_id, $start_time, $end_time, $slave_id))
+    exit_with_error('FailedToStoreResults', array('buildId' => $build_id));
+
+exit_with_success();
+
+?>
diff --git a/Websites/test-results/api/results.php b/Websites/test-results/api/results.php
new file mode 100644 (file)
index 0000000..0dd6a27
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+
+require_once('../include/json-shared.php');
+
+$db = connect();
+
+require_existence_of($_GET, array('test' => '/[A-Za-z0-9\._\- ]+/'));
+
+$test = $db->select_first_row('tests', NULL, array('name' => $_GET['test']));
+if (!$test)
+    exit_with_error('TestNotFound');
+
+$result_rows = $db->query_and_fetch_all(
+'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.test = $1 AND results.build = builds.id
+    GROUP BY results.id, builds.id', array($test['id']));
+if (!$result_rows)
+    exit_with_error('ResultsNotFound');
+
+date_default_timezone_set('UTC');
+function parse_revisions_array($postgres_array) {
+    // e.g. {"(WebKit,131456,\"2012-10-16 14:53:00\")","(Safari,162004,)"}
+    $outer_array = json_decode('[' . trim($postgres_array, '{}') . ']');
+    $revisions = array();
+    foreach ($outer_array as $item) {
+        $name_and_revision = explode(',', trim($item, '()'));
+        $time = strtotime(trim($name_and_revision[2], '"')) * 1000;
+        $revisions[trim($name_and_revision[0], '"')] = array(trim($name_and_revision[1], '"'), $time);
+    }
+    return $revisions;
+}
+
+$builders = array();
+foreach ($result_rows as $result) {
+    array_push(array_ensure_item_has_array($builders, $result['builder']),
+        array('buildTime' => strtotime($result['start_time']) * 1000,
+        'revisions' => parse_revisions_array($result['revisions']),
+        'builder' => $result['builder'],
+        'slave' => $result['slave'],
+        'buildNumber' => $result['number'],
+        'actual' => $result['actual'],
+        'expected' => $result['expected']));
+}
+
+exit_with_success(array('builders' => $builders));
+
+?>
diff --git a/Websites/test-results/include/config.json b/Websites/test-results/include/config.json
new file mode 100644 (file)
index 0000000..1479194
--- /dev/null
@@ -0,0 +1,14 @@
+{
+    "debug": true,
+    "database": {
+        "host": "localhost",
+        "port": "5432",
+        "username": "safari-test-history",
+        "password": "password",
+        "name": "safari-test-history-db"
+    },
+    "masters": [
+        "build.webkit.org",
+        "build-safari.apple.com"
+    ]
+}
diff --git a/Websites/test-results/include/db.php b/Websites/test-results/include/db.php
new file mode 100644 (file)
index 0000000..f042acd
--- /dev/null
@@ -0,0 +1,187 @@
+<?php
+
+// Note: This code is identical to SafariPerfMonitor's db.php
+
+error_reporting(E_ALL | E_STRICT);
+
+function ends_with($str, $key) {
+    return strrpos($str, $key) == strlen($str) - strlen($key);
+}
+
+function ctype_alnum_underscore($str) {
+    return ctype_alnum(str_replace('_', '', $str));
+}
+
+function &array_ensure_item_has_array(&$array, $key) {
+    if (!array_key_exists($key, $array))
+        $array[$key] = array();
+    return $array[$key];
+}
+
+function array_get($array, $key, $default = NULL) {
+    if (!array_key_exists($key, $array))
+        return $default;
+    return $array[$key];
+}
+
+$_config = NULL;
+
+function config($key) {
+    global $_config;
+    if (!$_config)
+        $_config = json_decode(file_get_contents(dirname(__FILE__) . '/config.json'), true);
+    return $_config[$key];
+}
+
+if (config('debug'))
+    ini_set('display_errors', 'On');
+
+class Database
+{
+    private $connection = false;
+
+    function __destruct() {
+        if ($this->connection)
+            pg_close($this->connection);
+        $this->connection = false;
+    }
+
+    function is_true($value) {
+        return $value == 't';
+    }
+
+    function connect() {
+        $databaseConfig = config('database');
+        $this->connection = pg_connect('host=' . $databaseConfig['host'] . ' port=' . $databaseConfig['port']
+            . ' dbname=' . $databaseConfig['name'] . ' user=' . $databaseConfig['username'] . ' password=' . $databaseConfig['password']);
+        return $this->connection ? true : false;
+    }
+
+    private function prefixed_column_names($columns, $prefix = NULL) {
+        if (!$prefix)
+            return join(', ', $columns);
+        return $prefix . '_' . join(', ' . $prefix . '_', $columns);
+    }
+
+    private function prefixed_name($column, $prefix = NULL) {
+        return $prefix ? $prefix . '_' . $column : $column;
+    }
+
+    private function prepare_params($params, &$placeholders, &$values) {
+        $column_names = array_keys($params);
+
+        $i = count($values) + 1;
+        foreach ($column_names as $name) {
+            assert(ctype_alnum_underscore($name));
+            array_push($placeholders, '$' . $i);
+            array_push($values, $params[$name]);
+            $i++;
+        }
+
+        return $column_names;
+    }
+
+    function insert_row($table, $prefix, $params, $returning = 'id') {
+        $placeholders = array();
+        $values = array();
+        $column_names = $this->prepare_params($params, $placeholders, $values);
+
+        assert(!$prefix || ctype_alnum_underscore($prefix));
+        $column_names = $this->prefixed_column_names($column_names, $prefix);
+        $placeholders = join(', ', $placeholders);
+
+        if ($returning) {
+            $returning_column_name = $this->prefixed_name($returning, $prefix);
+            $rows = $this->query_and_fetch_all("INSERT INTO $table ($column_names) VALUES ($placeholders) RETURNING $returning_column_name", $values);
+            return $rows ? $rows[0][$returning_column_name] : NULL;
+        }
+
+        return $this->query_and_get_affected_rows("INSERT INTO $table ($column_names) VALUES ($placeholders)", $values) == 1;
+    }
+
+    function select_or_insert_row($table, $prefix, $select_params, $insert_params = NULL, $returning = 'id') {
+        $values = array();
+
+        $select_placeholders = array();
+        $select_column_names = $this->prepare_params($select_params, $select_placeholders, $values);
+        $select_values = array_slice($values, 0);
+
+        if ($insert_params === NULL)
+            $insert_params = $select_params;
+        $insert_placeholders = array();
+        $insert_column_names = $this->prepare_params($insert_params, $insert_placeholders, $values);
+
+        assert(!!$returning);
+        assert(!$prefix || ctype_alnum_underscore($prefix));
+        $returning_column_name = $returning == '*' ? '*' : $this->prefixed_name($returning, $prefix);
+        $select_column_names = $this->prefixed_column_names($select_column_names, $prefix);
+        $select_placeholders = join(', ', $select_placeholders);
+        $query = "SELECT $returning_column_name FROM $table WHERE ($select_column_names) = ($select_placeholders)";
+
+        $insert_column_names = $this->prefixed_column_names($insert_column_names, $prefix);
+        $insert_placeholders = join(', ', $insert_placeholders);
+        $rows = $this->query_and_fetch_all("INSERT INTO $table ($insert_column_names) SELECT $insert_placeholders WHERE NOT EXISTS
+            ($query) RETURNING $returning_column_name", $values);
+        if (!$rows)
+            $rows = $this->query_and_fetch_all($query, $select_values);
+
+        return $rows ? ($returning == '*' ? $rows[0] : $rows[0][$returning_column_name]) : NULL;
+    }
+
+    function select_first_row($table, $prefix, $params, $order_by = NULL) {
+        $placeholders = array();
+        $values = array();
+        $column_names = join(', ', $this->prepare_params($params, $placeholders, $values));
+        $placeholders = join(', ', $placeholders);
+        $query = "SELECT * FROM $table WHERE ($column_names) = ($placeholders)";
+        if ($order_by) {
+            assert(!ctype_alnum_underscore($order_by));
+            $query .= ' ORDER BY ' . $this->prefixed_name($order_by, $prefix);
+        }
+        $rows = $this->query_and_fetch_all($query . ' LIMIT 1', $values);
+
+        return $rows ? $rows[0] : NULL;
+    }
+
+    function query_and_get_affected_rows($query, $params = array()) {
+        if (!$this->connection)
+            return 0;
+        $result = pg_query_params($this->connection, $query, $params);
+        if (!$result)
+            return 0;
+        return pg_affected_rows($result);
+    }
+
+    function query_and_fetch_all($query, $params = array()) {
+        if (!$this->connection)
+            return false;
+        $result = pg_query_params($this->connection, $query, $params);
+        if (!$result)
+            return false;
+        return pg_fetch_all($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;
+        $clauses = '';
+        if ($column_to_be_ordered_by)
+            $clauses .= 'ORDER BY ' . $column_to_be_ordered_by;
+        return $this->query_and_fetch_all("SELECT * FROM $table_name $clauses");
+    }
+
+    function begin_transaction() {
+        return $this->connection and pg_query($this->connection, "BEGIN");
+    }
+
+    function commit_transaction() {
+        return $this->connection and pg_query($this->connection, 'COMMIT');
+    }
+
+    function rollback_transaction() {
+        return $this->connection and pg_query($this->connection, 'ROLLBACK');
+    }
+
+}
+
+?>
\ No newline at end of file
diff --git a/Websites/test-results/include/init-database.sql b/Websites/test-results/include/init-database.sql
new file mode 100644 (file)
index 0000000..06d87e7
--- /dev/null
@@ -0,0 +1,57 @@
+DROP TABLE results CASCADE;
+DROP TABLE tests CASCADE;
+DROP TABLE build_revision_map CASCADE;
+DROP TABLE revisions CASCADE;
+DROP TABLE builds CASCADE;
+DROP TABLE slaves CASCADE;
+DROP TABLE builders CASCADE;
+
+CREATE TABLE builders (
+    id serial PRIMARY KEY,
+    master varchar(64) NOT NULL,
+    name varchar(64) NOT NULL UNIQUE);
+
+CREATE TABLE repositories (
+    id serial PRIMARY KEY,
+    name varchar(64) NOT NULL,
+    url varchar(1024),
+    blame_url varchar(1024));
+
+CREATE TABLE slaves (
+    id serial PRIMARY KEY,
+    name varchar(128) NOT NULL UNIQUE);
+
+CREATE TABLE builds (
+    id serial PRIMARY KEY,
+    builder integer REFERENCES builders ON DELETE CASCADE,
+    number integer NOT NULL,
+    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);
+
+CREATE TABLE build_revisions (
+    build integer NOT NULL REFERENCES builds ON DELETE CASCADE,
+    repository integer NOT NULL REFERENCES repositories ON DELETE CASCADE,
+    value varchar(64) NOT NULL,
+    time timestamp,
+    PRIMARY KEY (repository, build));
+CREATE INDEX revision_build_index ON build_revisions(build);
+CREATE INDEX revision_repository_index ON build_revisions(repository);
+
+CREATE TABLE tests (
+    id serial PRIMARY KEY,
+    name varchar(1024) NOT NULL UNIQUE,
+    reftest_type varchar(64));
+
+CREATE TABLE results (
+    id serial PRIMARY KEY,
+    test integer REFERENCES tests ON DELETE CASCADE,
+    build integer REFERENCES builds ON DELETE CASCADE,
+    expected varchar(64) NOT NULL,
+    actual varchar(64) NOT NULL);
+CREATE INDEX results_test ON results(test);
+CREATE INDEX results_build ON results(build);
diff --git a/Websites/test-results/include/json-shared.php b/Websites/test-results/include/json-shared.php
new file mode 100644 (file)
index 0000000..3ed00fc
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+
+require_once('../include/db.php');
+
+header('Content-type: application/json');
+
+function exit_with_error($status, $details = array()) {
+    $details['status'] = $status;
+    echo json_encode($details);
+    exit(1);
+}
+
+function exit_with_success($details = array()) {
+    $details['status'] = 'OK';
+    echo json_encode($details);
+    exit(0);
+}
+
+function connect() {
+    $db = new Database;
+    if (!$db->connect())
+        exit_with_error('DatabaseConnectionError');
+    return $db;
+}
+
+function camel_case_words_separated_by_underscore($name) {
+    return implode('', array_map('ucfirst', explode('_', $name)));
+}
+
+function require_format($key, $value, $pattern) {
+    if (!preg_match($pattern, $value))
+        exit_with_error('Invalid' . camel_case_words_separated_by_underscore($key), array('value' => $value));
+}
+
+function require_existence_of($array, $list_of_arguments, $prefix = '') {
+    if ($prefix)
+        $prefix .= '_';
+    foreach ($list_of_arguments as $key => $pattern) {
+        $name = camel_case_words_separated_by_underscore($prefix . $key);
+        if (!array_key_exists($key, $array))
+            exit_with_error($name . 'NotSpecified');
+        require_format($name, $array[$key], $pattern);
+    }
+}
+
+?>
diff --git a/Websites/test-results/include/test-results.php b/Websites/test-results/include/test-results.php
new file mode 100644 (file)
index 0000000..d77e05f
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+require_once('db.php');
+
+function float_to_time($time_in_float) {
+    $time = new DateTime();
+    $time->setTimestamp(floatval($time_in_float));
+    return $time;
+}
+
+function add_build($db, $master, $builder_name, $build_number) {
+    if (!in_array($master, config('masters')))
+        return NULL;
+
+    $builder_id = $db->select_or_insert_row('builders', NULL, array('master' => $master, 'name' => $builder_name));
+    if (!$builder_id)
+        return NULL;
+
+    return $db->select_or_insert_row('builds', NULL, array('builder' => $builder_id, 'number' => $build_number));
+}
+
+function add_slave($db, $name) {
+    return $db->select_or_insert_row('slaves', NULL, array('name' => $name));
+}
+
+function fetch_and_parse_test_results_json($url, $jsonp = FALSE) {
+    $json_contents = file_get_contents($url);
+    if (!$json_contents)
+        return NULL;
+
+    if ($jsonp)
+        $json_contents = preg_replace('/^\w+\(|\);$/', '', $json_contents);
+
+    return json_decode($json_contents, true);
+}
+
+function store_test_results($db, $test_results, $build_id, $start_time, $end_time, $slave_id) {
+    $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',
+            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) {
+        $db->rollback_transaction();
+        return FALSE;
+    }
+
+    return TRUE;
+}
+
+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);
+        return;
+    }
+
+    $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']));
+}
+
+?>
diff --git a/Websites/test-results/index.html b/Websites/test-results/index.html
new file mode 100644 (file)
index 0000000..367375c
--- /dev/null
@@ -0,0 +1,439 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>WebKit Test Results</title>
+<link rel="stylesheet" href="/common.css">
+<script src="js/autocompleter.js"></script>
+<script src="js/build.js"></script>
+<script src="js/dom.js"></script>
+</head>
+<body>
+
+<header id="title">
+<h1><a href="/">WebKit Test Results</a></h1>
+<ul>
+    <li><a href="http://build.webkit.org/waterfall">Waterfall</a></li>
+</ul>
+</header>
+
+<form id="navigationBar" onsubmit="TestResultsView.fetchTest(this['testName'].value);
+    TestResultsView.updateLocationHash();
+    return false;">
+<input id="testName" type="text" size="150" onpaste="pasteHelper(this, event)"
+    placeholder="Type in a test name, or copy and paste test names on results.html or NRWT stdout (including junks)"></form>
+
+<div id="container"></div>
+<div id="tooltipContainer"></div>
+
+<style>
+
+#testName {
+    width: 99%;
+    font-size: 1em;
+    outline: none;
+    border: 1px solid #ccc;
+    border-radius: 5px;
+    padding: 5px;
+}
+
+.testResults {
+    border: 1px solid #ccc;
+    border-radius: 5px;
+    padding: 5px;
+    margin: 10px 0px;
+    position: relative;
+}
+
+.closeButton {
+    position: absolute;
+    right: 5px;
+    top: 5px;
+    width: 1em;
+    height: 1em;
+    stroke: #999;
+}
+
+.resultsTable {
+    font-size: small;
+    border-collapse: collapse;
+    border: 2px solid #fff;
+    padding: 0;
+    margin: 0;
+}
+
+.resultsTable caption {
+    font-size: large;
+    font-weight: normal;
+    text-align: left;
+    margin-bottom: 0.3em;
+    white-space: pre;
+}
+
+.resultsTable td,
+.resultsTable th {
+    border: 2px solid #fff;
+    padding: 0;
+    margin: 0;
+}
+
+.resultsTable th,
+.resultsTable .passingRate {
+    font-weight: normal;
+    padding-right: 10px;
+}
+
+.resultsTable th {
+    width: 15em;
+}
+
+.resultsTable .passingRate {
+    width: 3em;
+}
+
+.resultsTable .resultCell {
+    display: inline-block;
+    padding: 0.2em 0.2em;
+}
+
+.resultsTable a {
+    display: block;
+    width: 1em;
+    height: 1.5em;
+    border-radius: 3px;
+}
+
+.resultsTable .PASS a {
+    background-color: #0c3;
+}
+
+.resultsTable .TEXT a {
+    background-color: #c33;
+}
+
+.resultsTable .IMAGE a {
+    background-color: #3cf;
+}
+
+.resultsTable .TEXT.PASS a {
+    background-color: #cf3;
+}
+
+.resultsTable .CRASH a {
+    background-color: #f00;
+}
+
+.candidateWindow {
+    z-index: 999;
+    position: absolute;
+    background: white;
+    color: black;
+    border: 1px solid #ccc;
+    border-radius: 5px;
+    margin: 5px 0 0 0;
+    padding: 5px;
+    font-size: 1em;
+    list-style: none;
+}
+
+.candidateWindow em {
+    background-color: #ccc;
+    font-style: normal;
+}
+
+.candidateWindow .selected {
+    background-color: #0cf;
+    color: white;
+}
+
+#tooltipContainer {
+    position: absolute;
+}
+
+.tooltip {
+    position: relative;
+    border-radius: 5px;
+    padding: 5px;
+    opacity: 0.9;
+    background: #333;
+    color: #eee;
+    font-size: small;
+    line-height: 130%;
+}
+
+.tooltip:after {
+    position: absolute;
+    width: 0;
+    height: 0;
+    left: 50%;
+    margin-left: -9px;
+    bottom: -19px;
+    content: "";
+    display: block;
+    border-style: solid;
+    border-width: 10px;
+    border-color: #333 transparent transparent transparent;
+}
+
+.tooltip ul,
+.tooltip li {
+    padding: 0;
+    margin: 0;
+    list-style: none;
+}
+
+.tooltip a {
+    color: white;
+    text-shadow: none;
+    text-decoration: underline;
+}
+
+</style>
+<script>
+
+var TestResultsView = new (function () {
+    var tooltipContainer = document.getElementById('tooltipContainer');
+
+    tooltipContainer.onclick = function (event) { event.insideTooltip = true; }
+    document.addEventListener('click', function (event) {
+        if (!event.insideTooltip)
+            tooltipContainer.style.display = 'none';
+    });
+
+    window.addEventListener('hashchange', function (event) {
+        TestResultsView.locationHashChanged();
+    });
+
+    this._tooltipContainer = tooltipContainer;
+    this._tests = [];
+    this._oldHash = null;
+    this._builders = [];
+    this._slaves = [];
+    this._repositories = [];
+});
+
+TestResultsView.setBuilders = function (builders) {
+    this._builders = builders;
+}
+
+TestResultsView.setSlaves = function (slaves) {
+    this._slaves = slaves;
+}
+
+TestResultsView.setRepositories = function (repositories) {
+    this._repositories = repositories;
+}
+
+TestResultsView.showTooltip = function (anchor, contentElement) {
+    var tooltipContainer = this._tooltipContainer;
+    tooltipContainer.style.display = null;
+
+    while (tooltipContainer.firstChild)
+        tooltipContainer.removeChild(tooltipContainer.firstChild);
+    tooltipContainer.appendChild(contentElement);
+
+    var rect = anchor.getBoundingClientRect();
+    tooltipContainer.style.left = (rect.left - contentElement.offsetWidth / 2 + anchor.offsetWidth / 2) + 'px';
+    tooltipContainer.style.top = (rect.top - 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 slave = result['slave'];
+    var build = result['buildNumber'];
+    var actual = result['actual'];
+    var expected = result['expected'];
+    var anchor = element('a', {'href': this._urlFromBuilder('result', master, builder, revision, build)});
+    anchor.onmouseenter = function () {
+        var repositoryById = TestResultsView._repositories;
+        var formattedRevisions = result.build.formattedRevisions(previousResult ? previousResult.build : null);
+        var revisionDescription = '';
+        for (var repositoryName in formattedRevisions) {
+            var revision = formattedRevisions[repositoryName];
+            if (revisionDescription)
+                revisionDescription += ', ';
+            if (revision.url)
+                revisionDescription += repositoryName + ': ' + revision.url;
+            else
+                revisionDescription += 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', ['Result: ' + actual]),
+            ])
+        ]));
+    }
+    var cell = element('span', {
+        'class': actual + ' resultCell',
+    }, [anchor]);
+    return cell;
+}
+
+TestResultsView._populatePane = function(testName, results, section) {
+    var table = element('table', {'class': 'resultsTable'}, [element('caption', [testName])]);
+    var resultsByBuilder = results['builders'];
+    for (var builderId in resultsByBuilder) {
+        var results = resultsByBuilder[builderId];
+        for (var i = 0; i < results.length; i++) {
+            results[i].build = new TestBuild(this._repositories, this._builders, results[i]);
+        }
+
+        var sortedResults = results.sort(function (result1, result2) { return result1.build.time() - result2.build.time(); });
+        var cells = new Array(sortedResults.length);
+        var builder = this._builders[builderId];
+        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) + '%';
+        table.appendChild(element('tr', [element('th', [builder.name]), element('td', {'class': 'passingRate'}, [passingRate]),
+            element('td', cells)]));
+        // FIXME: Add a master name if there is more than one.
+    }
+    section.appendChild(table);
+}
+
+TestResultsView.fetchTest = function (testName) {
+    if (this._tests.indexOf(testName) >= 0)
+        return;
+
+    var self = this;
+
+    var closeButton = element('div', {'class': 'closeButton'});
+    closeButton.innerHTML = '<svg viewBox="0 0 100 100"><g stroke-width="10">'
+        + '<circle cx="50" cy="50" r="45" fill="transparent"></circle><polygon points="30,30 70,70"></polygon>'
+        + '<polygon points="30,70 70,30"></polygon></g></svg>';
+    closeButton.addEventListener('click', function (event) {
+            self._removeTest(testName);
+            section.parentNode.removeChild(section);
+            event.preventDefault();
+        });
+    var section = element('section', {'id': testName, 'class': 'testResults'}, [closeButton]);
+
+    document.getElementById('container').appendChild(section);
+
+    var xhr = new XMLHttpRequest();
+    xhr.open("GET", 'api/results.php?test=' + testName, true);  
+    xhr.onload = function(event) {
+        var response = JSON.parse(xhr.response);
+        if (response['status'] != 'OK') {
+            section.appendChild(text('Failed to load results for ' + testName + ': ' + response['status']));
+            return;
+        }
+
+        self._populatePane(testName, response, section);
+    }
+    xhr.send();
+    this._tests.push(testName);
+}
+
+TestResultsView._removeTest = function (testName) {
+    var index = this._tests.indexOf(testName);
+    if (index < 0)
+        return;
+    this._tests.splice(index, 1);
+    this.updateLocationHash();
+}
+
+TestResultsView.fetchTests = function (testNames, doNotUpdateHash) {
+    for (var i = 0; i < testNames.length; i++)
+        this.fetchTest(testNames[i], doNotUpdateHash);
+    this.updateLocationHash();
+}
+
+TestResultsView.updateLocationHash = function () {
+    var params = {
+        'tests': this._tests.join(',')
+    };
+    var hash = '';
+    for (var key in params)
+        hash += decodeURIComponent(key) + '=' + decodeURIComponent(params[key]);
+    location.hash = hash;
+    this._oldHash = location.hash;
+}
+
+TestResultsView.locationHashChanged = function () {
+    var newHash = location.hash;
+    if (newHash == this._oldHash)
+        return;
+    this._oldHash = newHash;
+    this._tests = [];
+    document.getElementById('container').innerHTML = '';
+    this.loadTestsFromLocationHash();
+}
+
+TestResultsView.loadTestsFromLocationHash = function () {
+    var parsed = {};
+    location.hash.substr(1).split('&').forEach(function (component) {
+        var equalPosition = component.indexOf('=');
+        if (equalPosition < 0)
+            return;
+        var name = decodeURIComponent(component.substr(0, equalPosition));
+        parsed[name] = decodeURIComponent(component.substr(equalPosition + 1));
+    });
+    if (!parsed['tests'])
+        return;
+    var doNotUpdateHash = true;
+    this.fetchTests(parsed['tests'].split(','), doNotUpdateHash);
+}
+
+function fetchManifest(callback) {
+    var xhr = new XMLHttpRequest();
+    xhr.open("GET", 'api/manifest.php', true);  
+    xhr.onload = function(event) {
+        var response = JSON.parse(xhr.response);
+        if (response['status'] != 'OK') {
+            alert('Failed to load manifest:' + response['status']);
+            console.log(response);
+            return;
+        }
+        callback(response);
+    }  
+    xhr.send();
+}
+
+fetchManifest(function (response) {
+    var testNames = response['tests'].map(function (test) { return test['name']; })
+    var input = document.getElementById('testName');
+    input.autocompleter = new Autocompleter(input, testNames);
+
+    TestResultsView.setBuilders(response['builders']);
+    TestResultsView.setSlaves(response['slaves']);
+    TestResultsView.setRepositories(response['repositories']);
+    TestResultsView.loadTestsFromLocationHash();
+});
+
+function pasteHelper(input, event) {
+    function removeJunkFromNRWTStdout(input) {
+        return input.replace(/(\[[\w ]+\])|(.+\:.+)/g, '').replace(/^[ \t]+|[ \t]+$/gm, '');
+    }
+
+    function removeJunkFromResultsPage(input) {
+        return input.replace(/(^[^\/]+$)|(^\+)/gm, '').replace(/\([^)]+\)/g, '').replace(/\s+[A-Za-z]+(\s+[A-Za-z]+)*\s*$/gm, '');
+    }
+
+    var text = event.clipboardData.getData('text/plain');
+    if (text.indexOf('\n') < 0)
+        return;
+
+    var urls = removeJunkFromResultsPage(removeJunkFromNRWTStdout(text)).split('\n');
+    TestResultsView.fetchTests(urls.filter(function (url) { return url.length; }));
+    event.preventDefault();
+}
+
+</script>
+</body>
+</html>
\ No newline at end of file
diff --git a/Websites/test-results/js/autocompleter.js b/Websites/test-results/js/autocompleter.js
new file mode 100644 (file)
index 0000000..5708b50
--- /dev/null
@@ -0,0 +1,114 @@
+function Autocompleter(inputElement, list) {
+    this._inputElement = inputElement;
+    this._currentSelection = undefined;
+    this._list = list;
+    this._candidates = [];
+    this._currentFilter = null;
+    this._candidateWindow = null;
+
+    inputElement.addEventListener('focus', this.show.bind(this));
+    inputElement.addEventListener('blur', this.hide.bind(this));
+    inputElement.addEventListener('keyup', this.update.bind(this));
+    inputElement.addEventListener('keydown', this.navigate.bind(this));
+}
+
+Autocompleter.prototype._ensureCandidateWindow = function () {
+    if (this._candidateWindow)
+        return;
+
+    var container = element('ul');
+    container.className = 'candidateWindow';
+    container.style.position = 'absolute';
+    container.style.display = 'none';
+    this._inputElement.parentNode.appendChild(container);
+    this._candidateWindow = container;
+}
+
+Autocompleter.prototype._updateCandidates = function (filter) {
+    if (this._currentFilter == filter)
+        return false;
+
+    var candidates = this._list.filter(function (testName) { return testName.indexOf(filter) >= 0; });
+    if (candidates.length > 50 || candidates.length == 1)
+        candidates = [];
+    this._candidates = candidates;
+    this._currentFilter = filter;
+    this._currentSelection = undefined;
+    return true;
+}
+
+Autocompleter.prototype._showCandidateWindow = function () {
+    if (!this._candidateWindow)
+        return;
+    var style = this._candidateWindow.style;
+    style.display = 'block';
+    style.top = this._inputElement.offsetTop + this._inputElement.offsetHeight + 'px';
+    style.left = this._inputElement.offsetLeft + 'px';
+}
+
+Autocompleter.prototype._createItem = function (candidate) {
+    var tokens = candidate.split(this._currentFilter);
+    var children = [];
+    for (var i = 0; i < tokens.length; i++) {
+        children.push(text(tokens[i]));
+        if (i + 1 < tokens.length)
+            children.push(element('em', [this._currentFilter]));
+    }
+    return element('li', children);
+}
+
+Autocompleter.prototype.show = function () {
+    this.hide();
+    this._ensureCandidateWindow();
+
+    for (var i = 0; i < this._candidates.length; i++)
+        this._candidateWindow.appendChild(this._createItem(this._candidates[i]));
+    this._selectItem(this._currentSelection);
+
+    if (this._candidates.length)
+        this._showCandidateWindow();
+}
+
+Autocompleter.prototype.update = function () {
+    if (this._updateCandidates(this._inputElement.value))
+        this.show();
+}
+
+Autocompleter.prototype.hide = function () {
+    if (!this._candidateWindow)
+        return;
+    this._candidateWindow.style.display = 'none';
+    this._candidateWindow.innerHTML = '';
+}
+
+Autocompleter.prototype._selectItem = function (index) {
+    if (!this._candidateWindow || this._currentSelection == index)
+        return;
+
+    var item = this._candidateWindow.childNodes[index];
+    if (!item)
+        return;
+    item.classList.add('selected');
+
+    var oldItem = this._candidateWindow.childNodes[this._currentSelection];
+    if (oldItem)
+        oldItem.classList.remove('selected');
+
+    this._currentSelection = index;
+}
+
+Autocompleter.prototype.navigate = function (event) {
+    if (event.keyCode == 0x28 /* DOM_VK_DOWN */) {
+        this._selectItem(this._currentSelection === undefined ? 0 : Math.min(this._currentSelection + 1, this._candidates.length - 1));
+        event.preventDefault();
+    } else if (event.keyCode == 0x26 /* DOM_VK_UP */) {
+        this._selectItem(this._currentSelection === undefined ? this._candidates.length - 1 : Math.max(this._currentSelection - 1, 0));
+        event.preventDefault();
+    } else if (event.keyCode == 0x0D /* VK_RETURN */) {
+        if (this._currentSelection === undefined)
+            return;
+        this._inputElement.value = this._candidates[this._currentSelection];
+    } else if (event.keyCode == 0x1B /* DOM_VK_ESCAPE */) {
+        this.hide();
+    }
+}
diff --git a/Websites/test-results/js/build.js b/Websites/test-results/js/build.js
new file mode 100644 (file)
index 0000000..d2be2ff
--- /dev/null
@@ -0,0 +1,67 @@
+// FIXME: De-duplicate this code with perf.webkit.org
+function TestBuild(repositories, builders, rawRun) {
+    const revisions = rawRun.revisions;
+    var maxTime = 0;
+    var revisionCount = 0;
+    for (var repositoryName in revisions) {
+        maxTime = Math.max(maxTime, revisions[repositoryName][1]); // Revision is an pair (revision, time)
+        revisionCount++;
+    }
+    var maxTimeString = new Date(maxTime).toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
+    var buildTime = rawRun.buildTime;
+    var buildTimeString = new Date(buildTime).toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
+
+    this.time = function () { return maxTime; }
+    this.formattedTime = function () { return maxTimeString; }
+    this.buildTime = function () { return buildTime; }
+    this.formattedBuildTime = function () { return buildTimeString; }
+    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;
+    }
+    this.revision = function(repositoryId) { return revisions[repositoryId][0]; }
+    this.formattedRevisions = function (previousBuild) {
+        var result = {};
+        for (var repositoryId in revisions) {
+            var repository = repositories[repositoryId];
+            var repositoryName = repository ? repository.name : 'Unknown repository ' + repositoryId;
+            var previousRevision = previousBuild ? previousBuild.revision(repositoryId) : undefined;
+            var currentRevision = this.revision(repositoryId);
+            if (previousRevision === currentRevision)
+                previousRevision = undefined;
+
+            var revisionPrefix = '';
+            if (currentRevision.length < 10) { // SVN-like revision.
+                revisionPrefix = 'r';
+                if (previousRevision)
+                    previousRevision = (parseInt(previousRevision) + 1);
+            }
+
+            var labelForThisRepository = revisionCount ? repositoryName : '';
+            if (previousRevision) {
+                if (labelForThisRepository)
+                    labelForThisRepository += ' ';
+                labelForThisRepository += revisionPrefix + previousRevision + '-' + revisionPrefix + currentRevision;
+            } else
+                labelForThisRepository += ' @ ' + revisionPrefix + currentRevision;
+
+            var url;
+            if (repository) {
+                if (previousRevision)
+                    url = (repository['blameUrl'] || '').replace(/\$1/g, previousRevision).replace(/\$2/g, currentRevision);
+                else
+                    url = (repository['url'] || '').replace(/\$1/g, currentRevision);
+            }
+
+            result[repositoryName] = {
+                'label': labelForThisRepository,
+                'currentRevision': currentRevision,
+                'previousRevision': previousRevision,
+                'url': url,
+            };
+        }
+        return result;
+    }
+}
diff --git a/Websites/test-results/js/dom.js b/Websites/test-results/js/dom.js
new file mode 100644 (file)
index 0000000..ac00842
--- /dev/null
@@ -0,0 +1,24 @@
+function text(text) {
+    return document.createTextNode(text);
+}
+
+function element(elementName, attributesOrChildNodes, childNodes) {
+    var element = document.createElement(elementName);
+
+    if (attributesOrChildNodes instanceof Array)
+        childNodes = attributesOrChildNodes;
+    else if (attributesOrChildNodes) {
+        for (var attributeName in attributesOrChildNodes)
+            element.setAttribute(attributeName, attributesOrChildNodes[attributeName]);
+    }
+
+    if (childNodes) {
+        for (var i = 0; i < childNodes.length; i++) {
+            if (typeof(childNodes[i]) === 'string')
+                element.appendChild(document.createTextNode(childNodes[i]));
+            else
+                element.appendChild(childNodes[i]);
+        }
+    }
+    return element;
+}