Tests: migrate test runner to jquery-test-runner

Closes gh-5604
This commit is contained in:
Timmy Willison
2025-01-13 22:36:10 -05:00
committed by GitHub
parent 4466770992
commit 733e62d203
29 changed files with 1283 additions and 4077 deletions

View File

@@ -59,4 +59,6 @@ jobs:
run: npm run pretest
- name: Run tests
run: npm run test:unit -- -v --browserstack ${{ inputs.browser }} -m ${{ inputs.module }}
run: npm run test:unit -- \
-v --browserstack ${{ inputs.browser }} \
-f module=${{ inputs.module }}

View File

@@ -12,7 +12,7 @@ jobs:
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
NODE_VERSION: 20.x
NODE_VERSION: 22.x
name: ${{ matrix.BROWSER }}
concurrency:
group: ${{ github.workflow }}-${{ matrix.BROWSER }}
@@ -61,4 +61,7 @@ jobs:
run: npm run pretest
- name: Run tests
run: npm run test:unit -- -v --browserstack "${{ matrix.BROWSER }}" --run-id ${{ github.run_id }} --isolate --retries 3 --hard-retries 1
run: |
npm run test:unit -- -v -c jtr-isolate.yml \
--browserstack "${{ matrix.BROWSER }}" \
--run-id ${{ github.run_id }}

View File

@@ -16,7 +16,7 @@ jobs:
if: ${{ github.repository == 'jquery/jquery' }}
environment: filestash
env:
NODE_VERSION: 20.x
NODE_VERSION: 22.x
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

View File

@@ -20,28 +20,28 @@ jobs:
NPM_SCRIPT: ["test:browserless"]
include:
- NAME: "Node"
NODE_VERSION: "20.x"
NODE_VERSION: "22.x"
NPM_SCRIPT: "lint"
- NAME: "Chrome/Firefox"
NODE_VERSION: "20.x"
NODE_VERSION: "22.x"
NPM_SCRIPT: "test:browser"
- NAME: "Chrome"
NODE_VERSION: "20.x"
NODE_VERSION: "22.x"
NPM_SCRIPT: "test:slim"
- NAME: "Chrome"
NODE_VERSION: "20.x"
NODE_VERSION: "22.x"
NPM_SCRIPT: "test:no-deprecated"
- NAME: "Chrome"
NODE_VERSION: "20.x"
NODE_VERSION: "22.x"
NPM_SCRIPT: "test:selector-native"
- NAME: "Chrome"
NODE_VERSION: "20.x"
NODE_VERSION: "22.x"
NPM_SCRIPT: "test:esm"
- NAME: "Firefox ESR (new)"
NODE_VERSION: "20.x"
NODE_VERSION: "22.x"
NPM_SCRIPT: "test:firefox"
- NAME: "Firefox ESR (old)"
NODE_VERSION: "20.x"
NODE_VERSION: "22.x"
NPM_SCRIPT: "test:firefox"
steps:
- name: Checkout
@@ -80,7 +80,7 @@ jobs:
- name: Install dependencies
run: npm install
- name: Build All for Linting
- name: Build all for linting
run: npm run build:all
if: contains(matrix.NPM_SCRIPT, 'lint')
@@ -90,7 +90,7 @@ jobs:
ie:
runs-on: windows-latest
env:
NODE_VERSION: 20.x
NODE_VERSION: 22.x
name: test:ie - IE
steps:
- name: Checkout
@@ -118,7 +118,7 @@ jobs:
safari:
runs-on: macos-latest
env:
NODE_VERSION: 20.x
NODE_VERSION: 22.x
name: test:safari - Safari
steps:
- name: Checkout

View File

@@ -19,7 +19,7 @@ jobs:
# skip on forks
if: ${{ github.repository == 'jquery/jquery' }}
env:
NODE_VERSION: 20.x
NODE_VERSION: 22.x
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

View File

@@ -131,10 +131,10 @@ This will only run the "css" module tests. This will significantly speed up your
#### Change the test server port
The default port for the test server is 3000. You can change the port by setting the `PORT` environment variable.
The default port for the test server is 3000. You can change the port by setting the `--port` option.
```bash
$ PORT=3001 npm run test:server
$ npm run test:server -- --port 8000
```
#### Loading changes on the test page
@@ -165,7 +165,7 @@ Make sure jQuery is built (`npm run build:all`) and run the tests:
$ npm run test:unit
```
This will run each module in its own browser instance and report the results in the terminal.
This will run all tests and report the results in the terminal.
View the full help for the test suite for more info on running the tests from the command line:
@@ -173,6 +173,26 @@ View the full help for the test suite for more info on running the tests from th
$ npm run test:unit -- --help
```
#### Running a single module
All test modules run by default. Run a single module by specifying the module in a "flag":
```bash
$ npm run test:unit -- --flag module=css
```
Or, run multiple modules with multiple flags (`-f` is shorthand for `--flag`):
```bash
$ npm run test:unit -- -f module=css -f module=effects
```
Anything passed to the `--flag` option is passed as query parameters on the QUnit test page. For instance, run tests with unminified code with the `dev` flag:
```bash
$ npm run test:unit -- -f dev
```
### Repo organization
The jQuery source is organized with ECMAScript modules and then compiled into one file at build time.

