New buildbot syncing scripts that supports multiple builders and slaves
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 6 Apr 2016 23:19:29 +0000 (23:19 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 6 Apr 2016 23:19:29 +0000 (23:19 +0000)
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 "B" which has a parent test named "A".
(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.

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

20 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/public/v3/models/build-request.js
Websites/perf.webkit.org/public/v3/models/metric.js
Websites/perf.webkit.org/public/v3/models/platform.js
Websites/perf.webkit.org/public/v3/models/test.js
Websites/perf.webkit.org/server-tests/api-build-requests-tests.js
Websites/perf.webkit.org/server-tests/resources/mock-data.js [new file with mode: 0644]
Websites/perf.webkit.org/server-tests/resources/test-server.js
Websites/perf.webkit.org/server-tests/tools-buildbot-triggerable-tests.js [new file with mode: 0644]
Websites/perf.webkit.org/tools/detect-changes.js
Websites/perf.webkit.org/tools/js/buildbot-syncer.js
Websites/perf.webkit.org/tools/js/buildbot-triggerable.js [new file with mode: 0644]
Websites/perf.webkit.org/tools/js/database.js
Websites/perf.webkit.org/tools/js/parse-arguments.js [new file with mode: 0644]
Websites/perf.webkit.org/tools/js/remote.js
Websites/perf.webkit.org/tools/js/v3-models.js
Websites/perf.webkit.org/tools/sync-buildbot.js [new file with mode: 0644]
Websites/perf.webkit.org/unit-tests/buildbot-syncer-tests.js
Websites/perf.webkit.org/unit-tests/resources/mock-remote-api.js
Websites/perf.webkit.org/unit-tests/resources/mock-v3-models.js

index 523bdd8..cacfd0b 100644 (file)
@@ -1,3 +1,135 @@
+2016-04-05  Ryosuke Niwa  <rniwa@webkit.org>
+
+        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 "B" which has a parent test named "A".
+        (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.
+
 2016-03-30  Ryosuke Niwa  <rniwa@webkit.org>
 
         Simplify API of Test model by removing Test.setParentTest
index d773bd3..92f8232 100644 (file)
@@ -40,8 +40,10 @@ class BuildRequest extends DataModelObject {
     order() { return this._order; }
     rootSet() { return this._rootSet; }
 
+    status() { return this._status; }
     hasFinished() { return this._status == 'failed' || this._status == 'completed' || this._status == 'canceled'; }
     hasStarted() { return this._status != 'pending'; }
+    isScheduled() { return this._status == 'scheduled'; }
     isPending() { return this._status == 'pending'; }
     statusLabel()
     {
index ce5b4be..a195872 100644 (file)
@@ -33,10 +33,7 @@ class Metric extends LabeledObject {
 
     path() { return this._test.path().concat([this]); }
 
-    fullName()
-    {
-        return this._test.path().map(function (test) { return test.label(); }).join(' \u220B ') + ' : ' + this.label();
-    }
+    fullName() { return this._test.fullName() + ' : ' + this.label(); }
 
     label()
     {
index 8d95982..c54b65a 100644 (file)
@@ -8,10 +8,18 @@ class Platform extends LabeledObject {
         this._lastModifiedByMetric = object.lastModifiedByMetric;
         this._containingTests = null;
 
+        this.ensureNamedStaticMap('name')[object.name] = this;
+
         for (var metric of this._metrics)
             metric.addPlatform(this);
     }
 
+    static findByName(name)
+    {
+        var map = this.namedStaticMap('name');
+        return map ? map[name] : null;
+    }
+
     hasTest(test)
     {
         if (!this._containingTests) {
index 56fd29b..d3a41fd 100644 (file)
@@ -22,6 +22,25 @@ class Test extends LabeledObject {
 
     static topLevelTests() { return this.sortByName(this.listForStaticMap('topLevelTests')); }
 
+    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;
+    }
+
     parentTest() { return Test.findById(this._parentId); }
 
     path()
@@ -35,6 +54,8 @@ class Test extends LabeledObject {
         return path;
     }
 
+    fullName() { return this.path().map(function (test) { return test.label(); }).join(' \u220B '); }
+
     onlyContainsSingleMetric() { return !this.childTests().length && this._metrics.length == 1; }
 
     childTests()
index 6667530..2e63185 100644 (file)
@@ -2,8 +2,7 @@
 
 let assert = require('assert');
 
-require('../tools/js/v3-models.js');
-
+let MockData = require('./resources/mock-data.js');
 let TestServer = require('./resources/test-server.js');
 
 describe('/api/build-requests', function () {
@@ -11,17 +10,8 @@ describe('/api/build-requests', function () {
     TestServer.inject();
 
     beforeEach(function () {
-        AnalysisTask._fetchAllPromise = null;
-        AnalysisTask.clearStaticMap();
-        BuildRequest.clearStaticMap();
-        CommitLog.clearStaticMap();
-        Metric.clearStaticMap();
-        Platform.clearStaticMap();
-        Repository.clearStaticMap();
-        RootSet.clearStaticMap();
-        Test.clearStaticMap();
-        TestGroup.clearStaticMap();
-    })
+        MockData.resetV3Models();
+    });
 
     it('should return "TriggerableNotFound" when the database is empty', function (done) {
         TestServer.remoteAPI().getJSON('/api/build-requests/build-webkit').then(function (content) {
@@ -45,53 +35,10 @@ describe('/api/build-requests', function () {
         }).catch(done);
     });
 
-    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}),
-        ]);
-    }
-
     it('should return build requets associated with a given triggerable with appropriate roots and rootSets', function (done) {
         let db = TestServer.database();
         db.connect().then(function () {
-            return addMockData(db);
+            return MockData.addMockData(db);
         }).then(function () {
             return TestServer.remoteAPI().getJSONWithStatus('/api/build-requests/build-webkit');
         }).then(function (content) {
@@ -149,7 +96,7 @@ describe('/api/build-requests', function () {
     it('should support useLegacyIdResolution option', function (done) {
         let db = TestServer.database();
         db.connect().then(function () {
-            return addMockData(db);
+            return MockData.addMockData(db);
         }).then(function () {
             return TestServer.remoteAPI().getJSONWithStatus('/api/build-requests/build-webkit?useLegacyIdResolution=true');
         }).then(function (content) {
@@ -207,7 +154,7 @@ describe('/api/build-requests', function () {
     it('should be fetchable by BuildRequest.fetchForTriggerable', function (done) {
         let db = TestServer.database();
         db.connect().then(function () {
-            return addMockData(db);
+            return MockData.addMockData(db);
         }).then(function () {
             return Manifest.fetch();
         }).then(function () {
@@ -302,7 +249,7 @@ describe('/api/build-requests', function () {
     it('should not include a build request if all requests in the same group had been completed', function (done) {
         let db = TestServer.database();
         db.connect().then(function () {
-            return addMockData(db, ['completed', 'completed', 'completed', 'completed']);
+            return MockData.addMockData(db, ['completed', 'completed', 'completed', 'completed']);
         }).then(function () {
             return Manifest.fetch();
         }).then(function () {
@@ -316,7 +263,7 @@ describe('/api/build-requests', function () {
     it('should not include a build request if all requests in the same group had been failed or cancled', function (done) {
         let db = TestServer.database();
         db.connect().then(function () {
-            return addMockData(db, ['failed', 'failed', 'canceled', 'canceled']);
+            return MockData.addMockData(db, ['failed', 'failed', 'canceled', 'canceled']);
         }).then(function () {
             return Manifest.fetch();
         }).then(function () {
@@ -330,7 +277,7 @@ describe('/api/build-requests', function () {
     it('should include all build requests of a test group if one of the reqeusts in the group had not been finished', function (done) {
         let db = TestServer.database();
         db.connect().then(function () {
-            return addMockData(db, ['completed', 'completed', 'scheduled', 'pending']);
+            return MockData.addMockData(db, ['completed', 'completed', 'scheduled', 'pending']);
         }).then(function () {
             return Manifest.fetch();
         }).then(function () {
@@ -356,7 +303,7 @@ describe('/api/build-requests', function () {
     it('should include all build requests of a test group if one of the reqeusts in the group is still running', function (done) {
         let db = TestServer.database();
         db.connect().then(function () {
-            return addMockData(db, ['completed', 'completed', 'completed', 'running']);
+            return MockData.addMockData(db, ['completed', 'completed', 'completed', 'running']);
         }).then(function () {
             return Manifest.fetch();
         }).then(function () {
@@ -382,7 +329,7 @@ describe('/api/build-requests', function () {
     it('should order build requests based on test group and order', function (done) {
         let db = TestServer.database();
         db.connect().then(function () {
-            return Promise.all([addMockData(db), addAnotherMockTestGroup(db)])
+            return Promise.all([MockData.addMockData(db), MockData.addAnotherMockTestGroup(db)])
         }).then(function () {
             return Manifest.fetch();
         }).then(function () {
diff --git a/Websites/perf.webkit.org/server-tests/resources/mock-data.js b/Websites/perf.webkit.org/server-tests/resources/mock-data.js
new file mode 100644 (file)
index 0000000..15bcf30
--- /dev/null
@@ -0,0 +1,185 @@
+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;
index 542cc71..6ce6794 100644 (file)
@@ -26,6 +26,8 @@ let TestServer = (new class TestServer {
         this._databaseHost = Config.value('database.host');
         this._databasePort = Config.value('database.port');
         this._database = null;
+
+        this._remote = null
     }
 
     start()
@@ -48,9 +50,8 @@ let TestServer = (new class TestServer {
 
     remoteAPI()
     {
-        assert(this._server);
-        RemoteAPI.configure(this._server);
-        return RemoteAPI;
+        assert(this._remote);
+        return this._remote;
     }
 
     database()
@@ -112,7 +113,9 @@ let TestServer = (new class TestServer {
 
     _ensureTestDatabase()
     {
-        this._executePgsqlCommand('dropdb');
+        try {
+            this._executePgsqlCommand('dropdb');
+        } catch (error) { }
         this._executePgsqlCommand('createdb');
         this._executePgsqlCommand('psql', ['--command', `grant all privileges on database "${this._databaseName}" to "${this._databaseUser}";`]);
         this.initDatabase();
@@ -181,6 +184,9 @@ let TestServer = (new class TestServer {
         }
         this._pidWaitStart = Date.now();
         this._pidFile = pidFile;
+
+        this._remote = new RemoteAPI(this._server);
+
         return new Promise(this._waitForPid.bind(this, true));
     }
 
@@ -202,7 +208,7 @@ let TestServer = (new class TestServer {
     _waitForPid(shouldExist, resolve, reject)
     {
         if (fs.existsSync(this._pidFile) != shouldExist) {
-            if (Date.now() - this._pidWaitStart > 5000)
+            if (Date.now() - this._pidWaitStart > 8000)
                 reject();
             else
                 setTimeout(this._waitForPid.bind(this, shouldExist, resolve, reject), 100);
@@ -215,18 +221,23 @@ let TestServer = (new class TestServer {
     {
         let self = this;
         before(function () {
-            this.timeout(5000);
+            this.timeout(10000);
             return self.start();
         });
 
+        let originalRemote;
+
         beforeEach(function () {
             this.timeout(10000);
             self.initDatabase();
             self.cleanDataDirectory();
+            originalRemote = global.RemoteAPI;
+            global.RemoteAPI = self._remote;
         });
 
         after(function () {
-            this.timeout(5000);
+            this.timeout(10000);
+            global.RemoteAPI = originalRemote;
             return self.stop();
         });
     }
diff --git a/Websites/perf.webkit.org/server-tests/tools-buildbot-triggerable-tests.js b/Websites/perf.webkit.org/server-tests/tools-buildbot-triggerable-tests.js
new file mode 100644 (file)
index 0000000..dd7009b
--- /dev/null
@@ -0,0 +1,444 @@
+'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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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&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);
+        });
+    });
+});
index 36c71c4..518989d 100644 (file)
@@ -7,12 +7,13 @@ var data = require('../public/v2/data.js');
 var RunsData = data.RunsData;
 var Statistics = require('../public/shared/statistics.js');
 var StatisticsStrategies = require('../public/v2/statistics-strategies.js');
+var parseArguments = require('./js/parse-arguments.js').parseArguments;
 
 // FIXME: We shouldn't use a global variable like this.
 var settings;
 function main(argv)
 {
-    var options = parseArgument(argv, [
+    var options = parseArguments(argv, [
         {name: '--server-config-json', required: true},
         {name: '--change-detection-config-json', required: true},
         {name: '--seconds-to-sleep', type: parseFloat, default: 1200},
@@ -26,37 +27,6 @@ function main(argv)
     fetchManifestAndAnalyzeData(options['--server-config-json']);
 }
 
-function parseArgument(argv, acceptedOptions) {
-    var args = argv.slice(2);
-    var options = {}
-    for (var i = 0; i < 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'] && !(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;
-}
-
 function fetchManifestAndAnalyzeData(serverConfigJSON)
 {
     loadServerConfig(serverConfigJSON);
index 8b2cc1f..d5247ef 100644 (file)
@@ -26,6 +26,7 @@ class BuildbotBuildEntry {
         }
     }
 
+    syncer() { return this._syncer; }
     buildNumber() { return this._buildNumber; }
     slaveName() { return this._slaveName; }
     buildRequestId() { return this._buildRequestId; }
@@ -33,31 +34,106 @@ class BuildbotBuildEntry {
     isInProgress() { return this._isInProgress; }
     hasFinished() { return !this.isPending() && !this.isInProgress(); }
     url() { return this.isPending() ? this._syncer.url() : this._syncer.urlForBuildNumber(this._buildNumber); }
+
+    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;
+    }
 }
 
+
 class BuildbotSyncer {
 
-    constructor(url, object)
+    constructor(remote, object)
     {
-        this._url = url;
+        this._remote = remote;
+        this._testConfigurations = [];
         this._builderName = object.builder;
-        this._platformName = object.platform;
-        this._testPath = object.test;
-        this._propertiesTemplate = object.properties;
         this._slavePropertyName = object.slaveArgument;
+        this._slaveList = object.slaveList;
         this._buildRequestPropertyName = object.buildRequestArgument;
+        this._entryList = null;
     }
 
-    testPath() { return this._testPath }
     builderName() { return this._builderName; }
-    platformName() { return this._platformName; }
+
+    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() && 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;
+    }
 
     pullBuildbot(count)
     {
         let self = this;
-        return RemoteAPI.getJSON(this.urlForPendingBuildsJSON()).then(function (content) {
+        return this._remote.getJSON(this.pathForPendingBuildsJSON()).then(function (content) {
             let pendingEntries = content.map(function (entry) { return new BuildbotBuildEntry(self, entry); });
-
             return self._pullRecentBuilds(count).then(function (entries) {
                 let entryByRequest = {};
 
@@ -67,7 +143,13 @@ class BuildbotSyncer {
                 for (let entry of entries)
                     entryByRequest[entry.buildRequestId()] = entry;
 
-                return entryByRequest;
+                let entryList = [];
+                for (let id in entryByRequest)
+                    entryList.push(entryByRequest[id]);
+
+                self._entryList = entryList;
+
+                return entryList;
             });
         });
     }
@@ -82,8 +164,8 @@ class BuildbotSyncer {
             selectedBuilds[i] = -i - 1;
 
         let self = this;
-        return RemoteAPI.getJSON(this.urlForBuildJSON(selectedBuilds)).then(function (content) {
-            let entries = [];
+        return this._remote.getJSON(this.pathForBuildJSON(selectedBuilds)).then(function (content) {
+            var entries = [];
             for (let index of selectedBuilds) {
                 let entry = content[index];
                 if (entry && !entry['error'])
@@ -93,30 +175,38 @@ class BuildbotSyncer {
         });
     }
 
-    urlForPendingBuildsJSON() { return `${this._url}/json/builders/${this._builderName}/pendingBuilds`; }
-    urlForBuildJSON(selectedBuilds)
+    pathForPendingBuildsJSON() { return `/json/builders/${this._builderName}/pendingBuilds`; }
+    pathForBuildJSON(selectedBuilds)
     {
-        return `${this._url}/json/builders/${this._builderName}/builds/?`
+        return `/json/builders/${this._builderName}/builds/?`
             + selectedBuilds.map(function (number) { return 'select=' + number; }).join('&');
     }
+    pathForForceBuild() { return `/builders/${this._builderName}/force`; }
 
-    url() { return `${this._url}/builders/${this._builderName}/`; }
-    urlForBuildNumber(number) { return `${this._url}/builders/${this._builderName}/builds/${number}`; }
+    url() { return this._remote.url(`/builders/${this._builderName}/`); }
+    urlForBuildNumber(number) { return this._remote.url(`/builders/${this._builderName}/builds/${number}`); }
 
     _propertiesForBuildRequest(buildRequest)
     {
-        console.assert(buildRequest instanceof BuildRequest);
+        assert(buildRequest instanceof BuildRequest);
 
         let rootSet = buildRequest.rootSet();
-        console.assert(rootSet instanceof RootSet);
+        assert(rootSet instanceof RootSet);
 
         let repositoryByName = {};
         for (let repository of rootSet.repositories())
             repositoryByName[repository.name()] = repository;
 
+        let propertiesTemplate = null;
+        for (let config of this._testConfigurations) {
+            if (config.platform == buildRequest.platform() && config.test == buildRequest.test())
+                propertiesTemplate = config.propertiesTemplate;
+        }
+        assert(propertiesTemplate);
+
         let properties = {};
-        for (let key in this._propertiesTemplate) {
-            let value = this._propertiesTemplate[key];
+        for (let key in propertiesTemplate) {
+            let value = propertiesTemplate[key];
             if (typeof(value) != 'object')
                 properties[key] = value;
             else if ('root' in value) {
@@ -152,13 +242,13 @@ class BuildbotSyncer {
         return revisionSet;
     }
 
-    static _loadConfig(url, config)
+    static _loadConfig(remote, config)
     {
         let shared = config['shared'] || {};
         let types = config['types'] || {};
         let builders = config['builders'] || {};
 
-        let syncers = [];
+        let syncerByBuilder = new Map;
         for (let entry of config['configurations']) {
             let newConfig = {};
             this._validateAndMergeConfig(newConfig, shared);
@@ -180,10 +270,22 @@ class BuildbotSyncer {
             assert('builder' in newConfig, 'configuration must specify a builder');
             assert('properties' in newConfig, 'configuration must specify arguments to post on a builder');
             assert('buildRequestArgument' in newConfig, 'configuration must specify buildRequestArgument');
-            syncers.push(new BuildbotSyncer(url, newConfig));
+
+            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);
         }
 
-        return syncers;
+        return Array.from(syncerByBuilder.values());
     }
 
     static _validateAndMergeConfig(config, valuesToMerge)
@@ -202,6 +304,11 @@ class BuildbotSyncer {
                 assert(value.every(function (part) { return typeof part == 'string'; }), 'test should be an array of strings');
                 config[name] = value.slice();
                 break;
+            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;
             case 'type': // fallthrough
             case 'builder': // fallthrough
             case 'platform': // fallthrough
diff --git a/Websites/perf.webkit.org/tools/js/buildbot-triggerable.js b/Websites/perf.webkit.org/tools/js/buildbot-triggerable.js
new file mode 100644 (file)
index 0000000..6287ce6
--- /dev/null
@@ -0,0 +1,157 @@
+'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' && this._lookbackCount > 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 "${request.test().fullName()}" on "${request.platform().name()}".`);                
+                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 && pendingUpdates[request.id()]['status'] == 'scheduled'))
+                break;
+            if (request.isPending() && !(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;
index 6fb63ad..6074cc4 100644 (file)
@@ -85,6 +85,7 @@ let tableToPrefixMap = {
     'bug_trackers': 'tracker',
     'build_triggerables': 'triggerable',
     'build_requests': 'request',
+    'build_slaves': 'slave',
     'builders': 'builder',
     'commits': 'commit',
     'test_configurations': 'config',
diff --git a/Websites/perf.webkit.org/tools/js/parse-arguments.js b/Websites/perf.webkit.org/tools/js/parse-arguments.js
new file mode 100644 (file)
index 0000000..c63ae87
--- /dev/null
@@ -0,0 +1,34 @@
+
+function parseArgument(argv, acceptedOptions) {
+    var args = argv.slice(2);
+    var options = {}
+    for (var i = 0; i < 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'] && !(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;
index 3454bed..5245025 100644 (file)
@@ -3,14 +3,24 @@
 let assert = require('assert');
 let http = require('http');
 let https = require('https');
+let querystring = require('querystring');
 
-let RemoteAPI = new (class RemoteAPI {
-    constructor()
+class RemoteAPI {
+    constructor(server)
     {
-        this._server = {
-            scheme: 'http',
-            host: 'localhost',
-        }
+        this._server = null;
+        if (server)
+            this.configure(server);
+    }
+
+    url(path)
+    {
+        let scheme = this._server.scheme;
+        let port = this._server.port;
+        let portSuffix = (scheme == 'http' && port == 80) || (scheme == 'https' && port == 443) ? '' : `:${port}`;
+        if (path.charAt(0) != '/')
+            path = '/' + path;
+        return `${scheme}://${this._server.host}portSuffix${path}`;
     }
 
     configure(server)
@@ -22,27 +32,40 @@ let RemoteAPI = new (class RemoteAPI {
         this._server = server;
     }
 
-    getJSON(path, data)
+    getJSON(path)
     {
-        let contentType = null;
-        if (data) {
-            contentType = 'application/json';
-            data = JSON.stringify(data);
-        }
-        return this.sendHttpRequest(path, 'GET', contentType, data).then(function (result) {
+        return this.sendHttpRequest(path, 'GET', null, null).then(function (result) {
             return JSON.parse(result.responseText);
         });
     }
 
-    getJSONWithStatus(path, data)
+    getJSONWithStatus(path)
     {
-        return this.getJSON(path, data).then(function (result) {
+        return this.getJSON(path).then(function (result) {
             if (result['status'] != 'OK')
                 return Promise.reject(result);
             return result;
         });
     }
 
+    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;
+        });
+    }
+
     sendHttpRequest(path, method, contentType, content)
     {
         let server = this._server;
@@ -50,7 +73,7 @@ let RemoteAPI = new (class RemoteAPI {
             let options = {
                 hostname: server.host,
                 port: server.port || 80,
-                auth: server.auth,
+                auth: server.auth ? server.auth.username + ':' + server.auth.password : null,
                 method: method,
                 path: path,
             };
@@ -73,7 +96,7 @@ let RemoteAPI = new (class RemoteAPI {
             request.end();
         });
     }
-})
+};
 
 if (typeof module != 'undefined')
     module.exports.RemoteAPI = RemoteAPI;
index c820a5d..a438414 100644 (file)
@@ -29,7 +29,4 @@ importFromV3('models/test-group.js', 'TestGroup');
 
 importFromV3('instrumentation.js', 'Instrumentation');
 
-// RemoteAPI has a different implementation in node since XHR isn't available.
-global.RemoteAPI = require('./remote.js').RemoteAPI;
-
 global.Statistics = require('../../public/shared/statistics.js');
diff --git a/Websites/perf.webkit.org/tools/sync-buildbot.js b/Websites/perf.webkit.org/tools/sync-buildbot.js
new file mode 100644 (file)
index 0000000..2db9ec2
--- /dev/null
@@ -0,0 +1,46 @@
+#!/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);
index 82f0439..d409149 100644 (file)
@@ -30,19 +30,21 @@ function sampleiOSConfig()
                 'test': ['JetStream'],
                 'arguments': {'test_name': 'jetstream'}
             },
-            "dromaeo-dom": {
-                "test": ["Dromaeo", "DOM Core Tests"],
-                "arguments": {"tests": "dromaeo-dom"}
+            'dromaeo-dom': {
+                'test': ['Dromaeo', 'DOM Core Tests'],
+                'arguments': {'tests': 'dromaeo-dom'}
             },
         },
         'builders': {
             'iPhone-bench': {
                 'builder': 'ABTest-iPhone-RunBenchmark-Tests',
-                'arguments': { 'forcescheduler': 'ABTest-iPhone-RunBenchmark-Tests-ForceScheduler' }
+                'arguments': { 'forcescheduler': 'ABTest-iPhone-RunBenchmark-Tests-ForceScheduler' },
+                'slaveList': ['ABTest-iPhone-0'],
             },
             'iPad-bench': {
                 'builder': 'ABTest-iPad-RunBenchmark-Tests',
-                'arguments': { 'forcescheduler': 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler' }
+                'arguments': { 'forcescheduler': 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler' },
+                'slaveList': ['ABTest-iPad-0'],
             }
         },
         'configurations': [
@@ -71,6 +73,77 @@ let sampleRootSetData = {
     }
 };
 
+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],
+    };
+}
+
 function createSampleBuildRequest(platform, test)
 {
     assert(platform instanceof Platform);
@@ -82,11 +155,11 @@ function createSampleBuildRequest(platform, test)
         {'id': '88930', 'time': 0, 'repository': MockModels.ios, 'revision': '13A452'},
     ]});
 
-    let request = BuildRequest.ensureSingleton('16733', {'rootSet': rootSet, 'status': 'pending', 'platform': platform, 'test': test});
+    let request = BuildRequest.ensureSingleton('16733-' + platform.id(), {'rootSet': rootSet, 'status': 'pending', 'platform': platform, 'test': test});
     return request;
 }
 
-function samplePendingBuild(buildRequestId)
+function samplePendingBuild(buildRequestId, buildTime, slaveName)
 {
     return {
         'builderName': 'ABTest-iPad-RunBenchmark-Tests',
@@ -102,6 +175,7 @@ function samplePendingBuild(buildRequestId)
                 JSON.stringify(sampleRootSetData),
                 'Force Build Form'
             ],
+            ['slavename', slaveName, ''],
             ['scheduler', 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler', 'Scheduler']
         ],
         'source': {
@@ -113,11 +187,11 @@ function samplePendingBuild(buildRequestId)
             'repository': '',
             'revision': ''
         },
-        'submittedAt': 1458704983
+        'submittedAt': buildTime || 1458704983
     };
 }
 
-function sampleInProgressBuild()
+function sampleInProgressBuild(slaveName)
 {
     return {
         'blame': [],
@@ -149,7 +223,7 @@ function sampleInProgressBuild()
             ['reason', 'force build', 'Force Build Form'],
             ['roots_dict', JSON.stringify(sampleRootSetData), 'Force Build Form'],
             ['scheduler', 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler', 'Scheduler'],
-            ['slavename', 'ABTest-iPad-0', 'BuildSlave'],
+            ['slavename', slaveName || 'ABTest-iPad-0', 'BuildSlave'],
         ],
         'reason': 'A build was forced by \'<unknown>\': force build',
         'results': null,
@@ -207,7 +281,7 @@ function sampleInProgressBuild()
     };
 }
 
-function sampleFinishedBuild(buildRequestId)
+function sampleFinishedBuild(buildRequestId, slaveName)
 {
     return {
         'blame': [],
@@ -225,7 +299,7 @@ function sampleFinishedBuild(buildRequestId)
             ['reason', 'force build', 'Force Build Form'],
             ['roots_dict', JSON.stringify(sampleRootSetData), 'Force Build Form'],
             ['scheduler', 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler', 'Scheduler'],
-            ['slavename', 'ABTest-iPad-0', 'BuildSlave'],
+            ['slavename', slaveName || 'ABTest-iPad-0', 'BuildSlave'],
         ],
         'reason': 'A build was forced by \'<unknown>\': force build',
         'results': 2,
@@ -285,22 +359,12 @@ function sampleFinishedBuild(buildRequestId)
 
 describe('BuildbotSyncer', function () {
     MockModels.inject();
-    let requests = MockRemoteAPI.inject();
+    let requests = MockRemoteAPI.inject('http://build.webkit.org');
 
     describe('_loadConfig', function () {
 
-        function smallConfiguration()
-        {
-            return {
-                'builder': 'some builder',
-                'platform': 'some platform',
-                'test': ['some test'],
-                'arguments': {},
-                'buildRequestArgument': 'id'};
-        }
-
         it('should create BuildbotSyncer objects for a configuration that specify all required options', function () {
-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [smallConfiguration()]});
+            let syncers = BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [smallConfiguration()]});
             assert.equal(syncers.length, 1);
         });
 
@@ -308,27 +372,27 @@ describe('BuildbotSyncer', function () {
             assert.throws(function () {
                 let config = smallConfiguration();
                 delete config['builder'];
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
             }, 'builder should be a required option');
             assert.throws(function () {
                 let config = smallConfiguration();
                 delete config['platform'];
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
             }, 'platform should be a required option');
             assert.throws(function () {
                 let config = smallConfiguration();
                 delete config['test'];
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
             }, 'test should be a required option');
             assert.throws(function () {
                 let config = smallConfiguration();
                 delete config['arguments'];
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
             });
             assert.throws(function () {
                 let config = smallConfiguration();
                 delete config['buildRequestArgument'];
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
             });
         });
 
@@ -336,12 +400,12 @@ describe('BuildbotSyncer', function () {
             assert.throws(function () {
                 let config = smallConfiguration();
                 config.test = 'some test';
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
             });
             assert.throws(function () {
                 let config = smallConfiguration();
                 config.test = [1];
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
             });
         });
 
@@ -349,7 +413,7 @@ describe('BuildbotSyncer', function () {
             assert.throws(function () {
                 let config = smallConfiguration();
                 config.arguments = 'hello';
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
             });
         });
 
@@ -357,105 +421,101 @@ describe('BuildbotSyncer', function () {
             assert.throws(function () {
                 let config = smallConfiguration();
                 config.arguments = {'some': {'root': 'some root', 'rootsExcluding': ['other root']}};
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
             });
             assert.throws(function () {
                 let config = smallConfiguration();
                 config.arguments = {'some': {'otherKey': 'some root'}};
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
             });
             assert.throws(function () {
                 let config = smallConfiguration();
                 config.arguments = {'some': {'root': ['a', 'b']}};
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
             });
             assert.throws(function () {
                 let config = smallConfiguration();
                 config.arguments = {'some': {'root': 1}};
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
             });
             assert.throws(function () {
                 let config = smallConfiguration();
                 config.arguments = {'some': {'rootsExcluding': 'a'}};
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
             });
             assert.throws(function () {
                 let config = smallConfiguration();
                 config.arguments = {'some': {'rootsExcluding': [1]}};
-                BuildbotSyncer._loadConfig('http://build.webkit.org/', {'configurations': [config]});
+                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
             });
         });
 
         it('should create BuildbotSyncer objects for valid configurations', function () {
-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
-            assert.equal(syncers.length, 5);
+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
+            assert.equal(syncers.length, 2);
             assert.ok(syncers[0] instanceof BuildbotSyncer);
             assert.ok(syncers[1] instanceof BuildbotSyncer);
-            assert.ok(syncers[2] instanceof BuildbotSyncer);
-            assert.ok(syncers[3] instanceof BuildbotSyncer);
-            assert.ok(syncers[4] instanceof BuildbotSyncer);
         });
 
         it('should parse builder names correctly', function () {
-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
             assert.equal(syncers[0].builderName(), 'ABTest-iPhone-RunBenchmark-Tests');
-            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');
+            assert.equal(syncers[1].builderName(), 'ABTest-iPad-RunBenchmark-Tests');
         });
 
-        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');
-        });
-
-        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']);
+        it('should parse test configurations correctly', function () {
+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
+
+            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);
         });
     });
 
     describe('_propertiesForBuildRequest', function () {
         it('should include all properties specified in a given configuration', function () {
-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
             let properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
             assert.deepEqual(Object.keys(properties), ['desired_image', 'roots_dict', 'test_name', 'forcescheduler', 'build_request_id']);
         });
 
         it('should preserve non-parametric property values', function () {
-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
             let properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
             assert.equal(properties['test_name'], 'speedometer');
             assert.equal(properties['forcescheduler'], 'ABTest-iPhone-RunBenchmark-Tests-ForceScheduler');
 
-            properties = syncers[1]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
+            properties = syncers[1]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.ipad, MockModels.jetstream));
             assert.equal(properties['test_name'], 'jetstream');
-            assert.equal(properties['forcescheduler'], 'ABTest-iPhone-RunBenchmark-Tests-ForceScheduler');
+            assert.equal(properties['forcescheduler'], 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler');
         });
 
         it('should resolve "root"', function () {
-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
             let properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
             assert.equal(properties['desired_image'], '13A452');
         });
 
         it('should resolve "rootsExcluding"', function () {
-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
             let properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
             assert.equal(properties['roots_dict'], JSON.stringify(sampleRootSetData));
         });
 
         it('should set the property for the build request id', function () {
-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
             let request = createSampleBuildRequest(MockModels.iphone, MockModels.speedometer);
             let properties = syncers[0]._propertiesForBuildRequest(request);
             assert.equal(properties['build_request_id'], request.id());
@@ -464,51 +524,51 @@ describe('BuildbotSyncer', function () {
 
     describe('pullBuildbot', function () {
         it('should fetch pending builds from the right URL', function () {
-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
             assert.equal(syncer.builderName(), 'ABTest-iPad-RunBenchmark-Tests');
-            let expectedURL = 'http://build.webkit.org/json/builders/ABTest-iPad-RunBenchmark-Tests/pendingBuilds';
-            assert.equal(syncer.urlForPendingBuildsJSON(), expectedURL);
+            let expectedURL = '/json/builders/ABTest-iPad-RunBenchmark-Tests/pendingBuilds';
+            assert.equal(syncer.pathForPendingBuildsJSON(), expectedURL);
             syncer.pullBuildbot();
             assert.equal(requests.length, 1);
             assert.equal(requests[0].url, expectedURL);
         });
 
         it('should fetch recent builds once pending builds have been fetched', function (done) {
-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
             assert.equal(syncer.builderName(), 'ABTest-iPad-RunBenchmark-Tests');
 
             syncer.pullBuildbot(1);
             assert.equal(requests.length, 1);
-            assert.equal(requests[0].url, 'http://build.webkit.org/json/builders/ABTest-iPad-RunBenchmark-Tests/pendingBuilds');
+            assert.equal(requests[0].url, '/json/builders/ABTest-iPad-RunBenchmark-Tests/pendingBuilds');
             requests[0].resolve([]);
             Promise.resolve().then(function () {
                 assert.equal(requests.length, 2);
-                assert.equal(requests[1].url, 'http://build.webkit.org/json/builders/ABTest-iPad-RunBenchmark-Tests/builds/?select=-1');
+                assert.equal(requests[1].url, '/json/builders/ABTest-iPad-RunBenchmark-Tests/builds/?select=-1');
                 done();
             }).catch(done);
         });
 
         it('should fetch the right number of recent builds', function (done) {
-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
 
             syncer.pullBuildbot(3);
             assert.equal(requests.length, 1);
-            assert.equal(requests[0].url, 'http://build.webkit.org/json/builders/ABTest-iPad-RunBenchmark-Tests/pendingBuilds');
+            assert.equal(requests[0].url, '/json/builders/ABTest-iPad-RunBenchmark-Tests/pendingBuilds');
             requests[0].resolve([]);
             Promise.resolve().then(function () {
                 assert.equal(requests.length, 2);
-                assert.equal(requests[1].url, 'http://build.webkit.org/json/builders/ABTest-iPad-RunBenchmark-Tests/builds/?select=-1&select=-2&select=-3');
+                assert.equal(requests[1].url, '/json/builders/ABTest-iPad-RunBenchmark-Tests/builds/?select=-1&select=-2&select=-3');
                 done();
             }).catch(done);
         });
 
         it('should create BuildbotBuildEntry for pending builds', function (done) {
-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
             let promise = syncer.pullBuildbot();
             requests[0].resolve([samplePendingBuild()]);
             promise.then(function (entries) {
-                assert.deepEqual(Object.keys(entries), ['16733']);
-                let entry = entries['16733'];
+                assert.equal(entries.length, 1);
+                let entry = entries[0];
                 assert.ok(entry instanceof BuildbotBuildEntry);
                 assert.ok(!entry.buildNumber());
                 assert.ok(!entry.slaveName());
@@ -522,7 +582,7 @@ describe('BuildbotSyncer', function () {
         });
 
         it('should create BuildbotBuildEntry for in-progress builds', function (done) {
-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
 
             let promise = syncer.pullBuildbot(1);
             assert.equal(requests.length, 1);
@@ -533,8 +593,8 @@ describe('BuildbotSyncer', function () {
             }).catch(done);
 
             promise.then(function (entries) {
-                assert.deepEqual(Object.keys(entries), ['16733']);
-                let entry = entries['16733'];
+                assert.equal(entries.length, 1);
+                let entry = entries[0];
                 assert.ok(entry instanceof BuildbotBuildEntry);
                 assert.equal(entry.buildNumber(), 614);
                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
@@ -548,7 +608,7 @@ describe('BuildbotSyncer', function () {
         });
 
         it('should create BuildbotBuildEntry for finished builds', function (done) {
-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
 
             let promise = syncer.pullBuildbot(1);
             assert.equal(requests.length, 1);
@@ -559,8 +619,8 @@ describe('BuildbotSyncer', function () {
             }).catch(done);
 
             promise.then(function (entries) {
-                assert.deepEqual(Object.keys(entries), ['18935']);
-                let entry = entries['18935'];
+                assert.deepEqual(entries.length, 1);
+                let entry = entries[0];
                 assert.ok(entry instanceof BuildbotBuildEntry);
                 assert.equal(entry.buildNumber(), 1755);
                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
@@ -574,12 +634,12 @@ describe('BuildbotSyncer', function () {
         });
 
         it('should create BuildbotBuildEntry for mixed pending, in-progress, finished, and missing builds', function (done) {
-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
 
             let promise = syncer.pullBuildbot(5);
             assert.equal(requests.length, 1);
 
-            requests[0].resolve([samplePendingBuild(123, 456)]);
+            requests[0].resolve([samplePendingBuild(123)]);
 
             Promise.resolve().then(function () {
                 assert.equal(requests.length, 2);
@@ -587,9 +647,59 @@ describe('BuildbotSyncer', function () {
             }).catch(done);
 
             promise.then(function (entries) {
-                assert.deepEqual(Object.keys(entries), ['123', '16733', '18935']);
+                assert.deepEqual(entries.length, 3);
+
+                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(), 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[2];
+                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);
+        });
+
+        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['123'];
+                let entry = entries[0];
                 assert.ok(entry instanceof BuildbotBuildEntry);
                 assert.equal(entry.buildNumber(), null);
                 assert.equal(entry.slaveName(), null);
@@ -599,7 +709,17 @@ describe('BuildbotSyncer', function () {
                 assert.ok(!entry.hasFinished());
                 assert.equal(entry.url(), 'http://build.webkit.org/builders/ABTest-iPad-RunBenchmark-Tests/');
 
-                entry = entries['16733'];
+                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');
@@ -609,7 +729,7 @@ describe('BuildbotSyncer', function () {
                 assert.ok(!entry.hasFinished());
                 assert.equal(entry.url(), 'http://build.webkit.org/builders/ABTest-iPad-RunBenchmark-Tests/builds/614');
 
-                entry = entries['18935'];
+                entry = entries[3];
                 assert.ok(entry instanceof BuildbotBuildEntry);
                 assert.equal(entry.buildNumber(), 1755);
                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
@@ -624,7 +744,7 @@ describe('BuildbotSyncer', function () {
         });
 
         it('should override BuildbotBuildEntry for pending builds by in-progress builds', function (done) {
-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
 
             let promise = syncer.pullBuildbot(5);
             assert.equal(requests.length, 1);
@@ -637,9 +757,9 @@ describe('BuildbotSyncer', function () {
             }).catch(done);
 
             promise.then(function (entries) {
-                assert.deepEqual(Object.keys(entries), ['16733']);
+                assert.equal(entries.length, 1);
 
-                let entry = entries['16733'];
+                let entry = entries[0];
                 assert.ok(entry instanceof BuildbotBuildEntry);
                 assert.equal(entry.buildNumber(), 614);
                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
@@ -654,7 +774,7 @@ describe('BuildbotSyncer', function () {
         });
 
         it('should override BuildbotBuildEntry for pending builds by finished builds', function (done) {
-            let syncer = BuildbotSyncer._loadConfig('http://build.webkit.org', sampleiOSConfig())[3];
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
 
             let promise = syncer.pullBuildbot(5);
             assert.equal(requests.length, 1);
@@ -667,9 +787,9 @@ describe('BuildbotSyncer', function () {
             }).catch(done);
 
             promise.then(function (entries) {
-                assert.deepEqual(Object.keys(entries), ['16733']);
+                assert.equal(entries.length, 1);
 
-                let entry = entries['16733'];
+                let entry = entries[0];
                 assert.ok(entry instanceof BuildbotBuildEntry);
                 assert.equal(entry.buildNumber(), 1755);
                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
@@ -682,6 +802,166 @@ describe('BuildbotSyncer', function () {
                 done();
             }).catch(done);
         });
+    });
+
+    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': '{"WebKit":{"id":"111127","time":1456955807334,"repository":"WebKit","revision":"197463"},'
+                        + '"Shared":{"id":"111237","time":1456931874000,"repository":"Shared","revision":"80229"}}',
+                    'slavename': 'some-slave',
+                    'test_name': 'speedometer'
+                });
+                done();
+            }).catch(done);
+        });
+    });
+
+    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);
+        });
 
     });
 });
index 6aaa700..5fb93c9 100644 (file)
@@ -4,14 +4,28 @@ if (!assert.notReached)
     assert.notReached = function () { assert(false, 'This code path should not be reached'); }
 
 var MockRemoteAPI = {
+    url: function (path)
+    {
+        return `${this.urlPrefix}${path}`;
+    },
     getJSON: function (url)
     {
         return this.getJSONWithStatus(url);
     },
     getJSONWithStatus: function (url)
     {
+        return this._addRequest(url, 'GET', null);
+    },
+    postFormUrlencodedData: function (url, data)
+    {
+        return this._addRequest(url, 'POST', data);
+    },
+    _addRequest: function (url, method, data)
+    {
         var request = {
             url: url,
+            method: method,
+            data: data,
             promise: null,
             resolve: null,
             reject: null,
@@ -22,15 +36,30 @@ var MockRemoteAPI = {
             request.reject = reject;
         });
 
+        if (this._waitingPromise) {
+            this._waitingPromiseResolver();
+            this._waitingPromise = null;
+            this._waitingPromiseResolver = null;
+        }
+
         MockRemoteAPI.requests.push(request);
         return request.promise;
     },
-    inject: function ()
+    waitForRequest()
+    {
+        if (!this._waitingPromise) {
+            this._waitingPromise = new Promise(function (resolve, reject) {
+                MockRemoteAPI._waitingPromiseResolver = resolve;
+            });
+        }
+        return this._waitingPromise;
+    },
+    inject: function (urlPrefix)
     {
         var originalRemoteAPI = global.RemoteAPI;
 
         beforeEach(function () {
-            MockRemoteAPI.requests.length = 0;
+            MockRemoteAPI.reset(urlPrefix);
             originalRemoteAPI = global.RemoteAPI;
             global.RemoteAPI = MockRemoteAPI;
         });
@@ -40,9 +69,18 @@ var MockRemoteAPI = {
         });
 
         return MockRemoteAPI.requests;
-    }
+    },
+    reset: function (urlPrefix)
+    {
+        if (urlPrefix)
+            MockRemoteAPI.urlPrefix = urlPrefix;
+        MockRemoteAPI.requests.length = 0;
+    },
+    requests: [],
+    _waitingPromise: null,
+    _waitingPromiseResolver: null,
+    urlPrefix: 'http://mockhost',
 };
-MockRemoteAPI.requests = [];
 
 if (typeof module != 'undefined')
     module.exports.MockRemoteAPI = MockRemoteAPI;
index afc7334..cae12f1 100644 (file)
@@ -7,6 +7,8 @@ var MockModels = {
             AnalysisTask.clearStaticMap();
             CommitLog.clearStaticMap();
             Metric.clearStaticMap();
+            Platform.clearStaticMap();
+            Repository.clearStaticMap();
             RootSet.clearStaticMap();
             Test.clearStaticMap();
             TestGroup.clearStaticMap();