<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[199123] trunk/Websites/perf.webkit.org</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta">
<dt>Revision</dt> <dd><a href="http://trac.webkit.org/projects/webkit/changeset/199123">199123</a></dd>
<dt>Author</dt> <dd>rniwa@webkit.org</dd>
<dt>Date</dt> <dd>2016-04-06 16:19:29 -0700 (Wed, 06 Apr 2016)</dd>
</dl>

<h3>Log Message</h3>
<pre>New buildbot syncing scripts that supports multiple builders and slaves
https://bugs.webkit.org/show_bug.cgi?id=156269

Reviewed by Chris Dumez.

Add sync-buildbot.js that supports scheduling A/B testing jobs on multiple builders and slaves.
The old python script (sync-with-buildbot.py) could only support a single builder and slave
for each platform, test pair.

The main logic is implemented in BuildbotTriggerable.syncOnce. Various helper methods are added
throughout the codebase and tests have been refactored.

BuildbotSyncer has been updated to support multiple platform, test pairs. It's now responsible
for syncing everything on each builder (on a buildbot).

Added more unit tests for BuildbotSyncer and server tests for BuildbotTriggerable, and refactored
test helpers and mocks as needed.

* public/v3/models/build-request.js:
(BuildRequest.prototype.status): Added.
(BuildRequest.prototype.isScheduled): Added.
* public/v3/models/metric.js:
(Metric.prototype.fullName): Added.
* public/v3/models/platform.js:
(Platform): Added the map based on platform name.
(Platform.findByName): Added.
* public/v3/models/test.js:
(Test.topLevelTests):
(Test.findByPath): Added. Finds a test based on an array of test names; e.g. ['A', 'B'] would
find the test whose name is &quot;B&quot; which has a parent test named &quot;A&quot;.
(Test.prototype.fullName): Added.
* server-tests/api-build-requests-tests.js:
(addMockData): Moved to resources/mock-data.js.
(addAnotherMockTestGroup): Ditto.
* server-tests/resources/mock-data.js: Added.
(MockData.resetV3Models): Added.
(MockData.addMockData): Moved from api-build-requests-tests.js.
(MockData.addAnotherMockTestGroup): Ditto.
(MockData.mockTestSyncConfigWithSingleBuilder): Added.
(MockData.mockTestSyncConfigWithTwoBuilders): Added.
(MockData.pendingBuild): Added.
(MockData.runningBuild): Added.
(MockData.finishedBuild): Added.
* server-tests/resources/test-server.js:
(TestServer):
(TestServer.prototype.remoteAPI):
(TestServer.prototype._ensureTestDatabase): Don't fail even if the test database doesn't exit.
(TestServer.prototype._startApache): Create a RemoteAPI instance to access the test sever.
(TestServer.prototype._waitForPid): Increase the timeout.
(TestServer.prototype.inject): Replace global.RemoteAPI during the test and restore it afterwards.
* server-tests/tools-buildbot-triggerable-tests.js: Added. Tests BuildbotTriggerable.syncOnce.
(MockLogger): Added.
(MockLogger.prototype.log): Added.
(MockLogger.prototype.error): Added.
* tools/detect-changes.js:
(parseArgument): Moved to js/parse-arguments.js.
* tools/js/buildbot-syncer.js:
(BuildbotBuildEntry):
(BuildbotBuildEntry.prototype.syncer): Added.
(BuildbotBuildEntry.prototype.buildRequestStatusIfUpdateIsNeeded): Added. Returns a new status
for a build request (of the matching build request ID) if it needs to be updated in the server.
(BuildbotSyncer): This class 
(BuildbotSyncer.prototype.addTestConfiguration): Added.
(BuildbotSyncer.prototype.testConfigurations): Returns the list of test configurations.
(BuildbotSyncer.prototype.matchesConfiguration): Returns true iff the request can be scheduled on
this builder.
(BuildbotSyncer.prototype.scheduleRequest): Added. Schedules a new job on buildbot for a request.
(BuildbotSyncer.prototype.scheduleFirstRequestInGroupIfAvailable): Added. Schedules a new job for
the specified build request on the first slave that's available.
(BuildbotSyncer.prototype.pullBuildbot): Return a list of BuildbotBuildEntry instead of an object.
Also store it on an instance variable so that scheduleFirstRequestInGroupIfAvailable could use it.
(BuildbotSyncer.prototype._pullRecentBuilds):
(BuildbotSyncer.prototype.pathForPendingBuildsJSON): Renamed from urlForPendingBuildsJSON and now
only returns the path instead of the full URL since RemoteAPI takes a path, not full URL.
(BuildbotSyncer.prototype.pathForBuildJSON): Ditto from pathForBuildJSON.
(BuildbotSyncer.prototype.pathForForceBuild): Added.
(BuildbotSyncer.prototype.url): Use RemoteAPI's url method instead of manually constructing URL.
(BuildbotSyncer.prototype.urlForBuildNumber): Ditto.
(BuildbotSyncer.prototype._propertiesForBuildRequest): Now that each syncer can have multiple test
configurations associated with it, find the one matching for this request.
(BuildbotSyncer._loadConfig): Create a syncer per builder and add all test configurations to it.
(BuildbotSyncer._validateAndMergeConfig): Added the support for 'SlaveList', which is a list of
slave names present on this builder.
* tools/js/buildbot-triggerable.js: Added.
(BuildbotTriggerable): Added.
(BuildbotTriggerable.prototype.name): Added.
(BuildbotTriggerable.prototype.syncOnce): Added. The main logic for the syncing script. It pulls
existing build requests from the perf dashboard, pulls buildbot for pending and running/completed
builds on each builder (represented by each syncer), schedules build requests on buildbot if there
is any builder/slave available, and updates the status of build requests in the database.
(BuildbotTriggerable.prototype._validateRequests): Added.
(BuildbotTriggerable.prototype._pullBuildbotOnAllSyncers): Added.
(BuildbotTriggerable.prototype._scheduleNextRequestInGroupIfSlaveIsAvailable): Added.
(BuildbotTriggerable._testGroupMapForBuildRequests): Added.
* tools/js/database.js:
* tools/js/parse-arguments.js: Added. Extracted out of tools/detect-changes.js.
(parseArguments):
* tools/js/remote.js:
(RemoteAPI): Now optionally takes the server configuration.
(RemoteAPI.prototype.url): Added.
(RemoteAPI.prototype.getJSON): Removed the code for specifying request content.
(RemoteAPI.prototype.getJSONWithStatus): Ditto.
(RemoteAPI.prototype.postJSON): Added.
(RemoteAPI.prototype.postFormUrlencodedData): Added.
(RemoteAPI.prototype.sendHttpRequest): Fixed the code to specify auth.
* tools/js/v3-models.js: Don't include RemoteAPI here as they require a configuration for each host.
* tools/sync-buildbot.js: Added.
(main): Added. Parse the arguments and start the loop.
(syncLoop): Added.
* unit-tests/buildbot-syncer-tests.js: Added tests for pullBuildbot, scheduleRequest, as well as
scheduleFirstRequestInGroupIfAvailable. Refactored helper functions as needed.
(sampleiOSConfig):
(smallConfiguration): Added.
(smallPendingBuild): Added.
(smallInProgressBuild): Added.
(smallFinishedBuild): Added.
(createSampleBuildRequest): Create a unique build request for each platform.
(samplePendingBuild): Optionally specify build time and slave name.
(sampleInProgressBuild): Optionally specify slave name.
(sampleFinishedBuild): Ditto.
* unit-tests/resources/mock-remote-api.js:
(assert.notReached.assert.notReached):
(MockRemoteAPI.url): Added.
(MockRemoteAPI.postFormUrlencodedData): Added.
(MockRemoteAPI._addRequest): Extracted from getJSONWithStatus.
(MockRemoteAPI.waitForRequest): Extracted from inject. For tools-buildbot-triggerable-tests.js, we
need to instantiate a RemoteAPI for buildbot without replacing global.RemoteAPI.
(MockRemoteAPI.inject):
(MockRemoteAPI.reset): Added.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#trunkWebsitesperfwebkitorgChangeLog">trunk/Websites/perf.webkit.org/ChangeLog</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv3modelsbuildrequestjs">trunk/Websites/perf.webkit.org/public/v3/models/build-request.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv3modelsmetricjs">trunk/Websites/perf.webkit.org/public/v3/models/metric.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv3modelsplatformjs">trunk/Websites/perf.webkit.org/public/v3/models/platform.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv3modelstestjs">trunk/Websites/perf.webkit.org/public/v3/models/test.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsapibuildrequeststestsjs">trunk/Websites/perf.webkit.org/server-tests/api-build-requests-tests.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsresourcestestserverjs">trunk/Websites/perf.webkit.org/server-tests/resources/test-server.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsdetectchangesjs">trunk/Websites/perf.webkit.org/tools/detect-changes.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsjsbuildbotsyncerjs">trunk/Websites/perf.webkit.org/tools/js/buildbot-syncer.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsjsdatabasejs">trunk/Websites/perf.webkit.org/tools/js/database.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsjsremotejs">trunk/Websites/perf.webkit.org/tools/js/remote.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsjsv3modelsjs">trunk/Websites/perf.webkit.org/tools/js/v3-models.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgunittestsbuildbotsyncertestsjs">trunk/Websites/perf.webkit.org/unit-tests/buildbot-syncer-tests.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgunittestsresourcesmockremoteapijs">trunk/Websites/perf.webkit.org/unit-tests/resources/mock-remote-api.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgunittestsresourcesmockv3modelsjs">trunk/Websites/perf.webkit.org/unit-tests/resources/mock-v3-models.js</a></li>
</ul>