30
jtr-isolate.yml Normal file
View File

@@ -0,0 +1,30 @@
version: 1
base-url: /test/
runs:
module:
- basic
- ajax
- animation
- attributes
- callbacks
- core
- css
- data
- deferred
- deprecated
- dimensions
- effects
- event
- manipulation
- offset
- queue
- selector
- serialize
- support
- traversing
- tween
retries: 3
hard-retries: 1

3235
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -66,22 +66,22 @@
"release:verify": "node build/release/verify.js",
"start": "node --input-type=module -e \"import { buildDefaultFiles } from './build/tasks/build.js'; buildDefaultFiles({ watch: true })\"",
"test:bundlers": "npm run pretest && npm run build:all && node test/bundler_smoke_tests/run-jsdom-tests.js",
"test:browser": "npm run pretest && npm run build:main && npm run test:unit -- -b chrome -b firefox -h",
"test:browserless": "npm run pretest && npm run build:all && node test/bundler_smoke_tests/run-jsdom-tests.js && node build/tasks/node_smoke_tests.js && node build/tasks/promises_aplus_tests.js && npm run test:unit -- -b jsdom -m basic",
"test:jsdom": "npm run pretest && npm run build:main && npm run test:unit -- -b jsdom -m basic",
"test:browser": "npm run pretest && npm run build:main && npm run test:unit -- -b chrome -b firefox --headless",
"test:browserless": "npm run pretest && npm run build:all && node test/bundler_smoke_tests/run-jsdom-tests.js && node build/tasks/node_smoke_tests.js && node build/tasks/promises_aplus_tests.js && npm run test:unit -- -b jsdom -f module=basic",
"test:jsdom": "npm run pretest && npm run build:main && npm run test:unit -- -b jsdom -f module=basic",
"test:node_smoke_tests": "npm run pretest && npm run build:all && node build/tasks/node_smoke_tests.js",
"test:promises_aplus": "npm run build:main && node build/tasks/promises_aplus_tests.js",
"test:chrome": "npm run pretest && npm run build:main && npm run test:unit -- -v -b chrome -h",
"test:edge": "npm run pretest && npm run build:main && npm run test:unit -- -v -b edge -h",
"test:firefox": "npm run pretest && npm run build:main && npm run test:unit -- -v -b firefox -h",
"test:chrome": "npm run pretest && npm run build:main && npm run test:unit -- -v -b chrome --headless",
"test:edge": "npm run pretest && npm run build:main && npm run test:unit -- -v -b edge --headless",
"test:firefox": "npm run pretest && npm run build:main && npm run test:unit -- -v -b firefox --headless",
"test:ie": "npm run pretest && npm run build:main && npm run test:unit -- -v -b ie",
"test:safari": "npm run pretest && npm run build:main && npm run test:unit -- -b safari",
"test:server": "node test/runner/server.js",
"test:esm": "npm run pretest && npm run build:main && npm run test:unit -- --esm -h",
"test:no-deprecated": "npm run pretest && npm run build -- -e deprecated && npm run test:unit -- -h",
"test:selector-native": "npm run pretest && npm run build -- -e selector && npm run test:unit -- -h",
"test:slim": "npm run pretest && npm run build -- --slim && npm run test:unit -- -h",
"test:unit": "node test/runner/command.js",
"test:server": "jtr serve -m test/middleware-mockserver.cjs",
"test:esm": "npm run pretest && npm run build:main && npm run test:unit -- -f esmodules --headless",
"test:no-deprecated": "npm run pretest && npm run build -- -e deprecated && npm run test:unit -- --headless",
"test:selector-native": "npm run pretest && npm run build -- -e selector && npm run test:unit -- --headless",
"test:slim": "npm run pretest && npm run build -- --slim && npm run test:unit -- --headless",
"test:unit": "jtr -m test/middleware-mockserver.cjs",
"test": "npm run build:all && npm run lint && npm run test:browserless && npm run test:browser && npm run test:esm && npm run test:slim && npm run test:no-deprecated && npm run test:selector-native"
},
"homepage": "https://jquery.com",
@@ -111,38 +111,32 @@
"@rollup/plugin-commonjs": "26.0.1",
"@rollup/plugin-node-resolve": "15.2.3",
"@swc/core": "1.7.0",
"@types/selenium-webdriver": "4.1.24",
"archiver": "7.0.1",
"bootstrap": "5.3.3",
"browserstack-local": "1.5.5",
"chalk": "5.3.0",
"colors": "1.4.0",
"commitplease": "3.2.0",
"concurrently": "8.2.2",
"core-js-bundle": "3.37.1",
"cross-env": "7.0.3",
"diff": "5.2.0",
"eslint": "8.57.0",
"eslint-config-jquery": "3.0.2",
"eslint-plugin-import": "2.29.1",
"exit-hook": "4.0.0",
"globals": "15.8.0",
"globals": "15.14.0",
"husky": "9.1.1",
"jsdom": "24.1.1",
"jquery-test-runner": "0.2.1",
"jsdom": "25.0.1",
"marked": "13.0.2",
"multiparty": "4.2.3",
"native-promise-only": "0.8.1",
"promises-aplus-tests": "npm:@mgol/promises-aplus-tests@2.1.2-mgol.1",
"qunit": "2.21.1",
"raw-body": "2.5.2",
"raw-body": "3.0.0",
"release-it": "17.6.0",
"requirejs": "2.3.7",
"rimraf": "6.0.1",
"rollup": "4.22.4",
"selenium-webdriver": "4.23.0",
"sinon": "9.2.4",
"webpack": "5.94.0",
"yargs": "17.7.2"
"webpack": "5.94.0"
},
"commitplease": {
"nohook": true,

View File

@@ -1,248 +0,0 @@
import chalk from "chalk";
import { getBrowserString } from "./lib/getBrowserString.js";
import {
createWorker,
deleteWorker,
getAvailableSessions
} from "./browserstack/api.js";
import createDriver from "./selenium/createDriver.js";
import createWindow from "./jsdom/createWindow.js";
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, reportId, 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 if ( browser.browser === "jsdom" ) {
const window = await createWindow( { reportId, url, verbose } );
worker = {
quit: () => window.close()
};
} 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
);
}

