Syncing script's configuration duplicates a lot of boilerplate
[WebKit-https.git] / Websites / perf.webkit.org / unit-tests / buildbot-syncer-tests.js
index 72fa09f..b45a5b7 100644 (file)
@@ -3,8 +3,8 @@
 let assert = require('assert');
 
 require('../tools/js/v3-models.js');
-require('./resources/mock-remote-api.js');
-require('./resources/mock-v3-models.js');
+let MockRemoteAPI = require('./resources/mock-remote-api.js').MockRemoteAPI;
+let MockModels = require('./resources/mock-v3-models.js').MockModels;
 
 let BuildbotBuildEntry = require('../tools/js/buildbot-syncer.js').BuildbotBuildEntry;
 let BuildbotSyncer = require('../tools/js/buildbot-syncer.js').BuildbotSyncer;
@@ -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', 'ABTest-iPad-1'],
             }
         },
         'configurations': [
@@ -56,6 +58,58 @@ function sampleiOSConfig()
     };
 }
 
+function sampleiOSConfigWithExpansions()
+{
+    return {
+        "triggerableName": "build-webkit-ios",
+        "shared":
+            {
+                "arguments": {
+                    "webkit-revision": {"root": "WebKit"},
+                    "os-version": {"root": "iOS"}
+                },
+                "buildRequestArgument": "build-request-id"
+            },
+        "types": {
+            "iphone-plt": {
+                "test": ["PLT-iPhone"],
+                "arguments": {"test_name": "plt"}
+            },
+            "ipad-plt": {
+                "test": ["PLT-iPad"],
+                "arguments": {"test_name": "plt"}
+            },
+            "speedometer": {
+                "test": ["Speedometer"],
+                "arguments": {"tests": "speedometer"}
+            },
+        },
+        "builders": {
+            "iphone": {
+                "builder": "iPhone AB Tests",
+                "arguments": {"forcescheduler": "force-iphone-ab-tests"}
+            },
+            "ipad": {
+                "builder": "iPad AB Tests",
+                "arguments": {"forcescheduler": "force-ipad-ab-tests"}
+            },
+        },
+        "configurations": [
+            {
+                "builder": "iphone",
+                "platforms": ["iPhone", "iOS 10 iPhone"],
+                "types": ["iphone-plt", "speedometer"],
+            },
+            {
+                "builder": "ipad",
+                "platforms": ["iPad"],
+                "types": ["ipad-plt", "speedometer"],
+            },
+        ]
+    }
+    
+}
+
 let sampleRootSetData = {
     'WebKit': {
         'id': '111127',
@@ -71,19 +125,93 @@ let sampleRootSetData = {
     }
 };
 
-function createSampleBuildRequest()
+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);
+    assert(test instanceof Test);
+
     let rootSet = RootSet.ensureSingleton('4197', {roots: [
-        {'id': '111127', 'time': 1456955807334, 'repository': webkit, 'revision': '197463'},
-        {'id': '111237', 'time': 1456931874000, 'repository': sharedRepository, 'revision': '80229'},
-        {'id': '88930', 'time': 0, 'repository': ios, 'revision': '13A452'},
+        {'id': '111127', 'time': 1456955807334, 'repository': MockModels.webkit, 'revision': '197463'},
+        {'id': '111237', 'time': 1456931874000, 'repository': MockModels.sharedRepository, 'revision': '80229'},
+        {'id': '88930', 'time': 0, 'repository': MockModels.ios, 'revision': '13A452'},
     ]});
 
-    let request = BuildRequest.ensureSingleton('16733', {'rootSet': rootSet, 'status': 'pending'});
+    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',
@@ -99,6 +227,7 @@ function samplePendingBuild(buildRequestId)
                 JSON.stringify(sampleRootSetData),
                 'Force Build Form'
             ],
+            ['slavename', slaveName, ''],
             ['scheduler', 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler', 'Scheduler']
         ],
         'source': {
@@ -110,11 +239,11 @@ function samplePendingBuild(buildRequestId)
             'repository': '',
             'revision': ''
         },
-        'submittedAt': 1458704983
+        'submittedAt': buildTime || 1458704983
     };
 }
 
