aboutsummaryrefslogtreecommitdiffstats
path: root/test/runner/browserstack/api.js
diff options
context:
space:
mode:
Diffstat (limited to 'test/runner/browserstack/api.js')
-rw-r--r--test/runner/browserstack/api.js289
1 files changed, 289 insertions, 0 deletions
diff --git a/test/runner/browserstack/api.js b/test/runner/browserstack/api.js
new file mode 100644
index 000000000..40982c9b3
--- /dev/null
+++ b/test/runner/browserstack/api.js
@@ -0,0 +1,289 @@
+/**
+ * Browserstack API is documented at
+ * https://github.com/browserstack/api
+ */
+
+import { createAuthHeader } from "./createAuthHeader.js";
+
+const browserstackApi = "https://api.browserstack.com";
+const apiVersion = 5;
+
+const username = process.env.BROWSERSTACK_USERNAME;
+const accessKey = process.env.BROWSERSTACK_ACCESS_KEY;
+
+// iOS has null for version numbers,
+// and we do not need a similar check for OS versions.
+const rfinalVersion = /(?:^[0-9\.]+$)|(?:^null$)/;
+
+const rnonDigits = /(?:[^\d\.]+)|(?:20\d{2})/g;
+
+async function fetchAPI( path, options = {}, versioned = true ) {
+ if ( !username || !accessKey ) {
+ throw new Error(
+ "BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY environment variables must be set."
+ );
+ }
+ const init = {
+ method: "GET",
+ ...options,
+ headers: {
+ authorization: createAuthHeader( username, accessKey ),
+ accept: "application/json",
+ "content-type": "application/json",
+ ...options.headers
+ }
+ };
+ const response = await fetch(
+ `${ browserstackApi }/${ versioned ? `${ apiVersion }/` : "" }${ path }`,
+ init
+ );
+ if ( !response.ok ) {
+ console.log(
+ `\n${ init.method } ${ path }`,
+ response.status,
+ response.statusText
+ );
+ throw new Error( `Error fetching ${ path }` );
+ }
+ return response.json();
+}
+
+/**
+ * =============================
+ * Browsers API
+ * =============================
+ */
+
+function compareVersionNumbers( a, b ) {
+ if ( a != null && b == null ) {
+ return -1;
+ }
+ if ( a == null && b != null ) {
+ return 1;
+ }
+ if ( a == null && b == null ) {
+ return 0;
+ }
+ const aParts = a.replace( rnonDigits, "" ).split( "." );
+ const bParts = b.replace( rnonDigits, "" ).split( "." );
+
+ if ( aParts.length > bParts.length ) {
+ return -1;
+ }
+ if ( aParts.length < bParts.length ) {
+ return 1;
+ }
+
+ for ( let i = 0; i < aParts.length; i++ ) {
+ const aPart = Number( aParts[ i ] );
+ const bPart = Number( bParts[ i ] );
+ if ( aPart < bPart ) {
+ return -1;
+ }
+ if ( aPart > bPart ) {
+ return 1;
+ }
+ }
+}
+
+function sortBrowsers( a, b ) {
+ if ( a.browser < b.browser ) {
+ return -1;
+ }
+ if ( a.browser > b.browser ) {
+ return 1;
+ }
+ const browserComparison = compareVersionNumbers(
+ a.browser_version,
+ b.browser_version
+ );
+ if ( browserComparison ) {
+ return browserComparison;
+ }
+ if ( a.os < b.os ) {
+ return -1;
+ }
+ if ( a.os > b.os ) {
+ return 1;
+ }
+ const osComparison = compareVersionNumbers( a.os_version, b.os_version );
+ if ( osComparison ) {
+ return osComparison;
+ }
+ const deviceComparison = compareVersionNumbers( a.device, b.device );
+ if ( deviceComparison ) {
+ return deviceComparison;
+ }
+ return 0;
+}
+
+export async function getBrowsers( { flat = false } = {} ) {
+ const query = new URLSearchParams();
+ if ( flat ) {
+ query.append( "flat", true );
+ }
+ const browsers = await fetchAPI( `/browsers?${ query }` );
+ return browsers.sort( sortBrowsers );
+}
+
+function matchVersion( browserVersion, version ) {
+ if ( !version ) {
+ return false;
+ }
+ const regex = new RegExp(
+ `^${ version.replace( /\\/g, "\\\\" ).replace( /\./g, "\\." ) }\\b`,
+ "i"
+ );
+ return regex.test( browserVersion );
+}
+
+export async function filterBrowsers( filter ) {
+ const browsers = await getBrowsers( { flat: true } );
+ if ( !filter ) {
+ return browsers;
+ }
+ const filterBrowser = ( filter.browser ?? "" ).toLowerCase();
+ const filterVersion = ( filter.browser_version ?? "" ).toLowerCase();
+ const filterOs = ( filter.os ?? "" ).toLowerCase();
+ const filterOsVersion = ( filter.os_version ?? "" ).toLowerCase();
+ const filterDevice = ( filter.device ?? "" ).toLowerCase();
+
+ return browsers.filter( ( browser ) => {
+ return (
+ ( !filterBrowser || filterBrowser === browser.browser.toLowerCase() ) &&
+ ( !filterVersion ||
+ matchVersion( browser.browser_version, filterVersion ) ) &&
+ ( !filterOs || filterOs === browser.os.toLowerCase() ) &&
+ ( !filterOsVersion ||
+ filterOsVersion === browser.os_version.toLowerCase() ) &&
+ ( !filterDevice || filterDevice === ( browser.device || "" ).toLowerCase() )
+ );
+ } );
+}
+
+export async function listBrowsers( filter ) {
+ const browsers = await filterBrowsers( filter );
+ console.log( "Available browsers:" );
+ for ( const browser of browsers ) {
+ let message = ` ${ browser.browser }_`;
+ if ( browser.device ) {
+ message += `:${ browser.device }_`;
+ } else {
+ message += `${ browser.browser_version }_`;
+ }
+ message += `${ browser.os }_${ browser.os_version }`;
+ console.log( message );
+ }
+}
+
+export async function getLatestBrowser( filter ) {
+ const browsers = await filterBrowsers( filter );
+
+ // The list is sorted in ascending order,
+ // so the last item is the latest.
+ return browsers.findLast( ( browser ) =>
+ rfinalVersion.test( browser.browser_version )
+ );
+}
+
+/**
+ * =============================
+ * Workers API
+ * =============================
+ */
+
+/**
+ * A browser object may only have one of `browser` or `device` set;
+ * which property is set will depend on `os`.
+ *
+ * `options`: is an object with the following properties:
+ * `os`: The operating system.
+ * `os_version`: The operating system version.
+ * `browser`: The browser name.
+ * `browser_version`: The browser version.
+ * `device`: The device name.
+ * `url` (optional): Which URL to navigate to upon creation.
+ * `timeout` (optional): Maximum life of the worker (in seconds). Maximum value of `1800`. Specifying `0` will use the default of `300`.
+ * `name` (optional): Provide a name for the worker.
+ * `build` (optional): Group workers into a build.
+ * `project` (optional): Provide the project the worker belongs to.
+ * `resolution` (optional): Specify the screen resolution (e.g. "1024x768").
+ * `browserstack.local` (optional): Set to `true` to mark as local testing.
+ * `browserstack.video` (optional): Set to `false` to disable video recording.
+ * `browserstack.localIdentifier` (optional): ID of the local tunnel.
+ */
+export function createWorker( options ) {
+ return fetchAPI( "/worker", {
+ method: "POST",
+ body: JSON.stringify( options )
+ } );
+}
+
+/**
+ * Returns a worker object, if one exists, with the following properties:
+ * `id`: The worker id.
+ * `status`: A string representing the current status of the worker.
+ * Possible statuses: `"running"`, `"queue"`.
+ */
+export function getWorker( id ) {
+ return fetchAPI( `/worker/${ id }` );
+}
+
+export async function deleteWorker( id, verbose ) {
+ await fetchAPI( `/worker/${ id }`, { method: "DELETE" } );
+ if ( verbose ) {
+ console.log( `\nWorker ${ id } stopped.` );
+ }
+}
+
+export function getWorkers() {
+ return fetchAPI( "/workers" );
+}
+
+/**
+ * Change the URL of a worker,
+ * or refresh if it's the same URL.
+ */
+export function changeUrl( id, url ) {
+ return fetchAPI( `/worker/${ id }/url.json`, {
+ method: "PUT",
+ body: JSON.stringify( {
+ timeout: 20,
+ url: encodeURI( url )
+ } )
+ } );
+}
+
+/**
+ * Stop all workers
+ */
+export async function stopWorkers() {
+ const workers = await getWorkers();
+
+ // Run each request on its own
+ // to avoid connect timeout errors.
+ for ( const worker of workers ) {
+ try {
+ await deleteWorker( worker.id, true );
+ } catch ( error ) {
+
+ // Log the error, but continue trying to remove workers.
+ console.error( error );
+ }
+ }
+}
+
+/**
+ * =============================
+ * Plan API
+ * =============================
+ */
+
+export function getPlan() {
+ return fetchAPI( "/automate/plan.json", {}, false );
+}
+
+export async function getAvailableSessions() {
+ const [ plan, workers ] = await Promise.all( [ getPlan(), getWorkers() ] );
+ return plan.parallel_sessions_max_allowed - workers.length;
+}