View File

@@ -1,332 +0,0 @@
/**
* 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;
}
}

View File

@@ -1,20 +0,0 @@
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
};
}

View File

@@ -1,7 +0,0 @@
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 }`;
}

View File

@@ -1,34 +0,0 @@
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();
} );
} );
}
} );
}
);
} );
}

View File

@@ -1,134 +0,0 @@
import yargs from "yargs/yargs";
import { browsers } from "./flags/browsers.js";
import { getPlan, listBrowsers, stopWorkers } from "./browserstack/api.js";
import { buildBrowserFromString } from "./browserstack/buildBrowserFromString.js";
import { modules } from "./flags/modules.js";
import { run } from "./run.js";
const argv = yargs( process.argv.slice( 2 ) )
.version( false )
.strict()
.command( {
command: "[options]",
describe: "Run jQuery tests in a browser"
} )
.option( "module", {
alias: "m",
type: "array",
choices: modules,
description:
"Run tests for a specific module. " +
"Pass multiple modules by repeating the option. " +
"Defaults to all modules."
} )
.option( "browser", {
alias: "b",
type: "array",
choices: browsers,
description:
"Run tests in a specific browser." +
"Pass multiple browsers by repeating the option." +
"If using BrowserStack, specify browsers using --browserstack." +
"Only the basic module is supported on jsdom.",
default: [ "chrome" ]
} )
.option( "headless", {
alias: "h",
type: "boolean",
description:
"Run tests in headless mode. Cannot be used with --debug or --browserstack.",
conflicts: [ "debug", "browserstack" ]
} )
.option( "esm", {
alias: "esmodules",
type: "boolean",
description: "Run tests using jQuery's source, which is written with ECMAScript Modules."
} )
.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", {
alias: "d",
type: "boolean",
description:
"Leave the browser open for debugging. Cannot be used with --headless.",
conflicts: [ "headless" ]
} )
.option( "retries", {
alias: "r",
type: "number",
description: "Number of times to retry failed tests by refreshing the URL."
} )
.option( "hard-retries", {
type: "number",
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", {
alias: "v",
type: "boolean",
description: "Log additional information."
} )
.option( "isolate", {
type: "boolean",
description: "Run each module by itself in the test page. This can extend testing time."
} )
.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;
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 );
}

View File

@@ -1,192 +0,0 @@
import http from "node:http";
import { readFile, stat } from "node:fs/promises";
import { createReadStream } from "node:fs";
import mockServer from "../middleware-mockserver.cjs";
import getRawBody from "raw-body";
export async function createTestServer( report, { quiet } = {} ) {
const indexHTML = await readFile( "./test/index.html", "utf8" );
// Support connect-style middleware
const middlewares = [];
function use( middleware ) {
middlewares.push( middleware );
}
function run( req, res ) {
let i = 0;
// Log responses unless quiet is set
if ( !quiet ) {
const originalEnd = res.end;
res.end = function( ...args ) {
console.log( `${ req.method } ${ req.url } ${ this.statusCode }` );
originalEnd.call( this, ...args );
};
}
// Add a parsed URL object to the request object
req.parsedUrl = new URL(
`http://${ process.env.HOST ?? "localhost" }${ req.url }`
);
// Add a simplified redirect helper to the response object
res.redirect = ( status, location ) => {
if ( !location ) {
location = status;
status = 303;
}
res.writeHead( status, { Location: location } );
res.end();
};
const next = () => {
const middleware = middlewares[ i++ ];
if ( middleware ) {
try {
middleware( req, res, next );
} catch ( error ) {
console.error( error );
res.writeHead( 500, { "Content-Type": "application/json" } );
res.end( "Internal Server Error" );
}
} else {
res.writeHead( 404 );
res.end();
}
};
next();
}
// Redirect home to test page
use( ( req, res, next ) => {
if ( req.parsedUrl.pathname === "/" ) {
res.redirect( "/test/" );
} else {
next();
}
} );
// Redirect to trailing slash
use( ( req, res, next ) => {
if ( req.parsedUrl.pathname === "/test" ) {
res.redirect( 308, `${ req.parsedUrl.pathname }/${ req.parsedUrl.search }` );
} else {
next();
}
} );
// Add a script tag to the index.html to load the QUnit listeners
use( ( req, res, next ) => {
if (
( req.method === "GET" || req.method === "HEAD" ) &&
( req.parsedUrl.pathname === "/test/" ||
req.parsedUrl.pathname === "/test/index.html" )
) {
res.writeHead( 200, { "Content-Type": "text/html" } );
res.end(
indexHTML.replace(
"</head>",
"<script src=\"/test/runner/listeners.js\"></script></head>"
)
);
} else {
next();
}
} );
// Bind the reporter
use( async( req, res, next ) => {
if ( req.url !== "/api/report" || req.method !== "POST" ) {
return next();
}
let body;
try {
body = JSON.parse( await getRawBody( req ) );
} catch ( error ) {
if ( error.code === "ECONNABORTED" ) {
return;
}
console.error( error );
res.writeHead( 400, { "Content-Type": "application/json" } );
res.end( JSON.stringify( { error: "Invalid JSON" } ) );
return;
}
const response = await report( body );
if ( response ) {
res.writeHead( 200, { "Content-Type": "application/json" } );
res.end( JSON.stringify( response ) );
} else {
res.writeHead( 204 );
res.end();
}
} );
// Hook up mock server
use( mockServer() );
// Serve static files
const validMimeTypes = {
// No .mjs or .cjs files are used in tests
".js": "application/javascript",
".css": "text/css",
".html": "text/html",
".xml": "application/xml",
".xhtml": "application/xhtml+xml",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".map": "application/json",
".txt": "text/plain",
".log": "text/plain"
};
use( async( req, res, next ) => {
if (
!req.url.startsWith( "/dist/" ) &&
!req.url.startsWith( "/src/" ) &&
!req.url.startsWith( "/test/" ) &&
!req.url.startsWith( "/external/" )
) {
return next();
}
const file = req.parsedUrl.pathname.slice( 1 );
const ext = file.slice( file.lastIndexOf( "." ) );
// Allow POST to .html files in tests
if (
req.method !== "GET" &&
req.method !== "HEAD" &&
( ext !== ".html" || req.method !== "POST" )
) {
return next();
}
const mimeType = validMimeTypes[ ext ];
if ( mimeType ) {
try {
await stat( file );
} catch ( error ) {
res.writeHead( 404 );
res.end();
return;
}
res.writeHead( 200, { "Content-Type": mimeType } );
createReadStream( file )
.pipe( res )
.on( "error", ( error ) => {
console.error( error );
res.writeHead( 500 );
res.end();
} );
} else {
console.error( `Invalid file extension: ${ ext }` );
res.writeHead( 404 );
res.end();
}
} );
return http.createServer( run );
}

View File

@@ -1,25 +0,0 @@
// 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",
"jsdom"
];
// 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.concat( "jsdom" );
}

View File

@@ -1,24 +0,0 @@
export const modules = [
"basic",
"ajax",
"animation",
"attributes",
"callbacks",
"core",
"css",
"data",
"deferred",
"deprecated",
"dimensions",
"effects",
"event",
"manipulation",
"offset",
"queue",
"selector",
"serialize",
"support",
"traversing",
"tween"
];

View File

@@ -1,21 +0,0 @@
import jsdom from "jsdom";
const { JSDOM } = jsdom;
export default async function createWindow( { reportId, url, verbose } ) {
const virtualConsole = new jsdom.VirtualConsole();
virtualConsole.sendTo( console );
virtualConsole.removeAllListeners( "clear" );
const { window } = await JSDOM.fromURL( url, {
resources: "usable",
runScripts: "dangerously",
virtualConsole
} );
if ( verbose ) {
console.log( `JSDOM window created (${ reportId })` );
}
return window;
}

View File

@@ -1,29 +0,0 @@
import { generateModuleId } from "./generateHash.js";
export function buildTestUrl( modules, { browserstack, esm, jsdom, port, reportId } ) {
if ( !port ) {
throw new Error( "No port specified." );
}
const query = new URLSearchParams();
for ( const module of modules ) {
query.append( "moduleId", generateModuleId( module ) );
}
if ( esm ) {
query.append( "esmodules", "true" );
}
if ( jsdom ) {
query.append( "jsdom", "true" );
}
if ( reportId ) {
query.append( "reportId", reportId );
}
// 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 }/test/?${ query }`;
}

View File

@@ -1,50 +0,0 @@
import crypto from "node:crypto";
export function generateHash( string ) {
const hash = crypto.createHash( "md5" );
hash.update( string );
// QUnit hashes are 8 characters long
// We use 10 characters to be more visually distinct
return hash.digest( "hex" ).slice( 0, 10 );
}
/**
* A copy of the generate hash function from QUnit,
* used to generate a hash for the module name.
*
* QUnit errors on passing multiple modules to the
* module query parameter. We need to know
* the hash for each module before loading QUnit
* in order to pass multiple moduleId parameters instead.
*/
export function generateModuleId( module, browser ) {
// QUnit normally hashes the test name, but
// we've repurposed this function to generate
// report IDs for module/browser combinations.
// We still use it without the browser parameter
// to get the same module IDs as QUnit to pass
// multiple ahead-of-time in the query string.
const str = module + "\x1C" + browser;
let hash = 0;
for ( let i = 0; i < str.length; i++ ) {
hash = ( hash << 5 ) - hash + str.charCodeAt( i );
hash |= 0;
}
let hex = ( 0x100000000 + hash ).toString( 16 );
if ( hex.length < 8 ) {
hex = "0000000" + hex;
}
return hex.slice( -8 );
}
export function printModuleHashes( modules ) {
console.log( "Module hashes:" );
modules.forEach( ( module ) => {
console.log( ` ${ module }: ${ generateModuleId( module ) }` );
} );
}

