From 4e0b212bebf7196072968db3104769c4ab2d63fd Mon Sep 17 00:00:00 2001 From: Jason Holt Smith Date: Wed, 14 Jun 2023 02:28:31 +0100 Subject: [PATCH] Support any remote selenium grid Merges #32 from @bicarbon8 --- README.md | 66 ++++- index.js | 7 +- lib/webdriver.js | 56 ++-- .../jasmine-browser.json | 25 ++ .../remoteGridParamsSpec.js | 6 + .../sauceIntegration/jasmine-browser.json | 2 +- spec/indexSpec.js | 2 +- spec/remoteGridIntegrationSpec.js | 148 +++++++++++ spec/webdriverSpec.js | 242 ++++++++++++------ 9 files changed, 460 insertions(+), 94 deletions(-) create mode 100644 spec/fixtures/remoteGridIntegration/jasmine-browser.json create mode 100644 spec/fixtures/remoteGridIntegration/remoteGridParamsSpec.js create mode 100644 spec/remoteGridIntegrationSpec.js diff --git a/README.md b/README.md index 01526de..6dae01d 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,71 @@ To run the specs: 2. Run `npx jasmine-browser-runner`. 3. Visit . -## Saucelabs support +## Remote Grid support (Saucelabs, BrowserStack, etc.) + +jasmine-browser-runner can run your Jasmine specs on a remote grid +provider like [Saucelabs](https://saucelabs.com/), +[BrowserStack](https://browserstack.com) or your own Selenium Grid. +To use a remote grid hub, set the `browser` object +in your config file as follows: + +```json +// jasmine-browser.json +{ + // ... + // BrowserStack + "browser": { + "name": "safari", + "useRemoteSeleniumGrid": true, + "remoteSeleniumGrid": { + "url": "https://hub-cloud.browserstack.com/wd/hub", + "bstack:options": { + "browserVersion": "13", + "os": "OS X", + "osVersion": "Snow Leopard", + "local": "true", + "localIdentifier": "tunnel ID", + "debug": "true", + "userName": "your BrowserStack username", + "accessKey": "your BrowserStack access key" + } + } + } +} +``` +```json +// jasmine-browser.json +{ + // ... + // Saucelabs + "browser": { + "name": "safari", + "useRemoteSeleniumGrid": true, + "remoteSeleniumGrid": { + "url": "https://ondemand.saucelabs.com/wd/hub", + "platformName": "macOS 12", + "sauce:options": { + "tunnel-identifier": "tunnel ID", + "userName": "your Saucelabs username", + "accessKey": "your Saucelabs access key" + } + } + } +} +``` + +When using a remote grid provider, all properties of the `browser` object are +optional except for `name` which will be passed as the `browserName` capability, +and `useRemoteSeleniumGrid` which must be set to a value of `true`. if a +`remoteSeleniumGrid` object is included, any values it contains, with the +exception of the `url` will be used as `capabilties` sent to the grid hub url. +if no value is specified for the `url` then a default of +`http://localhost:4445/wd/hub` is used. + +## Saucelabs support (legacy) +> NOTE: the below configuration format only supports using Saucelabs in the US. if connecting from the EU, please use the above specifying a `url` value specific to your region (e.g. `https://ondemand.eu-central-1.saucelabs.com:443/wd/hub`) to avoid a connection error of `WebDriverError: This user is unauthorized to the region. Please try another region, or contact customer support.` + +> WARNING: the below configuration format may be removed in favour of using the above in the future so it is advised that you migrate to the above jasmine-browser-runner can run your Jasmine specs on [Saucelabs](https://saucelabs.com/). To use Saucelabs, set `browser.name`, `browser.useSauce`, and `browser.sauce` diff --git a/index.js b/index.js index 7a1f1d6..c479965 100644 --- a/index.js +++ b/index.js @@ -81,11 +81,14 @@ module.exports = { const reporters = await createReporters(options, deps); const useSauce = options.browser && options.browser.useSauce; + const useRemote = options.browser && options.browser.useRemoteSeleniumGrid; let portRequest; - if (useSauce) { + if (useSauce || useRemote) { if (options.port) { - throw new Error("Can't specify a port when browser.useSauce is true"); + throw new Error( + "Can't specify a port when browser.useSauce or browser.useRemoteSeleniumGrid is true" + ); } portRequest = 5555; diff --git a/lib/webdriver.js b/lib/webdriver.js index 341bb20..3cd19d0 100644 --- a/lib/webdriver.js +++ b/lib/webdriver.js @@ -4,6 +4,8 @@ function buildWebdriver(browserInfo, webdriverBuilder) { webdriverBuilder = webdriverBuilder || new webdriver.Builder(); const useSauce = typeof browserInfo === 'object' && browserInfo.useSauce; + const useRemote = + typeof browserInfo === 'object' && browserInfo.useRemoteSeleniumGrid; let browserName; if (typeof browserInfo === 'string') { @@ -14,7 +16,7 @@ function buildWebdriver(browserInfo, webdriverBuilder) { browserName = browserName || 'firefox'; - if (!useSauce) { + if (!(useRemote || useSauce)) { if (browserName === 'headlessChrome') { const caps = webdriver.Capabilities.chrome(); caps.set('goog:chromeOptions', { @@ -44,26 +46,46 @@ function buildWebdriver(browserInfo, webdriverBuilder) { } } - const sauce = browserInfo.sauce; - const capabilities = { - [Capability.BROWSER_NAME]: browserName, - build: sauce.build, - tags: sauce.tags, - }; + let url; + let capabilities; + if (useRemote) { + const remote = browserInfo.remoteSeleniumGrid; + if (remote) { + url = remote.url; + capabilities = { + ...remote, + [Capability.BROWSER_NAME]: browserName, + }; + delete capabilities.url; + } + } else if (useSauce) { + // handle legacy `sauce` object + const sauce = browserInfo.sauce; + if (sauce) { + url = `http://${sauce.username}:${sauce.accessKey}@ondemand.saucelabs.com/wd/hub`; + capabilities = { + [Capability.BROWSER_NAME]: browserName, + build: sauce.build, + tags: sauce.tags, + }; - capabilities[Capability.PLATFORM_NAME] = sauce.os; - capabilities[Capability.BROWSER_VERSION] = sauce.browserVersion; - capabilities['sauce:options'] = { - 'tunnel-identifier': sauce.tunnelIdentifier, - }; + capabilities[Capability.PLATFORM_NAME] = sauce.os; + capabilities[Capability.BROWSER_VERSION] = sauce.browserVersion; + capabilities['sauce:options'] = { + 'tunnel-identifier': sauce.tunnelIdentifier, + }; + } + } + + if (!capabilities) { + capabilities = { + [Capability.BROWSER_NAME]: browserName, + }; + } return webdriverBuilder .withCapabilities(capabilities) - .usingServer( - browserInfo.useSauce - ? `http://${sauce.username}:${sauce.accessKey}@ondemand.saucelabs.com/wd/hub` - : 'http://@localhost:4445/wd/hub' - ) + .usingServer(url || 'http://localhost:4445/wd/hub') .build(); } diff --git a/spec/fixtures/remoteGridIntegration/jasmine-browser.json b/spec/fixtures/remoteGridIntegration/jasmine-browser.json new file mode 100644 index 0000000..401d9dc --- /dev/null +++ b/spec/fixtures/remoteGridIntegration/jasmine-browser.json @@ -0,0 +1,25 @@ +{ + "srcDir": ".", + "srcFiles": [], + "specDir": ".", + "specFiles": [ + "remoteGridParamsSpec.js" + ], + "helpers": [], + "random": true, + "browser": { + "name": "<< JASMINE_BROWSER >>", + "useRemoteSeleniumGrid": true, + "remoteSeleniumGrid": { + "url": "https://ondemand.saucelabs.com/wd/hub", + "browserVersion": "<< SAUCE_BROWSER_VERSION >>", + "platformName": "<< SAUCE_OS >>", + "sauce:options": { + "tags": ["jasmine-browser"], + "tunnel-identifier": "<< SAUCE_TUNNEL_IDENTIFIER >>", + "username": "<< SAUCE_USERNAME >>", + "accessKey": "<< SAUCE_ACCESS_KEY >>" + } + } + } +} diff --git a/spec/fixtures/remoteGridIntegration/remoteGridParamsSpec.js b/spec/fixtures/remoteGridIntegration/remoteGridParamsSpec.js new file mode 100644 index 0000000..8cafbcc --- /dev/null +++ b/spec/fixtures/remoteGridIntegration/remoteGridParamsSpec.js @@ -0,0 +1,6 @@ + +describe('remote grid parameter handling', function() { + it('was run in the expected browser', function() { + expect(navigator.userAgent.toString()).toMatch(<< EXPECTED >>); + }); +}); \ No newline at end of file diff --git a/spec/fixtures/sauceIntegration/jasmine-browser.json b/spec/fixtures/sauceIntegration/jasmine-browser.json index 3a1ac80..efe4f64 100644 --- a/spec/fixtures/sauceIntegration/jasmine-browser.json +++ b/spec/fixtures/sauceIntegration/jasmine-browser.json @@ -19,4 +19,4 @@ "accessKey": "<< SAUCE_ACCESS_KEY >>" } } -} +} \ No newline at end of file diff --git a/spec/indexSpec.js b/spec/indexSpec.js index d3bec94..722ee6f 100644 --- a/spec/indexSpec.js +++ b/spec/indexSpec.js @@ -78,7 +78,7 @@ describe('index', function() { ); await expectAsync(promise).toBeRejectedWithError( - "Can't specify a port when browser.useSauce is true" + "Can't specify a port when browser.useSauce or browser.useRemoteSeleniumGrid is true" ); expect(this.server.start).not.toHaveBeenCalled(); }); diff --git a/spec/remoteGridIntegrationSpec.js b/spec/remoteGridIntegrationSpec.js new file mode 100644 index 0000000..885a9dd --- /dev/null +++ b/spec/remoteGridIntegrationSpec.js @@ -0,0 +1,148 @@ +const fs = require('fs'); +const os = require('os'); +const { exec } = require('child_process'); + +describe('remote grid parameter handling', function() { + // To reduce the amount of output that devs have to scroll past, pend a single + // spec and don't create the rest if Sauce isn't available. + if ( + !( + process.env.USE_SAUCE && + process.env.SAUCE_USERNAME && + process.env.SAUCE_ACCESS_KEY + ) + ) { + it('passes params to Saucelabs correctly', function() { + pending( + "Can't run remote grid integration tests unless USE_SAUCE, SAUCE_USERNAME, and SAUCE_ACCESS_KEY are set" + ); + }); + return; + } + + // These specs use browser+version+OS combos that are supported by Saucelabs. + // When more than one OS is supported, we use the older one to make sure that + // the OS parameter is actually being passed correctly. (Saucelabs generally + // defaults to the newest OS that has the requested browser version if the OS + // is not specified.) + + createSpec('firefox', '', '', /Gecko\/[0-9]+ Firefox\/[0-9.]+$/); + createSpec( + 'firefox', + '102', + 'Windows 10', + /Windows NT 10.0;.* Firefox\/102\.0$/ + ); + createSpec('chrome', '', '', /\(KHTML, like Gecko\) Chrome\/[0-9]+[0-9.]+/); + createSpec( + 'safari', + '15', + 'macOS 12', + // Safari on 12.x reports the OS as 10_15_7 + /Mac OS X 10_15.*Version\/15[0-9.]+ Safari/ + ); + createSpec( + 'safari', + '16', + 'macOS 12', + // Safari on 12.x reports the OS as 10_15_7 + /Mac OS X 10_15.*Version\/16[0-9.]+ Safari/ + ); + createSpec( + 'MicrosoftEdge', + '', + 'Windows 10', + /Windows NT 10\.0.*Edg\/[0-9]+\.[0-9.]+$/ + ); + + function createSpec(browser, version, sauceOS, expectedUserAgentRegex) { + const displayVersion = version + ? `version ${version}` + : 'unspecified version'; + const displayOS = sauceOS ? `OS ${sauceOS}` : 'unspecified OS'; + it( + `passes browser ${browser}, ${displayVersion}, and ${displayOS} correctly`, + function(done) { + const suiteDir = createSuite(); + const jasmineBrowserDir = process.cwd(); + let timedOut = false; + let timerId; + console.log('remote grid test may take a minute or two'); + + const jasmineBrowserProcess = exec( + `"${jasmineBrowserDir}/bin/jasmine-browser-runner" runSpecs --config=jasmine-browser.json`, + { cwd: suiteDir }, + function(err, stdout, stderr) { + try { + if (timedOut) { + return; + } + + clearTimeout(timerId); + + if (!err) { + expect(stdout).toContain('1 spec, 0 failures'); + done(); + } else { + if (err.code !== 1 || stdout === '' || stderr !== '') { + // Some kind of unexpected failure happened. Include all the info + // that we have. + done.fail( + `Child suite failed with error:\n${err}\n\n` + + `stdout:\n${stdout}\n\n` + + `stderr:\n${stderr}` + ); + } else { + // A normal suite failure. Just include the output. + done.fail(`Child suite failed with output:\n${stdout}`); + } + } + } catch (e) { + done.fail(e); + } + } + ); + + timerId = setTimeout(function() { + // Kill the child processs if we're about to time out, to free up + // the port. + timedOut = true; + jasmineBrowserProcess.kill(); + }, 239 * 1000); + }, + 240 * 1000 + ); + + function createSuite() { + const dir = fs.mkdtempSync(`${os.tmpdir()}/jasmine-browser-remote-grid-`); + processTemplate( + 'spec/fixtures/remoteGridIntegration/jasmine-browser.json', + `${dir}/jasmine-browser.json`, + { + JASMINE_BROWSER: browser, + SAUCE_BROWSER_VERSION: version, + SAUCE_OS: sauceOS, + SAUCE_TUNNEL_IDENTIFIER: process.env.SAUCE_TUNNEL_IDENTIFIER || '', + SAUCE_USERNAME: process.env.SAUCE_USERNAME, + SAUCE_ACCESS_KEY: process.env.SAUCE_ACCESS_KEY, + } + ); + processTemplate( + 'spec/fixtures/remoteGridIntegration/remoteGridParamsSpec.js', + `${dir}/remoteGridParamsSpec.js`, + { EXPECTED: expectedUserAgentRegex } + ); + return dir; + } + + function processTemplate(inPath, outPath, vars) { + const template = fs.readFileSync(inPath, { encoding: 'utf8' }); + + const output = Object.keys(vars).reduce(function(prev, k) { + return prev.replace(`<< ${k} >>`, vars[k]); + }, template); + + fs.writeFileSync(outPath, output); + } + } +}); diff --git a/spec/webdriverSpec.js b/spec/webdriverSpec.js index 17d2fc9..8223699 100644 --- a/spec/webdriverSpec.js +++ b/spec/webdriverSpec.js @@ -58,36 +58,85 @@ describe('webdriver', function() { }); }); - describe('When browserInfo is an object without useSauce=true', function() { + describe('When browserInfo is an object with useRemoteSeleniumGrid set to true', function() { it('uses browserInfo.name as the browser name', function() { const builder = new MockWebdriverBuilder(); - buildWebdriver({ name: 'IE' }, builder); + buildWebdriver({ name: 'IE', useRemoteSeleniumGrid: true }, builder); - expect(builder.browserName).toEqual('IE'); + expect(builder.capabilities.browserName).toEqual('IE'); }); describe('When browserInfo.name is undefined', function() { it('defaults to firefox', function() { const builder = new MockWebdriverBuilder(); - buildWebdriver({}, builder); + buildWebdriver( + { useRemoteSeleniumGrid: true, remoteSeleniumGrid: {} }, + builder + ); - expect(builder.browserName).toEqual('firefox'); + expect(builder.capabilities.browserName).toEqual('firefox'); }); }); it('does not use Sauce', function() { const builder = new MockWebdriverBuilder(); - buildWebdriver({ name: 'a browser name' }, builder); + buildWebdriver( + { + name: 'a browser name', + useRemoteSeleniumGrid: true, + remoteSeleniumGrid: { + url: 'a url to use', + }, + }, + builder + ); + + expect(builder.server).toMatch(/(a url to use)/); + }); - expect(builder.server).not.toMatch(/saucelabs/); + it('will use localhost grid hub url if no url specified', function() { + const builder = new MockWebdriverBuilder(); + + buildWebdriver( + { + name: 'a browser name', + useRemoteSeleniumGrid: true, + remoteSeleniumGrid: { + 'sauce:options': { + username: 'a user', + accessKey: 'a key', + }, + }, + }, + builder + ); + + expect(builder.server).toEqual('http://localhost:4445/wd/hub'); + }); + + it('can also use Saucelabs', function() { + const builder = new MockWebdriverBuilder(); + + buildWebdriver( + { + name: 'a browser name', + useRemoteSeleniumGrid: true, + remoteSeleniumGrid: { + url: 'https://ondemand.saucelabs.com/wd/hub', + }, + }, + builder + ); + + expect(builder.server).toMatch(/saucelabs/); }); }); }); - describe('When browserInfo is an object with useSauce=true', function() { + describe('When browserInfo is an object with useSauce set to true', function() { it('uses browserInfo.name as the browser name', function() { const builder = new MockWebdriverBuilder(); @@ -96,6 +145,14 @@ describe('webdriver', function() { expect(builder.capabilities.browserName).toEqual('IE'); }); + it('uses browserInfo.name as the browser name', function() { + const builder = new MockWebdriverBuilder(); + + buildWebdriver({ useSauce: true, name: 'IE' }, builder); + + expect(builder.capabilities.browserName).toEqual('IE'); + }); + describe('When browserInfo.name is undefined', function() { it('defaults to firefox', function() { const builder = new MockWebdriverBuilder(); @@ -106,6 +163,16 @@ describe('webdriver', function() { }); }); + describe('When browserInfo.name is undefined', function() { + it('defaults to firefox', function() { + const builder = new MockWebdriverBuilder(); + + buildWebdriver({ useSauce: true }, builder); + + expect(builder.capabilities.browserName).toEqual('firefox'); + }); + }); + it('uses Sauce', function() { const builder = new MockWebdriverBuilder(); @@ -117,73 +184,104 @@ describe('webdriver', function() { expect(builder.server).toMatch(/saucelabs/); }); - it('uses W3C keys', function() { - const builder = new MockWebdriverBuilder(); - function makeBrowser(name, version) { - buildWebdriver( - { - useSauce: true, - name: name, - sauce: { - os: 'MULTICS', - browserVersion: version, - tunnelIdentifier: 'a tunnel id', - }, - }, - builder - ); - } + const configMode = [ + { + mode: makeBrowser, + description: 'config format containing remoteSeleniumGrid object', + }, + { + mode: makeLegacyModeBrowser, + description: 'config format containing sauce object', + }, + ]; + configMode.forEach(modeObj => { + it(`uses W3C keys when using ${modeObj.description}`, function() { + const builder = new MockWebdriverBuilder(); - makeBrowser('safari', '12'); - expect(builder.capabilities.platformName).toEqual('MULTICS'); - expect(builder.capabilities.browserVersion).toEqual('12'); - expect(builder.capabilities['sauce:options']).toEqual({ - 'tunnel-identifier': 'a tunnel id', - }); - expect(builder.capabilities.platform).toBeUndefined(); - expect(builder.capabilities.version).toBeUndefined(); - expect(builder.capabilities.tunnelIdentifier).toBeUndefined(); - - makeBrowser('firefox', '68'); - expect(builder.capabilities.platformName).toEqual('MULTICS'); - expect(builder.capabilities.browserVersion).toEqual('68'); - expect(builder.capabilities['sauce:options']).toEqual({ - 'tunnel-identifier': 'a tunnel id', - }); - expect(builder.capabilities.platform).toBeUndefined(); - expect(builder.capabilities.version).toBeUndefined(); - expect(builder.capabilities.tunnelIdentifier).toBeUndefined(); - - makeBrowser('firefox', ''); - expect(builder.capabilities.platformName).toEqual('MULTICS'); - expect(builder.capabilities.browserVersion).toEqual(''); - expect(builder.capabilities['sauce:options']).toEqual({ - 'tunnel-identifier': 'a tunnel id', - }); - expect(builder.capabilities.platform).toBeUndefined(); - expect(builder.capabilities.version).toBeUndefined(); - expect(builder.capabilities.tunnelIdentifier).toBeUndefined(); - - makeBrowser('chrome', ''); - expect(builder.capabilities.platformName).toEqual('MULTICS'); - expect(builder.capabilities.browserVersion).toEqual(''); - expect(builder.capabilities['sauce:options']).toEqual({ - 'tunnel-identifier': 'a tunnel id', - }); - expect(builder.capabilities.platform).toBeUndefined(); - expect(builder.capabilities.version).toBeUndefined(); - expect(builder.capabilities.tunnelIdentifier).toBeUndefined(); - - makeBrowser('microsoftEdge', ''); - expect(builder.capabilities.platformName).toEqual('MULTICS'); - expect(builder.capabilities.browserVersion).toEqual(''); - expect(builder.capabilities['sauce:options']).toEqual({ - 'tunnel-identifier': 'a tunnel id', + modeObj.mode('safari', '12', builder); + expect(builder.capabilities.platformName).toEqual('MULTICS'); + expect(builder.capabilities.browserVersion).toEqual('12'); + expect(builder.capabilities['sauce:options']).toEqual({ + 'tunnel-identifier': 'a tunnel id', + }); + expect(builder.capabilities.platform).toBeUndefined(); + expect(builder.capabilities.version).toBeUndefined(); + expect(builder.capabilities.tunnelIdentifier).toBeUndefined(); + + modeObj.mode('firefox', '68', builder); + expect(builder.capabilities.platformName).toEqual('MULTICS'); + expect(builder.capabilities.browserVersion).toEqual('68'); + expect(builder.capabilities['sauce:options']).toEqual({ + 'tunnel-identifier': 'a tunnel id', + }); + expect(builder.capabilities.platform).toBeUndefined(); + expect(builder.capabilities.version).toBeUndefined(); + expect(builder.capabilities.tunnelIdentifier).toBeUndefined(); + + modeObj.mode('firefox', '', builder); + expect(builder.capabilities.platformName).toEqual('MULTICS'); + expect(builder.capabilities.browserVersion).toEqual(''); + expect(builder.capabilities['sauce:options']).toEqual({ + 'tunnel-identifier': 'a tunnel id', + }); + expect(builder.capabilities.platform).toBeUndefined(); + expect(builder.capabilities.version).toBeUndefined(); + expect(builder.capabilities.tunnelIdentifier).toBeUndefined(); + + modeObj.mode('chrome', '', builder); + expect(builder.capabilities.platformName).toEqual('MULTICS'); + expect(builder.capabilities.browserVersion).toEqual(''); + expect(builder.capabilities['sauce:options']).toEqual({ + 'tunnel-identifier': 'a tunnel id', + }); + expect(builder.capabilities.platform).toBeUndefined(); + expect(builder.capabilities.version).toBeUndefined(); + expect(builder.capabilities.tunnelIdentifier).toBeUndefined(); + + modeObj.mode('microsoftEdge', '', builder); + expect(builder.capabilities.platformName).toEqual('MULTICS'); + expect(builder.capabilities.browserVersion).toEqual(''); + expect(builder.capabilities['sauce:options']).toEqual({ + 'tunnel-identifier': 'a tunnel id', + }); + expect(builder.capabilities.platform).toBeUndefined(); + expect(builder.capabilities.version).toBeUndefined(); + expect(builder.capabilities.tunnelIdentifier).toBeUndefined(); }); - expect(builder.capabilities.platform).toBeUndefined(); - expect(builder.capabilities.version).toBeUndefined(); - expect(builder.capabilities.tunnelIdentifier).toBeUndefined(); }); + + function makeBrowser(name, version, builder) { + buildWebdriver( + { + name: name, + useRemoteSeleniumGrid: true, + remoteSeleniumGrid: { + url: 'https://ondemand.saucelabs.com/wd/hub', + platformName: 'MULTICS', + browserVersion: version, + 'sauce:options': { + 'tunnel-identifier': 'a tunnel id', + }, + }, + }, + builder + ); + } + + function makeLegacyModeBrowser(name, version, builder) { + buildWebdriver( + { + useSauce: true, + name: name, + sauce: { + os: 'MULTICS', + browserVersion: version, + tunnelIdentifier: 'a tunnel id', + }, + }, + builder + ); + } }); });