aboutsummaryrefslogtreecommitdiffstats
path: root/test/runner/browserstack/workers.js
diff options
context:
space:
mode:
Diffstat (limited to 'test/runner/browserstack/workers.js')
-rw-r--r--test/runner/browserstack/workers.js276
1 files changed, 276 insertions, 0 deletions
diff --git a/test/runner/browserstack/workers.js b/test/runner/browserstack/workers.js
new file mode 100644
index 000000000..8f0ab68f0
--- /dev/null
+++ b/test/runner/browserstack/workers.js
@@ -0,0 +1,276 @@
+import chalk from "chalk";
+import { getBrowserString } from "../lib/getBrowserString.js";
+import { changeUrl, createWorker, deleteWorker, getWorker } from "./api.js";
+
+const workers = Object.create( null );
+
+// Acknowledge the worker within the time limit.
+// BrowserStack can take much longer spinning up
+// some browsers, such as iOS 15 Safari.
+const ACKNOWLEDGE_WORKER_TIMEOUT = 60 * 1000 * 8;
+const ACKNOWLEDGE_WORKER_INTERVAL = 1000;
+
+// No report after the time limit
+// should refresh the worker
+const RUN_WORKER_TIMEOUT = 60 * 1000 * 2;
+const MAX_WORKER_RESTARTS = 5;
+const MAX_WORKER_REFRESHES = 1;
+const POLL_WORKER_TIMEOUT = 1000;
+
+export async function cleanupWorker( reportId, verbose ) {
+ const worker = workers[ reportId ];
+ if ( worker ) {
+ try {
+ delete workers[ reportId ];
+ await deleteWorker( worker.id, verbose );
+ } catch ( error ) {
+ console.error( error );
+ }
+ }
+}
+
+export function debugWorker( reportId ) {
+ const worker = workers[ reportId ];
+ if ( worker ) {
+ worker.debug = true;
+ }
+}
+
+/**
+ * Set the last time a request was
+ * received related to the worker.
+ */
+export function touchWorker( reportId ) {
+ const worker = workers[ reportId ];
+ if ( worker ) {
+ worker.lastTouch = Date.now();
+ }
+}
+
+export function retryTest( reportId, retries ) {
+ const worker = workers[ reportId ];
+ if ( worker ) {
+ worker.retries ||= 0;
+ worker.retries++;
+ if ( worker.retries <= retries ) {
+ worker.retry = true;
+ console.log( `\nRetrying test ${ reportId }...${ worker.retries }` );
+ return true;
+ }
+ }
+ return false;
+}
+
+export async function cleanupAllWorkers( verbose ) {
+ const workersRemaining = Object.keys( workers ).length;
+ if ( workersRemaining ) {
+ if ( verbose ) {
+ console.log(
+ `Stopping ${ workersRemaining } stray worker${
+ workersRemaining > 1 ? "s" : ""
+ }...`
+ );
+ }
+ await Promise.all(
+ Object.values( workers ).map( ( worker ) => deleteWorker( worker.id, verbose ) )
+ );
+ }
+}
+
+async function waitForAck( id, verbose ) {
+ return new Promise( ( resolve, reject ) => {
+ const interval = setInterval( () => {
+ const worker = workers[ id ];
+ if ( !worker ) {
+ clearTimeout( timeout );
+ clearInterval( interval );
+ return reject( new Error( `Worker ${ id } not found.` ) );
+ }
+ if ( worker.lastTouch ) {
+ if ( verbose ) {
+ console.log( `\nWorker ${ id } acknowledged.` );
+ }
+ clearTimeout( timeout );
+ clearInterval( interval );
+ resolve();
+ }
+ }, ACKNOWLEDGE_WORKER_INTERVAL );
+ const timeout = setTimeout( () => {
+ clearInterval( interval );
+ const worker = workers[ id ];
+ reject(
+ new Error(
+ `Worker ${
+ worker ? worker.id : ""
+ } for test ${ id } not acknowledged after ${
+ ACKNOWLEDGE_WORKER_TIMEOUT / 1000
+ }s.`
+ )
+ );
+ }, ACKNOWLEDGE_WORKER_TIMEOUT );
+ } );
+}
+
+export async function runWorker(
+ url,
+ browser,
+ options,
+ restarts = 0
+) {
+ const { modules, reportId, runId, verbose } = options;
+ const worker = await createWorker( {
+ ...browser,
+ url: encodeURI( url ),
+ project: "jquery",
+ build: `Run ${ runId }`,
+ name: `${ modules.join( "," ) } (${ reportId })`,
+
+ // Set the max here, so that we can
+ // control the timeout
+ timeout: 1800,
+
+ // Not documented in the API docs,
+ // but required to make local testing work.
+ // See https://www.browserstack.com/docs/automate/selenium/manage-multiple-connections#nodejs
+ "browserstack.local": true,
+ "browserstack.localIdentifier": runId
+ } );
+
+ workers[ reportId ] = worker;
+
+ const timeMessage = `\nWorker ${
+ worker.id
+ } created for test ${ reportId } (${ chalk.yellow( getBrowserString( browser ) ) })`;
+
+ if ( verbose ) {
+ console.time( timeMessage );
+ }
+
+ async function retryWorker() {
+ await cleanupWorker( reportId, verbose );
+ if ( verbose ) {
+ console.log( `Retrying worker for test ${ reportId }...${ restarts + 1 }` );
+ }
+ return runWorker( url, browser, options, restarts + 1 );
+ }
+
+ // Wait for the worker to be acknowledged
+ try {
+ await waitForAck( reportId );
+ } catch ( error ) {
+ if ( !workers[ reportId ] ) {
+
+ // The worker has already been cleaned up
+ return;
+ }
+
+ if ( restarts < MAX_WORKER_RESTARTS ) {
+ return retryWorker();
+ }
+
+ throw error;
+ }
+
+ if ( verbose ) {
+ console.timeEnd( timeMessage );
+ }
+
+ let refreshes = 0;
+ let loggedStarted = false;
+ return new Promise( ( resolve, reject ) => {
+ async function refreshWorker() {
+ try {
+ await changeUrl( worker.id, url );
+ touchWorker( reportId );
+ return tick();
+ } catch ( error ) {
+ if ( !workers[ reportId ] ) {
+
+ // The worker has already been cleaned up
+ return resolve();
+ }
+ console.error( error );
+ return retryWorker().then( resolve, reject );
+ }
+ }
+
+ async function checkWorker() {
+ const worker = workers[ reportId ];
+
+ if ( !worker || worker.debug ) {
+ return resolve();
+ }
+
+ let fetchedWorker;
+ try {
+ fetchedWorker = await getWorker( worker.id );
+ } catch ( error ) {
+ return reject( error );
+ }
+ if (
+ !fetchedWorker ||
+ ( fetchedWorker.status !== "running" && fetchedWorker.status !== "queue" )
+ ) {
+ return resolve();
+ }
+
+ if ( verbose && !loggedStarted ) {
+ loggedStarted = true;
+ console.log(
+ `\nTest ${ chalk.bold( reportId ) } is ${
+ worker.status === "running" ? "running" : "in the queue"
+ }.`
+ );
+ console.log( ` View at ${ fetchedWorker.browser_url }.` );
+ }
+
+ // Refresh the worker if a retry is triggered
+ if ( worker.retry ) {
+ worker.retry = false;
+
+ // Reset recovery refreshes
+ refreshes = 0;
+ return refreshWorker();
+ }
+
+ if ( worker.lastTouch > Date.now() - RUN_WORKER_TIMEOUT ) {
+ return tick();
+ }
+
+ refreshes++;
+
+ if ( refreshes >= MAX_WORKER_REFRESHES ) {
+ if ( restarts < MAX_WORKER_RESTARTS ) {
+ if ( verbose ) {
+ console.log(
+ `Worker ${ worker.id } not acknowledged after ${
+ ACKNOWLEDGE_WORKER_TIMEOUT / 1000
+ }s.`
+ );
+ }
+ return retryWorker().then( resolve, reject );
+ }
+ await cleanupWorker( reportId, verbose );
+ return reject(
+ new Error(
+ `Worker ${ worker.id } for test ${ reportId } timed out after ${ MAX_WORKER_RESTARTS } restarts.`
+ )
+ );
+ }
+
+ if ( verbose ) {
+ console.log(
+ `\nRefreshing worker ${ worker.id } for test ${ reportId }...${ refreshes }`
+ );
+ }
+
+ return refreshWorker();
+ }
+
+ function tick() {
+ setTimeout( checkWorker, POLL_WORKER_TIMEOUT );
+ }
+
+ checkWorker();
+ } );
+}