View File

@@ -1,49 +0,0 @@
const browserMap = {
chrome: "Chrome",
edge: "Edge",
firefox: "Firefox",
ie: "IE",
jsdom: "JSDOM",
opera: "Opera",
safari: "Safari"
};
export function browserSupportsHeadless( browser ) {
browser = browser.toLowerCase();
return (
browser === "chrome" ||
browser === "firefox" ||
browser === "edge"
);
}
export function getBrowserString(
{
browser,
browser_version: browserVersion,
device,
os,
os_version: osVersion
},
headless
) {
browser = browser.toLowerCase();
browser = browserMap[ browser ] || browser;
let str = browser;
if ( browserVersion ) {
str += ` ${ browserVersion }`;
}
if ( device ) {
str += ` for ${ device }`;
}
if ( os ) {
str += ` on ${ os }`;
}
if ( osVersion ) {
str += ` ${ osVersion }`;
}
if ( headless && browserSupportsHeadless( browser ) ) {
str += " (headless)";
}
return str;
}

View File

@@ -1,18 +0,0 @@
/**
* Pretty print a time in milliseconds.
*/
export function prettyMs( time ) {
const minutes = Math.floor( time / 60000 );
const seconds = Math.floor( time / 1000 );
const ms = Math.floor( time % 1000 );
let prettyTime = `${ ms }ms`;
if ( seconds > 0 ) {
prettyTime = `${ seconds }s ${ prettyTime }`;
}
if ( minutes > 0 ) {
prettyTime = `${ minutes }m ${ prettyTime }`;
}
return prettyTime;
}