<h3>Added Paths</h3>
<ul>
<li><a href="#trunkWebsitesperfwebkitorgservertestsresourcesmockdatajs">trunk/Websites/perf.webkit.org/server-tests/resources/mock-data.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgserverteststoolsbuildbottriggerabletestsjs">trunk/Websites/perf.webkit.org/server-tests/tools-buildbot-triggerable-tests.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsjsbuildbottriggerablejs">trunk/Websites/perf.webkit.org/tools/js/buildbot-triggerable.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsjsparseargumentsjs">trunk/Websites/perf.webkit.org/tools/js/parse-arguments.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolssyncbuildbotjs">trunk/Websites/perf.webkit.org/tools/sync-buildbot.js</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunkWebsitesperfwebkitorgChangeLog"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/ChangeLog (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/ChangeLog        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/ChangeLog        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -1,3 +1,135 @@
</span><ins>+2016-04-05  Ryosuke Niwa  &lt;rniwa@webkit.org&gt;
+
+        New buildbot syncing scripts that supports multiple builders and slaves
+        https://bugs.webkit.org/show_bug.cgi?id=156269
+
+        Reviewed by Chris Dumez.
+
+        Add sync-buildbot.js that supports scheduling A/B testing jobs on multiple builders and slaves.
+        The old python script (sync-with-buildbot.py) could only support a single builder and slave
+        for each platform, test pair.
+
+        The main logic is implemented in BuildbotTriggerable.syncOnce. Various helper methods are added
+        throughout the codebase and tests have been refactored.
+
+        BuildbotSyncer has been updated to support multiple platform, test pairs. It's now responsible
+        for syncing everything on each builder (on a buildbot).
+
+        Added more unit tests for BuildbotSyncer and server tests for BuildbotTriggerable, and refactored
+        test helpers and mocks as needed.
+
+        * public/v3/models/build-request.js:
+        (BuildRequest.prototype.status): Added.
+        (BuildRequest.prototype.isScheduled): Added.
+        * public/v3/models/metric.js:
+        (Metric.prototype.fullName): Added.
+        * public/v3/models/platform.js:
+        (Platform): Added the map based on platform name.
+        (Platform.findByName): Added.
+        * public/v3/models/test.js:
+        (Test.topLevelTests):
+        (Test.findByPath): Added. Finds a test based on an array of test names; e.g. ['A', 'B'] would
+        find the test whose name is &quot;B&quot; which has a parent test named &quot;A&quot;.
+        (Test.prototype.fullName): Added.
+        * server-tests/api-build-requests-tests.js:
+        (addMockData): Moved to resources/mock-data.js.
+        (addAnotherMockTestGroup): Ditto.
+        * server-tests/resources/mock-data.js: Added.
+        (MockData.resetV3Models): Added.
+        (MockData.addMockData): Moved from api-build-requests-tests.js.
+        (MockData.addAnotherMockTestGroup): Ditto.
+        (MockData.mockTestSyncConfigWithSingleBuilder): Added.
+        (MockData.mockTestSyncConfigWithTwoBuilders): Added.
+        (MockData.pendingBuild): Added.
+        (MockData.runningBuild): Added.
+        (MockData.finishedBuild): Added.
+        * server-tests/resources/test-server.js:
+        (TestServer):
+        (TestServer.prototype.remoteAPI):
+        (TestServer.prototype._ensureTestDatabase): Don't fail even if the test database doesn't exit.
+        (TestServer.prototype._startApache): Create a RemoteAPI instance to access the test sever.
+        (TestServer.prototype._waitForPid): Increase the timeout.
+        (TestServer.prototype.inject): Replace global.RemoteAPI during the test and restore it afterwards.
+        * server-tests/tools-buildbot-triggerable-tests.js: Added. Tests BuildbotTriggerable.syncOnce.
+        (MockLogger): Added.
+        (MockLogger.prototype.log): Added.
+        (MockLogger.prototype.error): Added.
+        * tools/detect-changes.js:
+        (parseArgument): Moved to js/parse-arguments.js.
+        * tools/js/buildbot-syncer.js:
+        (BuildbotBuildEntry):
+        (BuildbotBuildEntry.prototype.syncer): Added.
+        (BuildbotBuildEntry.prototype.buildRequestStatusIfUpdateIsNeeded): Added. Returns a new status
+        for a build request (of the matching build request ID) if it needs to be updated in the server.
+        (BuildbotSyncer): This class 
+        (BuildbotSyncer.prototype.addTestConfiguration): Added.
+        (BuildbotSyncer.prototype.testConfigurations): Returns the list of test configurations.
+        (BuildbotSyncer.prototype.matchesConfiguration): Returns true iff the request can be scheduled on
+        this builder.
+        (BuildbotSyncer.prototype.scheduleRequest): Added. Schedules a new job on buildbot for a request.
+        (BuildbotSyncer.prototype.scheduleFirstRequestInGroupIfAvailable): Added. Schedules a new job for
+        the specified build request on the first slave that's available.
+        (BuildbotSyncer.prototype.pullBuildbot): Return a list of BuildbotBuildEntry instead of an object.
+        Also store it on an instance variable so that scheduleFirstRequestInGroupIfAvailable could use it.
+        (BuildbotSyncer.prototype._pullRecentBuilds):
+        (BuildbotSyncer.prototype.pathForPendingBuildsJSON): Renamed from urlForPendingBuildsJSON and now
+        only returns the path instead of the full URL since RemoteAPI takes a path, not full URL.
+        (BuildbotSyncer.prototype.pathForBuildJSON): Ditto from pathForBuildJSON.
+        (BuildbotSyncer.prototype.pathForForceBuild): Added.
+        (BuildbotSyncer.prototype.url): Use RemoteAPI's url method instead of manually constructing URL.
+        (BuildbotSyncer.prototype.urlForBuildNumber): Ditto.
+        (BuildbotSyncer.prototype._propertiesForBuildRequest): Now that each syncer can have multiple test
+        configurations associated with it, find the one matching for this request.
+        (BuildbotSyncer._loadConfig): Create a syncer per builder and add all test configurations to it.
+        (BuildbotSyncer._validateAndMergeConfig): Added the support for 'SlaveList', which is a list of
+        slave names present on this builder.
+        * tools/js/buildbot-triggerable.js: Added.
+        (BuildbotTriggerable): Added.
+        (BuildbotTriggerable.prototype.name): Added.
+        (BuildbotTriggerable.prototype.syncOnce): Added. The main logic for the syncing script. It pulls
+        existing build requests from the perf dashboard, pulls buildbot for pending and running/completed
+        builds on each builder (represented by each syncer), schedules build requests on buildbot if there
+        is any builder/slave available, and updates the status of build requests in the database.
+        (BuildbotTriggerable.prototype._validateRequests): Added.
+        (BuildbotTriggerable.prototype._pullBuildbotOnAllSyncers): Added.
+        (BuildbotTriggerable.prototype._scheduleNextRequestInGroupIfSlaveIsAvailable): Added.
+        (BuildbotTriggerable._testGroupMapForBuildRequests): Added.
+        * tools/js/database.js:
+        * tools/js/parse-arguments.js: Added. Extracted out of tools/detect-changes.js.
+        (parseArguments):
+        * tools/js/remote.js:
+        (RemoteAPI): Now optionally takes the server configuration.
+        (RemoteAPI.prototype.url): Added.
+        (RemoteAPI.prototype.getJSON): Removed the code for specifying request content.
+        (RemoteAPI.prototype.getJSONWithStatus): Ditto.
+        (RemoteAPI.prototype.postJSON): Added.
+        (RemoteAPI.prototype.postFormUrlencodedData): Added.
+        (RemoteAPI.prototype.sendHttpRequest): Fixed the code to specify auth.
+        * tools/js/v3-models.js: Don't include RemoteAPI here as they require a configuration for each host.
+        * tools/sync-buildbot.js: Added.
+        (main): Added. Parse the arguments and start the loop.
+        (syncLoop): Added.
+        * unit-tests/buildbot-syncer-tests.js: Added tests for pullBuildbot, scheduleRequest, as well as
+        scheduleFirstRequestInGroupIfAvailable. Refactored helper functions as needed.
+        (sampleiOSConfig):
+        (smallConfiguration): Added.
+        (smallPendingBuild): Added.
+        (smallInProgressBuild): Added.
+        (smallFinishedBuild): Added.
+        (createSampleBuildRequest): Create a unique build request for each platform.
+        (samplePendingBuild): Optionally specify build time and slave name.
+        (sampleInProgressBuild): Optionally specify slave name.
+        (sampleFinishedBuild): Ditto.
+        * unit-tests/resources/mock-remote-api.js:
+        (assert.notReached.assert.notReached):
+        (MockRemoteAPI.url): Added.
+        (MockRemoteAPI.postFormUrlencodedData): Added.
+        (MockRemoteAPI._addRequest): Extracted from getJSONWithStatus.
+        (MockRemoteAPI.waitForRequest): Extracted from inject. For tools-buildbot-triggerable-tests.js, we
+        need to instantiate a RemoteAPI for buildbot without replacing global.RemoteAPI.
+        (MockRemoteAPI.inject):
+        (MockRemoteAPI.reset): Added.
+
</ins><span class="cx"> 2016-03-30  Ryosuke Niwa  &lt;rniwa@webkit.org&gt;
</span><span class="cx"> 
</span><span class="cx">         Simplify API of Test model by removing Test.setParentTest
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv3modelsbuildrequestjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/v3/models/build-request.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v3/models/build-request.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/public/v3/models/build-request.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -40,8 +40,10 @@
</span><span class="cx">     order() { return this._order; }
</span><span class="cx">     rootSet() { return this._rootSet; }
</span><span class="cx"> 
</span><ins>+    status() { return this._status; }
</ins><span class="cx">     hasFinished() { return this._status == 'failed' || this._status == 'completed' || this._status == 'canceled'; }
</span><span class="cx">     hasStarted() { return this._status != 'pending'; }
</span><ins>+    isScheduled() { return this._status == 'scheduled'; }
</ins><span class="cx">     isPending() { return this._status == 'pending'; }
</span><span class="cx">     statusLabel()
</span><span class="cx">     {
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv3modelsmetricjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/v3/models/metric.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v3/models/metric.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/public/v3/models/metric.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -33,10 +33,7 @@
</span><span class="cx"> 
</span><span class="cx">     path() { return this._test.path().concat([this]); }
</span><span class="cx"> 
</span><del>-    fullName()
-    {
-        return this._test.path().map(function (test) { return test.label(); }).join(' \u220B ') + ' : ' + this.label();
-    }
</del><ins>+    fullName() { return this._test.fullName() + ' : ' + this.label(); }
</ins><span class="cx"> 
</span><span class="cx">     label()
</span><span class="cx">     {
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv3modelsplatformjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/v3/models/platform.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v3/models/platform.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/public/v3/models/platform.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -8,10 +8,18 @@
</span><span class="cx">         this._lastModifiedByMetric = object.lastModifiedByMetric;
</span><span class="cx">         this._containingTests = null;
</span><span class="cx"> 
</span><ins>+        this.ensureNamedStaticMap('name')[object.name] = this;
+
</ins><span class="cx">         for (var metric of this._metrics)
</span><span class="cx">             metric.addPlatform(this);
</span><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    static findByName(name)
+    {
+        var map = this.namedStaticMap('name');
+        return map ? map[name] : null;
+    }
+
</ins><span class="cx">     hasTest(test)
</span><span class="cx">     {
</span><span class="cx">         if (!this._containingTests) {
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv3modelstestjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/v3/models/test.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v3/models/test.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/public/v3/models/test.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -22,6 +22,25 @@
</span><span class="cx"> 
</span><span class="cx">     static topLevelTests() { return this.sortByName(this.listForStaticMap('topLevelTests')); }
</span><span class="cx"> 
</span><ins>+    static findByPath(path)
+    {
+        var matchingTest = null;
+        var testList = this.topLevelTests();
+        for (var part of path) {
+            matchingTest = null;
+            for (var test of testList) {
+                if (part == test.name()) {
+                    matchingTest = test;
+                    break;
+                }
+            }
+            if (!matchingTest)
+                return null;
+            testList = matchingTest.childTests();
+        }
+        return matchingTest;
+    }
+
</ins><span class="cx">     parentTest() { return Test.findById(this._parentId); }
</span><span class="cx"> 
</span><span class="cx">     path()
</span><span class="lines">@@ -35,6 +54,8 @@
</span><span class="cx">         return path;
</span><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    fullName() { return this.path().map(function (test) { return test.label(); }).join(' \u220B '); }
+
</ins><span class="cx">     onlyContainsSingleMetric() { return !this.childTests().length &amp;&amp; this._metrics.length == 1; }
</span><span class="cx"> 
</span><span class="cx">     childTests()
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsapibuildrequeststestsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/server-tests/api-build-requests-tests.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/api-build-requests-tests.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/server-tests/api-build-requests-tests.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -2,8 +2,7 @@
</span><span class="cx"> 
</span><span class="cx"> let assert = require('assert');
</span><span class="cx"> 
</span><del>-require('../tools/js/v3-models.js');
-
</del><ins>+let MockData = require('./resources/mock-data.js');
</ins><span class="cx"> let TestServer = require('./resources/test-server.js');
</span><span class="cx"> 
</span><span class="cx"> describe('/api/build-requests', function () {
</span><span class="lines">@@ -11,17 +10,8 @@
</span><span class="cx">     TestServer.inject();
</span><span class="cx"> 
</span><span class="cx">     beforeEach(function () {
</span><del>-        AnalysisTask._fetchAllPromise = null;
-        AnalysisTask.clearStaticMap();
-        BuildRequest.clearStaticMap();
-        CommitLog.clearStaticMap();
-        Metric.clearStaticMap();
-        Platform.clearStaticMap();
-        Repository.clearStaticMap();
-        RootSet.clearStaticMap();
-        Test.clearStaticMap();
-        TestGroup.clearStaticMap();
-    })
</del><ins>+        MockData.resetV3Models();
+    });
</ins><span class="cx"> 
</span><span class="cx">     it('should return &quot;TriggerableNotFound&quot; when the database is empty', function (done) {
</span><span class="cx">         TestServer.remoteAPI().getJSON('/api/build-requests/build-webkit').then(function (content) {
</span><span class="lines">@@ -45,53 +35,10 @@
</span><span class="cx">         }).catch(done);
</span><span class="cx">     });
</span><span class="cx"> 
</span><del>-    function addMockData(db, statusList)
-    {
-        if (!statusList)
-            statusList = ['pending', 'pending', 'pending', 'pending'];
-        return Promise.all([
-            db.insert('build_triggerables', {id: 1, name: 'build-webkit'}),
-            db.insert('repositories', {id: 9, name: 'OS X'}),
-            db.insert('repositories', {id: 11, name: 'WebKit'}),
-            db.insert('commits', {id: 87832, repository: 9, revision: '10.11 15A284'}),
-            db.insert('commits', {id: 93116, repository: 11, revision: '191622', time: (new Date(1445945816878)).toISOString()}),
-            db.insert('commits', {id: 96336, repository: 11, revision: '192736', time: (new Date(1448225325650)).toISOString()}),
-            db.insert('platforms', {id: 65, name: 'some platform'}),
-            db.insert('tests', {id: 200, name: 'some test'}),
-            db.insert('test_metrics', {id: 300, test: 200, name: 'some metric'}),
-            db.insert('test_configurations', {id: 301, metric: 300, platform: 65, type: 'current'}),
-            db.insert('root_sets', {id: 401}),
-            db.insert('roots', {set: 401, commit: 87832}),
-            db.insert('roots', {set: 401, commit: 93116}),
-            db.insert('root_sets', {id: 402}),
-            db.insert('roots', {set: 402, commit: 87832}),
-            db.insert('roots', {set: 402, commit: 96336}),
-            db.insert('analysis_tasks', {id: 500, platform: 65, metric: 300, name: 'some task'}),
-            db.insert('analysis_test_groups', {id: 600, task: 500, name: 'some test group'}),
-            db.insert('build_requests', {id: 700, status: statusList[0], triggerable: 1, platform: 65, test: 200, group: 600, order: 0, root_set: 401}),
-            db.insert('build_requests', {id: 701, status: statusList[1], triggerable: 1, platform: 65, test: 200, group: 600, order: 1, root_set: 402}),
-            db.insert('build_requests', {id: 702, status: statusList[2], triggerable: 1, platform: 65, test: 200, group: 600, order: 2, root_set: 401}),
-            db.insert('build_requests', {id: 703, status: statusList[3], triggerable: 1, platform: 65, test: 200, group: 600, order: 3, root_set: 402}),
-        ]);
-    }
-
-    function addAnotherMockTestGroup(db, statusList)
-    {
-        if (!statusList)
-            statusList = ['pending', 'pending', 'pending', 'pending'];
-        return Promise.all([
-            db.insert('analysis_test_groups', {id: 599, task: 500, name: 'another test group'}),
-            db.insert('build_requests', {id: 713, status: statusList[3], triggerable: 1, platform: 65, test: 200, group: 599, order: 3, root_set: 402}),
-            db.insert('build_requests', {id: 710, status: statusList[0], triggerable: 1, platform: 65, test: 200, group: 599, order: 0, root_set: 401}),
-            db.insert('build_requests', {id: 712, status: statusList[2], triggerable: 1, platform: 65, test: 200, group: 599, order: 2, root_set: 401}),
-            db.insert('build_requests', {id: 711, status: statusList[1], triggerable: 1, platform: 65, test: 200, group: 599, order: 1, root_set: 402}),
-        ]);
-    }
-
</del><span class="cx">     it('should return build requets associated with a given triggerable with appropriate roots and rootSets', function (done) {
</span><span class="cx">         let db = TestServer.database();
</span><span class="cx">         db.connect().then(function () {
</span><del>-            return addMockData(db);
</del><ins>+            return MockData.addMockData(db);
</ins><span class="cx">         }).then(function () {
</span><span class="cx">             return TestServer.remoteAPI().getJSONWithStatus('/api/build-requests/build-webkit');
</span><span class="cx">         }).then(function (content) {
</span><span class="lines">@@ -149,7 +96,7 @@
</span><span class="cx">     it('should support useLegacyIdResolution option', function (done) {
</span><span class="cx">         let db = TestServer.database();
</span><span class="cx">         db.connect().then(function () {
</span><del>-            return addMockData(db);
</del><ins>+            return MockData.addMockData(db);
</ins><span class="cx">         }).then(function () {
</span><span class="cx">             return TestServer.remoteAPI().getJSONWithStatus('/api/build-requests/build-webkit?useLegacyIdResolution=true');
</span><span class="cx">         }).then(function (content) {
</span><span class="lines">@@ -207,7 +154,7 @@
</span><span class="cx">     it('should be fetchable by BuildRequest.fetchForTriggerable', function (done) {
</span><span class="cx">         let db = TestServer.database();
</span><span class="cx">         db.connect().then(function () {
</span><del>-            return addMockData(db);
</del><ins>+            return MockData.addMockData(db);
</ins><span class="cx">         }).then(function () {
</span><span class="cx">             return Manifest.fetch();
</span><span class="cx">         }).then(function () {
</span><span class="lines">@@ -302,7 +249,7 @@
</span><span class="cx">     it('should not include a build request if all requests in the same group had been completed', function (done) {
</span><span class="cx">         let db = TestServer.database();
</span><span class="cx">         db.connect().then(function () {
</span><del>-            return addMockData(db, ['completed', 'completed', 'completed', 'completed']);
</del><ins>+            return MockData.addMockData(db, ['completed', 'completed', 'completed', 'completed']);
</ins><span class="cx">         }).then(function () {
</span><span class="cx">             return Manifest.fetch();
</span><span class="cx">         }).then(function () {
</span><span class="lines">@@ -316,7 +263,7 @@
</span><span class="cx">     it('should not include a build request if all requests in the same group had been failed or cancled', function (done) {
</span><span class="cx">         let db = TestServer.database();
</span><span class="cx">         db.connect().then(function () {
</span><del>-            return addMockData(db, ['failed', 'failed', 'canceled', 'canceled']);
</del><ins>+            return MockData.addMockData(db, ['failed', 'failed', 'canceled', 'canceled']);
</ins><span class="cx">         }).then(function () {
</span><span class="cx">             return Manifest.fetch();
</span><span class="cx">         }).then(function () {
</span><span class="lines">@@ -330,7 +277,7 @@
</span><span class="cx">     it('should include all build requests of a test group if one of the reqeusts in the group had not been finished', function (done) {
</span><span class="cx">         let db = TestServer.database();
</span><span class="cx">         db.connect().then(function () {
</span><del>-            return addMockData(db, ['completed', 'completed', 'scheduled', 'pending']);
</del><ins>+            return MockData.addMockData(db, ['completed', 'completed', 'scheduled', 'pending']);
</ins><span class="cx">         }).then(function () {
</span><span class="cx">             return Manifest.fetch();
</span><span class="cx">         }).then(function () {
</span><span class="lines">@@ -356,7 +303,7 @@
</span><span class="cx">     it('should include all build requests of a test group if one of the reqeusts in the group is still running', function (done) {
</span><span class="cx">         let db = TestServer.database();
</span><span class="cx">         db.connect().then(function () {
</span><del>-            return addMockData(db, ['completed', 'completed', 'completed', 'running']);
</del><ins>+            return MockData.addMockData(db, ['completed', 'completed', 'completed', 'running']);
</ins><span class="cx">         }).then(function () {
</span><span class="cx">             return Manifest.fetch();
</span><span class="cx">         }).then(function () {
</span><span class="lines">@@ -382,7 +329,7 @@
</span><span class="cx">     it('should order build requests based on test group and order', function (done) {
</span><span class="cx">         let db = TestServer.database();
</span><span class="cx">         db.connect().then(function () {
</span><del>-            return Promise.all([addMockData(db), addAnotherMockTestGroup(db)])
</del><ins>+            return Promise.all([MockData.addMockData(db), MockData.addAnotherMockTestGroup(db)])
</ins><span class="cx">         }).then(function () {
</span><span class="cx">             return Manifest.fetch();
</span><span class="cx">         }).then(function () {
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsresourcesmockdatajs"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/server-tests/resources/mock-data.js (0 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/resources/mock-data.js                                (rev 0)
+++ trunk/Websites/perf.webkit.org/server-tests/resources/mock-data.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -0,0 +1,185 @@
</span><ins>+require('../../tools/js/v3-models.js');
+
+var crypto = require('crypto');
+
+MockData = {
+    resetV3Models: function ()
+    {
+        AnalysisTask._fetchAllPromise = null;
+        AnalysisTask.clearStaticMap();
+        BuildRequest.clearStaticMap();
+        CommitLog.clearStaticMap();
+        Metric.clearStaticMap();
+        Platform.clearStaticMap();
+        Repository.clearStaticMap();
+        RootSet.clearStaticMap();
+        Test.clearStaticMap();
+        TestGroup.clearStaticMap();
+    },
+    addMockData: function (db, statusList)
+    {
+        if (!statusList)
+            statusList = ['pending', 'pending', 'pending', 'pending'];
+        return Promise.all([
+            db.insert('build_triggerables', {id: 1, name: 'build-webkit'}),
+            db.insert('build_slaves', {id: 2, name: 'sync-slave', password_hash: crypto.createHash('sha256').update('password').digest('hex')}),
+            db.insert('repositories', {id: 9, name: 'OS X'}),
+            db.insert('repositories', {id: 11, name: 'WebKit'}),
+            db.insert('commits', {id: 87832, repository: 9, revision: '10.11 15A284'}),
+            db.insert('commits', {id: 93116, repository: 11, revision: '191622', time: (new Date(1445945816878)).toISOString()}),
+            db.insert('commits', {id: 96336, repository: 11, revision: '192736', time: (new Date(1448225325650)).toISOString()}),
+            db.insert('platforms', {id: 65, name: 'some platform'}),
+            db.insert('tests', {id: 200, name: 'some test'}),
+            db.insert('test_metrics', {id: 300, test: 200, name: 'some metric'}),
+            db.insert('test_configurations', {id: 301, metric: 300, platform: 65, type: 'current'}),
+            db.insert('root_sets', {id: 401}),
+            db.insert('roots', {set: 401, commit: 87832}),
+            db.insert('roots', {set: 401, commit: 93116}),
+            db.insert('root_sets', {id: 402}),
+            db.insert('roots', {set: 402, commit: 87832}),
+            db.insert('roots', {set: 402, commit: 96336}),
+            db.insert('analysis_tasks', {id: 500, platform: 65, metric: 300, name: 'some task'}),
+            db.insert('analysis_test_groups', {id: 600, task: 500, name: 'some test group'}),
+            db.insert('build_requests', {id: 700, status: statusList[0], triggerable: 1, platform: 65, test: 200, group: 600, order: 0, root_set: 401}),
+            db.insert('build_requests', {id: 701, status: statusList[1], triggerable: 1, platform: 65, test: 200, group: 600, order: 1, root_set: 402}),
+            db.insert('build_requests', {id: 702, status: statusList[2], triggerable: 1, platform: 65, test: 200, group: 600, order: 2, root_set: 401}),
+            db.insert('build_requests', {id: 703, status: statusList[3], triggerable: 1, platform: 65, test: 200, group: 600, order: 3, root_set: 402}),
+        ]);
+    },
+    addAnotherMockTestGroup: function (db, statusList)
+    {
+        if (!statusList)
+            statusList = ['pending', 'pending', 'pending', 'pending'];
+        return Promise.all([
+            db.insert('analysis_test_groups', {id: 599, task: 500, name: 'another test group'}),
+            db.insert('build_requests', {id: 713, status: statusList[3], triggerable: 1, platform: 65, test: 200, group: 599, order: 3, root_set: 402}),
+            db.insert('build_requests', {id: 710, status: statusList[0], triggerable: 1, platform: 65, test: 200, group: 599, order: 0, root_set: 401}),
+            db.insert('build_requests', {id: 712, status: statusList[2], triggerable: 1, platform: 65, test: 200, group: 599, order: 2, root_set: 401}),
+            db.insert('build_requests', {id: 711, status: statusList[1], triggerable: 1, platform: 65, test: 200, group: 599, order: 1, root_set: 402}),
+        ]);
+    },
+    mockTestSyncConfigWithSingleBuilder: function ()
+    {
+        return {
+            'triggerableName': 'build-webkit',
+            'lookbackCount': 2,
+            'configurations': [
+                {
+                    'platform': 'some platform',
+                    'test': ['some test'],
+                    'builder': 'some-builder-1',
+                    'arguments': {
+                        'wk': {'root': 'WebKit'},
+                        'os': {'root': 'OS X'},
+                    },
+                    'buildRequestArgument': 'build-request-id',
+                }
+            ]
+        }
+    },
+    mockTestSyncConfigWithTwoBuilders: function ()
+    {
+        return {
+            'triggerableName': 'build-webkit',
+            'lookbackCount': 2,
+            'configurations': [
+                {
+                    'platform': 'some platform',
+                    'test': ['some test'],
+                    'builder': 'some-builder-1',
+                    'arguments': {
+                        'wk': {'root': 'WebKit'},
+                        'os': {'root': 'OS X'},
+                    },
+                    'buildRequestArgument': 'build-request-id',
+                },
+                {
+                    'platform': 'some platform',
+                    'test': ['some test'],
+                    'builder': 'some-builder-2',
+                    'arguments': {
+                        'wk': {'root': 'WebKit'},
+                        'os': {'root': 'OS X'},
+                    },
+                    'buildRequestArgument': 'build-request-id',
+                }
+            ]
+        }
+    },
+    pendingBuild(options)
+    {
+        options = options || {};
+        return {
+            'builderName': options.builder || 'some-builder-1',
+            'builds': [],
+            'properties': [
+                ['wk', options.webkitRevision || '191622'],
+                ['os', options.osxRevision || '10.11 15A284'],
+                ['build-request-id', (options.buildRequestId || 702).toString(), ]
+            ],
+            'source': {
+                'branch': '',
+                'changes': [],
+                'codebase': 'WebKit',
+                'hasPatch': false,
+                'project': '',
+                'repository': '',
+                'revision': ''
+            },
+        };
+    },
+    runningBuild(options)
+    {
+        options = options || {};
+        return {
+            'builderName': options.builder || 'some-builder-1',
+            'builds': [],
+            'properties': [
+                ['wk', options.webkitRevision || '192736'],
+                ['os', options.osxRevision || '10.11 15A284'],
+                ['build-request-id', (options.buildRequestId || 701).toString(), ]
+            ],
+            'currentStep': {},
+            'eta': 721,
+            'number': 124,
+            'source': {
+                'branch': '',
+                'changes': [],
+                'codebase': 'WebKit',
+                'hasPatch': false,
+                'project': '',
+                'repository': '',
+                'revision': ''
+            },
+        };
+    },
+    finishedBuild(options)
+    {
+        options = options || {};
+        return {
+            'builderName': options.builder || 'some-builder-1',
+            'builds': [],
+            'properties': [
+                ['wk', options.webkitRevision || '191622'],
+                ['os', options.osxRevision || '10.11 15A284'],
+                ['build-request-id', (options.buildRequestId || 700).toString(), ]
+            ],
+            'currentStep': null,
+            'eta': null,
+            'number': 123,
+            'source': {
+                'branch': '',
+                'changes': [],
+                'codebase': 'WebKit',
+                'hasPatch': false,
+                'project': '',
+                'repository': '',
+                'revision': ''
+            },
+            'times': [0, 1],
+        };
+    }
+}
+
+if (typeof module != 'undefined')
+    module.exports = MockData;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsresourcestestserverjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/server-tests/resources/test-server.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/resources/test-server.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/server-tests/resources/test-server.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -26,6 +26,8 @@
</span><span class="cx">         this._databaseHost = Config.value('database.host');
</span><span class="cx">         this._databasePort = Config.value('database.port');
</span><span class="cx">         this._database = null;
</span><ins>+
+        this._remote = null
</ins><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     start()
</span><span class="lines">@@ -48,9 +50,8 @@
</span><span class="cx"> 
</span><span class="cx">     remoteAPI()
</span><span class="cx">     {
</span><del>-        assert(this._server);
-        RemoteAPI.configure(this._server);
-        return RemoteAPI;
</del><ins>+        assert(this._remote);
+        return this._remote;
</ins><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     database()
</span><span class="lines">@@ -112,7 +113,9 @@
</span><span class="cx"> 
</span><span class="cx">     _ensureTestDatabase()
</span><span class="cx">     {
</span><del>-        this._executePgsqlCommand('dropdb');
</del><ins>+        try {
+            this._executePgsqlCommand('dropdb');
+        } catch (error) { }
</ins><span class="cx">         this._executePgsqlCommand('createdb');
</span><span class="cx">         this._executePgsqlCommand('psql', ['--command', `grant all privileges on database &quot;${this._databaseName}&quot; to &quot;${this._databaseUser}&quot;;`]);
</span><span class="cx">         this.initDatabase();
</span><span class="lines">@@ -181,6 +184,9 @@
</span><span class="cx">         }
</span><span class="cx">         this._pidWaitStart = Date.now();
</span><span class="cx">         this._pidFile = pidFile;
</span><ins>+
+        this._remote = new RemoteAPI(this._server);
+
</ins><span class="cx">         return new Promise(this._waitForPid.bind(this, true));
</span><span class="cx">     }
</span><span class="cx"> 
</span><span class="lines">@@ -202,7 +208,7 @@
</span><span class="cx">     _waitForPid(shouldExist, resolve, reject)
</span><span class="cx">     {
</span><span class="cx">         if (fs.existsSync(this._pidFile) != shouldExist) {
</span><del>-            if (Date.now() - this._pidWaitStart &gt; 5000)
</del><ins>+            if (Date.now() - this._pidWaitStart &gt; 8000)
</ins><span class="cx">                 reject();
</span><span class="cx">             else
</span><span class="cx">                 setTimeout(this._waitForPid.bind(this, shouldExist, resolve, reject), 100);
</span><span class="lines">@@ -215,18 +221,23 @@
</span><span class="cx">     {
</span><span class="cx">         let self = this;
</span><span class="cx">         before(function () {
</span><del>-            this.timeout(5000);
</del><ins>+            this.timeout(10000);
</ins><span class="cx">             return self.start();
</span><span class="cx">         });
</span><span class="cx"> 
</span><ins>+        let originalRemote;
+
</ins><span class="cx">         beforeEach(function () {
</span><span class="cx">             this.timeout(10000);
</span><span class="cx">             self.initDatabase();
</span><span class="cx">             self.cleanDataDirectory();
</span><ins>+            originalRemote = global.RemoteAPI;
+            global.RemoteAPI = self._remote;
</ins><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         after(function () {
</span><del>-            this.timeout(5000);
</del><ins>+            this.timeout(10000);
+            global.RemoteAPI = originalRemote;
</ins><span class="cx">             return self.stop();
</span><span class="cx">         });
</span><span class="cx">     }
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgserverteststoolsbuildbottriggerabletestsjs"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/server-tests/tools-buildbot-triggerable-tests.js (0 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/tools-buildbot-triggerable-tests.js                                (rev 0)
+++ trunk/Websites/perf.webkit.org/server-tests/tools-buildbot-triggerable-tests.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -0,0 +1,444 @@
</span><ins>+'use strict';
+
+let assert = require('assert');
+
+let BuildbotTriggerable = require('../tools/js/buildbot-triggerable.js').BuildbotTriggerable;
+let MockData = require('./resources/mock-data.js');
+let MockRemoteAPI = require('../unit-tests/resources/mock-remote-api.js').MockRemoteAPI;
+let TestServer = require('./resources/test-server.js');
+
+class MockLogger {
+    constructor()
+    {
+        this._logs = [];
+    }
+
+    log(text) { this._logs.push(text); }
+    error(text) { this._logs.push(text); }
+}
+
+describe('BuildbotTriggerable', function () {
+    this.timeout(10000);
+    TestServer.inject();
+
+    beforeEach(function () {
+        MockData.resetV3Models();
+        MockRemoteAPI.reset('http://build.webkit.org');
+    });
+
+    describe('syncOnce', function () {
+        it('should schedule the next build request when there are no pending builds', function (done) {
+            let db = TestServer.database();
+            let syncPromise;
+            db.connect().then(function () {
+                return MockData.addMockData(db, ['completed', 'running', 'pending', 'pending']);
+            }).then(function () {
+                return Manifest.fetch();
+            }).then(function () {
+                let config = MockData.mockTestSyncConfigWithSingleBuilder();
+                let logger = new MockLogger;
+                let slaveInfo = {name: 'sync-slave', password: 'password'};
+                let triggerable = new BuildbotTriggerable(config, TestServer.remoteAPI(), MockRemoteAPI, slaveInfo, logger);
+                syncPromise = triggerable.syncOnce();
+                syncPromise.catch(done);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(BuildRequest.all().length, 4);
+                assert.equal(BuildRequest.findById(700).status(), 'completed');
+                assert.equal(BuildRequest.findById(701).status(), 'running');
+                assert.equal(BuildRequest.findById(702).status(), 'pending');
+                assert.equal(BuildRequest.findById(703).status(), 'pending');
+                assert.equal(MockRemoteAPI.requests[0].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[0].url, '/json/builders/some-builder-1/pendingBuilds');
+                MockRemoteAPI.requests[0].resolve([]);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests[1].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[1].url, '/json/builders/some-builder-1/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[1].resolve({[-1]: MockData.runningBuild(), [-2]: MockData.finishedBuild()});
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests[2].method, 'POST');
+                assert.equal(MockRemoteAPI.requests[2].url, '/builders/some-builder-1/force');
+                assert.deepEqual(MockRemoteAPI.requests[2].data, {'wk': '191622', 'os': '10.11 15A284', 'build-request-id': '702'});
+                MockRemoteAPI.requests[2].resolve('OK');
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests[3].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[3].url, '/json/builders/some-builder-1/pendingBuilds');
+                MockRemoteAPI.requests[3].resolve([MockData.pendingBuild()])
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests[4].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[4].url, '/json/builders/some-builder-1/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[4].resolve({[-1]: MockData.runningBuild(), [-2]: MockData.finishedBuild()});
+                return syncPromise;
+            }).then(function () {
+                return BuildRequest.fetchForTriggerable(MockData.mockTestSyncConfigWithSingleBuilder().triggerableName);
+            }).then(function () {
+                assert.equal(BuildRequest.all().length, 4);
+                assert.equal(BuildRequest.findById(700).status(), 'completed');
+                assert.equal(BuildRequest.findById(701).status(), 'running');
+                assert.equal(BuildRequest.findById(702).status(), 'scheduled');
+                assert.equal(BuildRequest.findById(703).status(), 'pending');
+                done();
+            }).catch(done);
+        });
+
+        it('should not schedule the next build request when there is a pending build', function (done) {
+            let db = TestServer.database();
+            let syncPromise;
+            db.connect().then(function () {
+                return MockData.addMockData(db, ['completed', 'running', 'pending', 'pending']);
+            }).then(function () {
+                return Manifest.fetch();
+            }).then(function () {
+                let config = MockData.mockTestSyncConfigWithSingleBuilder();
+                let logger = new MockLogger;
+                let slaveInfo = {name: 'sync-slave', password: 'password'};
+                let triggerable = new BuildbotTriggerable(config, TestServer.remoteAPI(), MockRemoteAPI, slaveInfo, logger);
+                syncPromise = triggerable.syncOnce();
+                syncPromise.catch(done);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests[0].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[0].url, '/json/builders/some-builder-1/pendingBuilds');
+                MockRemoteAPI.requests[0].resolve([MockData.pendingBuild()]);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests[1].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[1].url, '/json/builders/some-builder-1/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[1].resolve({[-1]: MockData.runningBuild(), [-2]: MockData.finishedBuild()});
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests[2].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[2].url, '/json/builders/some-builder-1/pendingBuilds');
+                MockRemoteAPI.requests[2].resolve([MockData.pendingBuild()])
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests[3].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[3].url, '/json/builders/some-builder-1/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[3].resolve({[-1]: MockData.runningBuild(), [-2]: MockData.finishedBuild()});
+                return syncPromise;
+            }).then(function () {
+                assert.equal(BuildRequest.all().length, 4);
+                assert.equal(BuildRequest.findById(700).status(), 'completed');
+                assert.equal(BuildRequest.findById(701).status(), 'running');
+                assert.equal(BuildRequest.findById(702).status(), 'pending');
+                assert.equal(BuildRequest.findById(703).status(), 'pending');
+                return BuildRequest.fetchForTriggerable(MockData.mockTestSyncConfigWithSingleBuilder().triggerableName);
+            }).then(function () {
+                assert.equal(BuildRequest.all().length, 4);
+                assert.equal(BuildRequest.findById(700).status(), 'completed');
+                assert.equal(BuildRequest.findById(701).status(), 'running');
+                assert.equal(BuildRequest.findById(702).status(), 'scheduled');
+                assert.equal(BuildRequest.findById(703).status(), 'pending');
+                done();
+            }).catch(done);
+        });
+
+        it('should schedule the build request on a builder without a pending build if it\'s the first request in the group', function (done) {
+            let db = TestServer.database();
+            let syncPromise;
+            db.connect().then(function () {
+                return MockData.addMockData(db, ['pending', 'pending', 'pending', 'pending']);
+            }).then(function () {
+                return Manifest.fetch();
+            }).then(function () {
+                let config = MockData.mockTestSyncConfigWithTwoBuilders();
+                let logger = new MockLogger;
+                let slaveInfo = {name: 'sync-slave', password: 'password'};
+                let triggerable = new BuildbotTriggerable(config, TestServer.remoteAPI(), MockRemoteAPI, slaveInfo, logger);
+                syncPromise = triggerable.syncOnce();
+                syncPromise.catch(done);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 2);
+                assert.equal(MockRemoteAPI.requests[0].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[0].url, '/json/builders/some-builder-1/pendingBuilds');
+                MockRemoteAPI.requests[0].resolve([MockData.pendingBuild({buildRequestId: 999})]);
+                assert.equal(MockRemoteAPI.requests[1].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[1].url, '/json/builders/some-builder-2/pendingBuilds');
+                MockRemoteAPI.requests[1].resolve([]);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 4);
+                assert.equal(MockRemoteAPI.requests[2].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[2].url, '/json/builders/some-builder-1/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[2].resolve({});
+                assert.equal(MockRemoteAPI.requests[3].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[3].url, '/json/builders/some-builder-2/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[3].resolve({});
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 5);
+                assert.equal(MockRemoteAPI.requests[4].method, 'POST');
+                assert.equal(MockRemoteAPI.requests[4].url, '/builders/some-builder-2/force');
+                assert.deepEqual(MockRemoteAPI.requests[4].data, {'wk': '191622', 'os': '10.11 15A284', 'build-request-id': '700'});
+                MockRemoteAPI.requests[4].resolve('OK');
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 7);
+                assert.equal(MockRemoteAPI.requests[5].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[5].url, '/json/builders/some-builder-1/pendingBuilds');
+                MockRemoteAPI.requests[5].resolve([MockData.pendingBuild({buildRequestId: 999})]);
+                assert.equal(MockRemoteAPI.requests[6].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[6].url, '/json/builders/some-builder-2/pendingBuilds');
+                MockRemoteAPI.requests[6].resolve([MockData.pendingBuild({builder: 'some-builder-2', buildRequestId: 700})]);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 9);
+                assert.equal(MockRemoteAPI.requests[7].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[7].url, '/json/builders/some-builder-1/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[7].resolve({});
+                assert.equal(MockRemoteAPI.requests[8].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[8].url, '/json/builders/some-builder-2/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[8].resolve({});
+                return syncPromise;
+            }).then(function () {
+                assert.equal(BuildRequest.all().length, 4);
+                assert.equal(BuildRequest.findById(700).status(), 'pending');
+                assert.equal(BuildRequest.findById(701).status(), 'pending');
+                assert.equal(BuildRequest.findById(702).status(), 'pending');
+                assert.equal(BuildRequest.findById(703).status(), 'pending');
+                return BuildRequest.fetchForTriggerable(MockData.mockTestSyncConfigWithTwoBuilders().triggerableName);
+            }).then(function () {
+                assert.equal(BuildRequest.all().length, 4);
+                assert.equal(BuildRequest.findById(700).status(), 'scheduled');
+                assert.equal(BuildRequest.findById(701).status(), 'pending');
+                assert.equal(BuildRequest.findById(702).status(), 'pending');
+                assert.equal(BuildRequest.findById(703).status(), 'pending');
+                done();
+            }).catch(done);
+        });
+
+        it('should not schedule a build request on a different builder than the one the first build request is pending', function (done) {
+            let db = TestServer.database();
+            let syncPromise;
+            db.connect().then(function () {
+                return MockData.addMockData(db, ['pending', 'pending', 'pending', 'pending']);
+            }).then(function () {
+                return Manifest.fetch();
+            }).then(function () {
+                let config = MockData.mockTestSyncConfigWithTwoBuilders();
+                let logger = new MockLogger;
+                let slaveInfo = {name: 'sync-slave', password: 'password'};
+                let triggerable = new BuildbotTriggerable(config, TestServer.remoteAPI(), MockRemoteAPI, slaveInfo, logger);
+                syncPromise = triggerable.syncOnce();
+                syncPromise.catch(done);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 2);
+                assert.equal(MockRemoteAPI.requests[0].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[0].url, '/json/builders/some-builder-1/pendingBuilds');
+                MockRemoteAPI.requests[0].resolve([MockData.pendingBuild({buildRequestId: 700})]);
+                assert.equal(MockRemoteAPI.requests[1].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[1].url, '/json/builders/some-builder-2/pendingBuilds');
+                MockRemoteAPI.requests[1].resolve([]);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 4);
+                assert.equal(MockRemoteAPI.requests[2].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[2].url, '/json/builders/some-builder-1/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[2].resolve({});
+                assert.equal(MockRemoteAPI.requests[3].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[3].url, '/json/builders/some-builder-2/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[3].resolve({});
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 6);
+                assert.equal(MockRemoteAPI.requests[4].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[4].url, '/json/builders/some-builder-1/pendingBuilds');
+                MockRemoteAPI.requests[4].resolve([MockData.pendingBuild({buildRequestId: 700})]);
+                assert.equal(MockRemoteAPI.requests[5].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[5].url, '/json/builders/some-builder-2/pendingBuilds');
+                MockRemoteAPI.requests[5].resolve([]);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 8);
+                assert.equal(MockRemoteAPI.requests[6].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[6].url, '/json/builders/some-builder-1/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[6].resolve({});
+                assert.equal(MockRemoteAPI.requests[7].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[7].url, '/json/builders/some-builder-2/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[7].resolve({});
+                return syncPromise;
+            }).then(function () {
+                assert.equal(BuildRequest.all().length, 4);
+                assert.equal(BuildRequest.findById(700).status(), 'pending');
+                assert.equal(BuildRequest.findById(701).status(), 'pending');
+                assert.equal(BuildRequest.findById(702).status(), 'pending');
+                assert.equal(BuildRequest.findById(703).status(), 'pending');
+                return BuildRequest.fetchForTriggerable(MockData.mockTestSyncConfigWithTwoBuilders().triggerableName);
+            }).then(function () {
+                assert.equal(BuildRequest.all().length, 4);
+                assert.equal(BuildRequest.findById(700).status(), 'scheduled');
+                assert.equal(BuildRequest.findById(701).status(), 'pending');
+                assert.equal(BuildRequest.findById(702).status(), 'pending');
+                assert.equal(BuildRequest.findById(703).status(), 'pending');
+                done();
+            }).catch(done);
+        });
+
+        it('should update the status of a pending build and schedule a new build if the pending build had started running', function (done) {
+            let db = TestServer.database();
+            let syncPromise;
+            db.connect().then(function () {
+                return MockData.addMockData(db, ['pending', 'pending', 'pending', 'pending']);
+            }).then(function () {
+                return Manifest.fetch();
+            }).then(function () {
+                let config = MockData.mockTestSyncConfigWithTwoBuilders();
+                let logger = new MockLogger;
+                let slaveInfo = {name: 'sync-slave', password: 'password'};
+                let triggerable = new BuildbotTriggerable(config, TestServer.remoteAPI(), MockRemoteAPI, slaveInfo, logger);
+                syncPromise = triggerable.syncOnce();
+                syncPromise.catch(done);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 2);
+                assert.equal(MockRemoteAPI.requests[0].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[0].url, '/json/builders/some-builder-1/pendingBuilds');
+                MockRemoteAPI.requests[0].resolve([]);
+                assert.equal(MockRemoteAPI.requests[1].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[1].url, '/json/builders/some-builder-2/pendingBuilds');
+                MockRemoteAPI.requests[1].resolve([]);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 4);
+                assert.equal(MockRemoteAPI.requests[2].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[2].url, '/json/builders/some-builder-1/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[2].resolve({[-1]: MockData.runningBuild({buildRequestId: 701}), [-2]: MockData.finishedBuild({buildRequestId: 700})});
+                assert.equal(MockRemoteAPI.requests[3].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[3].url, '/json/builders/some-builder-2/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[3].resolve({});
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 5);
+                assert.equal(MockRemoteAPI.requests[4].method, 'POST');
+                assert.equal(MockRemoteAPI.requests[4].url, '/builders/some-builder-1/force');
+                assert.deepEqual(MockRemoteAPI.requests[4].data, {'wk': '191622', 'os': '10.11 15A284', 'build-request-id': '702'});
+                MockRemoteAPI.requests[4].resolve('OK');
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 7);
+                assert.equal(MockRemoteAPI.requests[5].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[5].url, '/json/builders/some-builder-1/pendingBuilds');
+                MockRemoteAPI.requests[5].resolve([MockData.pendingBuild({buildRequestId: 702})]);
+                assert.equal(MockRemoteAPI.requests[6].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[6].url, '/json/builders/some-builder-2/pendingBuilds');
+                MockRemoteAPI.requests[6].resolve([]);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 9);
+                assert.equal(MockRemoteAPI.requests[7].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[7].url, '/json/builders/some-builder-1/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[7].resolve({[-1]: MockData.runningBuild({buildRequestId: 701}), [-2]: MockData.finishedBuild({buildRequestId: 700})});
+                assert.equal(MockRemoteAPI.requests[8].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[8].url, '/json/builders/some-builder-2/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[8].resolve({});
+                return syncPromise;
+            }).then(function () {
+                assert.equal(BuildRequest.all().length, 4);
+                assert.equal(BuildRequest.findById(700).status(), 'pending');
+                assert.equal(BuildRequest.findById(701).status(), 'pending');
+                assert.equal(BuildRequest.findById(702).status(), 'pending');
+                assert.equal(BuildRequest.findById(703).status(), 'pending');
+                return BuildRequest.fetchForTriggerable(MockData.mockTestSyncConfigWithTwoBuilders().triggerableName);
+            }).then(function () {
+                assert.equal(BuildRequest.all().length, 4);
+                assert.equal(BuildRequest.findById(700).status(), 'failed');
+                assert.equal(BuildRequest.findById(701).status(), 'running');
+                assert.equal(BuildRequest.findById(702).status(), 'scheduled');
+                assert.equal(BuildRequest.findById(703).status(), 'pending');
+                done();
+            }).catch(done);
+        });
+
+        it('should schedule a build request on a builder without pending builds if the request belongs to a new test group', function (done) {
+            let db = TestServer.database();
+            let syncPromise;
+            db.connect().then(function () {
+                return Promise.all([
+                    MockData.addMockData(db, ['completed', 'pending', 'pending', 'pending']),
+                    MockData.addAnotherMockTestGroup(db, ['pending', 'pending', 'pending', 'pending'])
+                ]);
+            }).then(function () {
+                return Manifest.fetch();
+            }).then(function () {
+                let config = MockData.mockTestSyncConfigWithTwoBuilders();
+                let logger = new MockLogger;
+                let slaveInfo = {name: 'sync-slave', password: 'password'};
+                let triggerable = new BuildbotTriggerable(config, TestServer.remoteAPI(), MockRemoteAPI, slaveInfo, logger);
+                syncPromise = triggerable.syncOnce();
+                syncPromise.catch(done);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 2);
+                assert.equal(MockRemoteAPI.requests[0].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[0].url, '/json/builders/some-builder-1/pendingBuilds');
+                MockRemoteAPI.requests[0].resolve([MockData.pendingBuild({buildRequestId: 702})]);
+                assert.equal(MockRemoteAPI.requests[1].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[1].url, '/json/builders/some-builder-2/pendingBuilds');
+                MockRemoteAPI.requests[1].resolve([]);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 4);
+                assert.equal(MockRemoteAPI.requests[2].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[2].url, '/json/builders/some-builder-1/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[2].resolve({[-1]: MockData.runningBuild({buildRequestId: 701}), [-2]: MockData.finishedBuild({buildRequestId: 700})});
+                assert.equal(MockRemoteAPI.requests[3].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[3].url, '/json/builders/some-builder-2/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[3].resolve({});
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 5);
+                assert.equal(MockRemoteAPI.requests[4].method, 'POST');
+                assert.equal(MockRemoteAPI.requests[4].url, '/builders/some-builder-2/force');
+                assert.deepEqual(MockRemoteAPI.requests[4].data, {'wk': '191622', 'os': '10.11 15A284', 'build-request-id': '710'});
+                MockRemoteAPI.requests[4].resolve('OK');
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 7);
+                assert.equal(MockRemoteAPI.requests[5].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[5].url, '/json/builders/some-builder-1/pendingBuilds');
+                MockRemoteAPI.requests[5].resolve([MockData.pendingBuild({buildRequestId: 702})]);
+                assert.equal(MockRemoteAPI.requests[6].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[6].url, '/json/builders/some-builder-2/pendingBuilds');
+                MockRemoteAPI.requests[6].resolve([MockData.pendingBuild({builder: 'some-builder-2', buildRequestId: 710})]);
+                return MockRemoteAPI.waitForRequest();
+            }).then(function () {
+                assert.equal(MockRemoteAPI.requests.length, 9);
+                assert.equal(MockRemoteAPI.requests[7].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[7].url, '/json/builders/some-builder-1/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[7].resolve({[-1]: MockData.runningBuild({buildRequestId: 701}), [-2]: MockData.finishedBuild({buildRequestId: 700})});
+                assert.equal(MockRemoteAPI.requests[8].method, 'GET');
+                assert.equal(MockRemoteAPI.requests[8].url, '/json/builders/some-builder-2/builds/?select=-1&amp;select=-2');
+                MockRemoteAPI.requests[8].resolve({});
+                return syncPromise;
+            }).then(function () {
+                assert.equal(BuildRequest.all().length, 8);
+                assert.equal(BuildRequest.findById(700).status(), 'completed');
+                assert.equal(BuildRequest.findById(701).status(), 'pending');
+                assert.equal(BuildRequest.findById(702).status(), 'pending');
+                assert.equal(BuildRequest.findById(703).status(), 'pending');
+                assert.equal(BuildRequest.findById(710).status(), 'pending');
+                assert.equal(BuildRequest.findById(711).status(), 'pending');
+                assert.equal(BuildRequest.findById(712).status(), 'pending');
+                assert.equal(BuildRequest.findById(713).status(), 'pending');
+                return BuildRequest.fetchForTriggerable(MockData.mockTestSyncConfigWithTwoBuilders().triggerableName);
+            }).then(function () {
+                assert.equal(BuildRequest.all().length, 8);
+                assert.equal(BuildRequest.findById(700).status(), 'completed');
+                assert.equal(BuildRequest.findById(701).status(), 'running');
+                assert.equal(BuildRequest.findById(702).status(), 'scheduled');
+                assert.equal(BuildRequest.findById(703).status(), 'pending');
+                assert.equal(BuildRequest.findById(710).status(), 'scheduled');
+                assert.equal(BuildRequest.findById(711).status(), 'pending');
+                assert.equal(BuildRequest.findById(712).status(), 'pending');
+                assert.equal(BuildRequest.findById(713).status(), 'pending');
+                done();
+            }).catch(done);
+        });
+    });
+});
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsdetectchangesjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/tools/detect-changes.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/detect-changes.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/tools/detect-changes.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -7,12 +7,13 @@
</span><span class="cx"> var RunsData = data.RunsData;
</span><span class="cx"> var Statistics = require('../public/shared/statistics.js');
</span><span class="cx"> var StatisticsStrategies = require('../public/v2/statistics-strategies.js');
</span><ins>+var parseArguments = require('./js/parse-arguments.js').parseArguments;
</ins><span class="cx"> 
</span><span class="cx"> // FIXME: We shouldn't use a global variable like this.
</span><span class="cx"> var settings;
</span><span class="cx"> function main(argv)
</span><span class="cx"> {
</span><del>-    var options = parseArgument(argv, [
</del><ins>+    var options = parseArguments(argv, [
</ins><span class="cx">         {name: '--server-config-json', required: true},
</span><span class="cx">         {name: '--change-detection-config-json', required: true},
</span><span class="cx">         {name: '--seconds-to-sleep', type: parseFloat, default: 1200},
</span><span class="lines">@@ -26,37 +27,6 @@
</span><span class="cx">     fetchManifestAndAnalyzeData(options['--server-config-json']);
</span><span class="cx"> }
</span><span class="cx"> 
</span><del>-function parseArgument(argv, acceptedOptions) {
-    var args = argv.slice(2);
-    var options = {}
-    for (var i = 0; i &lt; args.length; i += 2) {
-        var current = args[i];
-        var next = args[i + 1];
-        for (var option of acceptedOptions) {
-            if (current == option['name']) {
-                options[option['name']] = next;
-                next = null;
-                break;
-            }
-        }
-        if (next) {
-            console.error('Invalid argument:', current);
-            return null;
-        }
-    }
-    for (var option of acceptedOptions) {
-        var name = option['name'];
-        if (option['required'] &amp;&amp; !(name in options)) {
-            console.log('Required argument', name, 'is missing');
-            return null;
-        }
-        var value = options[name] || option['default'];
-        var converter = option['type'];
-        options[name] = converter ? converter(value) : value;
-    }
-    return options;
-}
-
</del><span class="cx"> function fetchManifestAndAnalyzeData(serverConfigJSON)
</span><span class="cx"> {
</span><span class="cx">     loadServerConfig(serverConfigJSON);
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsjsbuildbotsyncerjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/tools/js/buildbot-syncer.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/js/buildbot-syncer.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/tools/js/buildbot-syncer.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -26,6 +26,7 @@
</span><span class="cx">         }
</span><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    syncer() { return this._syncer; }
</ins><span class="cx">     buildNumber() { return this._buildNumber; }
</span><span class="cx">     slaveName() { return this._slaveName; }
</span><span class="cx">     buildRequestId() { return this._buildRequestId; }
</span><span class="lines">@@ -33,31 +34,106 @@
</span><span class="cx">     isInProgress() { return this._isInProgress; }
</span><span class="cx">     hasFinished() { return !this.isPending() &amp;&amp; !this.isInProgress(); }
</span><span class="cx">     url() { return this.isPending() ? this._syncer.url() : this._syncer.urlForBuildNumber(this._buildNumber); }
</span><ins>+
+    buildRequestStatusIfUpdateIsNeeded(request)
+    {
+        assert.equal(request.id(), this._buildRequestId);
+        if (!request)
+            return null;
+        if (this.isPending()) {
+            if (request.isPending())
+                return 'scheduled';
+        } else if (this.isInProgress()) {
+            if (!request.hasStarted())
+                return 'running';
+        } else if (this.hasFinished()) {
+            if (!request.hasFinished())
+                return 'failedIfNotCompleted';
+        }
+        return null;
+    }
</ins><span class="cx"> }
</span><span class="cx"> 
</span><ins>+
</ins><span class="cx"> class BuildbotSyncer {
</span><span class="cx"> 
</span><del>-    constructor(url, object)
</del><ins>+    constructor(remote, object)
</ins><span class="cx">     {
</span><del>-        this._url = url;
</del><ins>+        this._remote = remote;
+        this._testConfigurations = [];
</ins><span class="cx">         this._builderName = object.builder;
</span><del>-        this._platformName = object.platform;
-        this._testPath = object.test;
-        this._propertiesTemplate = object.properties;
</del><span class="cx">         this._slavePropertyName = object.slaveArgument;
</span><ins>+        this._slaveList = object.slaveList;
</ins><span class="cx">         this._buildRequestPropertyName = object.buildRequestArgument;
</span><ins>+        this._entryList = null;
</ins><span class="cx">     }
</span><span class="cx"> 
</span><del>-    testPath() { return this._testPath }
</del><span class="cx">     builderName() { return this._builderName; }
</span><del>-    platformName() { return this._platformName; }
</del><span class="cx"> 
</span><ins>+    addTestConfiguration(test, platform, propertiesTemplate)
+    {
+        assert(test instanceof Test);
+        assert(platform instanceof Platform);
+        this._testConfigurations.push({test: test, platform: platform, propertiesTemplate: propertiesTemplate});
+    }
+    testConfigurations() { return this._testConfigurations; }
+
+    matchesConfiguration(request)
+    {
+        for (let config of this._testConfigurations) {
+            if (config.platform == request.platform() &amp;&amp; config.test == request.test())
+                return true;
+        }
+        return false;
+    }
+
+    scheduleRequest(newRequest, slaveName)
+    {
+        let properties = this._propertiesForBuildRequest(newRequest);
+
+        assert.equal(!this._slavePropertyName, !slaveName);
+        if (this._slavePropertyName)
+            properties[this._slavePropertyName] = slaveName;
+
+        return this._remote.postFormUrlencodedData(this.pathForForceBuild(), properties);
+    }
+
+    scheduleFirstRequestInGroupIfAvailable(newRequest)
+    {
+        assert(newRequest instanceof BuildRequest);
+
+        if (!this.matchesConfiguration(newRequest))
+            return null;
+
+        let hasPendingBuildsWithoutSlaveNameSpecified = false;
+        let usedSlaves = new Set;
+        for (let entry of this._entryList) {
+            if (entry.isPending()) {
+                if (!entry.slaveName())
+                    hasPendingBuildsWithoutSlaveNameSpecified = true;
+                usedSlaves.add(entry.slaveName());
+            }
+        }
+
+        if (!this._slaveList || hasPendingBuildsWithoutSlaveNameSpecified) {
+            if (usedSlaves.size)
+                return null;
+            return this.scheduleRequest(newRequest, null);
+        }
+
+        for (let slaveName of this._slaveList) {
+            if (!usedSlaves.has(slaveName))
+                return this.scheduleRequest(newRequest, slaveName);
+        }
+
+        return null;
+    }
+
</ins><span class="cx">     pullBuildbot(count)
</span><span class="cx">     {
</span><span class="cx">         let self = this;
</span><del>-        return RemoteAPI.getJSON(this.urlForPendingBuildsJSON()).then(function (content) {
</del><ins>+        return this._remote.getJSON(this.pathForPendingBuildsJSON()).then(function (content) {
</ins><span class="cx">             let pendingEntries = content.map(function (entry) { return new BuildbotBuildEntry(self, entry); });
</span><del>-
</del><span class="cx">             return self._pullRecentBuilds(count).then(function (entries) {
</span><span class="cx">                 let entryByRequest = {};
</span><span class="cx"> 
</span><span class="lines">@@ -67,7 +143,13 @@
</span><span class="cx">                 for (let entry of entries)
</span><span class="cx">                     entryByRequest[entry.buildRequestId()] = entry;
</span><span class="cx"> 
</span><del>-                return entryByRequest;
</del><ins>+                let entryList = [];
+                for (let id in entryByRequest)
+                    entryList.push(entryByRequest[id]);
+
+                self._entryList = entryList;
+
+                return entryList;
</ins><span class="cx">             });
</span><span class="cx">         });
</span><span class="cx">     }
</span><span class="lines">@@ -82,8 +164,8 @@
</span><span class="cx">             selectedBuilds[i] = -i - 1;
</span><span class="cx"> 
</span><span class="cx">         let self = this;
</span><del>-        return RemoteAPI.getJSON(this.urlForBuildJSON(selectedBuilds)).then(function (content) {
-            let entries = [];
</del><ins>+        return this._remote.getJSON(this.pathForBuildJSON(selectedBuilds)).then(function (content) {
+            var entries = [];
</ins><span class="cx">             for (let index of selectedBuilds) {
</span><span class="cx">                 let entry = content[index];
</span><span class="cx">                 if (entry &amp;&amp; !entry['error'])
</span><span class="lines">@@ -93,30 +175,38 @@
</span><span class="cx">         });
</span><span class="cx">     }
</span><span class="cx"> 
</span><del>-    urlForPendingBuildsJSON() { return `${this._url}/json/builders/${this._builderName}/pendingBuilds`; }
-    urlForBuildJSON(selectedBuilds)
</del><ins>+    pathForPendingBuildsJSON() { return `/json/builders/${this._builderName}/pendingBuilds`; }
+    pathForBuildJSON(selectedBuilds)
</ins><span class="cx">     {
</span><del>-        return `${this._url}/json/builders/${this._builderName}/builds/?`
</del><ins>+        return `/json/builders/${this._builderName}/builds/?`
</ins><span class="cx">             + selectedBuilds.map(function (number) { return 'select=' + number; }).join('&amp;');
</span><span class="cx">     }
</span><ins>+    pathForForceBuild() { return `/builders/${this._builderName}/force`; }
</ins><span class="cx"> 
</span><del>-    url() { return `${this._url}/builders/${this._builderName}/`; }
-    urlForBuildNumber(number) { return `${this._url}/builders/${this._builderName}/builds/${number}`; }
</del><ins>+    url() { return this._remote.url(`/builders/${this._builderName}/`); }
+    urlForBuildNumber(number) { return this._remote.url(`/builders/${this._builderName}/builds/${number}`); }
</ins><span class="cx"> 
</span><span class="cx">     _propertiesForBuildRequest(buildRequest)
</span><span class="cx">     {
</span><del>-        console.assert(buildRequest instanceof BuildRequest);
</del><ins>+        assert(buildRequest instanceof BuildRequest);
</ins><span class="cx"> 
</span><span class="cx">         let rootSet = buildRequest.rootSet();
</span><del>-        console.assert(rootSet instanceof RootSet);
</del><ins>+        assert(rootSet instanceof RootSet);
</ins><span class="cx"> 
</span><span class="cx">         let repositoryByName = {};
</span><span class="cx">         for (let repository of rootSet.repositories())
</span><span class="cx">             repositoryByName[repository.name()] = repository;
</span><span class="cx"> 
</span><ins>+        let propertiesTemplate = null;
+        for (let config of this._testConfigurations) {
+            if (config.platform == buildRequest.platform() &amp;&amp; config.test == buildRequest.test())
+                propertiesTemplate = config.propertiesTemplate;
+        }
+        assert(propertiesTemplate);
+
</ins><span class="cx">         let properties = {};
</span><del>-        for (let key in this._propertiesTemplate) {
-            let value = this._propertiesTemplate[key];
</del><ins>+        for (let key in propertiesTemplate) {
+            let value = propertiesTemplate[key];
</ins><span class="cx">             if (typeof(value) != 'object')
</span><span class="cx">                 properties[key] = value;
</span><span class="cx">             else if ('root' in value) {
</span><span class="lines">@@ -152,13 +242,13 @@
</span><span class="cx">         return revisionSet;
</span><span class="cx">     }
</span><span class="cx"> 
</span><del>-    static _loadConfig(url, config)
</del><ins>+    static _loadConfig(remote, config)
</ins><span class="cx">     {
</span><span class="cx">         let shared = config['shared'] || {};
</span><span class="cx">         let types = config['types'] || {};
</span><span class="cx">         let builders = config['builders'] || {};
</span><span class="cx"> 
</span><del>-        let syncers = [];
</del><ins>+        let syncerByBuilder = new Map;
</ins><span class="cx">         for (let entry of config['configurations']) {
</span><span class="cx">             let newConfig = {};
</span><span class="cx">             this._validateAndMergeConfig(newConfig, shared);
</span><span class="lines">@@ -180,10 +270,22 @@
</span><span class="cx">             assert('builder' in newConfig, 'configuration must specify a builder');
</span><span class="cx">             assert('properties' in newConfig, 'configuration must specify arguments to post on a builder');
</span><span class="cx">             assert('buildRequestArgument' in newConfig, 'configuration must specify buildRequestArgument');
</span><del>-            syncers.push(new BuildbotSyncer(url, newConfig));
</del><ins>+
+            let test = Test.findByPath(newConfig.test);
+            assert(test, `${newConfig.test} is not a valid test path`);
+
+            let platform = Platform.findByName(newConfig.platform);
+            assert(platform, `${newConfig.platform} is not a valid platform name`);
+
+            let syncer = syncerByBuilder.get(newConfig.builder);
+            if (!syncer) {
+                syncer = new BuildbotSyncer(remote, newConfig);
+                syncerByBuilder.set(newConfig.builder, syncer);
+            }
+            syncer.addTestConfiguration(test, platform, newConfig.properties);
</ins><span class="cx">         }
</span><span class="cx"> 
</span><del>-        return syncers;
</del><ins>+        return Array.from(syncerByBuilder.values());
</ins><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     static _validateAndMergeConfig(config, valuesToMerge)
</span><span class="lines">@@ -202,6 +304,11 @@
</span><span class="cx">                 assert(value.every(function (part) { return typeof part == 'string'; }), 'test should be an array of strings');
</span><span class="cx">                 config[name] = value.slice();
</span><span class="cx">                 break;
</span><ins>+            case 'slaveList':
+                assert(value instanceof Array, 'slaveList should be an array');
+                assert(value.every(function (part) { return typeof part == 'string'; }), 'slaveList should be an array of strings');
+                config[name] = value;
+                break;
</ins><span class="cx">             case 'type': // fallthrough
</span><span class="cx">             case 'builder': // fallthrough
</span><span class="cx">             case 'platform': // fallthrough
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsjsbuildbottriggerablejs"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/tools/js/buildbot-triggerable.js (0 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/js/buildbot-triggerable.js                                (rev 0)
+++ trunk/Websites/perf.webkit.org/tools/js/buildbot-triggerable.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -0,0 +1,157 @@
</span><ins>+'use strict';
+
+let assert = require('assert');
+
+require('./v3-models.js');
+
+let BuildbotSyncer = require('./buildbot-syncer').BuildbotSyncer;
+
+class BuildbotTriggerable {
+    constructor(config, remote, buildbotRemote, slaveInfo, logger)
+    {
+        this._name = config.triggerableName;
+        assert(typeof(this._name) == 'string', 'triggerableName must be specified');
+
+        this._lookbackCount = config.lookbackCount;
+        assert(typeof(this._lookbackCount) == 'number' &amp;&amp; this._lookbackCount &gt; 0, 'lookbackCount must be a number greater than 0');
+
+        this._remote = remote;
+
+        this._slaveInfo = slaveInfo;
+        assert(typeof(slaveInfo.name) == 'string', 'slave name must be specified');
+        assert(typeof(slaveInfo.password) == 'string', 'slave password must be specified');
+
+        this._syncers = BuildbotSyncer._loadConfig(buildbotRemote, config);
+        this._logger = logger || {log: function () { }, error: function () { }};
+    }
+
+    name() { return this._name; }
+
+    syncOnce()
+    {
+        let syncerList = this._syncers;
+        let buildReqeustsByGroup = new Map;
+
+        let self = this;
+        this._logger.log(`Fetching build requests for ${this._name}...`);
+        return BuildRequest.fetchForTriggerable(this._name).then(function () {
+            let buildRequests = BuildRequest.all();
+            self._validateRequests(buildRequests);
+            buildReqeustsByGroup = BuildbotTriggerable._testGroupMapForBuildRequests(buildRequests);
+            return self._pullBuildbotOnAllSyncers(buildReqeustsByGroup);
+        }).then(function (updates) {
+            self._logger.log('Scheduling builds');
+            let promistList = [];
+            let testGroupList = Array.from(buildReqeustsByGroup.values()).sort(function (a, b) { return a.id - b.id; });
+            for (let group of testGroupList) {
+                let promise = self._scheduleNextRequestInGroupIfSlaveIsAvailable(group, updates);
+                if (promise)
+                    promistList.push(promise);
+            }
+            return Promise.all(promistList);
+        }).then(function () {
+            // Pull all buildbots for the second time since the previous step may have scheduled more builds.
+            return self._pullBuildbotOnAllSyncers(buildReqeustsByGroup);
+        }).then(function (updates) {
+            // FIXME: Add a new API that just updates the requests.
+            return self._remote.postJSON(`/api/build-requests/${self._name}`, {
+                'slaveName': self._slaveInfo.name,
+                'slavePassword': self._slaveInfo.password,
+                'buildRequestUpdates': updates});
+        }).then(function (response) {
+            if (response['status'] != 'OK')
+                self._logger.log('Failed to update the build requests status: ' + response['status']);
+        })
+    }
+
+    _validateRequests(buildRequests)
+    {
+        let testPlatformPairs = {};
+        for (let request of buildRequests) {
+            if (!this._syncers.some(function (syncer) { return syncer.matchesConfiguration(request); })) {
+                let key = request.platform().id + '-' + request.test().id();
+                if (!(key in testPlatformPairs))
+                    this._logger.error(`No matching configuration for &quot;${request.test().fullName()}&quot; on &quot;${request.platform().name()}&quot;.`);                
+                testPlatformPairs[key] = true;
+            }
+        }
+    }
+
+    _pullBuildbotOnAllSyncers(buildReqeustsByGroup)
+    {
+        let updates = {};
+        let self = this;
+        return Promise.all(this._syncers.map(function (syncer) {
+            self._logger.log(`Fetching builds on ${syncer.builderName()}`);
+            return syncer.pullBuildbot(self._lookbackCount).then(function (entryList) {
+                for (let entry of entryList) {
+                    let request = BuildRequest.findById(entry.buildRequestId());
+                    if (!request)
+                        continue;
+
+                    let info = buildReqeustsByGroup.get(request.testGroupId());
+                    assert(!info.syncer || info.syncer == syncer);
+                    info.syncer = syncer;
+                    if (entry.slaveName()) {
+                        assert(!info.slaveName || info.slaveName == entry.slaveName());
+                        info.slaveName = entry.slaveName();
+                    }
+
+                    let newStatus = entry.buildRequestStatusIfUpdateIsNeeded(request);
+                    if (newStatus) {
+                        self._logger.log(`Updating the status of build request ${request.id()} from ${request.status()} to ${newStatus}`);
+                        updates[entry.buildRequestId()] = {status: newStatus, url: entry.url()};
+                    }
+                }
+            });
+        })).then(function () { return updates; });
+    }
+
+    _scheduleNextRequestInGroupIfSlaveIsAvailable(groupInfo, pendingUpdates)
+    {
+        let orderedRequests = groupInfo.requests.sort(function (a, b) { return a.order() - b.order(); });
+        let nextRequest = null;
+        for (let request of orderedRequests) {
+            if (request.isScheduled() || (request.id() in pendingUpdates &amp;&amp; pendingUpdates[request.id()]['status'] == 'scheduled'))
+                break;
+            if (request.isPending() &amp;&amp; !(request.id() in pendingUpdates)) {
+                nextRequest = request;
+                break;
+            }
+        }
+        if (!nextRequest)
+            return null;
+
+        let firstRequest = !nextRequest.order();
+        if (firstRequest) {
+            this._logger.log(`Syncing build request ${nextRequest.id()} on ${groupInfo.slaveName} in ${groupInfo.syncer.builderName()}`);
+            return groupInfo.syncer.scheduleRequest(request, groupInfo.slaveName);
+        }
+
+        for (let syncer of this._syncers) {
+            let promise = syncer.scheduleFirstRequestInGroupIfAvailable(nextRequest);
+            if (promise) {
+                let slaveName = groupInfo.slaveName ? ` on ${groupInfo.slaveName}` : '';
+                this._logger.log(`Syncing build request ${nextRequest.id()}${slaveName} in ${syncer.builderName()}`);
+                return promise;
+            }
+        }
+        return null;
+    }
+
+    static _testGroupMapForBuildRequests(buildRequests)
+    {
+        let map = new Map;
+        for (let request of buildRequests) {
+            let groupId = request.testGroupId();
+            if (!map.has(groupId)) // Don't use real TestGroup objects to avoid executing postgres query in the server
+                map.set(groupId, {id: groupId, requests: [request], syncer: null, slaveName: null});
+            else
+                map.get(groupId).requests.push(request);
+        }
+        return map;
+    }
+}
+
+if (typeof module != 'undefined')
+    module.exports.BuildbotTriggerable = BuildbotTriggerable;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsjsdatabasejs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/tools/js/database.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/js/database.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/tools/js/database.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -85,6 +85,7 @@
</span><span class="cx">     'bug_trackers': 'tracker',
</span><span class="cx">     'build_triggerables': 'triggerable',
</span><span class="cx">     'build_requests': 'request',
</span><ins>+    'build_slaves': 'slave',
</ins><span class="cx">     'builders': 'builder',
</span><span class="cx">     'commits': 'commit',
</span><span class="cx">     'test_configurations': 'config',
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsjsparseargumentsjs"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/tools/js/parse-arguments.js (0 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/js/parse-arguments.js                                (rev 0)
+++ trunk/Websites/perf.webkit.org/tools/js/parse-arguments.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -0,0 +1,34 @@
</span><ins>+
+function parseArgument(argv, acceptedOptions) {
+    var args = argv.slice(2);
+    var options = {}
+    for (var i = 0; i &lt; args.length; i += 2) {
+        var current = args[i];
+        var next = args[i + 1];
+        for (var option of acceptedOptions) {
+            if (current == option['name']) {
+                options[option['name']] = next;
+                next = null;
+                break;
+            }
+        }
+        if (next) {
+            console.error('Invalid argument:', current);
+            return null;
+        }
+    }
+    for (var option of acceptedOptions) {
+        var name = option['name'];
+        if (option['required'] &amp;&amp; !(name in options)) {
+            console.log('Required argument', name, 'is missing');
+            return null;
+        }
+        var value = options[name] || option['default'];
+        var converter = option['type'];
+        options[name] = converter ? converter(value) : value;
+    }
+    return options;
+}
+
+if (typeof module != 'undefined')
+    module.exports.parseArgument = parseArgument;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsjsremotejs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/tools/js/remote.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/js/remote.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/tools/js/remote.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -3,16 +3,26 @@
</span><span class="cx"> let assert = require('assert');
</span><span class="cx"> let http = require('http');
</span><span class="cx"> let https = require('https');
</span><ins>+let querystring = require('querystring');
</ins><span class="cx"> 
</span><del>-let RemoteAPI = new (class RemoteAPI {
-    constructor()
</del><ins>+class RemoteAPI {
+    constructor(server)
</ins><span class="cx">     {
</span><del>-        this._server = {
-            scheme: 'http',
-            host: 'localhost',
-        }
</del><ins>+        this._server = null;
+        if (server)
+            this.configure(server);
</ins><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    url(path)
+    {
+        let scheme = this._server.scheme;
+        let port = this._server.port;
+        let portSuffix = (scheme == 'http' &amp;&amp; port == 80) || (scheme == 'https' &amp;&amp; port == 443) ? '' : `:${port}`;
+        if (path.charAt(0) != '/')
+            path = '/' + path;
+        return `${scheme}://${this._server.host}portSuffix${path}`;
+    }
+
</ins><span class="cx">     configure(server)
</span><span class="cx">     {
</span><span class="cx">         assert(server.scheme === 'http' || server.scheme === 'https');
</span><span class="lines">@@ -22,27 +32,40 @@
</span><span class="cx">         this._server = server;
</span><span class="cx">     }
</span><span class="cx"> 
</span><del>-    getJSON(path, data)
</del><ins>+    getJSON(path)
</ins><span class="cx">     {
</span><del>-        let contentType = null;
-        if (data) {
-            contentType = 'application/json';
-            data = JSON.stringify(data);
-        }
-        return this.sendHttpRequest(path, 'GET', contentType, data).then(function (result) {
</del><ins>+        return this.sendHttpRequest(path, 'GET', null, null).then(function (result) {
</ins><span class="cx">             return JSON.parse(result.responseText);
</span><span class="cx">         });
</span><span class="cx">     }
</span><span class="cx"> 
</span><del>-    getJSONWithStatus(path, data)
</del><ins>+    getJSONWithStatus(path)
</ins><span class="cx">     {
</span><del>-        return this.getJSON(path, data).then(function (result) {
</del><ins>+        return this.getJSON(path).then(function (result) {
</ins><span class="cx">             if (result['status'] != 'OK')
</span><span class="cx">                 return Promise.reject(result);
</span><span class="cx">             return result;
</span><span class="cx">         });
</span><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    postJSON(path, data)
+    {
+        const contentType = 'application/json';
+        const payload = JSON.stringify(data);
+        return this.sendHttpRequest(path, 'POST', 'application/json', payload).then(function (result) {
+            return JSON.parse(result.responseText);
+        });
+    }
+
+    postFormUrlencodedData(path, data)
+    {
+        const contentType = 'application/x-www-form-urlencoded';
+        const payload = querystring.stringify(data);
+        return this.sendHttpRequest(path, 'POST', contentType, payload).then(function (result) {
+            return result.responseText;
+        });
+    }
+
</ins><span class="cx">     sendHttpRequest(path, method, contentType, content)
</span><span class="cx">     {
</span><span class="cx">         let server = this._server;
</span><span class="lines">@@ -50,7 +73,7 @@
</span><span class="cx">             let options = {
</span><span class="cx">                 hostname: server.host,
</span><span class="cx">                 port: server.port || 80,
</span><del>-                auth: server.auth,
</del><ins>+                auth: server.auth ? server.auth.username + ':' + server.auth.password : null,
</ins><span class="cx">                 method: method,
</span><span class="cx">                 path: path,
</span><span class="cx">             };
</span><span class="lines">@@ -73,7 +96,7 @@
</span><span class="cx">             request.end();
</span><span class="cx">         });
</span><span class="cx">     }
</span><del>-})
</del><ins>+};
</ins><span class="cx"> 
</span><span class="cx"> if (typeof module != 'undefined')
</span><span class="cx">     module.exports.RemoteAPI = RemoteAPI;
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsjsv3modelsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/tools/js/v3-models.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/js/v3-models.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/tools/js/v3-models.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -29,7 +29,4 @@
</span><span class="cx"> 
</span><span class="cx"> importFromV3('instrumentation.js', 'Instrumentation');
</span><span class="cx"> 
</span><del>-// RemoteAPI has a different implementation in node since XHR isn't available.
-global.RemoteAPI = require('./remote.js').RemoteAPI;
-
</del><span class="cx"> global.Statistics = require('../../public/shared/statistics.js');
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolssyncbuildbotjs"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/tools/sync-buildbot.js (0 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/sync-buildbot.js                                (rev 0)
+++ trunk/Websites/perf.webkit.org/tools/sync-buildbot.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -0,0 +1,46 @@
</span><ins>+#!/usr/local/bin/node
+'use strict';
+
+let BuildbotTriggerable = require('./js/buildbot-triggerable.js').BuildbotTriggerable;
+let RemoteAPI = require('./js/remote.js').RemoteAPI;
+let fs = require('fs');
+let parseArguments = require('./js/parse-arguments.js').parseArguments;
+
+function main(argv)
+{
+    let options = parseArguments(argv, [
+        {name: '--server-config-json', required: true},
+        {name: '--buildbot-config-json', required: true},
+        {name: '--seconds-to-sleep', type: parseFloat, default: 120},
+    ]);
+    if (!options)
+        return;
+
+    syncLoop(options);
+}
+
+function syncLoop(options)
+{
+    let serverConfig = JSON.parse(fs.readFileSync(options['--server-config-json'], 'utf8'));
+    let buildbotConfig = JSON.parse(fs.readFileSync(options['--buildbot-config-json'], 'utf8'));
+    let buildbotRemote = new RemoteAPI(buildbotConfig.server);
+
+    // v3 models use the global RemoteAPI to access the perf dashboard.
+    global.RemoteAPI = new RemoteAPI(serverConfig.server);
+
+    console.log(`Fetching the manifest...`);
+    Manifest.fetch().then(function () {
+        let triggerable = new BuildbotTriggerable(buildbotConfig, global.RemoteAPI, buildbotRemote, serverConfig.slave, console);
+        return triggerable.syncOnce();
+    }).catch(function (error) {
+        console.error(error);
+        if (typeof(error.stack) == 'string') {
+            for (let line of error.stack.split('\n'))
+                console.error(line);
+        }
+    }).then(function () {
+        setTimeout(syncLoop.bind(global, options), options['--seconds-to-sleep'] * 1000);
+    });
+}
+
+main(process.argv);
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgunittestsbuildbotsyncertestsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/unit-tests/buildbot-syncer-tests.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/unit-tests/buildbot-syncer-tests.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/unit-tests/buildbot-syncer-tests.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -30,19 +30,21 @@
</span><span class="cx">                 'test': ['JetStream'],
</span><span class="cx">                 'arguments': {'test_name': 'jetstream'}
</span><span class="cx">             },
</span><del>-            &quot;dromaeo-dom&quot;: {
-                &quot;test&quot;: [&quot;Dromaeo&quot;, &quot;DOM Core Tests&quot;],
-                &quot;arguments&quot;: {&quot;tests&quot;: &quot;dromaeo-dom&quot;}
</del><ins>+            'dromaeo-dom': {
+                'test': ['Dromaeo', 'DOM Core Tests'],
+                'arguments': {'tests': 'dromaeo-dom'}
</ins><span class="cx">             },
</span><span class="cx">         },
</span><span class="cx">         'builders': {
</span><span class="cx">             'iPhone-bench': {
</span><span class="cx">                 'builder': 'ABTest-iPhone-RunBenchmark-Tests',
</span><del>-                'arguments': { 'forcescheduler': 'ABTest-iPhone-RunBenchmark-Tests-ForceScheduler' }
</del><ins>+                'arguments': { 'forcescheduler': 'ABTest-iPhone-RunBenchmark-Tests-ForceScheduler' },
+                'slaveList': ['ABTest-iPhone-0'],
</ins><span class="cx">             },
</span><span class="cx">             'iPad-bench': {
</span><span class="cx">                 'builder': 'ABTest-iPad-RunBenchmark-Tests',
</span><del>-                'arguments': { 'forcescheduler': 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler' }
</del><ins>+                'arguments': { 'forcescheduler': 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler' },
+                'slaveList': ['ABTest-iPad-0'],
</ins><span class="cx">             }
</span><span class="cx">         },
</span><span class="cx">         'configurations': [
</span><span class="lines">@@ -71,6 +73,77 @@
</span><span class="cx">     }
</span><span class="cx"> };
</span><span class="cx"> 
</span><ins>+function smallConfiguration()
+{
+    return {
+        'builder': 'some builder',
+        'platform': 'Some platform',
+        'test': ['Some test'],
+        'arguments': {},
+        'buildRequestArgument': 'id'};
+}
+
+function smallPendingBuild()
+{
+    return {
+        'builderName': 'some builder',
+        'builds': [],
+        'properties': [],
+        'source': {
+            'branch': '',
+            'changes': [],
+            'codebase': 'WebKit',
+            'hasPatch': false,
+            'project': '',
+            'repository': '',
+            'revision': ''
+        },
+    };
+}
+
+function smallInProgressBuild()
+{
+    return {
+        'builderName': 'some builder',
+        'builds': [],
+        'properties': [],
+        'currentStep': { },
+        'eta': 123,
+        'number': 456,
+        'source': {
+            'branch': '',
+            'changes': [],
+            'codebase': 'WebKit',
+            'hasPatch': false,
+            'project': '',
+            'repository': '',
+            'revision': ''
+        },
+    };
+}
+
+function smallFinishedBuild()
+{
+    return {
+        'builderName': 'some builder',
+        'builds': [],
+        'properties': [],
+        'currentStep': null,
+        'eta': null,
+        'number': 789,
+        'source': {
+            'branch': '',
+            'changes': [],
+            'codebase': 'WebKit',
+            'hasPatch': false,
+            'project': '',
+            'repository': '',
+            'revision': ''
+        },
+        'times': [0, 1],
+    };
+}
+
</ins><span class="cx"> function createSampleBuildRequest(platform, test)
</span><span class="cx"> {
</span><span class="cx">     assert(platform instanceof Platform);
</span><span class="lines">@@ -82,11 +155,11 @@
</span><span class="cx">         {'id': '88930', 'time': 0, 'repository': MockModels.ios, 'revision': '13A452'},
</span><span class="cx">     ]});
</span><span class="cx"> 
</span><del>-    let request = BuildRequest.ensureSingleton('16733', {'rootSet': rootSet, 'status': 'pending', 'platform': platform, 'test': test});
</del><ins>+    let request = BuildRequest.ensureSingleton('16733-' + platform.id(), {'rootSet': rootSet, 'status': 'pending', 'platform': platform, 'test': test});
</ins><span class="cx">     return request;
</span><span class="cx"> }
</span><span class="cx"> 
</span><del>-function samplePendingBuild(buildRequestId)
</del><ins>+function samplePendingBuild(buildRequestId, buildTime, slaveName)
</ins><span class="cx"> {
</span><span class="cx">     return {
</span><span class="cx">         'builderName': 'ABTest-iPad-RunBenchmark-Tests',
</span><span class="lines">@@ -102,6 +175,7 @@
</span><span class="cx">                 JSON.stringify(sampleRootSetData),
</span><span class="cx">                 'Force Build Form'
</span><span class="cx">             ],
</span><ins>+            ['slavename', slaveName, ''],
</ins><span class="cx">             ['scheduler', 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler', 'Scheduler']
</span><span class="cx">         ],
</span><span class="cx">         'source': {
</span><span class="lines">@@ -113,11 +187,11 @@
</span><span class="cx">             'repository': '',
</span><span class="cx">             'revision': ''
</span><span class="cx">         },
</span><del>-        'submittedAt': 1458704983
</del><ins>+        'submittedAt': buildTime || 1458704983
</ins><span class="cx">     };
</span><span class="cx"> }
</span><span class="cx"> 
</span><del>-function sampleInProgressBuild()
</del><ins>+function sampleInProgressBuild(slaveName)
</ins><span class="cx"> {
</span><span class="cx">     return {
</span><span class="cx">         'blame': [],
</span><span class="lines">@@ -149,7 +223,7 @@
</span><span class="cx">             ['reason', 'force build', 'Force Build Form'],
</span><span class="cx">             ['roots_dict', JSON.stringify(sampleRootSetData), 'Force Build Form'],
</span><span class="cx">             ['scheduler', 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler', 'Scheduler'],
</span><del>-            ['slavename', 'ABTest-iPad-0', 'BuildSlave'],
</del><ins>+            ['slavename', slaveName || 'ABTest-iPad-0', 'BuildSlave'],
</ins><span class="cx">         ],
</span><span class="cx">         'reason': 'A build was forced by \'&lt;unknown&gt;\': force build',
</span><span class="cx">         'results': null,
</span><span class="lines">@@ -207,7 +281,7 @@
</span><span class="cx">     };
</span><span class="cx"> }
</span><span class="cx"> 
</span><del>-function sampleFinishedBuild(buildRequestId)
</del><ins>+function sampleFinishedBuild(buildRequestId, slaveName)
</ins><span class="cx"> {
</span><span class="cx">     return {
</span><span class="cx">         'blame': [],
</span><span class="lines">@@ -225,7 +299,7 @@
</span><span class="cx">             ['reason', 'force build', 'Force Build Form'],
</span><span class="cx">             ['roots_dict', JSON.stringify(sampleRootSetData), 'Force Build Form'],
</span><span class="cx">             ['scheduler', 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler', 'Scheduler'],
</span><del>-            ['slavename', 'ABTest-iPad-0', 'BuildSlave'],
</del><ins>+            ['slavename', slaveName || 'ABTest-iPad-0', 'BuildSlave'],
</ins><span class="cx">         ],
</span><span class="cx">         'reason': 'A build was forced by \'&lt;unknown&gt;\': force build',
</span><span class="cx">         'results': 2,
</span><span class="lines">@@ -285,22 +359,12 @@
</span><span class="cx"> 
</span><span class="cx"> describe('BuildbotSyncer', function () {
</span><span class="cx">     MockModels.inject();
</span><del>-    let requests = MockRemoteAPI.inject();
</del><ins>+    let requests = MockRemoteAPI.inject('http://build.webkit.org');
</ins><span class="cx"> 
</span><span class="cx">     describe('_loadConfig', function () {
</span><span class="cx"> 
</span><del>-        function smallConfiguration()
-        {
-            return {
-                'builder': 'some builder',
-                'platform': 'some platform',
-                'test': ['some test'],
-                'arguments': {},
-                'buildRequestArgument': 'id'};
-        }
-
</del><span class="cx">         it('should create BuildbotSyncer objects for a configuration that specify all required options', function () {
</span><del>-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [smallConfiguration()]});
</del><ins>+            let syncers = BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [smallConfiguration()]});
</ins><span class="cx">             assert.equal(syncers.length, 1);
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="lines">@@ -308,27 +372,27 @@
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 delete config['builder'];
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</ins><span class="cx">             }, 'builder should be a required option');
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 delete config['platform'];
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</ins><span class="cx">             }, 'platform should be a required option');
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 delete config['test'];
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</ins><span class="cx">             }, 'test should be a required option');
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 delete config['arguments'];
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</ins><span class="cx">             });
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 delete config['buildRequestArgument'];
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</ins><span class="cx">             });
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="lines">@@ -336,12 +400,12 @@
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 config.test = 'some test';
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</ins><span class="cx">             });
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 config.test = [1];
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</ins><span class="cx">             });
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="lines">@@ -349,7 +413,7 @@
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 config.arguments = 'hello';
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</ins><span class="cx">             });
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="lines">@@ -357,105 +421,101 @@
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 config.arguments = {'some': {'root': 'some root', 'rootsExcluding': ['other root']}};
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
</ins><span class="cx">             });
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 config.arguments = {'some': {'otherKey': 'some root'}};
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
</ins><span class="cx">             });
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 config.arguments = {'some': {'root': ['a', 'b']}};
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
</ins><span class="cx">             });
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 config.arguments = {'some': {'root': 1}};
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
</ins><span class="cx">             });
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 config.arguments = {'some': {'rootsExcluding': 'a'}};
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
</ins><span class="cx">             });
</span><span class="cx">             assert.throws(function () {
</span><span class="cx">                 let config = smallConfiguration();
</span><span class="cx">                 config.arguments = {'some': {'rootsExcluding': [1]}};
</span><del>-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
</del><ins>+                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
</ins><span class="cx">             });
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should create BuildbotSyncer objects for valid configurations', function () {
</span><del>-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
-            assert.equal(syncers.length, 5);
</del><ins>+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
+            assert.equal(syncers.length, 2);
</ins><span class="cx">             assert.ok(syncers[0] instanceof BuildbotSyncer);
</span><span class="cx">             assert.ok(syncers[1] instanceof BuildbotSyncer);
</span><del>-            assert.ok(syncers[2] instanceof BuildbotSyncer);
-            assert.ok(syncers[3] instanceof BuildbotSyncer);
-            assert.ok(syncers[4] instanceof BuildbotSyncer);
</del><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should parse builder names correctly', function () {
</span><del>-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
</del><ins>+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
</ins><span class="cx">             assert.equal(syncers[0].builderName(), 'ABTest-iPhone-RunBenchmark-Tests');
</span><del>-            assert.equal(syncers[1].builderName(), 'ABTest-iPhone-RunBenchmark-Tests');
-            assert.equal(syncers[2].builderName(), 'ABTest-iPhone-RunBenchmark-Tests');
-            assert.equal(syncers[3].builderName(), 'ABTest-iPad-RunBenchmark-Tests');
-            assert.equal(syncers[4].builderName(), 'ABTest-iPad-RunBenchmark-Tests');
</del><ins>+            assert.equal(syncers[1].builderName(), 'ABTest-iPad-RunBenchmark-Tests');
</ins><span class="cx">         });
</span><span class="cx"> 
</span><del>-        it('should parse platform names correctly', function () {
-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
-            assert.equal(syncers[0].platformName(), 'iPhone');
-            assert.equal(syncers[1].platformName(), 'iPhone');
-            assert.equal(syncers[2].platformName(), 'iPhone');
-            assert.equal(syncers[3].platformName(), 'iPad');
-            assert.equal(syncers[4].platformName(), 'iPad');
-        });
</del><ins>+        it('should parse test configurations correctly', function () {
+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
</ins><span class="cx"> 
</span><del>-        it('should parse test names correctly', function () {
-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
-            assert.deepEqual(syncers[0].testPath(), ['Speedometer']);
-            assert.deepEqual(syncers[1].testPath(), ['JetStream']);
-            assert.deepEqual(syncers[2].testPath(), ['Dromaeo', 'DOM Core Tests']);
-            assert.deepEqual(syncers[3].testPath(), ['Speedometer']);
-            assert.deepEqual(syncers[4].testPath(), ['JetStream']);
</del><ins>+            let configurations = syncers[0].testConfigurations();
+            assert.equal(configurations.length, 3);
+            assert.equal(configurations[0].platform, MockModels.iphone);
+            assert.equal(configurations[0].test, MockModels.speedometer);
+            assert.equal(configurations[1].platform, MockModels.iphone);
+            assert.equal(configurations[1].test, MockModels.jetstream);
+            assert.equal(configurations[2].platform, MockModels.iphone);
+            assert.equal(configurations[2].test, MockModels.domcore);
+
+            configurations = syncers[1].testConfigurations();
+            assert.equal(configurations.length, 2);
+            assert.equal(configurations[0].platform, MockModels.ipad);
+            assert.equal(configurations[0].test, MockModels.speedometer);
+            assert.equal(configurations[1].platform, MockModels.ipad);
+            assert.equal(configurations[1].test, MockModels.jetstream);
</ins><span class="cx">         });
</span><span class="cx">     });
</span><span class="cx"> 
</span><span class="cx">     describe('_propertiesForBuildRequest', function () {
</span><span class="cx">         it('should include all properties specified in a given configuration', function () {
</span><del>-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
</del><ins>+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
</ins><span class="cx">             let properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
</span><span class="cx">             assert.deepEqual(Object.keys(properties), ['desired_image', 'roots_dict', 'test_name', 'forcescheduler', 'build_request_id']);
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should preserve non-parametric property values', function () {
</span><del>-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
</del><ins>+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
</ins><span class="cx">             let properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
</span><span class="cx">             assert.equal(properties['test_name'], 'speedometer');
</span><span class="cx">             assert.equal(properties['forcescheduler'], 'ABTest-iPhone-RunBenchmark-Tests-ForceScheduler');
</span><span class="cx"> 
</span><del>-            properties = syncers[1]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
</del><ins>+            properties = syncers[1]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.ipad, MockModels.jetstream));
</ins><span class="cx">             assert.equal(properties['test_name'], 'jetstream');
</span><del>-            assert.equal(properties['forcescheduler'], 'ABTest-iPhone-RunBenchmark-Tests-ForceScheduler');
</del><ins>+            assert.equal(properties['forcescheduler'], 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler');
</ins><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should resolve &quot;root&quot;', function () {
</span><del>-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
</del><ins>+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
</ins><span class="cx">             let properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
</span><span class="cx">             assert.equal(properties['desired_image'], '13A452');
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should resolve &quot;rootsExcluding&quot;', function () {
</span><del>-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
</del><ins>+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
</ins><span class="cx">             let properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
</span><span class="cx">             assert.equal(properties['roots_dict'], JSON.stringify(sampleRootSetData));
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should set the property for the build request id', function () {
</span><del>-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
</del><ins>+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
</ins><span class="cx">             let request = createSampleBuildRequest(MockModels.iphone, MockModels.speedometer);
</span><span class="cx">             let properties = syncers[0]._propertiesForBuildRequest(request);
</span><span class="cx">             assert.equal(properties['build_request_id'], request.id());
</span><span class="lines">@@ -464,51 +524,51 @@
</span><span class="cx"> 
</span><span class="cx">     describe('pullBuildbot', function () {
</span><span class="cx">         it('should fetch pending builds from the right URL', function () {
</span><del>-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
</ins><span class="cx">             assert.equal(syncer.builderName(), 'ABTest-iPad-RunBenchmark-Tests');
</span><del>-            let expectedURL = 'http://build.webkit.org/json/builders/ABTest-iPad-RunBenchmark-Tests/pendingBuilds';
-            assert.equal(syncer.urlForPendingBuildsJSON(), expectedURL);
</del><ins>+            let expectedURL = '/json/builders/ABTest-iPad-RunBenchmark-Tests/pendingBuilds';
+            assert.equal(syncer.pathForPendingBuildsJSON(), expectedURL);
</ins><span class="cx">             syncer.pullBuildbot();
</span><span class="cx">             assert.equal(requests.length, 1);
</span><span class="cx">             assert.equal(requests[0].url, expectedURL);
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should fetch recent builds once pending builds have been fetched', function (done) {
</span><del>-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
</ins><span class="cx">             assert.equal(syncer.builderName(), 'ABTest-iPad-RunBenchmark-Tests');
</span><span class="cx"> 
</span><span class="cx">             syncer.pullBuildbot(1);
</span><span class="cx">             assert.equal(requests.length, 1);
</span><del>-            assert.equal(requests[0].url, 'http://build.webkit.org/json/builders/ABTest-iPad-RunBenchmark-Tests/pendingBuilds');
</del><ins>+            assert.equal(requests[0].url, '/json/builders/ABTest-iPad-RunBenchmark-Tests/pendingBuilds');
</ins><span class="cx">             requests[0].resolve([]);
</span><span class="cx">             Promise.resolve().then(function () {
</span><span class="cx">                 assert.equal(requests.length, 2);
</span><del>-                assert.equal(requests[1].url, 'http://build.webkit.org/json/builders/ABTest-iPad-RunBenchmark-Tests/builds/?select=-1');
</del><ins>+                assert.equal(requests[1].url, '/json/builders/ABTest-iPad-RunBenchmark-Tests/builds/?select=-1');
</ins><span class="cx">                 done();
</span><span class="cx">             }).catch(done);
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should fetch the right number of recent builds', function (done) {
</span><del>-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
</ins><span class="cx"> 
</span><span class="cx">             syncer.pullBuildbot(3);
</span><span class="cx">             assert.equal(requests.length, 1);
</span><del>-            assert.equal(requests[0].url, 'http://build.webkit.org/json/builders/ABTest-iPad-RunBenchmark-Tests/pendingBuilds');
</del><ins>+            assert.equal(requests[0].url, '/json/builders/ABTest-iPad-RunBenchmark-Tests/pendingBuilds');
</ins><span class="cx">             requests[0].resolve([]);
</span><span class="cx">             Promise.resolve().then(function () {
</span><span class="cx">                 assert.equal(requests.length, 2);
</span><del>-                assert.equal(requests[1].url, 'http://build.webkit.org/json/builders/ABTest-iPad-RunBenchmark-Tests/builds/?select=-1&amp;select=-2&amp;select=-3');
</del><ins>+                assert.equal(requests[1].url, '/json/builders/ABTest-iPad-RunBenchmark-Tests/builds/?select=-1&amp;select=-2&amp;select=-3');
</ins><span class="cx">                 done();
</span><span class="cx">             }).catch(done);
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should create BuildbotBuildEntry for pending builds', function (done) {
</span><del>-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
</ins><span class="cx">             let promise = syncer.pullBuildbot();
</span><span class="cx">             requests[0].resolve([samplePendingBuild()]);
</span><span class="cx">             promise.then(function (entries) {
</span><del>-                assert.deepEqual(Object.keys(entries), ['16733']);
-                let entry = entries['16733'];
</del><ins>+                assert.equal(entries.length, 1);
+                let entry = entries[0];
</ins><span class="cx">                 assert.ok(entry instanceof BuildbotBuildEntry);
</span><span class="cx">                 assert.ok(!entry.buildNumber());
</span><span class="cx">                 assert.ok(!entry.slaveName());
</span><span class="lines">@@ -522,7 +582,7 @@
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should create BuildbotBuildEntry for in-progress builds', function (done) {
</span><del>-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
</ins><span class="cx"> 
</span><span class="cx">             let promise = syncer.pullBuildbot(1);
</span><span class="cx">             assert.equal(requests.length, 1);
</span><span class="lines">@@ -533,8 +593,8 @@
</span><span class="cx">             }).catch(done);
</span><span class="cx"> 
</span><span class="cx">             promise.then(function (entries) {
</span><del>-                assert.deepEqual(Object.keys(entries), ['16733']);
-                let entry = entries['16733'];
</del><ins>+                assert.equal(entries.length, 1);
+                let entry = entries[0];
</ins><span class="cx">                 assert.ok(entry instanceof BuildbotBuildEntry);
</span><span class="cx">                 assert.equal(entry.buildNumber(), 614);
</span><span class="cx">                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
</span><span class="lines">@@ -548,7 +608,7 @@
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should create BuildbotBuildEntry for finished builds', function (done) {
</span><del>-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
</ins><span class="cx"> 
</span><span class="cx">             let promise = syncer.pullBuildbot(1);
</span><span class="cx">             assert.equal(requests.length, 1);
</span><span class="lines">@@ -559,8 +619,8 @@
</span><span class="cx">             }).catch(done);
</span><span class="cx"> 
</span><span class="cx">             promise.then(function (entries) {
</span><del>-                assert.deepEqual(Object.keys(entries), ['18935']);
-                let entry = entries['18935'];
</del><ins>+                assert.deepEqual(entries.length, 1);
+                let entry = entries[0];
</ins><span class="cx">                 assert.ok(entry instanceof BuildbotBuildEntry);
</span><span class="cx">                 assert.equal(entry.buildNumber(), 1755);
</span><span class="cx">                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
</span><span class="lines">@@ -574,12 +634,12 @@
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should create BuildbotBuildEntry for mixed pending, in-progress, finished, and missing builds', function (done) {
</span><del>-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
</ins><span class="cx"> 
</span><span class="cx">             let promise = syncer.pullBuildbot(5);
</span><span class="cx">             assert.equal(requests.length, 1);
</span><span class="cx"> 
</span><del>-            requests[0].resolve([samplePendingBuild(123, 456)]);
</del><ins>+            requests[0].resolve([samplePendingBuild(123)]);
</ins><span class="cx"> 
</span><span class="cx">             Promise.resolve().then(function () {
</span><span class="cx">                 assert.equal(requests.length, 2);
</span><span class="lines">@@ -587,9 +647,9 @@
</span><span class="cx">             }).catch(done);
</span><span class="cx"> 
</span><span class="cx">             promise.then(function (entries) {
</span><del>-                assert.deepEqual(Object.keys(entries), ['123', '16733', '18935']);
</del><ins>+                assert.deepEqual(entries.length, 3);
</ins><span class="cx"> 
</span><del>-                let entry = entries['123'];
</del><ins>+                let entry = entries[0];
</ins><span class="cx">                 assert.ok(entry instanceof BuildbotBuildEntry);
</span><span class="cx">                 assert.equal(entry.buildNumber(), null);
</span><span class="cx">                 assert.equal(entry.slaveName(), null);
</span><span class="lines">@@ -599,7 +659,7 @@
</span><span class="cx">                 assert.ok(!entry.hasFinished());
</span><span class="cx">                 assert.equal(entry.url(), 'http://build.webkit.org/builders/ABTest-iPad-RunBenchmark-Tests/');
</span><span class="cx"> 
</span><del>-                entry = entries['16733'];
</del><ins>+                entry = entries[1];
</ins><span class="cx">                 assert.ok(entry instanceof BuildbotBuildEntry);
</span><span class="cx">                 assert.equal(entry.buildNumber(), 614);
</span><span class="cx">                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
</span><span class="lines">@@ -609,7 +669,7 @@
</span><span class="cx">                 assert.ok(!entry.hasFinished());
</span><span class="cx">                 assert.equal(entry.url(), 'http://build.webkit.org/builders/ABTest-iPad-RunBenchmark-Tests/builds/614');
</span><span class="cx"> 
</span><del>-                entry = entries['18935'];
</del><ins>+                entry = entries[2];
</ins><span class="cx">                 assert.ok(entry instanceof BuildbotBuildEntry);
</span><span class="cx">                 assert.equal(entry.buildNumber(), 1755);
</span><span class="cx">                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
</span><span class="lines">@@ -623,8 +683,68 @@
</span><span class="cx">             }).catch(done);
</span><span class="cx">         });
</span><span class="cx"> 
</span><ins>+        it('should sort BuildbotBuildEntry by order', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
+
+            let promise = syncer.pullBuildbot(5);
+            assert.equal(requests.length, 1);
+
+            requests[0].resolve([samplePendingBuild(456, 2), samplePendingBuild(123, 1)]);
+
+            Promise.resolve().then(function () {
+                assert.equal(requests.length, 2);
+                requests[1].resolve({[-3]: sampleFinishedBuild(), [-1]: {'error': 'Not available'}, [-2]: sampleInProgressBuild()});
+            }).catch(done);
+
+            promise.then(function (entries) {
+                assert.deepEqual(entries.length, 4);
+
+                let entry = entries[0];
+                assert.ok(entry instanceof BuildbotBuildEntry);
+                assert.equal(entry.buildNumber(), null);
+                assert.equal(entry.slaveName(), null);
+                assert.equal(entry.buildRequestId(), 123);
+                assert.ok(entry.isPending());
+                assert.ok(!entry.isInProgress());
+                assert.ok(!entry.hasFinished());
+                assert.equal(entry.url(), 'http://build.webkit.org/builders/ABTest-iPad-RunBenchmark-Tests/');
+
+                entry = entries[1];
+                assert.ok(entry instanceof BuildbotBuildEntry);
+                assert.equal(entry.buildNumber(), null);
+                assert.equal(entry.slaveName(), null);
+                assert.equal(entry.buildRequestId(), 456);
+                assert.ok(entry.isPending());
+                assert.ok(!entry.isInProgress());
+                assert.ok(!entry.hasFinished());
+                assert.equal(entry.url(), 'http://build.webkit.org/builders/ABTest-iPad-RunBenchmark-Tests/');
+
+                entry = entries[2];
+                assert.ok(entry instanceof BuildbotBuildEntry);
+                assert.equal(entry.buildNumber(), 614);
+                assert.equal(entry.slaveName(), 'ABTest-iPad-0');
+                assert.equal(entry.buildRequestId(), 16733);
+                assert.ok(!entry.isPending());
+                assert.ok(entry.isInProgress());
+                assert.ok(!entry.hasFinished());
+                assert.equal(entry.url(), 'http://build.webkit.org/builders/ABTest-iPad-RunBenchmark-Tests/builds/614');
+
+                entry = entries[3];
+                assert.ok(entry instanceof BuildbotBuildEntry);
+                assert.equal(entry.buildNumber(), 1755);
+                assert.equal(entry.slaveName(), 'ABTest-iPad-0');
+                assert.equal(entry.buildRequestId(), 18935);
+                assert.ok(!entry.isPending());
+                assert.ok(!entry.isInProgress());
+                assert.ok(entry.hasFinished());
+                assert.equal(entry.url(), 'http://build.webkit.org/builders/ABTest-iPad-RunBenchmark-Tests/builds/1755');
+
+                done();
+            }).catch(done);
+        });
+
</ins><span class="cx">         it('should override BuildbotBuildEntry for pending builds by in-progress builds', function (done) {
</span><del>-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
</ins><span class="cx"> 
</span><span class="cx">             let promise = syncer.pullBuildbot(5);
</span><span class="cx">             assert.equal(requests.length, 1);
</span><span class="lines">@@ -637,9 +757,9 @@
</span><span class="cx">             }).catch(done);
</span><span class="cx"> 
</span><span class="cx">             promise.then(function (entries) {
</span><del>-                assert.deepEqual(Object.keys(entries), ['16733']);
</del><ins>+                assert.equal(entries.length, 1);
</ins><span class="cx"> 
</span><del>-                let entry = entries['16733'];
</del><ins>+                let entry = entries[0];
</ins><span class="cx">                 assert.ok(entry instanceof BuildbotBuildEntry);
</span><span class="cx">                 assert.equal(entry.buildNumber(), 614);
</span><span class="cx">                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
</span><span class="lines">@@ -654,7 +774,7 @@
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should override BuildbotBuildEntry for pending builds by finished builds', function (done) {
</span><del>-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
</ins><span class="cx"> 
</span><span class="cx">             let promise = syncer.pullBuildbot(5);
</span><span class="cx">             assert.equal(requests.length, 1);
</span><span class="lines">@@ -667,9 +787,9 @@
</span><span class="cx">             }).catch(done);
</span><span class="cx"> 
</span><span class="cx">             promise.then(function (entries) {
</span><del>-                assert.deepEqual(Object.keys(entries), ['16733']);
</del><ins>+                assert.equal(entries.length, 1);
</ins><span class="cx"> 
</span><del>-                let entry = entries['16733'];
</del><ins>+                let entry = entries[0];
</ins><span class="cx">                 assert.ok(entry instanceof BuildbotBuildEntry);
</span><span class="cx">                 assert.equal(entry.buildNumber(), 1755);
</span><span class="cx">                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
</span><span class="lines">@@ -682,6 +802,166 @@
</span><span class="cx">                 done();
</span><span class="cx">             }).catch(done);
</span><span class="cx">         });
</span><ins>+    });
</ins><span class="cx"> 
</span><ins>+    describe('scheduleRequest', function () {
+        it('should schedule a build request on a specified slave', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[0];
+
+            syncer.scheduleRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer), 'some-slave');
+            Promise.resolve().then(function () {
+                assert.equal(requests.length, 1);
+                assert.equal(requests[0].url, '/builders/ABTest-iPhone-RunBenchmark-Tests/force');
+                assert.equal(requests[0].method, 'POST');
+                assert.deepEqual(requests[0].data, {
+                    'build_request_id': '16733-' + MockModels.iphone.id(),
+                    'desired_image': '13A452',
+                    'forcescheduler': 'ABTest-iPhone-RunBenchmark-Tests-ForceScheduler',
+                    'roots_dict': '{&quot;WebKit&quot;:{&quot;id&quot;:&quot;111127&quot;,&quot;time&quot;:1456955807334,&quot;repository&quot;:&quot;WebKit&quot;,&quot;revision&quot;:&quot;197463&quot;},'
+                        + '&quot;Shared&quot;:{&quot;id&quot;:&quot;111237&quot;,&quot;time&quot;:1456931874000,&quot;repository&quot;:&quot;Shared&quot;,&quot;revision&quot;:&quot;80229&quot;}}',
+                    'slavename': 'some-slave',
+                    'test_name': 'speedometer'
+                });
+                done();
+            }).catch(done);
+        });
</ins><span class="cx">     });
</span><ins>+
+    describe('scheduleFirstRequestInGroupIfAvailable', function () {
+
+        function pullBuildbotWithAssertion(syncer, pendingBuilds, inProgressAndFinishedBuilds)
+        {
+            let promise = syncer.pullBuildbot(5);
+            assert.equal(requests.length, 1);
+            requests[0].resolve(pendingBuilds);
+            return Promise.resolve().then(function () {
+                assert.equal(requests.length, 2);
+                requests[1].resolve(inProgressAndFinishedBuilds);
+                requests.length = 0;
+            }).then(function () {
+                return promise;
+            });
+        }
+
+        it('should schedule a build if builder has no builds if slaveList is not specified', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [smallConfiguration()]})[0];
+
+            pullBuildbotWithAssertion(syncer, [], {}).then(function () {
+                syncer.scheduleFirstRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.somePlatform, MockModels.someTest));
+            }).then(function () {
+                assert.equal(requests.length, 1);
+                assert.equal(requests[0].url, '/builders/some builder/force');
+                assert.equal(requests[0].method, 'POST');
+                assert.deepEqual(requests[0].data, {id: '16733-' + MockModels.somePlatform.id()});
+                done();
+            }).catch(done);
+        });
+
+        it('should schedule a build if builder only has finished builds if slaveList is not specified', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [smallConfiguration()]})[0];
+
+            pullBuildbotWithAssertion(syncer, [], {[-1]: smallFinishedBuild()}).then(function () {
+                syncer.scheduleFirstRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.somePlatform, MockModels.someTest));
+            }).then(function () {
+                assert.equal(requests.length, 1);
+                assert.equal(requests[0].url, '/builders/some builder/force');
+                assert.equal(requests[0].method, 'POST');
+                assert.deepEqual(requests[0].data, {id: '16733-' + MockModels.somePlatform.id()});
+                done();
+            }).catch(done);
+        });
+
+        it('should not schedule a build if builder has a pending build if slaveList is not specified', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [smallConfiguration()]})[0];
+
+            pullBuildbotWithAssertion(syncer, [smallPendingBuild()], {}).then(function () {
+                syncer.scheduleFirstRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.somePlatform, MockModels.someTest));
+            }).then(function () {
+                assert.equal(requests.length, 0);
+                done();
+            }).catch(done);
+        });
+
+        it('should schedule a build if builder does not have pending or completed builds on the matching slave', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[0];
+
+            pullBuildbotWithAssertion(syncer, [], {}).then(function () {
+                syncer.scheduleFirstRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
+            }).then(function () {
+                assert.equal(requests.length, 1);
+                assert.equal(requests[0].url, '/builders/ABTest-iPhone-RunBenchmark-Tests/force');
+                assert.equal(requests[0].method, 'POST');
+                done();
+            }).catch(done);
+        });
+
+        it('should schedule a build if builder only has finished builds on the matching slave', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
+
+            pullBuildbotWithAssertion(syncer, [], {[-1]: sampleFinishedBuild()}).then(function () {
+                syncer.scheduleFirstRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.ipad, MockModels.speedometer));
+            }).then(function () {
+                assert.equal(requests.length, 1);
+                assert.equal(requests[0].url, '/builders/ABTest-iPad-RunBenchmark-Tests/force');
+                assert.equal(requests[0].method, 'POST');
+                done();
+            }).catch(done);
+        });
+
+        it('should not schedule a build if builder has a pending build on the maching slave', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
+
+            pullBuildbotWithAssertion(syncer, [samplePendingBuild()], {}).then(function () {
+                syncer.scheduleFirstRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.ipad, MockModels.speedometer));
+            }).then(function () {
+                assert.equal(requests.length, 0);
+                done();
+            }).catch(done);
+        });
+
+        it('should schedule a build if builder only has a pending build on a non-maching slave', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
+
+            pullBuildbotWithAssertion(syncer, [samplePendingBuild(1, 1, 'another-slave')], {}).then(function () {
+                syncer.scheduleFirstRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.ipad, MockModels.speedometer));
+            }).then(function () {
+                assert.equal(requests.length, 1);
+                done();
+            }).catch(done);
+        });
+
+        it('should schedule a build if builder only has an in-progress build on the matching slave', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
+
+            pullBuildbotWithAssertion(syncer, [], {[-1]: sampleInProgressBuild()}).then(function () {
+                syncer.scheduleFirstRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.ipad, MockModels.speedometer));
+            }).then(function () {
+                assert.equal(requests.length, 1);
+                done();
+            }).catch(done);
+        });
+
+        it('should schedule a build if builder has an in-progress build on another slave', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
+
+            pullBuildbotWithAssertion(syncer, [], {[-1]: sampleInProgressBuild('other-slave')}).then(function () {
+                syncer.scheduleFirstRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.ipad, MockModels.speedometer));
+            }).then(function () {
+                assert.equal(requests.length, 1);
+                done();
+            }).catch(done);
+        });
+
+        it('should not schedule a build if the request does not match any configuration', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[0];
+
+            pullBuildbotWithAssertion(syncer, [], {}).then(function () {
+                syncer.scheduleFirstRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.ipad, MockModels.speedometer));
+            }).then(function () {
+                assert.equal(requests.length, 0);
+                done();
+            }).catch(done);
+        });
+
+    });
</ins><span class="cx"> });
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgunittestsresourcesmockremoteapijs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/unit-tests/resources/mock-remote-api.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/unit-tests/resources/mock-remote-api.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/unit-tests/resources/mock-remote-api.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -4,14 +4,28 @@
</span><span class="cx">     assert.notReached = function () { assert(false, 'This code path should not be reached'); }
</span><span class="cx"> 
</span><span class="cx"> var MockRemoteAPI = {
</span><ins>+    url: function (path)
+    {
+        return `${this.urlPrefix}${path}`;
+    },
</ins><span class="cx">     getJSON: function (url)
</span><span class="cx">     {
</span><span class="cx">         return this.getJSONWithStatus(url);
</span><span class="cx">     },
</span><span class="cx">     getJSONWithStatus: function (url)
</span><span class="cx">     {
</span><ins>+        return this._addRequest(url, 'GET', null);
+    },
+    postFormUrlencodedData: function (url, data)
+    {
+        return this._addRequest(url, 'POST', data);
+    },
+    _addRequest: function (url, method, data)
+    {
</ins><span class="cx">         var request = {
</span><span class="cx">             url: url,
</span><ins>+            method: method,
+            data: data,
</ins><span class="cx">             promise: null,
</span><span class="cx">             resolve: null,
</span><span class="cx">             reject: null,
</span><span class="lines">@@ -22,15 +36,30 @@
</span><span class="cx">             request.reject = reject;
</span><span class="cx">         });
</span><span class="cx"> 
</span><ins>+        if (this._waitingPromise) {
+            this._waitingPromiseResolver();
+            this._waitingPromise = null;
+            this._waitingPromiseResolver = null;
+        }
+
</ins><span class="cx">         MockRemoteAPI.requests.push(request);
</span><span class="cx">         return request.promise;
</span><span class="cx">     },
</span><del>-    inject: function ()
</del><ins>+    waitForRequest()
</ins><span class="cx">     {
</span><ins>+        if (!this._waitingPromise) {
+            this._waitingPromise = new Promise(function (resolve, reject) {
+                MockRemoteAPI._waitingPromiseResolver = resolve;
+            });
+        }
+        return this._waitingPromise;
+    },
+    inject: function (urlPrefix)
+    {
</ins><span class="cx">         var originalRemoteAPI = global.RemoteAPI;
</span><span class="cx"> 
</span><span class="cx">         beforeEach(function () {
</span><del>-            MockRemoteAPI.requests.length = 0;
</del><ins>+            MockRemoteAPI.reset(urlPrefix);
</ins><span class="cx">             originalRemoteAPI = global.RemoteAPI;
</span><span class="cx">             global.RemoteAPI = MockRemoteAPI;
</span><span class="cx">         });
</span><span class="lines">@@ -40,9 +69,18 @@
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         return MockRemoteAPI.requests;
</span><del>-    }
</del><ins>+    },
+    reset: function (urlPrefix)
+    {
+        if (urlPrefix)
+            MockRemoteAPI.urlPrefix = urlPrefix;
+        MockRemoteAPI.requests.length = 0;
+    },
+    requests: [],
+    _waitingPromise: null,
+    _waitingPromiseResolver: null,
+    urlPrefix: 'http://mockhost',
</ins><span class="cx"> };
</span><del>-MockRemoteAPI.requests = [];
</del><span class="cx"> 
</span><span class="cx"> if (typeof module != 'undefined')
</span><span class="cx">     module.exports.MockRemoteAPI = MockRemoteAPI;
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgunittestsresourcesmockv3modelsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/unit-tests/resources/mock-v3-models.js (199122 => 199123)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/unit-tests/resources/mock-v3-models.js        2016-04-06 22:51:58 UTC (rev 199122)
+++ trunk/Websites/perf.webkit.org/unit-tests/resources/mock-v3-models.js        2016-04-06 23:19:29 UTC (rev 199123)
</span><span class="lines">@@ -7,6 +7,8 @@
</span><span class="cx">             AnalysisTask.clearStaticMap();
</span><span class="cx">             CommitLog.clearStaticMap();
</span><span class="cx">             Metric.clearStaticMap();
</span><ins>+            Platform.clearStaticMap();
+            Repository.clearStaticMap();
</ins><span class="cx">             RootSet.clearStaticMap();
</span><span class="cx">             Test.clearStaticMap();
</span><span class="cx">             TestGroup.clearStaticMap();
</span></span></pre>
</div>
</div>

</body>
</html>