mirror of
https://github.com/jquery/jquery-ui.git
synced 2024-11-21 11:04:24 +00:00
Tests: align test runner with other repos
Close gh-2234
This commit is contained in:
parent
213fdbaa28
commit
4af5caed7a
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@ bower_components
|
|||||||
node_modules
|
node_modules
|
||||||
.sizecache.json
|
.sizecache.json
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
local.log
|
||||||
|
@ -56,6 +56,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"body-parser": "1.20.2",
|
"body-parser": "1.20.2",
|
||||||
|
"browserstack-local": "1.5.5",
|
||||||
"commitplease": "3.2.0",
|
"commitplease": "3.2.0",
|
||||||
"diff": "5.2.0",
|
"diff": "5.2.0",
|
||||||
"eslint-config-jquery": "3.0.2",
|
"eslint-config-jquery": "3.0.2",
|
||||||
|
@ -7,13 +7,9 @@
|
|||||||
{
|
{
|
||||||
"files": ["**/*"],
|
"files": ["**/*"],
|
||||||
"env": {
|
"env": {
|
||||||
|
"es6": true,
|
||||||
"node": true
|
"node": true
|
||||||
},
|
},
|
||||||
"globals": {
|
|
||||||
"fetch": false,
|
|
||||||
"Promise": false,
|
|
||||||
"require": false
|
|
||||||
},
|
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": 2022,
|
"ecmaVersion": 2022,
|
||||||
"sourceType": "module"
|
"sourceType": "module"
|
||||||
@ -27,7 +23,8 @@
|
|||||||
},
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
"QUnit": false,
|
"QUnit": false,
|
||||||
"Symbol": false
|
"Symbol": false,
|
||||||
|
"require": false
|
||||||
},
|
},
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": 5,
|
"ecmaVersion": 5,
|
||||||
|
@ -1,4 +1,242 @@
|
|||||||
// This list is static, so no requests are required
|
import chalk from "chalk";
|
||||||
// in the command help menu.
|
import { getBrowserString } from "./lib/getBrowserString.js";
|
||||||
|
import {
|
||||||
|
createWorker,
|
||||||
|
deleteWorker,
|
||||||
|
getAvailableSessions
|
||||||
|
} from "./browserstack/api.js";
|
||||||
|
import createDriver from "./selenium/createDriver.js";
|
||||||
|
|
||||||
export const browsers = [ "chrome", "ie", "firefox", "edge", "safari", "opera" ];
|
const workers = Object.create( null );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys are browser strings
|
||||||
|
* Structure of a worker:
|
||||||
|
* {
|
||||||
|
* browser: object // The browser object
|
||||||
|
* debug: boolean // Stops the worker from being cleaned up when finished
|
||||||
|
* lastTouch: number // The last time a request was received
|
||||||
|
* restarts: number // The number of times the worker has been restarted
|
||||||
|
* options: object // The options to create the worker
|
||||||
|
* url: string // The URL the worker is on
|
||||||
|
* quit: function // A function to stop the worker
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Acknowledge the worker within the time limit.
|
||||||
|
// BrowserStack can take much longer spinning up
|
||||||
|
// some browsers, such as iOS 15 Safari.
|
||||||
|
const ACKNOWLEDGE_INTERVAL = 1000;
|
||||||
|
const ACKNOWLEDGE_TIMEOUT = 60 * 1000 * 5;
|
||||||
|
|
||||||
|
const MAX_WORKER_RESTARTS = 5;
|
||||||
|
|
||||||
|
// No report after the time limit
|
||||||
|
// should refresh the worker
|
||||||
|
const RUN_WORKER_TIMEOUT = 60 * 1000 * 2;
|
||||||
|
|
||||||
|
const WORKER_WAIT_TIME = 30000;
|
||||||
|
|
||||||
|
// Limit concurrency to 8 by default in selenium
|
||||||
|
const MAX_SELENIUM_CONCURRENCY = 8;
|
||||||
|
|
||||||
|
export async function createBrowserWorker( url, browser, options, restarts = 0 ) {
|
||||||
|
if ( restarts > MAX_WORKER_RESTARTS ) {
|
||||||
|
throw new Error(
|
||||||
|
`Reached the maximum number of restarts for ${ chalk.yellow(
|
||||||
|
getBrowserString( browser )
|
||||||
|
) }`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { browserstack, debug, headless, runId, tunnelId, verbose } = options;
|
||||||
|
while ( await maxWorkersReached( options ) ) {
|
||||||
|
if ( verbose ) {
|
||||||
|
console.log( "\nWaiting for available sessions..." );
|
||||||
|
}
|
||||||
|
await new Promise( ( resolve ) => setTimeout( resolve, WORKER_WAIT_TIME ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullBrowser = getBrowserString( browser );
|
||||||
|
|
||||||
|
let worker;
|
||||||
|
|
||||||
|
if ( browserstack ) {
|
||||||
|
worker = await createWorker( {
|
||||||
|
...browser,
|
||||||
|
url: encodeURI( url ),
|
||||||
|
project: "jquery",
|
||||||
|
build: `Run ${ runId }`,
|
||||||
|
|
||||||
|
// This is the maximum timeout allowed
|
||||||
|
// by BrowserStack. We do this because
|
||||||
|
// we control the timeout in the runner.
|
||||||
|
// See https://github.com/browserstack/api/blob/b324a6a5bc1b6052510d74e286b8e1c758c308a7/README.md#timeout300
|
||||||
|
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": tunnelId
|
||||||
|
} );
|
||||||
|
worker.quit = () => deleteWorker( worker.id );
|
||||||
|
} else {
|
||||||
|
const driver = await createDriver( {
|
||||||
|
browserName: browser.browser,
|
||||||
|
headless,
|
||||||
|
url,
|
||||||
|
verbose
|
||||||
|
} );
|
||||||
|
worker = {
|
||||||
|
quit: () => driver.quit()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.debug = !!debug;
|
||||||
|
worker.url = url;
|
||||||
|
worker.browser = browser;
|
||||||
|
worker.restarts = restarts;
|
||||||
|
worker.options = options;
|
||||||
|
touchBrowser( browser );
|
||||||
|
workers[ fullBrowser ] = worker;
|
||||||
|
|
||||||
|
// Wait for the worker to show up in the list
|
||||||
|
// before returning it.
|
||||||
|
return ensureAcknowledged( worker );
|
||||||
|
}
|
||||||
|
|
||||||
|
export function touchBrowser( browser ) {
|
||||||
|
const fullBrowser = getBrowserString( browser );
|
||||||
|
const worker = workers[ fullBrowser ];
|
||||||
|
if ( worker ) {
|
||||||
|
worker.lastTouch = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setBrowserWorkerUrl( browser, url ) {
|
||||||
|
const fullBrowser = getBrowserString( browser );
|
||||||
|
const worker = workers[ fullBrowser ];
|
||||||
|
if ( worker ) {
|
||||||
|
worker.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartBrowser( browser ) {
|
||||||
|
const fullBrowser = getBrowserString( browser );
|
||||||
|
const worker = workers[ fullBrowser ];
|
||||||
|
if ( worker ) {
|
||||||
|
await restartWorker( worker );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that all browsers have received
|
||||||
|
* a response in the given amount of time.
|
||||||
|
* If not, the worker is restarted.
|
||||||
|
*/
|
||||||
|
export async function checkLastTouches() {
|
||||||
|
for ( const [ fullBrowser, worker ] of Object.entries( workers ) ) {
|
||||||
|
if ( Date.now() - worker.lastTouch > RUN_WORKER_TIMEOUT ) {
|
||||||
|
const options = worker.options;
|
||||||
|
if ( options.verbose ) {
|
||||||
|
console.log(
|
||||||
|
`\nNo response from ${ chalk.yellow( fullBrowser ) } in ${
|
||||||
|
RUN_WORKER_TIMEOUT / 1000 / 60
|
||||||
|
}min.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await restartWorker( worker );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupAllBrowsers( { verbose } ) {
|
||||||
|
const workersRemaining = Object.values( workers );
|
||||||
|
const numRemaining = workersRemaining.length;
|
||||||
|
if ( numRemaining ) {
|
||||||
|
try {
|
||||||
|
await Promise.all( workersRemaining.map( ( worker ) => worker.quit() ) );
|
||||||
|
if ( verbose ) {
|
||||||
|
console.log(
|
||||||
|
`Stopped ${ numRemaining } browser${ numRemaining > 1 ? "s" : "" }.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch ( error ) {
|
||||||
|
|
||||||
|
// Log the error, but do not consider the test run failed
|
||||||
|
console.error( error );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maxWorkersReached( {
|
||||||
|
browserstack,
|
||||||
|
concurrency = MAX_SELENIUM_CONCURRENCY
|
||||||
|
} ) {
|
||||||
|
if ( browserstack ) {
|
||||||
|
return ( await getAvailableSessions() ) <= 0;
|
||||||
|
}
|
||||||
|
return workers.length >= concurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAck( worker, { fullBrowser, verbose } ) {
|
||||||
|
delete worker.lastTouch;
|
||||||
|
return new Promise( ( resolve, reject ) => {
|
||||||
|
const interval = setInterval( () => {
|
||||||
|
if ( worker.lastTouch ) {
|
||||||
|
if ( verbose ) {
|
||||||
|
console.log( `\n${ fullBrowser } acknowledged.` );
|
||||||
|
}
|
||||||
|
clearTimeout( timeout );
|
||||||
|
clearInterval( interval );
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, ACKNOWLEDGE_INTERVAL );
|
||||||
|
|
||||||
|
const timeout = setTimeout( () => {
|
||||||
|
clearInterval( interval );
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`${ fullBrowser } not acknowledged after ${
|
||||||
|
ACKNOWLEDGE_TIMEOUT / 1000 / 60
|
||||||
|
}min.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, ACKNOWLEDGE_TIMEOUT );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAcknowledged( worker ) {
|
||||||
|
const fullBrowser = getBrowserString( worker.browser );
|
||||||
|
const verbose = worker.options.verbose;
|
||||||
|
try {
|
||||||
|
await waitForAck( worker, { fullBrowser, verbose } );
|
||||||
|
return worker;
|
||||||
|
} catch ( error ) {
|
||||||
|
console.error( error.message );
|
||||||
|
await restartWorker( worker );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupWorker( worker, { verbose } ) {
|
||||||
|
for ( const [ fullBrowser, w ] of Object.entries( workers ) ) {
|
||||||
|
if ( w === worker ) {
|
||||||
|
delete workers[ fullBrowser ];
|
||||||
|
await worker.quit();
|
||||||
|
if ( verbose ) {
|
||||||
|
console.log( `\nStopped ${ fullBrowser }.` );
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartWorker( worker ) {
|
||||||
|
await cleanupWorker( worker, worker.options );
|
||||||
|
await createBrowserWorker(
|
||||||
|
worker.url,
|
||||||
|
worker.browser,
|
||||||
|
worker.options,
|
||||||
|
worker.restarts + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
332
tests/runner/browserstack/api.js
Normal file
332
tests/runner/browserstack/api.js
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* 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 rlatest = /^latest-(\d+)$/;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( rnonDigits.test( a ) && !rnonDigits.test( b ) ) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if ( !rnonDigits.test( a ) && rnonDigits.test( b ) ) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
const filteredWithoutVersion = browsers.filter( ( browser ) => {
|
||||||
|
return (
|
||||||
|
( !filterBrowser || filterBrowser === browser.browser.toLowerCase() ) &&
|
||||||
|
( !filterOs || filterOs === browser.os.toLowerCase() ) &&
|
||||||
|
( !filterOsVersion || matchVersion( browser.os_version, filterOsVersion ) ) &&
|
||||||
|
( !filterDevice || filterDevice === ( browser.device || "" ).toLowerCase() )
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
|
||||||
|
if ( !filterVersion ) {
|
||||||
|
return filteredWithoutVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( filterVersion.startsWith( "latest" ) ) {
|
||||||
|
const groupedByName = filteredWithoutVersion
|
||||||
|
.filter( ( b ) => rfinalVersion.test( b.browser_version ) )
|
||||||
|
.reduce( ( acc, browser ) => {
|
||||||
|
acc[ browser.browser ] = acc[ browser.browser ] ?? [];
|
||||||
|
acc[ browser.browser ].push( browser );
|
||||||
|
return acc;
|
||||||
|
}, Object.create( null ) );
|
||||||
|
|
||||||
|
const filtered = [];
|
||||||
|
for ( const group of Object.values( groupedByName ) ) {
|
||||||
|
const latest = group[ group.length - 1 ];
|
||||||
|
|
||||||
|
// Mobile devices do not have browser version.
|
||||||
|
// Skip the version check for these,
|
||||||
|
// but include the latest in the list if it made it
|
||||||
|
// through filtering.
|
||||||
|
if ( !latest.browser_version ) {
|
||||||
|
|
||||||
|
// Do not include in the list for latest-n.
|
||||||
|
if ( filterVersion === "latest" ) {
|
||||||
|
filtered.push( latest );
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the latest version and subtract the number from the filter,
|
||||||
|
// ignoring any patch versions, which may differ between major versions.
|
||||||
|
const num = rlatest.exec( filterVersion );
|
||||||
|
const version = parseInt( latest.browser_version ) - ( num ? num[ 1 ] : 0 );
|
||||||
|
const match = group.findLast( ( browser ) => {
|
||||||
|
return matchVersion( browser.browser_version, version.toString() );
|
||||||
|
} );
|
||||||
|
if ( match ) {
|
||||||
|
filtered.push( match );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredWithoutVersion.filter( ( browser ) => {
|
||||||
|
return matchVersion( browser.browser_version, filterVersion );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ) {
|
||||||
|
if ( !filter.browser_version ) {
|
||||||
|
filter.browser_version = "latest";
|
||||||
|
}
|
||||||
|
const browsers = await filterBrowsers( filter );
|
||||||
|
return browsers[ browsers.length - 1 ];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* =============================
|
||||||
|
* 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 ) {
|
||||||
|
return fetchAPI( `/worker/${ id }`, { method: "DELETE" } );
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkers() {
|
||||||
|
return fetchAPI( "/workers" );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all workers
|
||||||
|
*/
|
||||||
|
export async function stopWorkers() {
|
||||||
|
const workers = await getWorkers();
|
||||||
|
|
||||||
|
// Run each request on its own
|
||||||
|
// to avoid connect timeout errors.
|
||||||
|
console.log( `${ workers.length } workers running...` );
|
||||||
|
for ( const worker of workers ) {
|
||||||
|
try {
|
||||||
|
await deleteWorker( worker.id );
|
||||||
|
} catch ( error ) {
|
||||||
|
|
||||||
|
// Log the error, but continue trying to remove workers.
|
||||||
|
console.error( error );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log( "All workers stopped." );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* =============================
|
||||||
|
* Plan API
|
||||||
|
* =============================
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function getPlan() {
|
||||||
|
return fetchAPI( "/automate/plan.json", {}, false );
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAvailableSessions() {
|
||||||
|
try {
|
||||||
|
const [ plan, workers ] = await Promise.all( [ getPlan(), getWorkers() ] );
|
||||||
|
return plan.parallel_sessions_max_allowed - workers.length;
|
||||||
|
} catch ( error ) {
|
||||||
|
console.error( error );
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
20
tests/runner/browserstack/buildBrowserFromString.js
Normal file
20
tests/runner/browserstack/buildBrowserFromString.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export function buildBrowserFromString( str ) {
|
||||||
|
const [ browser, versionOrDevice, os, osVersion ] = str.split( "_" );
|
||||||
|
|
||||||
|
// If the version starts with a colon, it's a device
|
||||||
|
if ( versionOrDevice && versionOrDevice.startsWith( ":" ) ) {
|
||||||
|
return {
|
||||||
|
browser,
|
||||||
|
device: versionOrDevice.slice( 1 ),
|
||||||
|
os,
|
||||||
|
os_version: osVersion
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
browser,
|
||||||
|
browser_version: versionOrDevice,
|
||||||
|
os,
|
||||||
|
os_version: osVersion
|
||||||
|
};
|
||||||
|
}
|
7
tests/runner/browserstack/createAuthHeader.js
Normal file
7
tests/runner/browserstack/createAuthHeader.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const textEncoder = new TextEncoder();
|
||||||
|
|
||||||
|
export function createAuthHeader( username, accessKey ) {
|
||||||
|
const encoded = textEncoder.encode( `${ username }:${ accessKey }` );
|
||||||
|
const base64 = btoa( String.fromCodePoint.apply( null, encoded ) );
|
||||||
|
return `Basic ${ base64 }`;
|
||||||
|
}
|
34
tests/runner/browserstack/local.js
Normal file
34
tests/runner/browserstack/local.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import browserstackLocal from "browserstack-local";
|
||||||
|
|
||||||
|
export async function localTunnel( localIdentifier, opts = {} ) {
|
||||||
|
const tunnel = new browserstackLocal.Local();
|
||||||
|
|
||||||
|
return new Promise( ( resolve, reject ) => {
|
||||||
|
|
||||||
|
// https://www.browserstack.com/docs/local-testing/binary-params
|
||||||
|
tunnel.start(
|
||||||
|
{
|
||||||
|
"enable-logging-for-api": "",
|
||||||
|
localIdentifier,
|
||||||
|
...opts
|
||||||
|
},
|
||||||
|
async( error ) => {
|
||||||
|
if ( error || !tunnel.isRunning() ) {
|
||||||
|
return reject( error );
|
||||||
|
}
|
||||||
|
resolve( {
|
||||||
|
stop: function stopTunnel() {
|
||||||
|
return new Promise( ( resolve, reject ) => {
|
||||||
|
tunnel.stop( ( error ) => {
|
||||||
|
if ( error ) {
|
||||||
|
return reject( error );
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
}
|
@ -1,8 +1,10 @@
|
|||||||
import yargs from "yargs/yargs";
|
import yargs from "yargs/yargs";
|
||||||
import { browsers } from "./browsers.js";
|
import { browsers } from "./flags/browsers.js";
|
||||||
import { suites } from "./suites.js";
|
import { getPlan, listBrowsers, stopWorkers } from "./browserstack/api.js";
|
||||||
|
import { buildBrowserFromString } from "./browserstack/buildBrowserFromString.js";
|
||||||
|
import { jquery } from "./flags/jquery.js";
|
||||||
|
import { suites } from "./flags/suites.js";
|
||||||
import { run } from "./run.js";
|
import { run } from "./run.js";
|
||||||
import { jquery } from "./jquery.js";
|
|
||||||
|
|
||||||
const argv = yargs( process.argv.slice( 2 ) )
|
const argv = yargs( process.argv.slice( 2 ) )
|
||||||
.version( false )
|
.version( false )
|
||||||
@ -40,16 +42,25 @@ const argv = yargs( process.argv.slice( 2 ) )
|
|||||||
type: "array",
|
type: "array",
|
||||||
choices: browsers,
|
choices: browsers,
|
||||||
description:
|
description:
|
||||||
"Run tests in a specific browser.\n" +
|
"Run tests in a specific browser." +
|
||||||
"Pass multiple browsers by repeating the option.",
|
"Pass multiple browsers by repeating the option." +
|
||||||
|
"If using BrowserStack, specify browsers using --browserstack.",
|
||||||
default: [ "chrome" ]
|
default: [ "chrome" ]
|
||||||
} )
|
} )
|
||||||
.option( "headless", {
|
.option( "headless", {
|
||||||
alias: "h",
|
alias: "h",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
description:
|
description:
|
||||||
"Run tests in headless mode. Cannot be used with --debug.",
|
"Run tests in headless mode. Cannot be used with --debug or --browserstack.",
|
||||||
conflicts: [ "debug" ]
|
conflicts: [ "debug", "browserstack" ]
|
||||||
|
} )
|
||||||
|
.option( "concurrency", {
|
||||||
|
alias: "c",
|
||||||
|
type: "number",
|
||||||
|
description:
|
||||||
|
"Run tests in parallel in multiple browsers. " +
|
||||||
|
"Defaults to 8 in normal mode. In browserstack mode, " +
|
||||||
|
"defaults to the maximum available under your BrowserStack plan."
|
||||||
} )
|
} )
|
||||||
.option( "debug", {
|
.option( "debug", {
|
||||||
alias: "d",
|
alias: "d",
|
||||||
@ -61,18 +72,69 @@ const argv = yargs( process.argv.slice( 2 ) )
|
|||||||
.option( "retries", {
|
.option( "retries", {
|
||||||
alias: "r",
|
alias: "r",
|
||||||
type: "number",
|
type: "number",
|
||||||
description: "Number of times to retry failed tests."
|
description: "Number of times to retry failed tests by refreshing the URL."
|
||||||
} )
|
} )
|
||||||
.option( "concurrency", {
|
.option( "hard-retries", {
|
||||||
alias: "c",
|
|
||||||
type: "number",
|
type: "number",
|
||||||
description: "Run tests in parallel in multiple browsers. Defaults to 8."
|
description:
|
||||||
|
"Number of times to retry failed tests by restarting the worker. " +
|
||||||
|
"This is in addition to the normal retries " +
|
||||||
|
"and are only used when the normal retries are exhausted."
|
||||||
} )
|
} )
|
||||||
.option( "verbose", {
|
.option( "verbose", {
|
||||||
alias: "v",
|
alias: "v",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
description: "Log additional information."
|
description: "Log additional information."
|
||||||
} )
|
} )
|
||||||
|
.option( "browserstack", {
|
||||||
|
type: "array",
|
||||||
|
description:
|
||||||
|
"Run tests in BrowserStack.\n" +
|
||||||
|
"Requires BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY environment variables.\n" +
|
||||||
|
"The value can be empty for the default configuration, or a string in the format of\n" +
|
||||||
|
"\"browser_[browserVersion | :device]_os_osVersion\" (see --list-browsers).\n" +
|
||||||
|
"Pass multiple browsers by repeating the option.\n" +
|
||||||
|
"The --browser option is ignored when --browserstack has a value.\n" +
|
||||||
|
"Otherwise, the --browser option will be used, " +
|
||||||
|
"with the latest version/device for that browser, on a matching OS."
|
||||||
|
} )
|
||||||
|
.option( "run-id", {
|
||||||
|
type: "string",
|
||||||
|
description: "A unique identifier for the run in BrowserStack."
|
||||||
|
} )
|
||||||
|
.option( "list-browsers", {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"List available BrowserStack browsers and exit.\n" +
|
||||||
|
"Leave blank to view all browsers or pass " +
|
||||||
|
"\"browser_[browserVersion | :device]_os_osVersion\" with each parameter " +
|
||||||
|
"separated by an underscore to filter the list (any can be omitted).\n" +
|
||||||
|
"\"latest\" can be used in place of \"browserVersion\" to find the latest version.\n" +
|
||||||
|
"\"latest-n\" can be used to find the nth latest browser version.\n" +
|
||||||
|
"Use a colon to indicate a device.\n" +
|
||||||
|
"Examples: \"chrome__windows_10\", \"safari_latest\", " +
|
||||||
|
"\"Mobile Safari\", \"Android Browser_:Google Pixel 8 Pro\".\n" +
|
||||||
|
"Use quotes if spaces are necessary."
|
||||||
|
} )
|
||||||
|
.option( "stop-workers", {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"WARNING: This will stop all BrowserStack workers that may exist and exit," +
|
||||||
|
"including any workers running from other projects.\n" +
|
||||||
|
"This can be used as a failsafe when there are too many stray workers."
|
||||||
|
} )
|
||||||
|
.option( "browserstack-plan", {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Show BrowserStack plan information and exit."
|
||||||
|
} )
|
||||||
.help().argv;
|
.help().argv;
|
||||||
|
|
||||||
run( argv );
|
if ( typeof argv.listBrowsers === "string" ) {
|
||||||
|
listBrowsers( buildBrowserFromString( argv.listBrowsers ) );
|
||||||
|
} else if ( argv.stopWorkers ) {
|
||||||
|
stopWorkers();
|
||||||
|
} else if ( argv.browserstackPlan ) {
|
||||||
|
console.log( await getPlan() );
|
||||||
|
} else {
|
||||||
|
run( argv );
|
||||||
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
import bodyParser from "body-parser";
|
import bodyParser from "body-parser";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import bodyParserErrorHandler from "express-body-parser-error-handler";
|
import bodyParserErrorHandler from "express-body-parser-error-handler";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
export async function createTestServer( report ) {
|
export async function createTestServer( report ) {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
24
tests/runner/flags/browsers.js
Normal file
24
tests/runner/flags/browsers.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// This list is static, so no requests are required
|
||||||
|
// in the command help menu.
|
||||||
|
|
||||||
|
import { getBrowsers } from "../browserstack/api.js";
|
||||||
|
|
||||||
|
export const browsers = [
|
||||||
|
"chrome",
|
||||||
|
"ie",
|
||||||
|
"firefox",
|
||||||
|
"edge",
|
||||||
|
"safari",
|
||||||
|
"opera",
|
||||||
|
"yandex",
|
||||||
|
"IE Mobile",
|
||||||
|
"Android Browser",
|
||||||
|
"Mobile Safari"
|
||||||
|
];
|
||||||
|
|
||||||
|
// A function that can be used to update the above list.
|
||||||
|
export async function getAvailableBrowsers() {
|
||||||
|
const browsers = await getBrowsers( { flat: true } );
|
||||||
|
const available = [ ...new Set( browsers.map( ( { browser } ) => browser ) ) ];
|
||||||
|
return available;
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
export function buildTestUrl( suite, { jquery, migrate, port, reportId } ) {
|
export function buildTestUrl( suite, { browserstack, jquery, migrate, port, reportId } ) {
|
||||||
if ( !port ) {
|
if ( !port ) {
|
||||||
throw new Error( "No port specified." );
|
throw new Error( "No port specified." );
|
||||||
}
|
}
|
||||||
@ -17,5 +17,8 @@ export function buildTestUrl( suite, { jquery, migrate, port, reportId } ) {
|
|||||||
query.append( "reportId", reportId );
|
query.append( "reportId", reportId );
|
||||||
}
|
}
|
||||||
|
|
||||||
return `http://localhost:${ port }/tests/unit/${ suite }/${ suite }.html?${ query }`;
|
// BrowserStack supplies a custom domain for local testing,
|
||||||
|
// which is especially necessary for iOS testing.
|
||||||
|
const host = browserstack ? "bs-local.com" : "localhost";
|
||||||
|
return `http://${ host }:${ port }/tests/unit/${ suite }/${ suite }.html?${ query }`;
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,6 @@ const browserMap = {
|
|||||||
edge: "Edge",
|
edge: "Edge",
|
||||||
firefox: "Firefox",
|
firefox: "Firefox",
|
||||||
ie: "IE",
|
ie: "IE",
|
||||||
jsdom: "JSDOM",
|
|
||||||
opera: "Opera",
|
opera: "Opera",
|
||||||
safari: "Safari"
|
safari: "Safari"
|
||||||
};
|
};
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { getBrowserString } from "../lib/getBrowserString.js";
|
import { getBrowserString } from "./lib/getBrowserString.js";
|
||||||
import {
|
import {
|
||||||
checkLastTouches,
|
checkLastTouches,
|
||||||
createBrowserWorker,
|
createBrowserWorker,
|
||||||
|
restartBrowser,
|
||||||
setBrowserWorkerUrl
|
setBrowserWorkerUrl
|
||||||
} from "./browsers.js";
|
} from "./browsers.js";
|
||||||
|
|
||||||
@ -44,23 +45,44 @@ export function retryTest( reportId, maxRetries ) {
|
|||||||
test.retries++;
|
test.retries++;
|
||||||
if ( test.retries <= maxRetries ) {
|
if ( test.retries <= maxRetries ) {
|
||||||
console.log(
|
console.log(
|
||||||
`\nRetrying test ${ reportId } for ${ chalk.yellow( test.options.suite ) }...${
|
`\nRetrying test ${ reportId } for ${ chalk.yellow(
|
||||||
test.retries
|
test.options.suite
|
||||||
}`
|
) }...${ test.retries }`
|
||||||
);
|
);
|
||||||
return test;
|
return test;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hardRetryTest( reportId, maxHardRetries ) {
|
||||||
|
if ( !maxHardRetries ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const test = queue.find( ( test ) => test.id === reportId );
|
||||||
|
if ( test ) {
|
||||||
|
test.hardRetries++;
|
||||||
|
if ( test.hardRetries <= maxHardRetries ) {
|
||||||
|
console.log(
|
||||||
|
`\nHard retrying test ${ reportId } for ${ chalk.yellow(
|
||||||
|
test.options.suite
|
||||||
|
) }...${ test.hardRetries }`
|
||||||
|
);
|
||||||
|
await restartBrowser( test.browser );
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function addRun( url, browser, options ) {
|
export function addRun( url, browser, options ) {
|
||||||
queue.push( {
|
queue.push( {
|
||||||
browser,
|
browser,
|
||||||
fullBrowser: getBrowserString( browser ),
|
fullBrowser: getBrowserString( browser ),
|
||||||
|
hardRetries: 0,
|
||||||
id: options.reportId,
|
id: options.reportId,
|
||||||
retries: 0,
|
|
||||||
url,
|
url,
|
||||||
options,
|
options,
|
||||||
|
retries: 0,
|
||||||
running: false
|
running: false
|
||||||
} );
|
} );
|
||||||
}
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import * as Diff from "diff";
|
||||||
import { getBrowserString } from "./lib/getBrowserString.js";
|
import { getBrowserString } from "./lib/getBrowserString.js";
|
||||||
import { prettyMs } from "./lib/prettyMs.js";
|
import { prettyMs } from "./lib/prettyMs.js";
|
||||||
import * as Diff from "diff";
|
|
||||||
|
|
||||||
function serializeForDiff( value ) {
|
function serializeForDiff( value ) {
|
||||||
|
|
||||||
|
@ -1,13 +1,22 @@
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { asyncExitHook, gracefulExit } from "exit-hook";
|
import { asyncExitHook, gracefulExit } from "exit-hook";
|
||||||
|
import { getLatestBrowser } from "./browserstack/api.js";
|
||||||
|
import { buildBrowserFromString } from "./browserstack/buildBrowserFromString.js";
|
||||||
|
import { localTunnel } from "./browserstack/local.js";
|
||||||
import { reportEnd, reportTest } from "./reporter.js";
|
import { reportEnd, reportTest } from "./reporter.js";
|
||||||
import { createTestServer } from "./createTestServer.js";
|
import { createTestServer } from "./createTestServer.js";
|
||||||
import { buildTestUrl } from "./lib/buildTestUrl.js";
|
import { buildTestUrl } from "./lib/buildTestUrl.js";
|
||||||
import { generateHash } from "./lib/generateHash.js";
|
import { generateHash } from "./lib/generateHash.js";
|
||||||
import { getBrowserString } from "./lib/getBrowserString.js";
|
import { getBrowserString } from "./lib/getBrowserString.js";
|
||||||
import { suites as allSuites } from "./suites.js";
|
import { suites as allSuites } from "./flags/suites.js";
|
||||||
import { cleanupAllBrowsers, touchBrowser } from "./selenium/browsers.js";
|
import { cleanupAllBrowsers, touchBrowser } from "./browsers.js";
|
||||||
import { addRun, getNextBrowserTest, retryTest, runAll } from "./selenium/queue.js";
|
import {
|
||||||
|
addRun,
|
||||||
|
getNextBrowserTest,
|
||||||
|
hardRetryTest,
|
||||||
|
retryTest,
|
||||||
|
runAll
|
||||||
|
} from "./queue.js";
|
||||||
|
|
||||||
const EXIT_HOOK_WAIT_TIMEOUT = 60 * 1000;
|
const EXIT_HOOK_WAIT_TIMEOUT = 60 * 1000;
|
||||||
|
|
||||||
@ -16,12 +25,15 @@ const EXIT_HOOK_WAIT_TIMEOUT = 60 * 1000;
|
|||||||
*/
|
*/
|
||||||
export async function run( {
|
export async function run( {
|
||||||
browser: browserNames = [],
|
browser: browserNames = [],
|
||||||
|
browserstack,
|
||||||
concurrency,
|
concurrency,
|
||||||
debug,
|
debug,
|
||||||
|
hardRetries,
|
||||||
headless,
|
headless,
|
||||||
jquery: jquerys = [],
|
jquery: jquerys = [],
|
||||||
migrate,
|
migrate,
|
||||||
retries = 0,
|
retries = 0,
|
||||||
|
runId,
|
||||||
suite: suites = [],
|
suite: suites = [],
|
||||||
verbose
|
verbose
|
||||||
} ) {
|
} ) {
|
||||||
@ -40,11 +52,25 @@ export async function run( {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( verbose ) {
|
||||||
|
console.log( browserstack ? "Running in BrowserStack." : "Running locally." );
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessages = [];
|
const errorMessages = [];
|
||||||
const pendingErrors = {};
|
const pendingErrors = {};
|
||||||
|
|
||||||
// Convert browser names to browser objects
|
// Convert browser names to browser objects
|
||||||
let browsers = browserNames.map( ( b ) => ( { browser: b } ) );
|
let browsers = browserNames.map( ( b ) => ( { browser: b } ) );
|
||||||
|
const tunnelId = generateHash(
|
||||||
|
`${ Date.now() }-${ suites.join( ":" ) }-${ ( browserstack || [] )
|
||||||
|
.concat( browserNames )
|
||||||
|
.join( ":" ) }`
|
||||||
|
);
|
||||||
|
|
||||||
|
// A unique identifier for this run
|
||||||
|
if ( !runId ) {
|
||||||
|
runId = tunnelId;
|
||||||
|
}
|
||||||
|
|
||||||
// Create the test app and
|
// Create the test app and
|
||||||
// hook it up to the reporter
|
// hook it up to the reporter
|
||||||
@ -96,6 +122,10 @@ export async function run( {
|
|||||||
return retry;
|
return retry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return early if hardRetryTest returns true
|
||||||
|
if ( await hardRetryTest( reportId, hardRetries ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
errorMessages.push( ...Object.values( pendingErrors[ reportId ] ) );
|
errorMessages.push( ...Object.values( pendingErrors[ reportId ] ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,22 +173,84 @@ export async function run( {
|
|||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
console.log( "Cleaning up..." );
|
||||||
|
|
||||||
|
await cleanupAllBrowsers( { verbose } );
|
||||||
|
|
||||||
|
if ( tunnel ) {
|
||||||
|
await tunnel.stop();
|
||||||
|
if ( verbose ) {
|
||||||
|
console.log( "Stopped BrowserStackLocal." );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
asyncExitHook(
|
asyncExitHook(
|
||||||
async() => {
|
async() => {
|
||||||
await cleanupAllBrowsers( { verbose } );
|
await cleanup();
|
||||||
await stopServer();
|
await stopServer();
|
||||||
},
|
},
|
||||||
{ wait: EXIT_HOOK_WAIT_TIMEOUT }
|
{ wait: EXIT_HOOK_WAIT_TIMEOUT }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Start up BrowserStackLocal
|
||||||
|
let tunnel;
|
||||||
|
if ( browserstack ) {
|
||||||
|
if ( headless ) {
|
||||||
|
console.warn(
|
||||||
|
chalk.italic(
|
||||||
|
"BrowserStack does not support headless mode. Running in normal mode."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
headless = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert browserstack to browser objects.
|
||||||
|
// If browserstack is an empty array, fall back
|
||||||
|
// to the browsers array.
|
||||||
|
if ( browserstack.length ) {
|
||||||
|
browsers = browserstack.map( ( b ) => {
|
||||||
|
if ( !b ) {
|
||||||
|
return browsers[ 0 ];
|
||||||
|
}
|
||||||
|
return buildBrowserFromString( b );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill out browser defaults
|
||||||
|
browsers = await Promise.all(
|
||||||
|
browsers.map( async( browser ) => {
|
||||||
|
|
||||||
|
// Avoid undici connect timeout errors
|
||||||
|
await new Promise( ( resolve ) => setTimeout( resolve, 100 ) );
|
||||||
|
|
||||||
|
const latestMatch = await getLatestBrowser( browser );
|
||||||
|
if ( !latestMatch ) {
|
||||||
|
console.error(
|
||||||
|
chalk.red( `Browser not found: ${ getBrowserString( browser ) }.` )
|
||||||
|
);
|
||||||
|
gracefulExit( 1 );
|
||||||
|
}
|
||||||
|
return latestMatch;
|
||||||
|
} )
|
||||||
|
);
|
||||||
|
|
||||||
|
tunnel = await localTunnel( tunnelId );
|
||||||
|
if ( verbose ) {
|
||||||
|
console.log( "Started BrowserStackLocal." );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function queueRuns( suite, browser ) {
|
function queueRuns( suite, browser ) {
|
||||||
const fullBrowser = getBrowserString( browser, headless );
|
const fullBrowser = getBrowserString( browser, headless );
|
||||||
|
|
||||||
for ( const jquery of jquerys ) {
|
for ( const jquery of jquerys ) {
|
||||||
const reportId = generateHash( `${ suite } ${ fullBrowser }` );
|
const reportId = generateHash( `${ suite } ${ jquery } ${ fullBrowser }` );
|
||||||
reports[ reportId ] = { browser, headless, jquery, migrate, suite };
|
reports[ reportId ] = { browser, headless, jquery, migrate, suite };
|
||||||
|
|
||||||
const url = buildTestUrl( suite, {
|
const url = buildTestUrl( suite, {
|
||||||
|
browserstack,
|
||||||
jquery,
|
jquery,
|
||||||
migrate,
|
migrate,
|
||||||
port,
|
port,
|
||||||
@ -166,12 +258,16 @@ export async function run( {
|
|||||||
} );
|
} );
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
|
browserstack,
|
||||||
|
concurrency,
|
||||||
debug,
|
debug,
|
||||||
headless,
|
headless,
|
||||||
jquery,
|
jquery,
|
||||||
migrate,
|
migrate,
|
||||||
reportId,
|
reportId,
|
||||||
|
runId,
|
||||||
suite,
|
suite,
|
||||||
|
tunnelId,
|
||||||
verbose
|
verbose
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -181,12 +277,13 @@ export async function run( {
|
|||||||
|
|
||||||
for ( const browser of browsers ) {
|
for ( const browser of browsers ) {
|
||||||
for ( const suite of suites ) {
|
for ( const suite of suites ) {
|
||||||
queueRuns( suite, browser );
|
queueRuns( [ suite ], browser );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runAll( { concurrency, verbose } );
|
console.log( `Starting Run ${ runId }...` );
|
||||||
|
await runAll();
|
||||||
} catch ( error ) {
|
} catch ( error ) {
|
||||||
console.error( error );
|
console.error( error );
|
||||||
if ( !debug ) {
|
if ( !debug ) {
|
||||||
@ -213,7 +310,7 @@ export async function run( {
|
|||||||
}
|
}
|
||||||
console.log( chalk.green( "All tests passed!" ) );
|
console.log( chalk.green( "All tests passed!" ) );
|
||||||
|
|
||||||
if ( !debug ) {
|
if ( !debug || browserstack ) {
|
||||||
gracefulExit( 0 );
|
gracefulExit( 0 );
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -224,7 +321,14 @@ export async function run( {
|
|||||||
|
|
||||||
if ( debug ) {
|
if ( debug ) {
|
||||||
console.log();
|
console.log();
|
||||||
console.log( "Leaving browsers open for debugging." );
|
if ( browserstack ) {
|
||||||
|
console.log( "Leaving browsers with failures open for debugging." );
|
||||||
|
console.log(
|
||||||
|
"View running sessions at https://automate.browserstack.com/dashboard/v2/"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log( "Leaving browsers open for debugging." );
|
||||||
|
}
|
||||||
console.log( "Press Ctrl+C to exit." );
|
console.log( "Press Ctrl+C to exit." );
|
||||||
} else {
|
} else {
|
||||||
gracefulExit( 1 );
|
gracefulExit( 1 );
|
||||||
|
@ -1,200 +0,0 @@
|
|||||||
import chalk from "chalk";
|
|
||||||
import { getBrowserString } from "../lib/getBrowserString.js";
|
|
||||||
import createDriver from "./createDriver.js";
|
|
||||||
|
|
||||||
const workers = Object.create( null );
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keys are browser strings
|
|
||||||
* Structure of a worker:
|
|
||||||
* {
|
|
||||||
* debug: boolean, // Stops the worker from being cleaned up when finished
|
|
||||||
* id: string,
|
|
||||||
* lastTouch: number, // The last time a request was received
|
|
||||||
* url: string,
|
|
||||||
* browser: object, // The browser object
|
|
||||||
* options: object // The options to create the worker
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Acknowledge the worker within the time limit.
|
|
||||||
const ACKNOWLEDGE_INTERVAL = 1000;
|
|
||||||
const ACKNOWLEDGE_TIMEOUT = 60 * 1000 * 1;
|
|
||||||
|
|
||||||
const MAX_WORKER_RESTARTS = 5;
|
|
||||||
|
|
||||||
// No report after the time limit
|
|
||||||
// should refresh the worker
|
|
||||||
const RUN_WORKER_TIMEOUT = 60 * 1000 * 2;
|
|
||||||
|
|
||||||
const WORKER_WAIT_TIME = 30000;
|
|
||||||
|
|
||||||
// Limit concurrency to 8 by default in selenium
|
|
||||||
const MAX_CONCURRENCY = 8;
|
|
||||||
|
|
||||||
export function touchBrowser( browser ) {
|
|
||||||
const fullBrowser = getBrowserString( browser );
|
|
||||||
const worker = workers[ fullBrowser ];
|
|
||||||
if ( worker ) {
|
|
||||||
worker.lastTouch = Date.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForAck( worker, { fullBrowser, verbose } ) {
|
|
||||||
delete worker.lastTouch;
|
|
||||||
return new Promise( ( resolve, reject ) => {
|
|
||||||
const interval = setInterval( () => {
|
|
||||||
if ( worker.lastTouch ) {
|
|
||||||
if ( verbose ) {
|
|
||||||
console.log( `\n${ fullBrowser } acknowledged.` );
|
|
||||||
}
|
|
||||||
clearTimeout( timeout );
|
|
||||||
clearInterval( interval );
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}, ACKNOWLEDGE_INTERVAL );
|
|
||||||
|
|
||||||
const timeout = setTimeout( () => {
|
|
||||||
clearInterval( interval );
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`${ fullBrowser } not acknowledged after ${
|
|
||||||
ACKNOWLEDGE_TIMEOUT / 1000 / 60
|
|
||||||
}min.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, ACKNOWLEDGE_TIMEOUT );
|
|
||||||
} );
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restartWorker( worker ) {
|
|
||||||
await cleanupWorker( worker, worker.options );
|
|
||||||
await createBrowserWorker(
|
|
||||||
worker.url,
|
|
||||||
worker.browser,
|
|
||||||
worker.options,
|
|
||||||
worker.restarts + 1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureAcknowledged( worker ) {
|
|
||||||
const fullBrowser = getBrowserString( worker.browser );
|
|
||||||
const verbose = worker.options.verbose;
|
|
||||||
try {
|
|
||||||
await waitForAck( worker, { fullBrowser, verbose } );
|
|
||||||
return worker;
|
|
||||||
} catch ( error ) {
|
|
||||||
console.error( error.message );
|
|
||||||
await restartWorker( worker );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createBrowserWorker( url, browser, options, restarts = 0 ) {
|
|
||||||
if ( restarts > MAX_WORKER_RESTARTS ) {
|
|
||||||
throw new Error(
|
|
||||||
`Reached the maximum number of restarts for ${ chalk.yellow(
|
|
||||||
getBrowserString( browser )
|
|
||||||
) }`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const { concurrency = MAX_CONCURRENCY, debug, headless, verbose } = options;
|
|
||||||
while ( workers.length >= concurrency ) {
|
|
||||||
if ( verbose ) {
|
|
||||||
console.log( "\nWaiting for available sessions..." );
|
|
||||||
}
|
|
||||||
await new Promise( ( resolve ) => setTimeout( resolve, WORKER_WAIT_TIME ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullBrowser = getBrowserString( browser );
|
|
||||||
|
|
||||||
const driver = await createDriver( {
|
|
||||||
browserName: browser.browser,
|
|
||||||
headless,
|
|
||||||
url,
|
|
||||||
verbose
|
|
||||||
} );
|
|
||||||
|
|
||||||
const worker = {
|
|
||||||
debug: !!debug,
|
|
||||||
driver,
|
|
||||||
url,
|
|
||||||
browser,
|
|
||||||
restarts,
|
|
||||||
options
|
|
||||||
};
|
|
||||||
|
|
||||||
worker.debug = !!debug;
|
|
||||||
worker.url = url;
|
|
||||||
worker.browser = browser;
|
|
||||||
worker.restarts = restarts;
|
|
||||||
worker.options = options;
|
|
||||||
touchBrowser( browser );
|
|
||||||
workers[ fullBrowser ] = worker;
|
|
||||||
|
|
||||||
// Wait for the worker to show up in the list
|
|
||||||
// before returning it.
|
|
||||||
return ensureAcknowledged( worker );
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setBrowserWorkerUrl( browser, url ) {
|
|
||||||
const fullBrowser = getBrowserString( browser );
|
|
||||||
const worker = workers[ fullBrowser ];
|
|
||||||
if ( worker ) {
|
|
||||||
worker.url = url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks that all browsers have received
|
|
||||||
* a response in the given amount of time.
|
|
||||||
* If not, the worker is restarted.
|
|
||||||
*/
|
|
||||||
export async function checkLastTouches() {
|
|
||||||
for ( const [ fullBrowser, worker ] of Object.entries( workers ) ) {
|
|
||||||
if ( Date.now() - worker.lastTouch > RUN_WORKER_TIMEOUT ) {
|
|
||||||
const options = worker.options;
|
|
||||||
if ( options.verbose ) {
|
|
||||||
console.log(
|
|
||||||
`\nNo response from ${ chalk.yellow( fullBrowser ) } in ${
|
|
||||||
RUN_WORKER_TIMEOUT / 1000 / 60
|
|
||||||
}min.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await restartWorker( worker );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cleanupWorker( worker, { verbose } ) {
|
|
||||||
for ( const [ fullBrowser, w ] of Object.entries( workers ) ) {
|
|
||||||
if ( w === worker ) {
|
|
||||||
delete workers[ fullBrowser ];
|
|
||||||
await w.driver.quit();
|
|
||||||
if ( verbose ) {
|
|
||||||
console.log( `\nStopped ${ fullBrowser }.` );
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cleanupAllBrowsers( { verbose } ) {
|
|
||||||
const workersRemaining = Object.values( workers );
|
|
||||||
const numRemaining = workersRemaining.length;
|
|
||||||
if ( numRemaining ) {
|
|
||||||
try {
|
|
||||||
await Promise.all(
|
|
||||||
workersRemaining.map( ( worker ) => worker.driver.quit() )
|
|
||||||
);
|
|
||||||
if ( verbose ) {
|
|
||||||
console.log(
|
|
||||||
`Stopped ${ numRemaining } browser${ numRemaining > 1 ? "s" : "" }.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch ( error ) {
|
|
||||||
|
|
||||||
// Log the error, but do not consider the test run failed
|
|
||||||
console.error( error );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user