View File

@@ -1,110 +0,0 @@
( function() {
"use strict";
// Get the report ID from the URL.
var match = location.search.match( /reportId=([^&]+)/ );
if ( !match ) {
return;
}
var id = match[ 1 ];
// Adopted from https://github.com/douglascrockford/JSON-js
// Support: IE 11+
// Using the replacer argument of JSON.stringify in IE has issues
// TODO: Replace this with a circular replacer + JSON.stringify + WeakSet
function decycle( object ) {
var objects = [];
// The derez function recurses through the object, producing the deep copy.
function derez( value ) {
if (
typeof value === "object" &&
value !== null &&
!( value instanceof Boolean ) &&
!( value instanceof Date ) &&
!( value instanceof Number ) &&
!( value instanceof RegExp ) &&
!( value instanceof String )
) {
// Return a string early for elements
if ( value.nodeType ) {
return value.toString();
}
if ( objects.indexOf( value ) > -1 ) {
return;
}
objects.push( value );
if ( Array.isArray( value ) ) {
// If it is an array, replicate the array.
return value.map( derez );
} else {
// If it is an object, replicate the object.
var nu = Object.create( null );
Object.keys( value ).forEach( function( name ) {
nu[ name ] = derez( value[ name ] );
} );
return nu;
}
}
// Serialize Symbols as string representations so they are
// sent over the wire after being stringified.
if ( typeof value === "symbol" ) {
// We can *describe* unique symbols, but note that their identity
// (e.g., `Symbol() !== Symbol()`) is lost
var ctor = Symbol.keyFor( value ) !== undefined ? "Symbol.for" : "Symbol";
return ctor + "(" + JSON.stringify( value.description ) + ")";
}
return value;
}
return derez( object );
}
function send( type, data ) {
var json = JSON.stringify( {
id: id,
type: type,
data: data ? decycle( data ) : undefined
} );
var request = new XMLHttpRequest();
request.open( "POST", "/api/report", true );
request.setRequestHeader( "Content-Type", "application/json" );
request.send( json );
return request;
}
// Send acknowledgement to the server.
send( "ack" );
QUnit.on( "testEnd", function( data ) {
send( "testEnd", data );
} );
QUnit.on( "runEnd", function( data ) {
// Reduce the payload size.
// childSuites is large and unused.
data.childSuites = undefined;
var request = send( "runEnd", data );
request.onload = function() {
if ( request.status === 200 && request.responseText ) {
try {
var json = JSON.parse( request.responseText );
window.location = json.url;
} catch ( e ) {
console.error( e );
}
}
};
} );
} )();

View File

