mirror of
https://github.com/jquery/jquery.git
synced 2026-01-07 21:33:57 -05:00
Tests: migrate test runner to jquery-test-runner
Closes gh-5604
This commit is contained in:
4
.github/workflows/browserstack-dispatch.yml
vendored
4
.github/workflows/browserstack-dispatch.yml
vendored
@@ -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 }}
|
||||
|
||||
7
.github/workflows/browserstack.yml
vendored
7
.github/workflows/browserstack.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/filestash.yml
vendored
2
.github/workflows/filestash.yml
vendored
@@ -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
|
||||
|
||||
22
.github/workflows/node.js.yml
vendored
22
.github/workflows/node.js.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/verify-release.yml
vendored
2
.github/workflows/verify-release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
30
jtr-isolate.yml
Normal 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
3235
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 }`;
|
||||
}
|
||||
@@ -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();
|
||||
} );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
}
|
||||
);
|
||||
} );
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
@@ -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" );
|
||||
}
|
||||
@@ -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"
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }`;
|
||||
}
|
||||
@@ -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 ) }` );
|
||||
} );
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
};
|
||||
} );
|
||||
} )();
|
||||
@@ -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();
|
||||
} );
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user