-function sampleInProgressBuild()
+function sampleInProgressBuild(slaveName)
 {
     return {
         'blame': [],
@@ -146,7 +275,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,
@@ -204,7 +333,7 @@ function sampleInProgressBuild()
     };
 }
 
-function sampleFinishedBuild(buildRequestId)
+function sampleFinishedBuild(buildRequestId, slaveName)
 {
     return {
         'blame': [],
@@ -222,7 +351,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,
@@ -281,20 +410,13 @@ function sampleFinishedBuild(buildRequestId)
 }
 
 describe('BuildbotSyncer', function () {
-    describe('_loadConfig', function () {
+    MockModels.inject();
+    let requests = MockRemoteAPI.inject('http://build.webkit.org');
 
-        function smallConfiguration()
-        {
-            return {
-                'builder': 'some builder',
-                'platform': 'some platform',
-                'test': ['some test'],
-                'arguments': {},
-                'buildRequestArgument': 'id'};
-        }
+    describe('_loadConfig', function () {
 
         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);
         });
 
@@ -302,27 +424,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]});
             });
         });
 
@@ -330,12 +452,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]});
             });
         });
 
@@ -343,7 +465,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]});
             });
         });
 
@@ -351,157 +473,178 @@ 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 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);
         });
 
-        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 with types and platforms expansions correctly', function () {
+            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfigWithExpansions());
+
+            assert.equal(syncers.length, 2);
+
+            let configurations = syncers[0].testConfigurations();
+            assert.equal(configurations.length, 4);
+            assert.equal(configurations[0].platform, MockModels.iphone);
+            assert.equal(configurations[0].test, MockModels.iPhonePLT);
+            assert.equal(configurations[1].platform, MockModels.iOS10iPhone);
+            assert.equal(configurations[1].test, MockModels.iPhonePLT);
+            assert.equal(configurations[2].platform, MockModels.iphone);
+            assert.equal(configurations[2].test, MockModels.speedometer);
+            assert.equal(configurations[3].platform, MockModels.iOS10iPhone);
+            assert.equal(configurations[3].test, MockModels.speedometer);
+
+            configurations = syncers[1].testConfigurations();
+            assert.equal(configurations.length, 2);
+            assert.equal(configurations[0].platform, MockModels.ipad);
+            assert.equal(configurations[0].test, MockModels.iPadPLT);
+            assert.equal(configurations[1].platform, MockModels.ipad);
+            assert.equal(configurations[1].test, MockModels.speedometer);
         });
     });
 
     describe('_propertiesForBuildRequest', function () {
         it('should include all properties specified in a given configuration', function () {
-            let syncers = BuildbotSyncer._loadConfig('http://build.webkit.org/', sampleiOSConfig());
-            let properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest());
+            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 properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest());
+            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());
+            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 properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest());
+            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 properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest());
+            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 properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest());
-            assert.equal(properties['build_request_id'], createSampleBuildRequest().id());
+            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());
         });
     });
 
     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());
@@ -515,7 +658,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);
@@ -526,8 +669,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');
@@ -541,7 +684,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);
@@ -552,8 +695,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');
@@ -567,12 +710,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);
@@ -580,9 +723,9 @@ 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['123'];
+                let entry = entries[0];
                 assert.ok(entry instanceof BuildbotBuildEntry);
                 assert.equal(entry.buildNumber(), null);
                 assert.equal(entry.slaveName(), null);
@@ -592,7 +735,7 @@ 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(), 614);
                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