@@ -1,119 +0,0 @@
import chalk from "chalk";
import { getBrowserString } from "./lib/getBrowserString.js";
import {
checkLastTouches,
createBrowserWorker,
restartBrowser,
setBrowserWorkerUrl
} from "./browsers.js";
const TEST_POLL_TIMEOUT = 1000;
const queue = [];
export function getNextBrowserTest( reportId ) {
const index = queue.findIndex( ( test ) => test.id === reportId );
if ( index === -1 ) {
return;
}
// Remove the completed test from the queue
const previousTest = queue[ index ];
queue.splice( index, 1 );
// Find the next test for the same browser
for ( const test of queue.slice( index ) ) {
if ( test.fullBrowser === previousTest.fullBrowser ) {
// Set the URL for our tracking
setBrowserWorkerUrl( test.browser, test.url );
test.running = true;
// Return the URL for the next test.
// listeners.js will use this to set the browser URL.
return { url: test.url };
}
}
}
export function retryTest( reportId, maxRetries ) {
if ( !maxRetries ) {
return;
}
const test = queue.find( ( test ) => test.id === reportId );
if ( test ) {
test.retries++;
if ( test.retries <= maxRetries ) {
console.log(
`\nRetrying test ${ reportId } for ${ chalk.yellow(
test.options.modules.join( ", " )
) }...${ test.retries }`
);
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.modules.join( ", " )
) }...${ test.hardRetries }`
);
await restartBrowser( test.browser );
return true;
}
}
return false;
}
export function addRun( url, browser, options ) {
queue.push( {
browser,
fullBrowser: getBrowserString( browser ),
hardRetries: 0,
id: options.reportId,
url,
options,
retries: 0,
running: false
} );
}
export async function runAll() {
return new Promise( async( resolve, reject ) => {
while ( queue.length ) {
try {
await checkLastTouches();
} catch ( error ) {
reject( error );
}
// Run one test URL per browser at a time
const browsersTaken = [];
for ( const test of queue ) {
if ( browsersTaken.indexOf( test.fullBrowser ) > -1 ) {
continue;
}
browsersTaken.push( test.fullBrowser );
if ( !test.running ) {
test.running = true;
try {
await createBrowserWorker( test.url, test.browser, test.options );
} catch ( error ) {
reject( error );
}
}
}
await new Promise( ( resolve ) => setTimeout( resolve, TEST_POLL_TIMEOUT ) );
}
resolve();
} );
}

View File

@@ -1,132 +0,0 @@
import chalk from "chalk";
import { getBrowserString } from "./lib/getBrowserString.js";
import { prettyMs } from "./lib/prettyMs.js";
import * as Diff from "diff";
function serializeForDiff( value ) {
// Use naive serialization for everything except types with confusable values
if ( typeof value === "string" ) {
return JSON.stringify( value );
}
if ( typeof value === "bigint" ) {
return `${ value }n`;
}
return `${ value }`;
}
export function reportTest( test, reportId, { browser, headless } ) {
if ( test.status === "passed" ) {
// Write to console without newlines
process.stdout.write( "." );
return;
}
let message = `${ chalk.bold( `${ test.suiteName }: ${ test.name }` ) }`;
message += `\nTest ${ test.status } on ${ chalk.yellow(
getBrowserString( browser, headless )
) } (${ chalk.bold( reportId ) }).`;
// test.assertions only contains passed assertions;
// test.errors contains all failed asssertions
if ( test.errors.length ) {
for ( const error of test.errors ) {
message += "\n";
if ( error.message ) {
message += `\n${ error.message }`;
}
message += `\n${ chalk.gray( error.stack ) }`;
// Show expected and actual values
// if either is defined and non-null.
// error.actual is set to null for failed
// assert.expect() assertions, so skip those as well.
// This should be fine because error.expected would
// have to also be null for this to be skipped.
if ( error.expected != null || error.actual != null ) {
message += `\nexpected: ${ chalk.red( JSON.stringify( error.expected ) ) }`;
message += `\nactual: ${ chalk.green( JSON.stringify( error.actual ) ) }`;
let diff;
if ( Array.isArray( error.expected ) && Array.isArray( error.actual ) ) {
// Diff arrays
diff = Diff.diffArrays( error.expected, error.actual );
} else if (
typeof error.expected === "object" &&
typeof error.actual === "object"
) {
// Diff objects
diff = Diff.diffJson( error.expected, error.actual );
} else if (
typeof error.expected === "number" &&
typeof error.actual === "number"
) {
// Diff numbers directly
const value = error.actual - error.expected;
if ( value > 0 ) {
diff = [ { added: true, value: `+${ value }` } ];
} else {
diff = [ { removed: true, value: `${ value }` } ];
}
} else if (
typeof error.expected === "string" &&
typeof error.actual === "string"
) {
// Diff the characters of strings
diff = Diff.diffChars( error.expected, error.actual );
} else {
// Diff everything else as words
diff = Diff.diffWords(
serializeForDiff( error.expected ),
serializeForDiff( error.actual )
);
}
if ( diff ) {
message += "\n";
message += diff
.map( ( part ) => {
if ( part.added ) {
return chalk.green( part.value );
}
if ( part.removed ) {
return chalk.red( part.value );
}
return chalk.gray( part.value );
} )
.join( "" );
}
}
}
}
console.log( `\n\n${ message }` );
// Only return failed messages
if ( test.status === "failed" ) {
return message;
}
}
export function reportEnd( result, reportId, { browser, headless, modules } ) {
const fullBrowser = getBrowserString( browser, headless );
console.log(
`\n\nTests finished in ${ prettyMs( result.runtime ) } ` +
`for ${ chalk.yellow( modules.join( "," ) ) } ` +
`in ${ chalk.yellow( fullBrowser ) } (${ chalk.bold( reportId ) })...`
);
console.log(
( result.status !== "passed" ?
`${ chalk.red( result.testCounts.failed ) } failed. ` :
"" ) +
`${ chalk.green( result.testCounts.total ) } passed. ` +
`${ chalk.gray( result.testCounts.skipped ) } skipped.`
);
return result.testCounts;
}

View File

@@ -1,338 +0,0 @@
import chalk from "chalk";
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 { createTestServer } from "./createTestServer.js";
import { buildTestUrl } from "./lib/buildTestUrl.js";
import { generateHash, printModuleHashes } from "./lib/generateHash.js";
import { getBrowserString } from "./lib/getBrowserString.js";
import { modules as allModules } from "./flags/modules.js";
import { cleanupAllBrowsers, touchBrowser } from "./browsers.js";
import {
addRun,
getNextBrowserTest,
hardRetryTest,
retryTest,
runAll
} from "./queue.js";
const EXIT_HOOK_WAIT_TIMEOUT = 60 * 1000;
/**
* Run modules in parallel in different browser instances.
*/
export async function run( {
browser: browserNames = [],
browserstack,
concurrency,
debug,
esm,
hardRetries,
headless,
isolate,
module: modules = [],
retries = 0,
runId,
verbose
} ) {
if ( !browserNames.length ) {
browserNames = [ "chrome" ];
}
if ( !modules.length ) {
modules = allModules;
}
if ( headless && debug ) {
throw new Error(
"Cannot run in headless mode and debug mode at the same time."
);
}
if ( verbose ) {
console.log( browserstack ? "Running in BrowserStack." : "Running locally." );
}
const errorMessages = [];
const pendingErrors = {};
// Convert browser names to browser objects
let browsers = browserNames.map( ( b ) => ( { browser: b } ) );
const tunnelId = generateHash(
`${ Date.now() }-${ modules.join( ":" ) }-${ ( browserstack || [] )
.concat( browserNames )
.join( ":" ) }`
);
// A unique identifier for this run
if ( !runId ) {
runId = tunnelId;
}
// Create the test app and
// hook it up to the reporter
const reports = Object.create( null );
const app = await createTestServer( async( message ) => {
switch ( message.type ) {
case "testEnd": {
const reportId = message.id;
const report = reports[ reportId ];
touchBrowser( report.browser );
const errors = reportTest( message.data, reportId, report );
pendingErrors[ reportId ] ??= Object.create( null );
if ( errors ) {
pendingErrors[ reportId ][ message.data.name ] = errors;
} else {
const existing = pendingErrors[ reportId ][ message.data.name ];
// Show a message for flakey tests
if ( existing ) {
console.log();
console.warn(
chalk.italic(
chalk.gray( existing.replace( "Test failed", "Test flakey" ) )
)
);
console.log();
delete pendingErrors[ reportId ][ message.data.name ];
}
}
break;
}
case "runEnd": {
const reportId = message.id;
const report = reports[ reportId ];
touchBrowser( report.browser );
const { failed, total } = reportEnd(
message.data,
message.id,
reports[ reportId ]
);
report.total = total;
// Handle failure
if ( failed ) {
const retry = retryTest( reportId, retries );
// Retry if retryTest returns a test
if ( retry ) {
return retry;
}
// Return early if hardRetryTest returns true
if ( await hardRetryTest( reportId, hardRetries ) ) {
return;
}
errorMessages.push( ...Object.values( pendingErrors[ reportId ] ) );
}
// Run the next test
return getNextBrowserTest( reportId );
}
case "ack": {
const report = reports[ message.id ];
touchBrowser( report.browser );
break;
}
default:
console.warn( "Received unknown message type:", message.type );
}
// Hide test server request logs in CLI output
}, { quiet: true } );
// Start up local test server
let server;
let port;
await new Promise( ( resolve ) => {
// Pass 0 to choose a random, unused port
server = app.listen( 0, () => {
port = server.address().port;
resolve();
} );
} );
if ( !server || !port ) {
throw new Error( "Server not started." );
}
if ( verbose ) {
console.log( `Server started on port ${ port }.` );
}
function stopServer() {
return new Promise( ( resolve ) => {
server.close( () => {
if ( verbose ) {
console.log( "Server stopped." );
}
resolve();
} );
} );
}
async function cleanup() {
console.log( "Cleaning up..." );
await cleanupAllBrowsers( { verbose } );
if ( tunnel ) {
await tunnel.stop();
if ( verbose ) {
console.log( "Stopped BrowserStackLocal." );
}
}
}
asyncExitHook(
async() => {
await cleanup();
await stopServer();
},
{ 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." );
printModuleHashes( modules );
}
}
function queueRun( modules, browser ) {
const fullBrowser = getBrowserString( browser, headless );
const reportId = generateHash( `${ modules.join( ":" ) } ${ fullBrowser }` );
reports[ reportId ] = { browser, headless, modules };
const url = buildTestUrl( modules, {
browserstack,
esm,
jsdom: browser.browser === "jsdom",
port,
reportId
} );
const options = {
browserstack,
concurrency,
debug,
headless,
modules,
reportId,
runId,
tunnelId,
verbose
};
addRun( url, browser, options );
}
for ( const browser of browsers ) {
if ( isolate ) {
for ( const module of modules ) {
queueRun( [ module ], browser );
}
} else {
queueRun( modules, browser );
}
}
try {
console.log( `Starting Run ${ runId }...` );
await runAll();
} catch ( error ) {
console.error( error );
if ( !debug ) {
gracefulExit( 1 );
}
} finally {
console.log();
if ( errorMessages.length === 0 ) {
let stop = false;
for ( const report of Object.values( reports ) ) {
if ( !report.total ) {
stop = true;
console.error(
chalk.red(
`No tests were run for ${ report.modules.join(
", "
) } in ${ getBrowserString( report.browser ) }`
)
);
}
}
if ( stop ) {
return gracefulExit( 1 );
}
console.log( chalk.green( "All tests passed!" ) );
if ( !debug || browserstack ) {
gracefulExit( 0 );
}
} else {
console.error( chalk.red( `${ errorMessages.length } tests failed.` ) );
console.log(
errorMessages.map( ( error, i ) => `\n${ i + 1 }. ${ error }` ).join( "\n" )
);
if ( debug ) {
console.log();
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." );
} else {
gracefulExit( 1 );
}
}
}
}

View File

@@ -1,97 +0,0 @@
import { Builder, Capabilities, logging } from "selenium-webdriver";
import Chrome from "selenium-webdriver/chrome.js";
import Edge from "selenium-webdriver/edge.js";
import Firefox from "selenium-webdriver/firefox.js";
import IE from "selenium-webdriver/ie.js";
import { browserSupportsHeadless } from "../lib/getBrowserString.js";
// Set script timeout to 10min
const DRIVER_SCRIPT_TIMEOUT = 1000 * 60 * 10;
export default async function createDriver( { browserName, headless, url, verbose } ) {
const capabilities = Capabilities[ browserName ]();
// Support: IE 11+
// When those are set for IE, the process crashes with an error:
// "Unable to match capability set 0: goog:loggingPrefs is an unknown
// extension capability for IE".
if ( browserName !== "ie" ) {
const prefs = new logging.Preferences();
prefs.setLevel( logging.Type.BROWSER, logging.Level.ALL );
capabilities.setLoggingPrefs( prefs );
}
let driver = new Builder().withCapabilities( capabilities );
const chromeOptions = new Chrome.Options();
chromeOptions.addArguments( "--enable-chrome-browser-cloud-management" );
// Alter the chrome binary path if
// the CHROME_BIN environment variable is set
if ( process.env.CHROME_BIN ) {
if ( verbose ) {
console.log( `Setting chrome binary to ${ process.env.CHROME_BIN }` );
}
chromeOptions.setChromeBinaryPath( process.env.CHROME_BIN );
}
const firefoxOptions = new Firefox.Options();
if ( process.env.FIREFOX_BIN ) {
if ( verbose ) {
console.log( `Setting firefox binary to ${ process.env.FIREFOX_BIN }` );
}
firefoxOptions.setBinary( process.env.FIREFOX_BIN );
}
const edgeOptions = new Edge.Options();
edgeOptions.addArguments( "--enable-chrome-browser-cloud-management" );
// Alter the edge binary path if
// the EDGE_BIN environment variable is set
if ( process.env.EDGE_BIN ) {
if ( verbose ) {
console.log( `Setting edge binary to ${ process.env.EDGE_BIN }` );
}
edgeOptions.setEdgeChromiumBinaryPath( process.env.EDGE_BIN );
}
const ieOptions = new IE.Options();
ieOptions.setEdgeChromium( true );
ieOptions.setEdgePath( "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" );
if ( headless ) {
chromeOptions.addArguments( "--headless=new" );
firefoxOptions.addArguments( "--headless" );
edgeOptions.addArguments( "--headless=new" );
if ( !browserSupportsHeadless( browserName ) ) {
console.log(
`Headless mode is not supported for ${ browserName }.` +
"Running in normal mode instead."
);
}
}
driver = await driver
.setChromeOptions( chromeOptions )
.setFirefoxOptions( firefoxOptions )
.setEdgeOptions( edgeOptions )
.setIeOptions( ieOptions )
.build();
if ( verbose ) {
const driverCapabilities = await driver.getCapabilities();
const name = driverCapabilities.getBrowserName();
const version = driverCapabilities.getBrowserVersion();
console.log( `\nDriver created for ${ name } ${ version }` );
}
// Increase script timeout to 10min
await driver.manage().setTimeouts( { script: DRIVER_SCRIPT_TIMEOUT } );
// Set the first URL for the browser
await driver.get( url );
return driver;
}

View File

@@ -1,13 +0,0 @@
import { createTestServer } from "./createTestServer.js";
const port = process.env.PORT || 3000;
async function runServer() {
const app = await createTestServer();
app.listen( { port, host: "0.0.0.0" }, function() {
console.log( `Open tests at http://localhost:${ port }/test/` );
} );
}
runServer();