|  | 
| 1 |  | -// This list is static, so no requests are required | 
| 2 |  | -// in the command help menu. | 
|  | 1 | +import chalk from "chalk"; | 
|  | 2 | +import { getBrowserString } from "./lib/getBrowserString.js"; | 
|  | 3 | +import { | 
|  | 4 | +createWorker, | 
|  | 5 | +deleteWorker, | 
|  | 6 | +getAvailableSessions | 
|  | 7 | +} from "./browserstack/api.js"; | 
|  | 8 | +import createDriver from "./selenium/createDriver.js"; | 
| 3 | 9 | 
 | 
| 4 |  | -export const browsers = [ "chrome", "ie", "firefox", "edge", "safari", "opera" ]; | 
|  | 10 | +const workers = Object.create( null ); | 
|  | 11 | + | 
|  | 12 | +/** | 
|  | 13 | + * Keys are browser strings | 
|  | 14 | + * Structure of a worker: | 
|  | 15 | + * { | 
|  | 16 | + * browser: object // The browser object | 
|  | 17 | + * debug: boolean // Stops the worker from being cleaned up when finished | 
|  | 18 | + * lastTouch: number // The last time a request was received | 
|  | 19 | + * restarts: number // The number of times the worker has been restarted | 
|  | 20 | + * options: object // The options to create the worker | 
|  | 21 | + * url: string // The URL the worker is on | 
|  | 22 | + * quit: function // A function to stop the worker | 
|  | 23 | + * } | 
|  | 24 | + */ | 
|  | 25 | + | 
|  | 26 | +// Acknowledge the worker within the time limit. | 
|  | 27 | +// BrowserStack can take much longer spinning up | 
|  | 28 | +// some browsers, such as iOS 15 Safari. | 
|  | 29 | +const ACKNOWLEDGE_INTERVAL = 1000; | 
|  | 30 | +const ACKNOWLEDGE_TIMEOUT = 60 * 1000 * 5; | 
|  | 31 | + | 
|  | 32 | +const MAX_WORKER_RESTARTS = 5; | 
|  | 33 | + | 
|  | 34 | +// No report after the time limit | 
|  | 35 | +// should refresh the worker | 
|  | 36 | +const RUN_WORKER_TIMEOUT = 60 * 1000 * 2; | 
|  | 37 | + | 
|  | 38 | +const WORKER_WAIT_TIME = 30000; | 
|  | 39 | + | 
|  | 40 | +// Limit concurrency to 8 by default in selenium | 
|  | 41 | +const MAX_SELENIUM_CONCURRENCY = 8; | 
|  | 42 | + | 
|  | 43 | +export async function createBrowserWorker( url, browser, options, restarts = 0 ) { | 
|  | 44 | +if ( restarts > MAX_WORKER_RESTARTS ) { | 
|  | 45 | +throw new Error( | 
|  | 46 | +`Reached the maximum number of restarts for ${ chalk.yellow( | 
|  | 47 | +getBrowserString( browser ) | 
|  | 48 | +) }` | 
|  | 49 | +); | 
|  | 50 | +} | 
|  | 51 | +const { browserstack, debug, headless, runId, tunnelId, verbose } = options; | 
|  | 52 | +while ( await maxWorkersReached( options ) ) { | 
|  | 53 | +if ( verbose ) { | 
|  | 54 | +console.log( "\nWaiting for available sessions..." ); | 
|  | 55 | +} | 
|  | 56 | +await new Promise( ( resolve ) => setTimeout( resolve, WORKER_WAIT_TIME ) ); | 
|  | 57 | +} | 
|  | 58 | + | 
|  | 59 | +const fullBrowser = getBrowserString( browser ); | 
|  | 60 | + | 
|  | 61 | +let worker; | 
|  | 62 | + | 
|  | 63 | +if ( browserstack ) { | 
|  | 64 | +worker = await createWorker( { | 
|  | 65 | +...browser, | 
|  | 66 | +url: encodeURI( url ), | 
|  | 67 | +project: "jquery", | 
|  | 68 | +build: `Run ${ runId }`, | 
|  | 69 | + | 
|  | 70 | +// This is the maximum timeout allowed | 
|  | 71 | +// by BrowserStack. We do this because | 
|  | 72 | +// we control the timeout in the runner. | 
|  | 73 | +// See https://github.com/browserstack/api/blob/b324a6a5bc1b6052510d74e286b8e1c758c308a7/README.md#timeout300 | 
|  | 74 | +timeout: 1800, | 
|  | 75 | + | 
|  | 76 | +// Not documented in the API docs, | 
|  | 77 | +// but required to make local testing work. | 
|  | 78 | +// See https://www.browserstack.com/docs/automate/selenium/manage-multiple-connections#nodejs | 
|  | 79 | +"browserstack.local": true, | 
|  | 80 | +"browserstack.localIdentifier": tunnelId | 
|  | 81 | +} ); | 
|  | 82 | +worker.quit = () => deleteWorker( worker.id ); | 
|  | 83 | +} else { | 
|  | 84 | +const driver = await createDriver( { | 
|  | 85 | +browserName: browser.browser, | 
|  | 86 | +headless, | 
|  | 87 | +url, | 
|  | 88 | +verbose | 
|  | 89 | +} ); | 
|  | 90 | +worker = { | 
|  | 91 | +quit: () => driver.quit() | 
|  | 92 | +}; | 
|  | 93 | +} | 
|  | 94 | + | 
|  | 95 | +worker.debug = !!debug; | 
|  | 96 | +worker.url = url; | 
|  | 97 | +worker.browser = browser; | 
|  | 98 | +worker.restarts = restarts; | 
|  | 99 | +worker.options = options; | 
|  | 100 | +touchBrowser( browser ); | 
|  | 101 | +workers[ fullBrowser ] = worker; | 
|  | 102 | + | 
|  | 103 | +// Wait for the worker to show up in the list | 
|  | 104 | +// before returning it. | 
|  | 105 | +return ensureAcknowledged( worker ); | 
|  | 106 | +} | 
|  | 107 | + | 
|  | 108 | +export function touchBrowser( browser ) { | 
|  | 109 | +const fullBrowser = getBrowserString( browser ); | 
|  | 110 | +const worker = workers[ fullBrowser ]; | 
|  | 111 | +if ( worker ) { | 
|  | 112 | +worker.lastTouch = Date.now(); | 
|  | 113 | +} | 
|  | 114 | +} | 
|  | 115 | + | 
|  | 116 | +export async function setBrowserWorkerUrl( browser, url ) { | 
|  | 117 | +const fullBrowser = getBrowserString( browser ); | 
|  | 118 | +const worker = workers[ fullBrowser ]; | 
|  | 119 | +if ( worker ) { | 
|  | 120 | +worker.url = url; | 
|  | 121 | +} | 
|  | 122 | +} | 
|  | 123 | + | 
|  | 124 | +export async function restartBrowser( browser ) { | 
|  | 125 | +const fullBrowser = getBrowserString( browser ); | 
|  | 126 | +const worker = workers[ fullBrowser ]; | 
|  | 127 | +if ( worker ) { | 
|  | 128 | +await restartWorker( worker ); | 
|  | 129 | +} | 
|  | 130 | +} | 
|  | 131 | + | 
|  | 132 | +/** | 
|  | 133 | + * Checks that all browsers have received | 
|  | 134 | + * a response in the given amount of time. | 
|  | 135 | + * If not, the worker is restarted. | 
|  | 136 | + */ | 
|  | 137 | +export async function checkLastTouches() { | 
|  | 138 | +for ( const [ fullBrowser, worker ] of Object.entries( workers ) ) { | 
|  | 139 | +if ( Date.now() - worker.lastTouch > RUN_WORKER_TIMEOUT ) { | 
|  | 140 | +const options = worker.options; | 
|  | 141 | +if ( options.verbose ) { | 
|  | 142 | +console.log( | 
|  | 143 | +`\nNo response from ${ chalk.yellow( fullBrowser ) } in ${ | 
|  | 144 | +RUN_WORKER_TIMEOUT / 1000 / 60 | 
|  | 145 | +}min.` | 
|  | 146 | +); | 
|  | 147 | +} | 
|  | 148 | +await restartWorker( worker ); | 
|  | 149 | +} | 
|  | 150 | +} | 
|  | 151 | +} | 
|  | 152 | + | 
|  | 153 | +export async function cleanupAllBrowsers( { verbose } ) { | 
|  | 154 | +const workersRemaining = Object.values( workers ); | 
|  | 155 | +const numRemaining = workersRemaining.length; | 
|  | 156 | +if ( numRemaining ) { | 
|  | 157 | +try { | 
|  | 158 | +await Promise.all( workersRemaining.map( ( worker ) => worker.quit() ) ); | 
|  | 159 | +if ( verbose ) { | 
|  | 160 | +console.log( | 
|  | 161 | +`Stopped ${ numRemaining } browser${ numRemaining > 1 ? "s" : "" }.` | 
|  | 162 | +); | 
|  | 163 | +} | 
|  | 164 | +} catch ( error ) { | 
|  | 165 | + | 
|  | 166 | +// Log the error, but do not consider the test run failed | 
|  | 167 | +console.error( error ); | 
|  | 168 | +} | 
|  | 169 | +} | 
|  | 170 | +} | 
|  | 171 | + | 
|  | 172 | +async function maxWorkersReached( { | 
|  | 173 | +browserstack, | 
|  | 174 | +concurrency = MAX_SELENIUM_CONCURRENCY | 
|  | 175 | +} ) { | 
|  | 176 | +if ( browserstack ) { | 
|  | 177 | +return ( await getAvailableSessions() ) <= 0; | 
|  | 178 | +} | 
|  | 179 | +return workers.length >= concurrency; | 
|  | 180 | +} | 
|  | 181 | + | 
|  | 182 | +async function waitForAck( worker, { fullBrowser, verbose } ) { | 
|  | 183 | +delete worker.lastTouch; | 
|  | 184 | +return new Promise( ( resolve, reject ) => { | 
|  | 185 | +const interval = setInterval( () => { | 
|  | 186 | +if ( worker.lastTouch ) { | 
|  | 187 | +if ( verbose ) { | 
|  | 188 | +console.log( `\n${ fullBrowser } acknowledged.` ); | 
|  | 189 | +} | 
|  | 190 | +clearTimeout( timeout ); | 
|  | 191 | +clearInterval( interval ); | 
|  | 192 | +resolve(); | 
|  | 193 | +} | 
|  | 194 | +}, ACKNOWLEDGE_INTERVAL ); | 
|  | 195 | + | 
|  | 196 | +const timeout = setTimeout( () => { | 
|  | 197 | +clearInterval( interval ); | 
|  | 198 | +reject( | 
|  | 199 | +new Error( | 
|  | 200 | +`${ fullBrowser } not acknowledged after ${ | 
|  | 201 | +ACKNOWLEDGE_TIMEOUT / 1000 / 60 | 
|  | 202 | +}min.` | 
|  | 203 | +) | 
|  | 204 | +); | 
|  | 205 | +}, ACKNOWLEDGE_TIMEOUT ); | 
|  | 206 | +} ); | 
|  | 207 | +} | 
|  | 208 | + | 
|  | 209 | +async function ensureAcknowledged( worker ) { | 
|  | 210 | +const fullBrowser = getBrowserString( worker.browser ); | 
|  | 211 | +const verbose = worker.options.verbose; | 
|  | 212 | +try { | 
|  | 213 | +await waitForAck( worker, { fullBrowser, verbose } ); | 
|  | 214 | +return worker; | 
|  | 215 | +} catch ( error ) { | 
|  | 216 | +console.error( error.message ); | 
|  | 217 | +await restartWorker( worker ); | 
|  | 218 | +} | 
|  | 219 | +} | 
|  | 220 | + | 
|  | 221 | +async function cleanupWorker( worker, { verbose } ) { | 
|  | 222 | +for ( const [ fullBrowser, w ] of Object.entries( workers ) ) { | 
|  | 223 | +if ( w === worker ) { | 
|  | 224 | +delete workers[ fullBrowser ]; | 
|  | 225 | +await worker.quit(); | 
|  | 226 | +if ( verbose ) { | 
|  | 227 | +console.log( `\nStopped ${ fullBrowser }.` ); | 
|  | 228 | +} | 
|  | 229 | +return; | 
|  | 230 | +} | 
|  | 231 | +} | 
|  | 232 | +} | 
|  | 233 | + | 
|  | 234 | +async function restartWorker( worker ) { | 
|  | 235 | +await cleanupWorker( worker, worker.options ); | 
|  | 236 | +await createBrowserWorker( | 
|  | 237 | +worker.url, | 
|  | 238 | +worker.browser, | 
|  | 239 | +worker.options, | 
|  | 240 | +worker.restarts + 1 | 
|  | 241 | +); | 
|  | 242 | +} | 
0 commit comments