@@ -602,7 +745,67 @@ 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[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[0];
+                assert.ok(entry instanceof BuildbotBuildEntry);
+                assert.equal(entry.buildNumber(), null);
+                assert.equal(entry.slaveName(), null);
+                assert.equal(entry.buildRequestId(), 123);
+                assert.ok(entry.isPending());
+                assert.ok(!entry.isInProgress());
+                assert.ok(!entry.hasFinished());
+                assert.equal(entry.url(), 'http://build.webkit.org/builders/ABTest-iPad-RunBenchmark-Tests/');
+
+                entry = entries[1];
+                assert.ok(entry instanceof BuildbotBuildEntry);
+                assert.equal(entry.buildNumber(), null);
+                assert.equal(entry.slaveName(), null);
+                assert.equal(entry.buildRequestId(), 456);
+                assert.ok(entry.isPending());
+                assert.ok(!entry.isInProgress());
+                assert.ok(!entry.hasFinished());
+                assert.equal(entry.url(), 'http://build.webkit.org/builders/ABTest-iPad-RunBenchmark-Tests/');
+
+                entry = entries[2];
+                assert.ok(entry instanceof BuildbotBuildEntry);
+                assert.equal(entry.buildNumber(), 614);
+                assert.equal(entry.slaveName(), 'ABTest-iPad-0');
+                assert.equal(entry.buildRequestId(), 16733);
+                assert.ok(!entry.isPending());
+                assert.ok(entry.isInProgress());
+                assert.ok(!entry.hasFinished());
+                assert.equal(entry.url(), 'http://build.webkit.org/builders/ABTest-iPad-RunBenchmark-Tests/builds/614');
+
+                entry = entries[3];
                 assert.ok(entry instanceof BuildbotBuildEntry);
                 assert.equal(entry.buildNumber(), 1755);
                 assert.equal(entry.slaveName(), 'ABTest-iPad-0');
@@ -617,7 +820,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);
@@ -630,9 +833,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');
@@ -647,7 +850,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);
@@ -660,9 +863,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');
@@ -675,6 +878,208 @@ 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('scheduleRequestInGroupIfAvailable', 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.scheduleRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.somePlatform, MockModels.someTest));
+            }).then(function () {
+                assert.equal(requests.length, 1);
+                assert.equal(requests[0].url, '/builders/some%20builder/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.scheduleRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.somePlatform, MockModels.someTest));
+            }).then(function () {
+                assert.equal(requests.length, 1);
+                assert.equal(requests[0].url, '/builders/some%20builder/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.scheduleRequestInGroupIfAvailable(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.scheduleRequestInGroupIfAvailable(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.scheduleRequestInGroupIfAvailable(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.scheduleRequestInGroupIfAvailable(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.scheduleRequestInGroupIfAvailable(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.scheduleRequestInGroupIfAvailable(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.scheduleRequestInGroupIfAvailable(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.scheduleRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.ipad, MockModels.speedometer));
+            }).then(function () {
+                assert.equal(requests.length, 0);
+                done();
+            }).catch(done);
+        });
+
+        it('should not schedule a build if a new request had been submitted to the same slave', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
+
+            pullBuildbotWithAssertion(syncer, [], {}).then(function () {
+                syncer.scheduleRequest(createSampleBuildRequest(MockModels.ipad, MockModels.speedometer), 'ABTest-iPad-0');
+                syncer.scheduleRequest(createSampleBuildRequest(MockModels.ipad, MockModels.speedometer), 'ABTest-iPad-1');
+            }).then(function () {
+                assert.equal(requests.length, 2);
+                syncer.scheduleRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.ipad, MockModels.speedometer));
+            }).then(function () {
+                assert.equal(requests.length, 2);
+                done();
+            }).catch(done);
+        });
+
+        it('should schedule a build if a new request had been submitted to another slave', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, sampleiOSConfig())[1];
+
+            pullBuildbotWithAssertion(syncer, [], {}).then(function () {
+                syncer.scheduleRequest(createSampleBuildRequest(MockModels.ipad, MockModels.speedometer), 'ABTest-iPad-0');
+            }).then(function () {
+                assert.equal(requests.length, 1);
+                syncer.scheduleRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.ipad, MockModels.speedometer), 'ABTest-iPad-1');
+            }).then(function () {
+                assert.equal(requests.length, 2);
+                done();
+            }).catch(done);
+        });
+
+        it('should not schedule a build if a new request had been submitted to the same builder without slaveList', function (done) {
+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [smallConfiguration()]})[0];
+
+            pullBuildbotWithAssertion(syncer, [], {}).then(function () {
+                syncer.scheduleRequest(createSampleBuildRequest(MockModels.somePlatform, MockModels.someTest), null);
+            }).then(function () {
+                assert.equal(requests.length, 1);
+                syncer.scheduleRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.somePlatform, MockModels.someTest));
+            }).then(function () {
+                assert.equal(requests.length, 1);
+                done();
+            }).catch(done);
+        });
     });
 });