diff --git a/.dockerignore b/.dockerignore index aed076dc..93288afc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,6 @@ * -!bin !docker -!lib -!ts-defs -!package.json +!test +!Gulpfile.js +!.publishrc +!*.tgz \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index df8fc7fc..0fa26e5b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -33,6 +33,7 @@ "no-trailing-spaces": 2, "no-undef-init": 2, "no-unused-expressions": 2, + "no-var": 2, "no-with": 2, "camelcase": 2, "comma-spacing": 2, diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 59f75de5..c0248da7 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,31 +1,38 @@ ### Are you requesting a feature or reporting a bug? + +### What is your Test Scenario? + -### What is the current behavior? +### What is the Current behavior? + -### What is the expected behavior? - +### What is the Expected behavior? + ### How would you reproduce the current behavior (if this is a bug)? - + #### Provide the test code and the tested page URL (if applicable) + + +Your website URL (or attach your complete example): -Tested page URL: - -Test code +Your complete test code (or attach your test files) ```js ``` - -### Specify your - -* operating system: -* testcafe version: -* node.js version: \ No newline at end of file +### Your Environment details: + +* testcafe version: +* node.js version: +* command-line arguments: +* browser name and version: +* platform and version: +* other: diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 00000000..321ca2bd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,70 @@ +--- +name: Bug report +about: Submit the behavior you consider invalid + +--- + + + +### What is your Test Scenario? + + +### What is the Current behavior? + + +### What is the Expected behavior? + + +### What is your web application and your TestCafe test code? + + +Your website URL (or attach your complete example): + +
+Your complete test code (or attach your test files): + + +```js + +``` +
+ +
+Your complete test report: + + +``` + +``` +
+ +
+Screenshots: + + +``` + +``` +
+ +### Steps to Reproduce: + + +1. Go to my website ... +3. Execute this command... +4. See the error... + +### Your Environment details: + +* testcafe version: +* node.js version: +* command-line arguments: +* browser name and version: +* platform and version: +* other: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..784e3c04 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Share your ideas for this project + +--- + + + +### What is your Test Scenario? + + +### What are you suggesting? + + +### What alternatives have you considered? + + +### Additional context + diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 00000000..1998035d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,13 @@ +--- +name: Question +about: Route your questions to StackOverflow + +--- + +#### HEADS UP! + +For TestCafe API, usage and configuration inquiries, we strongly recommend [StackOverflow](https://stackoverflow.com/questions/ask?tags=testcafe) for community support. +  +You may also find answers in our up-to-date [documentation](https://devexpress.github.io/testcafe/documentation/getting-started/) and [answered questions](https://stackoverflow.com/questions/tagged/testcafe) on StackOverflow. This may save your time. +  +If you have already looked there and have not found an appropriate answer, [file a new question on StackOverflow](https://stackoverflow.com/questions/ask?tags=testcafe). diff --git a/.github/no-response.yml b/.github/no-response.yml new file mode 100644 index 00000000..9c181c65 --- /dev/null +++ b/.github/no-response.yml @@ -0,0 +1,13 @@ +# Configuration for probot-no-response - https://github.com/probot/no-response + +# Number of days of inactivity before an Issue is closed for lack of response +daysUntilClose: 10 +# Label requiring a response +responseRequiredLabel: "STATE: Need clarification" +# Comment to post when closing an Issue for lack of response. Set to `false` to disable +closeComment: > + This issue has been automatically closed because there has been no response + to our request for more information from the original author. With only the + information that is currently in the issue, we don't have enough information + to take action. Please reach out if you have or find the answers we need so + that we can investigate further. diff --git a/.gitignore b/.gitignore index f28c0e62..21703f57 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ -node_modules +/node_modules .idea .vscode -/lib /site .sass-cache .publish diff --git a/.travis-docs.yml b/.travis-docs.yml index 9b285cef..d2ad0d0d 100644 --- a/.travis-docs.yml +++ b/.travis-docs.yml @@ -6,14 +6,14 @@ matrix: fast_finish: true before_install: - - rvm install 2.4.2 + - rvm install 2.5.1 - curl -o- -L https://yarnpkg.com/install.sh | bash - export PATH=$HOME/.yarn/bin:$PATH - yarn install cache: yarn -install: gem install jekyll htmlentities sanitize redcarpet jekyll-sitemap +install: gem install jekyll htmlentities sanitize redcarpet jekyll-sitemap jekyll-redirect-from branches: except: diff --git a/.travis.yml b/.travis.yml index 64364c82..b0ca9d0b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,5 @@ +if: NOT (commit_message =~ /^\[docs\]/ OR branch = 'new-docs') + addons: chrome: stable firefox: latest @@ -12,26 +14,21 @@ matrix: env: GULP_TASK="test-server" - node_js: "stable" env: GULP_TASK="test-server" - - node_js: "stable" + - node_js: "8" env: GULP_TASK="test-client-travis" - - node_js: "stable" + - node_js: "8" env: GULP_TASK="test-client-travis-mobile" - - node_js: "stable" - env: GULP_TASK="test-functional-travis-desktop-osx-and-ms-edge" - - node_js: "stable" - env: GULP_TASK="test-functional-travis-mobile" - - node_js: "stable" - env: GULP_TASK="test-functional-local-headless" + - node_js: "8" + env: GULP_TASK="test-functional-local-headless-chrome" + - node_js: "8" + env: GULP_TASK="test-functional-local-headless-firefox" fast_finish: true -cache: yarn - -before_install: - - export $(dbus-launch) - - curl -o- -L https://yarnpkg.com/install.sh | bash - - export PATH=$HOME/.yarn/bin:$PATH +cache: + directories: + - $HOME/.npm -install: yarn +install: travis_retry npm install branches: except: diff --git a/CHANGELOG.md b/CHANGELOG.md index 40181724..a028da92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,306 @@ # Changelog +## v0.23.2 (2018-11-12) + +### Bug Fixes + +* TestCafe no longer posts internal messages to the browser console ([#3099](https://github.com/DevExpress/testcafe/issues/3099)) +* The TestCafe process no longer terminates before the report is written to a file ([#2502](https://github.com/DevExpress/testcafe/issues/2502)) + +## v0.23.1 (2018-11-7) + +### Enhancements + +#### :gear: Select Tests and Fixtures to Run by Their Metadata ([#2527](https://github.com/DevExpress/testcafe/issues/2527)) by [@NickCis](https://github.com/NickCis) + +You can now run only those tests or fixtures whose [metadata](https://devexpress.github.io/testcafe/documentation/test-api/test-code-structure.html#specifying-testing-metadata) contains a specific set of values. + +Use the [--test-meta](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#--test-meta-keyvaluekey2value2) flag to specify values to look for in test metadata. + +```sh +testcafe chrome my-tests --test-meta device=mobile,env=production +``` + +To select fixtures by their metadata, use the [--fixture-meta](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#--fixture-meta-keyvaluekey2value2) flag. + +```sh +testcafe chrome my-tests --fixture-meta subsystem=payments,type=regression +``` + +In the API, test and fixture metadata is now passed to the [runner.filter](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#filter) method in the `testMeta` and `fixtureMeta` parameters. Use this metadata to decide whether to run the current test. + +```js +runner.filter((testName, fixtureName, fixturePath, testMeta, fixtureMeta) => { + return testMeta.mobile === 'true' && + fixtureMeta.env === 'staging'; +}); +``` + +#### :gear: Run Dynamically Loaded Tests ([#2074](https://github.com/DevExpress/testcafe/issues/2074)) + +You can now run tests imported from external libraries or generated dynamically even if the `.js` file you provide to TestCafe does not contain any tests. + +Previously, this was not possible because TestCafe required test files to contain the [fixture](https://devexpress.github.io/testcafe/documentation/test-api/test-code-structure.html#fixtures) and [test](https://devexpress.github.io/testcafe/documentation/test-api/test-code-structure.html#tests) directives. Now you can bypass this check. To do this, provide the [--disable-test-syntax-validation](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#--disable-test-syntax-validation) command line flag. + +```sh +testcafe safari test.js --disable-test-syntax-validation +``` + +In the API, use the [disableTestSyntaxValidation](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#run) option. + +```js +runner.run({ disableTestSyntaxValidation: true }) +``` + +### Bug Fixes + +* Touch events are now simulated with correct touch properties (`touches`, `targetTouches`, `changedTouches`) ([#2856](https://github.com/DevExpress/testcafe/issues/2856)) +* Google Chrome now closes correctly on macOS after tests are finished ([#2860](https://github.com/DevExpress/testcafe/issues/2860)) +* Internal attribute and node changes no longer provoke `MutationObserver` notifications ([testcafe-hammerhead/#1769](https://github.com/DevExpress/testcafe-hammerhead/issues/1769)) +* The `ECONNABORTED` error is no longer raised ([testcafe-hammerhead/#1744](https://github.com/DevExpress/testcafe-hammerhead/issues/1744)) +* Websites that use `Location.ancestorOrigins` are now proxied correctly ([testcafe-hammerhead/#1342](https://github.com/DevExpress/testcafe-hammerhead/issues/1342)) + +## v0.23.0 (2018-10-25) + +### Enhancements + +#### :gear: Stop Test Run After the First Test Fail ([#1323](https://github.com/DevExpress/testcafe/issues/1323)) + +You can now configure TestCafe to stop the entire test run after the first test fail. This saves your time when you fix problems with your tests one by one. + +Specify the [--sf](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#--sf---stop-on-first-fail) flag to enable this feature when you run tests from the command line. + +```sh +testcafe chrome my-tests --sf +``` + +In the API, use the [stopOnFirstFail](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#run) option. + +```js +runner.run({ stopOnFirstFail: true }) +``` + +#### :gear: View the JavaScript Errors' Stack Traces in Reports ([#2043](https://github.com/DevExpress/testcafe/issues/2043)) + +Now when a JavaScript error occurs on the tested webpage, the test run report includes a stack trace for this error (only if the [--skip-js-errors](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#-e---skip-js-errors) option is disabled). + +![A report that contains a stack trace for a client JS error](docs/articles/images/client-error-stack-report.png) + +#### :gear: Browsers are Automatically Restarted When They Stop Responding ([#1815](https://github.com/DevExpress/testcafe/issues/1815)) + +If a browser stops responding while it executes tests, TestCafe restarts the browser and reruns the current test in a new browser instance. +If the same problem occurs with this test two more times, the test run finishes and an error is thrown. + +### Bug Fixes + +* An error message about an unawaited call to an async function is no longer displayed when an uncaught error occurs ([#2557](https://github.com/DevExpress/testcafe/issues/2557)) +* A request hook is no longer added multiple times when a filter rule is used ([#2650](https://github.com/DevExpress/testcafe/issues/2650)) +* Screenshot links in test run reports now contain paths specified by the `--screenshot-pattern` option ([#2726](https://github.com/DevExpress/testcafe/issues/2726)) +* Assertion chains no longer produce unhandled promise rejections ([#2852](https://github.com/DevExpress/testcafe/issues/2852)) +* The `moment` loader now works correctly in the Jest environment ([#2500](https://github.com/DevExpress/testcafe/issues/2500)) +* TestCafe no longer hangs if the screenshot directory contains forbidden symbols ([#681](https://github.com/DevExpress/testcafe/issues/681)) +* The `--ssl` option's parameters are now parsed correctly ([#2924](https://github.com/DevExpress/testcafe/issues/2924)) +* TestCafe now throws a meaningful error if an assertion method is missing ([#1063](https://github.com/DevExpress/testcafe/issues/1063)) +* TestCafe no longer hangs when it clicks a custom element ([#2861](https://github.com/DevExpress/testcafe/issues/2861)) +* TestCafe now performs keyboard navigation between radio buttons/groups in a way that matches the native browser behavior ([#2067](https://github.com/DevExpress/testcafe/issues/2067), [#2045](https://github.com/DevExpress/testcafe/issues/2045)) +* The `fetch` method can now be used with data URLs ([#2865](https://github.com/DevExpress/testcafe/issues/2865)) +* The `switchToIframe` function no longer throws an error ([#2956](https://github.com/DevExpress/testcafe/issues/2956)) +* TestCafe can now scroll through fixed elements when the action has custom offsets ([#2978](https://github.com/DevExpress/testcafe/issues/2978)) +* You can now specify the current directory or its parent directories as the base path to store screenshots ([#2975](https://github.com/DevExpress/testcafe/issues/2975)) +* Tests no longer hang up when you try to debug in headless browsers ([#2846](https://github.com/DevExpress/testcafe/issues/2846)) +* The `removeEventListener` function now works correctly when an object is passed as its third argument ([testcafe-hammerhead/#1737](https://github.com/DevExpress/testcafe-hammerhead/issues/1737)) +* Hammerhead no longer adds the `event` property to a null `contentWindow` in IE11 ([testcafe-hammerhead/#1684](https://github.com/DevExpress/testcafe-hammerhead/issues/1684)) +* The browser no longer resets connection with the server for no reason ([testcafe-hammerhead/#1647](https://github.com/DevExpress/testcafe-hammerhead/issues/1647)) +* Hammerhead now stringifies values correctly before outputting them to the console ([testcafe-hammerhead/#1750](https://github.com/DevExpress/testcafe-hammerhead/issues/1750)) +* A document fragment from the top window can now be correctly appended to an iframe ([testcafe-hammerhead/#912](https://github.com/DevExpress/testcafe-hammerhead/issues/912)) +* Lifecycle callbacks that result from the `document.registerElement` method are no longer called twice ([testcafe-hammerhead/#695](https://github.com/DevExpress/testcafe-hammerhead/issues/695)) + +## v0.22.0 (2018-9-3) + +### Enhancements + +#### :gear: CoffeeScript Support ([#1556](https://github.com/DevExpress/testcafe/issues/1556)) by [@GeoffreyBooth](https://github.com/GeoffreyBooth) + +TestCafe now allows you to write tests in CoffeeScript. You do not need to compile CoffeeScript manually or make any customizations - everything works out of the box. + +```coffee +import { Selector } from 'testcafe' + +fixture 'CoffeeScript Example' + .page 'https://devexpress.github.io/testcafe/example/' + +nameInput = Selector '#developer-name' + +test 'Test', (t) => + await t + .typeText(nameInput, 'Peter') + .typeText(nameInput, 'Paker', { replace: true }) + .typeText(nameInput, 'r', { caretPos: 2 }) + .expect(nameInput.value).eql 'Parker'; +``` + +#### :gear: Failed Selector Method Pinpointed in the Report ([#2568](https://github.com/DevExpress/testcafe/issues/2568)) + +Now the test run report can identify which selector's method does not match any DOM element. + +![Failed Selector Report](docs/articles/images/failed-selector-report.png) + +#### :gear: Fail on Uncaught Server Errors ([#2546](https://github.com/DevExpress/testcafe/issues/2546)) + +Previously, TestCafe ignored uncaught errors and unhandled promise rejections that occurred on the server. Whenever an error or a promise rejection happened, test execution continued. + +Starting from v0.22.0, tests fail if a server error or promise rejection is unhandled. To return to the previous behavior, we have introduced the `skipUncaughtErrors` option. Use the [--skip-uncaught-errors](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#-u---skip-uncaught-errors) flag in the command line or the [skipUncaughtErrors](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#run) option in the API. + +```sh +testcafe chrome tests/fixture.js --skipUncaughtErrors +``` + +```js +runner.run({skipUncaughtErrors:true}) +``` + +#### :gear: Use Glob Patterns in `runner.src` ([#980](https://github.com/DevExpress/testcafe/issues/980)) + +You can now use [glob patterns](https://github.com/isaacs/node-glob#glob-primer) in the [runner.src](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#src) method to specify a set of test files. + +```js +runner.src(['/home/user/tests/**/*.js', '!/home/user/tests/foo.js']); +``` + +### Bug Fixes + +* `RequestLogger` no longer fails when it tries to stringify a null request body ([#2718](https://github.com/DevExpress/testcafe/issues/2718)) +* Temporary directories are now correctly removed when the test run is finished ([#2735](https://github.com/DevExpress/testcafe/issues/2735)) +* TestCafe no longer throws `ECONNRESET` when run against a Webpack project ([#2711](https://github.com/DevExpress/testcafe/issues/2711)) +* An error is no longer thrown when TestCafe tests Sencha ExtJS applications in IE11 ([#2639](https://github.com/DevExpress/testcafe/issues/2639)) +* Firefox no longer waits for page elements to appear without necessity ([#2080](https://github.com/DevExpress/testcafe/issues/2080)) +* `${BROWSER}` in the screenshot pattern now correctly resolves to the browser name ([#2742](https://github.com/DevExpress/testcafe/issues/2742)) +* The `toString` function now returns a native string for overridden descriptor ancestors ([testcafe-hammerhead/#1713](https://github.com/DevExpress/testcafe-hammerhead/issues/1713)) +* The `iframe` flag is no longer added when a form with `target="_parent"` is submitted ([testcafe-hammerhead/#1680](https://github.com/DevExpress/testcafe-hammerhead/issues/1680)) +* Hammerhead no longer sends request headers in lower case ([testcafe-hammerhead/#1380](https://github.com/DevExpress/testcafe-hammerhead/issues/1380)) +* The overridden `createHTMLDocument` method has the right context now ([testcafe-hammerhead/#1722](https://github.com/DevExpress/testcafe-hammerhead/issues/1722)) +* Tests no longer lose connection ([testcafe-hammerhead/#1647](https://github.com/DevExpress/testcafe-hammerhead/issues/1647)) +* The case when both the `X-Frame-Options` header and a CSP with `frame-ancestors` are set is now handled correctly ([testcafe-hammerhead/#1666](https://github.com/DevExpress/testcafe-hammerhead/issues/1666)) +* The mechanism that resolves URLs on the client now works correctly ([testcafe-hammerhead/#1701](https://github.com/DevExpress/testcafe-hammerhead/issues/1701)) +* `LiveNodeListWrapper` now imitates the native behavior correctly ([testcafe-hammerhead/#1376](https://github.com/DevExpress/testcafe-hammerhead/issues/1376)) + +## v0.21.1 (2018-8-8) + +### Bug fixes + +* The `RequestLogger.clear` method no longer raises an error if it is called during a long running request ([#2688](https://github.com/DevExpress/testcafe/issues/2688)) +* TestCafe now uses native methods to work with the `fetch` request ([#2686](https://github.com/DevExpress/testcafe/issues/2686)) +* A URL now resolves correctly for elements in a `document.implementation` instance ([testcafe-hammerhead/#1673](https://github.com/DevExpress/testcafe-hammerhead/issues/1673)) +* Response header names specified via the `respond` function are lower-cased now ([testcafe-hammerhead/#1704](https://github.com/DevExpress/testcafe-hammerhead/issues/1704)) +* The cookie domain validation rule on the client side has been fixed ([testcafe-hammerhead/#1702](https://github.com/DevExpress/testcafe-hammerhead/issues/1702)) + +## v0.21.0 (2018-8-2) + +### Enhancements + +#### :gear: Test Web Pages Served Over HTTPS ([#1985](https://github.com/DevExpress/testcafe/issues/1985)) + +Some browser features (like [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API), [Geolocation API](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API), [ApplePaySession](https://developer.apple.com/documentation/apple_pay_on_the_web/applepaysession), or [SubtleCrypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto)) require a secure origin. This means that the website should use the HTTPS protocol. + +Starting with v0.21.0, TestCafe can serve proxied web pages over HTTPS. This allows you to test pages that require a secure origin. + +To enable HTTPS when you use TestCafe through the command line, specify the [--ssl](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#--ssl-options) flag followed by the [HTTPS server options](https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener). The most commonly used options are described in the [TLS topic](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) in the Node.js documentation. + +```sh +testcafe --ssl pfx=path/to/file.pfx;rejectUnauthorized=true;... +``` + +When you use a programming API, pass the HTTPS server options to the [createTestCafe](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/createtestcafe.html) method. + +```js +'use strict'; + +const createTestCafe = require('testcafe'); +const selfSignedSertificate = require('openssl-self-signed-certificate'); +let runner = null; + +const sslOptions = { + key: selfSignedSertificate.key, + cert: selfSignedSertificate.cert +}; + +createTestCafe('localhost', 1337, 1338, sslOptions) + .then(testcafe => { + runner = testcafe.createRunner(); + }) + .then(() => { + return runner + .src('test.js') + + // Browsers restrict self-signed certificate usage unless you + // explicitly set a flag specific to each browser. + // For Chrome, this is '--allow-insecure-localhost'. + .browsers('chrome --allow-insecure-localhost') + .run(); + }); +``` + +See [Connect to TestCafe Server over HTTPS](https://devexpress.github.io/testcafe/documentation/using-testcafe/common-concepts/connect-to-the-testcafe-server-over-https.html) for more information. + +#### :gear: Construct Screenshot Paths with Patterns ([#2152](https://github.com/DevExpress/testcafe/issues/2152)) + +You can now use patterns to construct paths to screenshots. TestCafe provides a number of placeholders you can include in the path, for example, `${DATE}`, `${TIME}`, `${USERAGENT}`, etc. For a complete list, refer to the command line [--screenshot-path-pattern flag description](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#-p---screenshot-path-pattern). + +You specify a screenshot path pattern when you run tests. Each time TestCafe takes a screenshot, it substitutes the placeholders with actual values and saves the screenshot to the resulting path. + +The following example shows how to specify a screenshot path pattern through the command line: + +```sh +testcafe all test.js -s screenshots -p '${DATE}_${TIME}/test-${TEST_INDEX}/${USERAGENT}/${FILE_INDEX}.png' +``` + +When you use a programming API, pass the screenshot path pattern to the [runner.screenshots method](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#screenshots). + +```js +runner.screenshots('reports/screenshots/', true, '${TEST_INDEX}/${OS}/${BROWSER}-v${BROWSER_VERSION}/${FILE_INDEX}.png'); +``` + +#### :gear: Add Info About Screenshots and Quarantine Attempts to Custom Reports ([#2216](https://github.com/DevExpress/testcafe/issues/2216)) + +Custom reporters can now access screenshots' data and the history of quarantine attempts (if the test run in the quarantine mode). + +The following information about screenshots is now available: + +* the path to the screenshot file, +* the path to the thumbnail image, +* the browser's user agent, +* the quarantine attempt number (if the screenshot was taken in the quarantine mode), +* whether the screenshot was taken because the test failed. + +If the test was run in the quarantine mode, you can also determine which attempts failed and passed. + +Refer to the [reportTestDone method description](https://devexpress.github.io/testcafe/documentation/extending-testcafe/reporter-plugin/reporter-methods.html#reporttestdone) for details on how to access this information. + +### Bug Fixes + +* HTML5 drag events are no longer simulated if `event.preventDefault` is called for the `mousedown` event ([#2529](https://github.com/DevExpress/testcafe/issues/2529)) +* File upload no longer causes an exception when there are several file inputs on the page ([#2642](https://github.com/DevExpress/testcafe/issues/2642)) +* File upload now works with inputs that have the `required` attribute ([#2509](https://github.com/DevExpress/testcafe/issues/2509)) +* The `load` event listener is no longer triggered when added to an image ([testcafe-hammerhead/#1688](https://github.com/DevExpress/testcafe-hammerhead/issues/1688)) + +## v0.20.5 (2018-7-18) + +### Bug fixes + +* The `buttons` property was added to the `MouseEvent` instance ([#2056](https://github.com/DevExpress/testcafe/issues/2056)) +* Response headers were converted to lowercase ([#2534](https://github.com/DevExpress/testcafe/issues/2534)) +* Updated flow definitions ([#2053](https://github.com/DevExpress/testcafe/issues/2053)) +* An `AttributesWrapper` instance is now updated when the the element's property specifies the `disabled` attribute ([#2539](https://github.com/DevExpress/testcafe/issues/2539)) +* TestCafe no longer hangs when it redirects from a tested page to the 'about:error' page with a hash ([#2371](https://github.com/DevExpress/testcafe/issues/2371)) +* TestCafe now reports a warning for a mocked request if CORS validation failed ([#2482](https://github.com/DevExpress/testcafe/issues/2482)) +* Prevented situations when a request logger tries to stringify a body that is not logged ([#2555](https://github.com/DevExpress/testcafe/issues/2555)) +* The Selector API now reports `NaN` instead of `integer` when type validation fails ([#2470](https://github.com/DevExpress/testcafe/issues/2470)) +* Enabled `noImplicitAny` and disabled `skipLibCheck` in the TypeScript compiler ([#2497](https://github.com/DevExpress/testcafe/issues/2497)) +* Pages with `rel=prefetch` links no longer hang during test execution ([#2528](https://github.com/DevExpress/testcafe/issues/2528)) +* Fixed the `TypeError: this.res.setHeader is not a function` error in Firefox ([#2438](https://github.com/DevExpress/testcafe/issues/2438)) +* The `formtarget` attribute was overridden ([testcafe-hammerhead/#1513](https://github.com/DevExpress/testcafe-hammerhead/issues/1513)) +* `fetch.toString()` now equals `function fetch() { [native code] }` ([testcafe-hammerhead/#1662](https://github.com/DevExpress/testcafe-hammerhead/issues/1662)) + ## v0.20.4 (2018-6-25) ### Enhancements @@ -8,7 +309,7 @@ ### Bug fixes -* `fetch` requests are now correctly proxied in a specific case ([testcafe-hammerhead/#1613](https://github.com/DevExpress/testcafe-hammerhead/issues/1613)) +* `fetch` requests now correctly proxied in a specific case ([testcafe-hammerhead/#1613](https://github.com/DevExpress/testcafe-hammerhead/issues/1613)) * Resources responding with `304` HTTP status code and with the 'content-length: ' header are proxied correctly now ([testcafe-hammerhead/#1602](https://github.com/DevExpress/testcafe-hammerhead/issues/1602)) * The `transfer` argument of `window.postMessage` is passed correctly now ([testcafe-hammerhead/#1535](https://github.com/DevExpress/testcafe-hammerhead/issues/1535)) * Incorrect focus events order in IE has been fixed ([#2072](https://github.com/DevExpress/testcafe/issues/2072)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43d0585d..2a5f90d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,15 +16,14 @@ TestCafe has adopted a [Contributor Code of Conduct](CODE_OF_CONDUCT.md), abide ## General Discussion -If you have a question about TestCafe feel free to ask us on StackOverflow. We review and answer questions with the [TestCafe](https://stackoverflow.com/questions/tagged/testcafe) tag. +Join the TestCafe community on Stack Overflow: ask and answer [questions with the TestCafe tag](https://stackoverflow.com/questions/tagged/testcafe). ## Reporting a Problem If you find a problem when using TestCafe, please file an issue in our [GitHub repository](https://github.com/DevExpress/testcafe/issues). However, to save some time, please search through the existing issues to see if the problem has already been reported or addressed. -When you create a new issue, template text is automatically added to its body. -To help us understand the issue you're describing, be sure to fill in all sections in this template. +When you create a new issue, template text is automatically added to its body. To help us understand the issue you're describing, be sure to fill in all sections in this template. We process issues with insufficient details after all the others, which may take significant time without any guarantees. ## Code Contribution @@ -69,4 +68,4 @@ Please follow the steps below when submitting your code. with an appropriate issue number. Documentation pull requests should have the `[docs]` prefix in their title. - This ensures that documentation tests are triggered against these pull requests. \ No newline at end of file + This ensures that documentation tests are triggered against these pull requests. diff --git a/Gulpfile.js b/Gulpfile.js index cc7eea1a..366ff006 100644 --- a/Gulpfile.js +++ b/Gulpfile.js @@ -1,38 +1,41 @@ -var babel = require('babel-core'); -var gulp = require('gulp'); -var gulpStep = require('gulp-step'); -var gulpBabel = require('gulp-babel'); -var data = require('gulp-data'); -var less = require('gulp-less'); -var qunitHarness = require('gulp-qunit-harness'); -var git = require('gulp-git'); -var ghpages = require('gulp-gh-pages'); -var mocha = require('gulp-mocha-simple'); -var mustache = require('gulp-mustache'); -var rename = require('gulp-rename'); -var webmake = require('gulp-webmake'); -var gulpif = require('gulp-if'); -var uglify = require('gulp-uglify'); -var ll = require('gulp-ll-next'); -var del = require('del'); -var fs = require('fs'); -var path = require('path'); -var globby = require('globby'); -var opn = require('opn'); -var connect = require('connect'); -var spawn = require('cross-spawn'); -var serveStatic = require('serve-static'); -var Promise = require('pinkie'); -var markdownlint = require('markdownlint'); -var minimist = require('minimist'); -var prompt = require('gulp-prompt'); -var functionalTestConfig = require('./test/functional/config'); -var assignIn = require('lodash').assignIn; -var yaml = require('js-yaml'); -var childProcess = require('child_process'); -var listBrowsers = require('testcafe-browser-tools').getInstallations; -var npmAuditor = require('npm-auditor'); -var checkLicenses = require('./test/dependency-licenses-checker'); +const babel = require('babel-core'); +const gulp = require('gulp'); +const gulpStep = require('gulp-step'); +const gulpBabel = require('gulp-babel'); +const data = require('gulp-data'); +const less = require('gulp-less'); +const qunitHarness = require('gulp-qunit-harness'); +const git = require('gulp-git'); +const ghpages = require('gulp-gh-pages'); +const mocha = require('gulp-mocha-simple'); +const mustache = require('gulp-mustache'); +const rename = require('gulp-rename'); +const webmake = require('gulp-webmake'); +const uglify = require('gulp-uglify'); +const ll = require('gulp-ll-next'); +const clone = require('gulp-clone'); +const mergeStreams = require('merge-stream'); +const del = require('del'); +const fs = require('fs'); +const path = require('path'); +const globby = require('globby'); +const opn = require('opn'); +const connect = require('connect'); +const spawn = require('cross-spawn'); +const serveStatic = require('serve-static'); +const Promise = require('pinkie'); +const markdownlint = require('markdownlint'); +const minimist = require('minimist'); +const prompt = require('gulp-prompt'); +const functionalTestConfig = require('./test/functional/config'); +const assignIn = require('lodash').assignIn; +const yaml = require('js-yaml'); +const childProcess = require('child_process'); +const listBrowsers = require('testcafe-browser-tools').getInstallations; +const npmAuditor = require('npm-auditor'); +const checkLicenses = require('./test/dependency-licenses-checker'); +const sourcemaps = require('gulp-sourcemaps'); +const packageInfo = require('./package'); gulpStep.install(); @@ -49,22 +52,22 @@ ll 'client-scripts-bundle' ]); -var ARGS = minimist(process.argv.slice(2)); -var DEV_MODE = 'dev' in ARGS; +const ARGS = minimist(process.argv.slice(2)); +const DEV_MODE = 'dev' in ARGS; -var CLIENT_TESTS_PATH = 'test/client/fixtures'; -var CLIENT_TESTS_LEGACY_PATH = 'test/client/legacy-fixtures'; +const CLIENT_TESTS_PATH = 'test/client/fixtures'; +const CLIENT_TESTS_LEGACY_PATH = 'test/client/legacy-fixtures'; -var CLIENT_TESTS_SETTINGS_BASE = { +const CLIENT_TESTS_SETTINGS_BASE = { port: 2000, crossDomainPort: 2001, scripts: [ { src: '/async.js', path: 'test/client/vendor/async.js' }, - { src: '/hammerhead.js', path: 'node_modules/testcafe-hammerhead/lib/client/hammerhead.js' }, - { src: '/core.js', path: 'lib/client/core/index.js' }, - { src: '/ui.js', path: 'lib/client/ui/index.js' }, - { src: '/automation.js', path: 'lib/client/automation/index.js' }, + { src: '/hammerhead.js', path: 'node_modules/testcafe-hammerhead/lib/client/hammerhead.min.js' }, + { src: '/core.js', path: 'lib/client/core/index.min.js' }, + { src: '/ui.js', path: 'lib/client/ui/index.min.js' }, + { src: '/automation.js', path: 'lib/client/automation/index.min.js' }, { src: '/driver.js', path: 'lib/client/driver/index.js' }, { src: '/legacy-runner.js', path: 'node_modules/testcafe-legacy-api/lib/client/index.js' }, { src: '/before-test.js', path: 'test/client/before-test.js' } @@ -73,11 +76,11 @@ var CLIENT_TESTS_SETTINGS_BASE = { configApp: require('./test/client/config-qunit-server-app') }; -var CLIENT_TESTS_SETTINGS = assignIn({}, CLIENT_TESTS_SETTINGS_BASE, { basePath: CLIENT_TESTS_PATH }); -var CLIENT_TESTS_LOCAL_SETTINGS = assignIn({}, CLIENT_TESTS_SETTINGS); -var CLIENT_TESTS_LEGACY_SETTINGS = assignIn({}, CLIENT_TESTS_SETTINGS_BASE, { basePath: CLIENT_TESTS_LEGACY_PATH }); +const CLIENT_TESTS_SETTINGS = assignIn({}, CLIENT_TESTS_SETTINGS_BASE, { basePath: CLIENT_TESTS_PATH }); +const CLIENT_TESTS_LOCAL_SETTINGS = assignIn({}, CLIENT_TESTS_SETTINGS); +const CLIENT_TESTS_LEGACY_SETTINGS = assignIn({}, CLIENT_TESTS_SETTINGS_BASE, { basePath: CLIENT_TESTS_LEGACY_PATH }); -var CLIENT_TESTS_DESKTOP_BROWSERS = [ +const CLIENT_TESTS_DESKTOP_BROWSERS = [ { platform: 'Windows 10', browserName: 'microsoftedge' @@ -96,9 +99,9 @@ var CLIENT_TESTS_DESKTOP_BROWSERS = [ version: '11.0' }, { - platform: 'OS X 10.12', + platform: 'macOS 10.13', browserName: 'safari', - version: '11.0' + version: '11.1' }, { platform: 'OS X 10.11', @@ -110,20 +113,7 @@ var CLIENT_TESTS_DESKTOP_BROWSERS = [ } ]; -var CLIENT_TESTS_OLD_BROWSERS = [ - { - platform: 'Windows 8', - browserName: 'internet explorer', - version: '10.0' - }, - { - platform: 'Windows 7', - browserName: 'internet explorer', - version: '9.0' - } -]; - -var CLIENT_TESTS_MOBILE_BROWSERS = [ +const CLIENT_TESTS_MOBILE_BROWSERS = [ { platform: 'Linux', browserName: 'android', @@ -141,7 +131,7 @@ var CLIENT_TESTS_MOBILE_BROWSERS = [ } ]; -var CLIENT_TESTS_SAUCELABS_SETTINGS = { +const CLIENT_TESTS_SAUCELABS_SETTINGS = { username: process.env.SAUCE_USERNAME, accessKey: process.env.SAUCE_ACCESS_KEY, build: process.env.TRAVIS_BUILD_ID || '', @@ -150,13 +140,13 @@ var CLIENT_TESTS_SAUCELABS_SETTINGS = { timeout: 720 }; -var CLIENT_TEST_LOCAL_BROWSERS_ALIASES = ['ie', 'edge', 'chrome', 'firefox', 'safari']; +const CLIENT_TEST_LOCAL_BROWSERS_ALIASES = ['ie', 'edge', 'chrome', 'firefox', 'safari']; -var PUBLISH_TAG = JSON.parse(fs.readFileSync(path.join(__dirname, '.publishrc')).toString()).publishTag; +const PUBLISH_TAG = JSON.parse(fs.readFileSync(path.join(__dirname, '.publishrc')).toString()).publishTag; -var websiteServer = null; +let websiteServer = null; -gulp.task('audit', function () { +gulp.task('audit', () => { return npmAuditor() .then(result => { process.stdout.write(result.report); @@ -167,14 +157,14 @@ gulp.task('audit', function () { }); }); -gulp.task('clean', function () { +gulp.task('clean', () => { return del('lib'); }); // Lint -gulp.task('lint', function () { - var eslint = require('gulp-eslint'); +gulp.task('lint', () => { + const eslint = require('gulp-eslint'); return gulp .src([ @@ -190,13 +180,13 @@ gulp.task('lint', function () { }); // License checker -gulp.task('check-licenses', function () { +gulp.task('check-licenses', () => { return checkLicenses(); }); // Build -gulp.step('client-scripts-bundle', function () { +gulp.step('client-scripts-bundle', () => { return gulp .src([ 'src/client/core/index.js', @@ -207,8 +197,8 @@ gulp.step('client-scripts-bundle', function () { ], { base: 'src' }) .pipe(webmake({ sourceMap: false, - transform: function (filename, code) { - var transformed = babel.transform(code, { + transform: (filename, code) => { + const transformed = babel.transform(code, { sourceMap: false, ast: false, filename: filename, @@ -225,48 +215,71 @@ gulp.step('client-scripts-bundle', function () { return { code: transformed.code.replace(/^('|")use strict('|");?/, '') }; } })) - .pipe(gulpif(!DEV_MODE, uglify())) .pipe(gulp.dest('lib')); }); -gulp.step('client-scripts-templates-render', function () { - return gulp +gulp.step('client-scripts-templates-render', () => { + const scripts = gulp .src([ 'src/client/core/index.js.wrapper.mustache', 'src/client/ui/index.js.wrapper.mustache', 'src/client/automation/index.js.wrapper.mustache', 'src/client/driver/index.js.wrapper.mustache' ], { base: 'src' }) - .pipe(rename(wrapperPath => { - wrapperPath.extname = ''; - wrapperPath.basename = wrapperPath.basename.replace('.wrapper', ''); + .pipe(rename(file => { + file.extname = ''; + file.basename = file.basename.replace('.js.wrapper', ''); + })) + .pipe(data(file => { + const sourceFilePath = path.resolve('lib', file.relative + '.js'); + + return { + source: fs.readFileSync(sourceFilePath) + }; })) - .pipe(data(file => ({ source: fs.readFileSync(path.resolve('lib', file.relative)) }))) .pipe(mustache()) - .pipe(gulpif(!DEV_MODE, uglify())) + .pipe(rename(file => { + file.extname = '.js'; + })); + + const bundledScripts = scripts + .pipe(clone()) + .pipe(uglify()) + .pipe(rename(file => { + file.extname = '.min.js'; + })); + + return mergeStreams(scripts, bundledScripts) .pipe(gulp.dest('lib')); }); gulp.step('client-scripts', gulp.series('client-scripts-bundle', 'client-scripts-templates-render')); -gulp.step('server-scripts', function () { +gulp.step('server-scripts', () => { return gulp .src([ 'src/**/*.js', '!src/client/**/*.js' ]) + .pipe(sourcemaps.init()) .pipe(gulpBabel()) + .pipe(sourcemaps.mapSources(sourcePath => { + const libPath = path.join('src', sourcePath); + + return path.relative(sourcePath, libPath); + })) + .pipe(sourcemaps.write()) .pipe(gulp.dest('lib')); }); -gulp.step('styles', function () { +gulp.step('styles', () => { return gulp .src('src/**/*.less') .pipe(less()) .pipe(gulp.dest('lib')); }); -gulp.step('templates', function () { +gulp.step('templates', () => { return gulp .src([ 'src/**/*.mustache', @@ -275,7 +288,7 @@ gulp.step('templates', function () { .pipe(gulp.dest('lib')); }); -gulp.step('images', function () { +gulp.step('images', () => { return gulp .src([ 'src/**/*.png', @@ -292,7 +305,7 @@ gulp.task('fast-build', gulp.series('clean', 'package-content')); gulp.task('build', DEV_MODE ? gulp.registry().get('fast-build') : gulp.parallel('lint', 'fast-build')); // Test -gulp.step('test-server-run', function () { +gulp.step('test-server-run', () => { return gulp .src('test/server/*-test.js', { read: false }) .pipe(mocha({ @@ -314,11 +327,11 @@ function testClient (tests, settings, envSettings, cliMode) { if (!cliMode) return runTests(envSettings); - return listBrowsers().then(function (browsers) { + return listBrowsers().then(browsers => { const browserNames = Object.keys(browsers); const targetBrowsers = []; - browserNames.forEach(function (browserName) { + browserNames.forEach(browserName => { if (CLIENT_TEST_LOCAL_BROWSERS_ALIASES.includes(browserName)) targetBrowsers.push({ browserInfo: browsers[browserName], browserName: browserName }); }); @@ -327,26 +340,26 @@ function testClient (tests, settings, envSettings, cliMode) { }); } -gulp.step('test-client-run', function () { +gulp.step('test-client-run', () => { return testClient('test/client/fixtures/**/*-test.js', CLIENT_TESTS_SETTINGS); }); gulp.task('test-client', gulp.series('build', 'test-client-run')); -gulp.step('test-client-local-run', function () { +gulp.step('test-client-local-run', () => { return testClient('test/client/fixtures/**/*-test.js', CLIENT_TESTS_LOCAL_SETTINGS, {}, true); }); gulp.task('test-client-local', gulp.series('build', 'test-client-local-run')); -gulp.step('test-client-legacy-run', function () { +gulp.step('test-client-legacy-run', () => { return testClient('test/client/legacy-fixtures/**/*-test.js', CLIENT_TESTS_LEGACY_SETTINGS); }); gulp.task('test-client-legacy', gulp.series('build', 'test-client-legacy-run')); -gulp.step('test-client-travis-run', function () { - var saucelabsSettings = CLIENT_TESTS_SAUCELABS_SETTINGS; +gulp.step('test-client-travis-run', () => { + const saucelabsSettings = CLIENT_TESTS_SAUCELABS_SETTINGS; saucelabsSettings.browsers = CLIENT_TESTS_DESKTOP_BROWSERS; @@ -355,18 +368,8 @@ gulp.step('test-client-travis-run', function () { gulp.task('test-client-travis', gulp.series('build', 'test-client-travis-run')); -gulp.step('test-client-old-browsers-travis-run', function () { - var saucelabsSettings = CLIENT_TESTS_SAUCELABS_SETTINGS; - - saucelabsSettings.browsers = CLIENT_TESTS_OLD_BROWSERS; - - return testClient('test/client/fixtures/**/*-test.js', CLIENT_TESTS_SETTINGS, saucelabsSettings); -}); - -gulp.task('test-client-old-browsers-travis', gulp.series('build', 'test-client-old-browsers-travis-run')); - -gulp.step('test-client-travis-mobile-run', function () { - var saucelabsSettings = CLIENT_TESTS_SAUCELABS_SETTINGS; +gulp.step('test-client-travis-mobile-run', () => { + const saucelabsSettings = CLIENT_TESTS_SAUCELABS_SETTINGS; saucelabsSettings.browsers = CLIENT_TESTS_MOBILE_BROWSERS; @@ -375,8 +378,8 @@ gulp.step('test-client-travis-mobile-run', function () { gulp.task('test-client-travis-mobile', gulp.series('build', 'test-client-travis-mobile-run')); -gulp.step('test-client-legacy-travis-run', function () { - var saucelabsSettings = CLIENT_TESTS_SAUCELABS_SETTINGS; +gulp.step('test-client-legacy-travis-run', () => { + const saucelabsSettings = CLIENT_TESTS_SAUCELABS_SETTINGS; saucelabsSettings.browsers = CLIENT_TESTS_DESKTOP_BROWSERS; @@ -385,8 +388,8 @@ gulp.step('test-client-legacy-travis-run', function () { gulp.task('test-client-legacy-travis', gulp.series('build', 'test-client-legacy-travis-run')); -gulp.step('test-client-legacy-travis-mobile-run', function () { - var saucelabsSettings = CLIENT_TESTS_SAUCELABS_SETTINGS; +gulp.step('test-client-legacy-travis-mobile-run', () => { + const saucelabsSettings = CLIENT_TESTS_SAUCELABS_SETTINGS; saucelabsSettings.browsers = CLIENT_TESTS_MOBILE_BROWSERS; @@ -396,15 +399,15 @@ gulp.step('test-client-legacy-travis-mobile-run', function () { gulp.task('test-client-legacy-travis-mobile', gulp.series('build', 'test-client-legacy-travis-mobile-run')); //Documentation -gulp.task('generate-docs-readme', function (done) { +gulp.task('generate-docs-readme', done => { function generateItem (name, url, level) { return ' '.repeat(level * 2) + '* [' + name + '](articles' + url + ')\n'; } function generateDirectory (tocItems, level) { - var res = ''; + let res = ''; - tocItems.forEach(function (item) { + tocItems.forEach(item => { res += generateItem(item.name ? item.name : item.url, item.url, level); if (item.content) @@ -415,7 +418,7 @@ gulp.task('generate-docs-readme', function (done) { } function generateReadme (toc) { - var tocList = generateDirectory(toc, 0); + const tocList = generateDirectory(toc, 0); return '# Documentation\n\n> This is the documentation\'s development version. ' + 'The functionality described here may not be included in the current release version. ' + @@ -424,19 +427,19 @@ gulp.task('generate-docs-readme', function (done) { tocList; } - var toc = yaml.safeLoad(fs.readFileSync('docs/nav/nav-menu.yml', 'utf8')); - var readme = generateReadme(toc); + const toc = yaml.safeLoad(fs.readFileSync('docs/nav/nav-menu.yml', 'utf8')); + const readme = generateReadme(toc); fs.writeFileSync('docs/README.md', readme); done(); }); -gulp.task('lint-docs', function () { +gulp.task('lint-docs', () => { function lintFiles (files, config) { - return new Promise(function (resolve, reject) { - markdownlint({ files: files, config: config }, function (err, result) { - var lintErr = err || result && result.toString(); + return new Promise((resolve, reject) => { + markdownlint({ files: files, config: config }, (err, result) => { + const lintErr = err || result && result.toString(); if (lintErr) reject(lintErr); @@ -446,66 +449,66 @@ gulp.task('lint-docs', function () { }); } - var lintDocsAndExamples = globby([ + const lintDocsAndExamples = globby([ 'docs/articles/**/*.md', '!docs/articles/faq/**/*.md', '!docs/articles/documentation/recipes/**/*.md', 'examples/**/*.md' - ]).then(function (files) { + ]).then(files => { return lintFiles(files, require('./.md-lint/docs.json')); }); - var lintFaq = globby([ + const lintFaq = globby([ 'docs/articles/faq/**/*.md' - ]).then(function (files) { + ]).then(files => { return lintFiles(files, require('./.md-lint/faq.json')); }); - var lintRecipes = globby([ + const lintRecipes = globby([ 'docs/articles/documentation/recipes/**/*.md' - ]).then(function (files) { + ]).then(files => { return lintFiles(files, require('./.md-lint/recipes.json')); }); - var lintReadme = lintFiles('README.md', require('./.md-lint/readme.json')); - var lintChangelog = lintFiles('CHANGELOG.md', require('./.md-lint/changelog.json')); + const lintReadme = lintFiles('README.md', require('./.md-lint/readme.json')); + const lintChangelog = lintFiles('CHANGELOG.md', require('./.md-lint/changelog.json')); return Promise.all([lintDocsAndExamples, lintReadme, lintChangelog, lintRecipes, lintFaq]); }); -gulp.task('clean-website', function () { +gulp.task('clean-website', () => { return del('site'); }); -gulp.step('fetch-assets-repo', function (cb) { +gulp.step('fetch-assets-repo', cb => { git.clone('https://github.com/DevExpress/testcafe-gh-page-assets.git', { args: 'site' }, cb); }); -gulp.step('put-in-articles', function () { +gulp.step('put-in-articles', () => { return gulp .src(['docs/articles/**/*', '!docs/articles/blog/**/*']) .pipe(gulp.dest('site/src')); }); -gulp.step('put-in-posts', function () { +gulp.step('put-in-posts', () => { return gulp .src('docs/articles/blog/**/*') .pipe(gulp.dest('site/src/_posts')); }); -gulp.step('put-in-navigation', function () { +gulp.step('put-in-navigation', () => { return gulp .src('docs/nav/**/*') .pipe(gulp.dest('site/src/_data')); }); -gulp.step('put-in-publications', function () { +gulp.step('put-in-publications', () => { return gulp .src('docs/publications/**/*') .pipe(gulp.dest('site/src/_data')); }); -gulp.step('put-in-tweets', function () { +gulp.step('put-in-tweets', () => { return gulp .src('docs/tweets/**/*') .pipe(gulp.dest('site/src/_data')); @@ -517,7 +520,7 @@ gulp.step('prepare-website-content', gulp.series('clean-website', 'fetch-assets- gulp.step('prepare-website', gulp.parallel('lint-docs', 'prepare-website-content')); function buildWebsite (mode, cb) { - var options = mode ? { stdio: 'inherit', env: { JEKYLL_ENV: mode } } : { stdio: 'inherit' }; + const options = mode ? { stdio: 'inherit', env: { JEKYLL_ENV: mode } } : { stdio: 'inherit' }; spawn('jekyll', ['build', '--source', 'site/src/', '--destination', 'site/deploy'], options) .on('exit', cb); @@ -537,52 +540,52 @@ function buildWebsite (mode, cb) { // - In production mode, public comment threads are displayed. // * Google Analytics is enabled in production mode only. -gulp.step('build-website-production-run', function (cb) { +gulp.step('build-website-production-run', cb => { buildWebsite('production', cb); }); gulp.task('build-website-production', gulp.series('prepare-website', 'build-website-production-run')); -gulp.step('build-website-development-run', function (cb) { +gulp.step('build-website-development-run', cb => { buildWebsite('development', cb); }); gulp.task('build-website-development', gulp.series('prepare-website', 'build-website-development-run')); -gulp.step('build-website-testing-run', function (cb) { +gulp.step('build-website-testing-run', cb => { buildWebsite('testing', cb); }); gulp.task('build-website-testing', gulp.series('prepare-website', 'build-website-testing-run')); -gulp.step('build-website-run', function (cb) { +gulp.step('build-website-run', cb => { buildWebsite('', cb); }); gulp.task('build-website', gulp.series('prepare-website', 'build-website-run')); -gulp.task('serve-website', function (cb) { - var app = connect() +gulp.task('serve-website', cb => { + const app = connect() .use('/testcafe', serveStatic('site/deploy')); websiteServer = app.listen(8080, cb); }); -gulp.step('preview-website-open', function () { +gulp.step('preview-website-open', () => { return opn('http://localhost:8080/testcafe'); }); gulp.task('preview-website', gulp.series('build-website-development', 'serve-website', 'preview-website-open')); -gulp.step('test-website-run', function () { - var WebsiteTester = require('./test/website/test.js'); - var websiteTester = new WebsiteTester(); +gulp.step('test-website-run', () => { + const WebsiteTester = require('./test/website/test.js'); + const websiteTester = new WebsiteTester(); return websiteTester .checkLinks() - .then(function (failed) { - return new Promise(function (resolve, reject) { - websiteServer.close(function () { + .then(failed => { + return new Promise((resolve, reject) => { + websiteServer.close(() => { if (failed) reject('Broken links found!'); else @@ -596,10 +599,10 @@ gulp.task('test-website', gulp.series('build-website-testing', 'serve-website', gulp.task('test-website-travis', gulp.series('build-website', 'serve-website', 'test-website-run')); -gulp.step('website-publish-run', function () { +gulp.step('website-publish-run', () => { return gulp .src('site/deploy/**/*') - .pipe(rename(function (filePath) { + .pipe(rename(filePath => { filePath.dirname = filePath.dirname.toLowerCase(); return filePath; @@ -617,61 +620,70 @@ gulp.task('test-docs-travis', gulp.parallel('test-website-travis', 'lint')); function testFunctional (fixturesDir, testingEnvironmentName, browserProviderName) { - process.env.TESTING_ENVIRONMENT = testingEnvironmentName; - process.env.BROWSER_PROVIDER = browserProviderName; + process.env.TESTING_ENVIRONMENT = testingEnvironmentName; + process.env.BROWSER_PROVIDER = browserProviderName; + + if (DEV_MODE) + process.env.DEV_MODE = 'true'; return gulp .src(['test/functional/setup.js', fixturesDir + '/**/test.js']) .pipe(mocha({ ui: 'bdd', reporter: 'spec', - timeout: typeof v8debug === 'undefined' ? 30000 : Infinity // NOTE: disable timeouts in debug + timeout: typeof v8debug === 'undefined' ? 3 * 60 * 1000 : Infinity // NOTE: disable timeouts in debug })); } -gulp.step('test-functional-travis-desktop-osx-and-ms-edge-run', function () { +gulp.step('test-functional-travis-desktop-osx-and-ms-edge-run', () => { return testFunctional('test/functional/fixtures', functionalTestConfig.testingEnvironmentNames.osXDesktopAndMSEdgeBrowsers, functionalTestConfig.browserProviderNames.browserstack); }); gulp.task('test-functional-travis-desktop-osx-and-ms-edge', gulp.series('build', 'test-functional-travis-desktop-osx-and-ms-edge-run')); -gulp.step('test-functional-travis-mobile-run', function () { +gulp.step('test-functional-travis-mobile-run', () => { return testFunctional('test/functional/fixtures', functionalTestConfig.testingEnvironmentNames.mobileBrowsers, functionalTestConfig.browserProviderNames.browserstack); }); gulp.task('test-functional-travis-mobile', gulp.series('build', 'test-functional-travis-mobile-run')); -gulp.step('test-functional-local-run', function () { +gulp.step('test-functional-local-run', () => { return testFunctional('test/functional/fixtures', functionalTestConfig.testingEnvironmentNames.localBrowsers); }); gulp.task('test-functional-local', gulp.series('build', 'test-functional-local-run')); -gulp.step('test-functional-local-ie-run', function () { +gulp.step('test-functional-local-ie-run', () => { return testFunctional('test/functional/fixtures', functionalTestConfig.testingEnvironmentNames.localBrowsersIE); }); gulp.task('test-functional-local-ie', gulp.series('build', 'test-functional-local-ie-run')); -gulp.step('test-functional-local-chrome-firefox-run', function () { +gulp.step('test-functional-local-chrome-firefox-run', () => { return testFunctional('test/functional/fixtures', functionalTestConfig.testingEnvironmentNames.localBrowsersChromeFirefox); }); gulp.task('test-functional-local-chrome-firefox', gulp.series('build', 'test-functional-local-chrome-firefox-run')); -gulp.step('test-functional-local-headless-run', function () { - return testFunctional('test/functional/fixtures', functionalTestConfig.testingEnvironmentNames.localHeadlessBrowsers); +gulp.step('test-functional-local-headless-chrome-run', () => { + return testFunctional('test/functional/fixtures', functionalTestConfig.testingEnvironmentNames.localHeadlessChrome); +}); + +gulp.task('test-functional-local-headless-chrome', gulp.series('build', 'test-functional-local-headless-chrome-run')); + +gulp.step('test-functional-local-headless-firefox-run', () => { + return testFunctional('test/functional/fixtures', functionalTestConfig.testingEnvironmentNames.localHeadlessFirefox); }); -gulp.task('test-functional-local-headless', gulp.series('build', 'test-functional-local-headless-run')); +gulp.task('test-functional-local-headless-firefox', gulp.series('build', 'test-functional-local-headless-firefox-run')); -gulp.step('test-functional-local-legacy-run', function () { +gulp.step('test-functional-local-legacy-run', () => { return testFunctional('test/functional/legacy-fixtures', functionalTestConfig.testingEnvironmentNames.legacy); }); gulp.task('test-functional-local-legacy', gulp.series('build', 'test-functional-local-legacy-run')); -gulp.step('test-functional-travis-old-browsers-run', function () { +gulp.step('test-functional-travis-old-browsers-run', () => { return testFunctional('test/functional/fixtures', functionalTestConfig.testingEnvironmentNames.oldBrowsers, functionalTestConfig.browserProviderNames.sauceLabs); }); @@ -682,13 +694,13 @@ function getDockerEnv (machineName) { .execSync('docker-machine env --shell bash ' + machineName) .toString() .split('\n') - .map(function (line) { + .map(line => { return line.match(/export\s*(.*)="(.*)"$/); }) - .filter(function (match) { + .filter(match => { return !!match; }) - .reduce(function (env, match) { + .reduce((env, match) => { env[match[1]] = match[2]; return env; }, {}); @@ -714,7 +726,7 @@ function isDockerMachineExist (machineName) { } function startDocker () { - var dockerMachineName = process.env['DOCKER_MACHINE_NAME'] || 'default'; + const dockerMachineName = process.env['DOCKER_MACHINE_NAME'] || 'default'; if (!isDockerMachineExist(dockerMachineName)) childProcess.execSync('docker-machine create -d virtualbox ' + dockerMachineName); @@ -722,12 +734,14 @@ function startDocker () { if (!isDockerMachineRunning(dockerMachineName)) childProcess.execSync('docker-machine start ' + dockerMachineName); - var dockerEnv = getDockerEnv(dockerMachineName); + const dockerEnv = getDockerEnv(dockerMachineName); assignIn(process.env, dockerEnv); } -gulp.task('docker-build', function (done) { +gulp.task('docker-build', done => { + childProcess.execSync('npm pack', { env: process.env }).toString(); + if (!process.env['DOCKER_HOST']) { try { startDocker(); @@ -738,8 +752,9 @@ gulp.task('docker-build', function (done) { } } - var imageId = childProcess - .execSync('docker build -q -t testcafe -f docker/Dockerfile .', { env: process.env }) + const packageId = `${packageInfo.name}-${packageInfo.version}.tgz`; + const command = `docker build --no-cache --build-arg packageId=${packageId} -q -t testcafe -f docker/Dockerfile .`; + const imageId = childProcess.execSync(command, { env: process.env }) .toString() .replace(/\n/g, ''); @@ -751,12 +766,29 @@ gulp.task('docker-build', function (done) { done(); }); -gulp.step('docker-publish-run', function (done) { +gulp.task('docker-test', done => { + if (!process.env['DOCKER_HOST']) { + try { + startDocker(); + } + catch (e) { + throw new Error('Unable to initialize Docker environment. Use Docker terminal to run this task.\n' + + e.stack); + } + } + + childProcess.spawnSync(`docker build --build-arg tag=${PUBLISH_TAG} -q -t docker-server-tests -f test/docker/Dockerfile .`, + { stdio: 'inherit', env: process.env, shell: true }); + + done(); +}); + +gulp.step('docker-publish-run', done => { childProcess.execSync('docker push testcafe/testcafe:' + PUBLISH_TAG, { stdio: 'inherit', env: process.env }); done(); }); -gulp.task('docker-publish', gulp.series('docker-build', 'docker-publish-run')); +gulp.task('docker-publish', gulp.series('docker-build', 'docker-test', 'docker-publish-run')); gulp.task('travis', process.env.GULP_TASK ? gulp.series(process.env.GULP_TASK) : () => {}); diff --git a/LICENSE b/LICENSE index 87b38819..1e408654 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License -Copyright (C) 2012-2017 Developer Express Inc. +Copyright (C) 2012-2018 Developer Express Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 02e871f6..d691d472 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- testcafe + testcafe

@@ -33,12 +33,16 @@ ## Table of contents * [Features](#features) +* [IDE for End-to-End Web Testing](#ide-for-end-to-end-web-testing) * [Getting Started](#getting-started) * [Documentation](#documentation) -* [Community](#community) -* [Badge](#badge) +* [Get Help](#get-help) +* [Issue Tracker](#issue-tracker) +* [Stay in Touch](#stay-in-touch) * [Contributing](#contributing) * [Plugins](#plugins) +* [Different Versions of TestCafe](#different-versions-of-testcafe) +* [Badge](#badge) * [License](#license) * [Creators](#creators) @@ -81,11 +85,23 @@ const macOSInput = Selector('.column').find('label').withText('MacOS').child('in You can run TestCafe from a console, and its reports can be viewed in a CI system's interface (TeamCity, Jenkins, Travis & etc.) +## IDE for End-to-End Web Testing + +We've got one more tool for you! + +Check out [TestCafe Studio](https://testcafe-studio.devexpress.com): all the perks of TestCafe + GUI + Visual Test Recorder + +![Get Started with TestCafe Studio](https://raw.githubusercontent.com/DevExpress/testcafe/master/media/testcafe-studio-get-started.gif) + +

+Record and Run a Test in TestCafe Studio +

+ ## Getting Started ### Installation -Ensure that [Node.js](https://nodejs.org/) (version 4 or newer) and [npm](https://www.npmjs.com/) are installed on your computer before running it: +Ensure that [Node.js](https://nodejs.org/) (version 6 or newer) and [npm](https://www.npmjs.com/) are installed on your computer before running it: ```sh npm install -g testcafe @@ -144,31 +160,25 @@ Read the [Getting Started](https://devexpress.github.io/testcafe/documentation/g ## Documentation -Go to our website for full [documentation](http://devexpress.github.io/testcafe/documentation/using-testcafe/) on TestCafe. +Go to our website for full [documentation](https://devexpress.github.io/testcafe/documentation/getting-started/) on TestCafe. -## Community +## Get Help -Follow us on [Twitter](https://twitter.com/DXTestCafe). We post TestCafe news and updates, several times a week. +Join the TestCafe community on Stack Overflow to get help. Ask and answer [questions with the TestCafe tag](https://stackoverflow.com/questions/tagged/testcafe). -## Badge +## Issue Tracker -Show everyone you are using TestCafe: ![Tested with TestCafe](https://img.shields.io/badge/tested%20with-TestCafe-2fa4cf.svg) +Use our GitHub issues page to [report bugs](https://github.com/DevExpress/testcafe/issues/new?template=bug-report.md) and [suggest improvements](https://github.com/DevExpress/testcafe/issues/new?template=feature_request.md). -To display this badge, add the following code to your repository readme: +## Stay in Touch -```html - - Tested with TestCafe - -``` +Follow us on [Twitter](https://twitter.com/DXTestCafe). We post TestCafe news and updates, several times a week. ## Contributing -Report bugs and request features on our [issues page](https://github.com/DevExpress/testcafe/issues).
-Ask and answer questions on StackOverflow. We review and answer questions with the [TestCafe](https://stackoverflow.com/questions/tagged/testcafe) tag.
-For more information on how to help us improve TestCafe, see the [CONTRIBUTING.md](https://github.com/DevExpress/testcafe/blob/master/CONTRIBUTING.md). +Read our [Contributing Guide](https://github.com/DevExpress/testcafe/blob/master/CONTRIBUTING.md) to learn how to contribute to the project. -You can use these plugin generators to create your own plugins: +To create your own plugin for TestCafe, you can use these plugin generators: * [Build a browser provider](https://devexpress.github.io/testcafe/documentation/extending-testcafe/browser-provider-plugin/) to set up tests on your on-premises server farm, to use a cloud testing platform, or to start your local browsers in a special way. Use this [Yeoman generator](https://www.npmjs.com/package/generator-testcafe-browser-provider) to write only a few lines of code. @@ -177,6 +187,8 @@ You can use these plugin generators to create your own plugins: If you want your plugin to be listed below, [send us a note in a Github issue](https://github.com/DevExpress/testcafe/issues/new). +Thank you to all the people who already contributed to TestCafe! + ## Plugins TestCafe developers and community members made these plugins: @@ -226,6 +238,47 @@ TestCafe developers and community members made these plugins: * **ESLint**
Use ESLint when writing and editing TestCafe tests. * [ESLint plugin](https://github.com/miherlosev/eslint-plugin-testcafe) (by [@miherlosev](https://github.com/miherlosev)) + +## Different Versions of TestCafe + +There is a line of products called `TestCafe`. Below are the similarities and key differences between them. + +* All three versions share the same core features: + * No need for WebDriver, browser plugins or other tools. + * Cross-platform and cross-browser out of the box. + +* [**TestCafe**](https://testcafe.devexpress.com/)
+ *first released in 2013, commercial web application* + * Visual Test Recorder and web GUI to create, edit and run tests. + * You can record tests or edit them as JavaScript code. + +* [**TestCafe**](https://devexpress.github.io/testcafe) - you are here
+ *first released in 2016, free and open-source node.js application* + * You can write tests in the latest JavaScript or TypeScript. + * Clearer and more flexible [API](https://devexpress.github.io/testcafe/documentation/test-api/) supports ES6 and [PageModel pattern](https://devexpress.github.io/testcafe/documentation/recipes/using-page-model.html). + * More stable tests due to the [Smart Assertion Query Mechanism](https://devexpress.github.io/testcafe/documentation/test-api/assertions/#smart-assertion-query-mechanism). + * Tests run faster due to improved [Automatic Waiting Mechanism](https://devexpress.github.io/testcafe/documentation/test-api/waiting-for-page-elements-to-appear.html) and [Concurrent Test Execution](https://devexpress.github.io/testcafe/documentation/using-testcafe/common-concepts/concurrent-test-execution.html). + * Easy integration: it is a node.js solution with CLI and reporters for popular CI systems. + * You can extend it with [plugins](#plugins) and other Node.js modules. + +* [**TestCafe Studio**](https://testcafe-studio.devexpress.com/)
+ *Preview released in 2018, commercial desktop application* + * Based on the open-source TestCafe, and supports its major features. + * You can record tests or edit them as JavaScript or TypeScript code. + * New [Visual Test Recorder](https://testcafe-studio.devexpress.com/documentation/guides/record-tests/) and [IDE-like GUI](https://testcafe-studio.devexpress.com/documentation/guides/write-test-code.html) to record, edit, run and debug tests. + * Currently available as a free preview version. The release version will replace the 2013 version of TestCafe. + +## Badge + +Show everyone you are using TestCafe: ![Tested with TestCafe](https://img.shields.io/badge/tested%20with-TestCafe-2fa4cf.svg) + +To display this badge, add the following code to your repository readme: + +```html + + Tested with TestCafe + +``` ## Thanks to BrowserStack diff --git a/appveyor.yml b/appveyor.yml index 4d3f88d9..80b869ef 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -4,9 +4,35 @@ clone_depth: 1 skip_commits: message: /^\[docs\]/ +branches: + except: + - new-docs + environment: - GULP_TASK: "test-functional-local-ie" - NODEJS_VERSION: "stable" + NODEJS_VERSION: "8" + + matrix: + - GULP_TASK: "test-functional-local-ie" + +for: +- + branches: + only: + - master + + notifications: + - provider: Email + to: + - boris.kirov@devexpress.com + - andrey.belym@devexpress.com + - elena.dikareva@devexpress.com + on_build_success: false + on_build_status_changed: false + + environment: + matrix: + - GULP_TASK: "test-functional-local-chrome-firefox" + - GULP_TASK: "test-functional-local-legacy" install: - ps: >- @@ -27,27 +53,3 @@ build: off test_script: - cmd: npm test - -for: -- - branches: - only: - - master - - notifications: - - provider: Email - to: - - boris.kirov@devexpress.com - - andrey.belym@devexpress.com - - elena.dikareva@devexpress.com - on_build_success: false - on_build_status_changed: false - - test_script: - - cmd: >- - node node_modules/gulp/bin/gulp test-functional-local-ie - - node node_modules/gulp/bin/gulp test-functional-local-chrome-firefox - - node node_modules/gulp/bin/gulp test-functional-local-legacy - diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 00000000..7815703e --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,37 @@ +trigger: + branches: + exclude: + - build-bot-temp-* + - new-docs + + paths: + include: + - /bin + - /src + - /test + - /azure-pipelines.yml + - /Gulpfile.js + - /package.json + +pool: 'BrowserStack agents' + +steps: +- bash: | + if [[ $BUILD_SOURCEVERSIONMESSAGE =~ ^\[docs\] ]] || [[ ! -f "appveyor.yml" ]]; then + echo '##vso[task.setvariable variable=isDocCommit]true' + fi + displayName: 'Check commit type' + +- task: NodeTool@0 + displayName: 'Install Node.js' + condition: and(succeeded(), not(variables['isDocCommit'])) + timeoutInMinutes: 40 + inputs: + versionSpec: '8.x' + +- bash: | + npm install --no-progress --loglevel error + npm test + displayName: 'Run tests' + condition: and(succeeded(), not(variables['isDocCommit'])) + timeoutInMinutes: 40 diff --git a/docker/Dockerfile b/docker/Dockerfile index 92b9632c..b7fba364 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,16 +1,20 @@ FROM alpine:edge +ARG packageId -RUN apk --no-cache --repository http://dl-3.alpinelinux.org/alpine/edge/testing/ add \ - nodejs nodejs-npm chromium firefox xwininfo xvfb dbus eudev ttf-freefont fluxbox +COPY ${packageId} /opt/testcafe/${packageId} +COPY docker/testcafe-docker.sh /opt/testcafe/docker/testcafe-docker.sh -COPY . /opt/testcafe +RUN apk --no-cache --repository http://dl-3.alpinelinux.org/alpine/edge/testing/ upgrade +RUN apk --no-cache --repository http://dl-3.alpinelinux.org/alpine/edge/testing/ add \ + nodejs nodejs-npm chromium firefox xwininfo xvfb dbus eudev ttf-freefont fluxbox procps -RUN cd /opt/testcafe; \ - npm install --production && \ +RUN npm install -g /opt/testcafe/${packageId} && \ npm cache clean --force && \ rm -rf /tmp/* && \ chmod +x /opt/testcafe/docker/testcafe-docker.sh && \ - adduser -D user + adduser -D user && \ + rm /opt/testcafe/${packageId} + USER user EXPOSE 1337 1338 diff --git a/docker/testcafe-docker.sh b/docker/testcafe-docker.sh index 646b2460..d2141f7a 100644 --- a/docker/testcafe-docker.sh +++ b/docker/testcafe-docker.sh @@ -6,4 +6,4 @@ dbus-daemon --session --fork Xvfb :1 -screen 0 "${XVFB_SCREEN_WIDTH}x${XVFB_SCREEN_HEIGHT}x24" >/dev/null 2>&1 & export DISPLAY=:1.0 fluxbox >/dev/null 2>&1 & -node /opt/testcafe/bin/testcafe.js --ports 1337,1338 "$@" +node /usr/lib/node_modules/testcafe/bin/testcafe.js --ports 1337,1338 "$@" diff --git a/docs/README.md b/docs/README.md index c886dd8c..5413e97e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -70,7 +70,7 @@ * [Specifying Which Requests are Handled by the Hook](articles/documentation/test-api/intercepting-http-requests/specifying-which-requests-are-handled-by-the-hook.md) * [Attaching Hooks to Tests and Fixtures](articles/documentation/test-api/intercepting-http-requests/attaching-hooks-to-tests-and-fixtures.md) * [requestOptions Object](articles/documentation/test-api/intercepting-http-requests/requestoptions-object.md) - * [Waiting for Page Elements to Appear](articles/documentation/test-api/waiting-for-page-elements-to-appear.md) + * [Built-In Waiting Mechanisms](articles/documentation/test-api/built-in-waiting-mechanisms.md) * [Authentication](articles/documentation/test-api/authentication/README.md) * [User Roles](articles/documentation/test-api/authentication/user-roles.md) * [HTTP Authentication](articles/documentation/test-api/authentication/http-authentication.md) @@ -87,8 +87,8 @@ * [Browser Provider Plugin](articles/documentation/extending-testcafe/browser-provider-plugin/README.md) * [Browser Provider Methods](articles/documentation/extending-testcafe/browser-provider-plugin/browser-provider-methods.md) * [RECIPES](articles/documentation/recipes/README.md) - * [Debugging with Chrome Developer Tools](articles/documentation/recipes/debugging-with-chrome-dev-tools.md) - * [Debugging with Visual Studio Code](articles/documentation/recipes/debugging-with-visual-studio-code.md) + * [Debug in Chrome Developer Tools](articles/documentation/recipes/debug-in-chrome-dev-tools.md) + * [Debug in Visual Studio Code](articles/documentation/recipes/debug-in-visual-studio-code.md) * [Integrating TestCafe with CI Systems](articles/documentation/recipes/integrating-testcafe-with-ci-systems/README.md) * [AppVeyor](articles/documentation/recipes/integrating-testcafe-with-ci-systems/appveyor.md) * [CircleCI](articles/documentation/recipes/integrating-testcafe-with-ci-systems/circleci.md) diff --git a/docs/articles/blog/2016-11-8-testcafe-v0-10-0-released.md b/docs/articles/blog/2016-11-8-testcafe-v0-10-0-released.md index de95deb3..df11af92 100644 --- a/docs/articles/blog/2016-11-8-testcafe-v0-10-0-released.md +++ b/docs/articles/blog/2016-11-8-testcafe-v0-10-0-released.md @@ -64,7 +64,7 @@ if (await selector.hasClass('foo')) { } ``` -See [Snapshot API Shorthands](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#obtain-element-state). +See [Snapshot API Shorthands](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/using-selectors.html#obtain-element-state). ### Improved automatic wait mechanism diff --git a/docs/articles/blog/2016-12-8-testcafe-v0-11-0-released.md b/docs/articles/blog/2016-12-8-testcafe-v0-11-0-released.md index 2a7320cb..0eb7a895 100644 --- a/docs/articles/blog/2016-12-8-testcafe-v0-11-0-released.md +++ b/docs/articles/blog/2016-12-8-testcafe-v0-11-0-released.md @@ -15,7 +15,7 @@ Redesigned selector system, built-in assertions and lots of bug fixes! 🚀🚀 #### New selector methods -Multiple [filtering](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#filter-dom-nodes) and [hierarchical](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#search-for-elements-in-the-dom-hierarchy) methods were introduced for selectors. +Multiple [filtering](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.html#filter-dom-nodes) and [hierarchical](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.html#search-for-elements-in-the-dom-hierarchy) methods were introduced for selectors. Now you can build flexible, lazily-evaluated functional-style selector chains. *Here are some examples:* @@ -30,7 +30,7 @@ Then, for each `label` element finds a parent that matches the `div.someClass` s ------ Like in jQuery, if you request a [property](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/dom-node-state.html#members-common-across-all-nodes) of the matching set or try evaluate -a [snapshot](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#dom-node-snapshot), the selector returns values for the first element in the set. +a [snapshot](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/using-selectors.html#dom-node-snapshot), the selector returns values for the first element in the set. ```js // Returns id of the first element in the set @@ -72,7 +72,7 @@ In this example the selector: #### Getting selector matching set length -Also, now you can get selector matching set length and check matching elements existence by using selector [`count` and `exists` properties](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#check-if-an-element-exists). +Also, now you can get selector matching set length and check matching elements existence by using selector [`count` and `exists` properties](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/using-selectors.html#check-if-an-element-exists). #### Unawaited parametrized selector calls now allowed outside test context @@ -141,14 +141,14 @@ const id = await t.select('.someClass').id; const id = await Selector('.someClass').id; ``` -* `selectorOptions.index` - use [selector.nth()](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#nth) instead. -* `selectorOptions.text` - use [selector.withText()](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#withtext) instead. -* `selectorOptions.dependencies` - use [filtering](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#filter-dom-nodes) and [hierarchical](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#search-for-elements-in-the-dom-hierarchy) methods to build combined selectors instead. +* `selectorOptions.index` - use [selector.nth()](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.html#nth) instead. +* `selectorOptions.text` - use [selector.withText()](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.html#withtext) instead. +* `selectorOptions.dependencies` - use [filtering](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.html#filter-dom-nodes) and [hierarchical](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.html#search-for-elements-in-the-dom-hierarchy) methods to build combined selectors instead. ### ⚙ Built-in assertions. ([#998](https://github.com/DevExpress/testcafe/issues/998)) TestCafe now ships with [numerous built-in BDD-style assertions](http://devexpress.github.io/testcafe/documentation/test-api/assertions/assertion-api.html). -If the TestCafe assertion receives a [Selector's DOM node state property](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#define-assertion-actual-value) as an actual value, TestCafe uses the [smart assertion query mechanism](http://devexpress.github.io/testcafe/documentation/test-api/assertions/index.html#smart-assertion-query-mechanism): +If the TestCafe assertion receives a [Selector's DOM node state property](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/using-selectors.html#define-assertion-actual-value) as an actual value, TestCafe uses the [smart assertion query mechanism](http://devexpress.github.io/testcafe/documentation/test-api/assertions/index.html#smart-assertion-query-mechanism): if an assertion did not passed, the test does not fail immediately. The assertion retries to pass multiple times and each time it re-requests the actual shorthand value. The test fails if the assertion could not complete successfully within a timeout. This approach allows you to create stable tests that lack random errors and decrease the amount of time required to run all your tests due to the lack of extra waitings. diff --git a/docs/articles/blog/2017-1-19-testcafe-v0-12-0-released.md b/docs/articles/blog/2017-1-19-testcafe-v0-12-0-released.md index dea46034..20282fb2 100644 --- a/docs/articles/blog/2017-1-19-testcafe-v0-12-0-released.md +++ b/docs/articles/blog/2017-1-19-testcafe-v0-12-0-released.md @@ -74,7 +74,7 @@ The `t.takeScreenshot`, `t.resizeWindow`, `t.resizeWindowToFitDevice` and `t.max The state of webpage elements can now be extended with custom properties. -We have added the [addCustomDOMProperties](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#custom-properties) +We have added the [addCustomDOMProperties](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/extending-selectors.html#custom-properties) method to the selector, so that you can add properties to the element state like in the following example. ```js diff --git a/docs/articles/blog/2017-2-16-testcafe-v0-13-0-released.md b/docs/articles/blog/2017-2-16-testcafe-v0-13-0-released.md index 74fcfd11..01254477 100644 --- a/docs/articles/blog/2017-2-16-testcafe-v0-13-0-released.md +++ b/docs/articles/blog/2017-2-16-testcafe-v0-13-0-released.md @@ -197,9 +197,9 @@ const id = await t.select('.someClass').id; const id = await Selector('.someClass').id; ``` -* `selectorOptions.index` - use [selector.nth()](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#nth) instead. -* `selectorOptions.text` - use [selector.withText()](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#withtext) instead. -* `selectorOptions.dependencies` - use [filtering](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#filter-dom-nodes) and [hierarchical](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#search-for-elements-in-the-dom-hierarchy) methods to build combined selectors instead. +* `selectorOptions.index` - use [selector.nth()](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.html#nth) instead. +* `selectorOptions.text` - use [selector.withText()](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.html#withtext) instead. +* `selectorOptions.dependencies` - use [filtering](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.html#filter-dom-nodes) and [hierarchical](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.html#search-for-elements-in-the-dom-hierarchy) methods to build combined selectors instead. ## Bug Fixes diff --git a/docs/articles/blog/2017-3-28-testcafe-v0-14-0-released.md b/docs/articles/blog/2017-3-28-testcafe-v0-14-0-released.md index c7f15c24..2ee233b9 100644 --- a/docs/articles/blog/2017-3-28-testcafe-v0-14-0-released.md +++ b/docs/articles/blog/2017-3-28-testcafe-v0-14-0-released.md @@ -151,7 +151,7 @@ test('Navigate to local pages', async t => { ### ⚙ Adding custom methods to the selector ([#1212](https://github.com/DevExpress/testcafe/issues/1212)) -You can now extend selectors with custom methods executed on the client. Use the [addCustomMethods](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#custom-methods) method to provide custom methods. +You can now extend selectors with custom methods executed on the client. Use the [addCustomMethods](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/extending-selectors.html#custom-methods) method to provide custom methods. ```js const myTable = Selector('.my-table').addCustomMethods({ diff --git a/docs/articles/blog/2017-4-26-testcafe-v0-15-0-released.md b/docs/articles/blog/2017-4-26-testcafe-v0-15-0-released.md index 53fe367c..25958200 100644 --- a/docs/articles/blog/2017-4-26-testcafe-v0-15-0-released.md +++ b/docs/articles/blog/2017-4-26-testcafe-v0-15-0-released.md @@ -13,7 +13,7 @@ Plugins for React and Vue.js, TestCafe Docker image, support for Internet access ### New calls to selector's withText method no longer override previous calls -We have changed the way the [withText](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#withtext) +We have changed the way the [withText](https://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.html#withtext) method behaves when it is called in a chain. ```js @@ -103,7 +103,7 @@ docker pull testcafe/testcafe docker run -v //user/tests:/tests -it testcafe/testcafe firefox tests/**/*.js ``` -To learn more, see [Using TestCafe Docker Image](https://devexpress.github.io/testcafe/documentation/using-testcafe/installing-testcafe.html#using-testcafe-docker-image) +To learn more, see [Using TestCafe Docker Image](https://devexpress.github.io/testcafe/documentation/using-testcafe/using-testcafe-docker-image.html) ### ⚙ Support for Internet access proxies ([#1206](https://github.com/DevExpress/testcafe/issues/1206)) diff --git a/docs/articles/blog/2018-05-15-testcafe-v0-20-0-released.md b/docs/articles/blog/2018-05-15-testcafe-v0-20-0-released.md index df57daf2..bb19fac6 100644 --- a/docs/articles/blog/2018-05-15-testcafe-v0-20-0-released.md +++ b/docs/articles/blog/2018-05-15-testcafe-v0-20-0-released.md @@ -21,7 +21,7 @@ See [Intercepting HTTP Requests](https://devexpress.github.io/testcafe/documenta TestCafe now allows you to bypass the proxy server when accessing specific resources. -To specify resources that require direct access, use the [--proxy-bypass](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#--proxy-bypass-rules) flag in the command line or the [useProxy](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html) API method's parameters. +To specify resources that require direct access, use the [--proxy-bypass](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#--proxy-bypass-rules) flag in the command line or the [useProxy](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#useproxy) API method's parameters. ```sh testcafe chrome my-tests/**/*.js --proxy proxy.corp.mycompany.com --proxy-bypass localhost:8080,internal-resource.corp.mycompany.com diff --git a/docs/articles/blog/2018-08-02-testcafe-v0-21-0-released.md b/docs/articles/blog/2018-08-02-testcafe-v0-21-0-released.md new file mode 100644 index 00000000..4af3826d --- /dev/null +++ b/docs/articles/blog/2018-08-02-testcafe-v0-21-0-released.md @@ -0,0 +1,97 @@ +--- +layout: post +title: TestCafe v0.21.0 Released +permalink: /blog/:title.html +--- +# TestCafe v0.21.0 Released + +Test web pages served over HTTPS, construct screenshot paths with patterns and use more info in custom reporters. + + + +## Enhancements + +### ⚙ Test Web Pages Served Over HTTPS ([#1985](https://github.com/DevExpress/testcafe/issues/1985)) + +Some browser features (like [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API), [Geolocation API](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API), [ApplePaySession](https://developer.apple.com/documentation/apple_pay_on_the_web/applepaysession), or [SubtleCrypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto)) require a secure origin. This means that the website should use the HTTPS protocol. + +Starting with v0.21.0, TestCafe can serve proxied web pages over HTTPS. This allows you to test pages that require a secure origin. + +To enable HTTPS when you use TestCafe through the command line, specify the [--ssl](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#--ssl-options) flag followed by the [HTTPS server options](https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener). The most commonly used options are described in the [TLS topic](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) in the Node.js documentation. + +```sh +testcafe --ssl pfx=path/to/file.pfx;rejectUnauthorized=true;... +``` + +When you use a programming API, pass the HTTPS server options to the [createTestCafe](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/createtestcafe.html) method. + +```js +'use strict'; + +const createTestCafe = require('testcafe'); +const selfSignedSertificate = require('openssl-self-signed-certificate'); +let runner = null; + +const sslOptions = { + key: selfSignedSertificate.key, + cert: selfSignedSertificate.cert +}; + +createTestCafe('localhost', 1337, 1338, sslOptions) + .then(testcafe => { + runner = testcafe.createRunner(); + }) + .then(() => { + return runner + .src('test.js') + + // Browsers restrict self-signed certificate usage unless you + // explicitly set a flag specific to each browser. + // For Chrome, this is '--allow-insecure-localhost'. + .browsers('chrome --allow-insecure-localhost') + .run(); + }); +``` + +See [Connect to TestCafe Server over HTTPS](https://devexpress.github.io/testcafe/documentation/using-testcafe/common-concepts/connect-to-the-testcafe-server-over-https.html) for more information. + +### ⚙ Construct Screenshot Paths with Patterns ([#2152](https://github.com/DevExpress/testcafe/issues/2152)) + +You can now use patterns to construct paths to screenshots. TestCafe provides a number of placeholders you can include in the path, for example, `${DATE}`, `${TIME}`, `${USERAGENT}`, etc. For a complete list, refer to the command line [--screenshot-path-pattern flag description](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#-p---screenshot-path-pattern). + +You specify a screenshot path pattern when you run tests. Each time TestCafe takes a screenshot, it substitutes the placeholders with actual values and saves the screenshot to the resulting path. + +The following example shows how to specify a screenshot path pattern through the command line: + +```sh +testcafe all test.js -s screenshots -p '${DATE}_${TIME}/test-${TEST_INDEX}/${USERAGENT}/${FILE_INDEX}.png' +``` + +When you use a programming API, pass the screenshot path pattern to the [runner.screenshots method](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#screenshots). + +```js +runner.screenshots('reports/screenshots/', true, '${TEST_INDEX}/${OS}/${BROWSER}-v${BROWSER_VERSION}/${FILE_INDEX}.png'); +``` + +### ⚙ Add Info About Screenshots and Quarantine Attempts to Custom Reports ([#2216](https://github.com/DevExpress/testcafe/issues/2216)) + +Custom reporters can now access screenshots' data and the history of quarantine attempts (if the test run in the quarantine mode). + +The following information about screenshots is now available: + +* the path to the screenshot file, +* the path to the thumbnail image, +* the user agent of the browser in which the screenshot was taken, +* the quarantine attempt number (if the screenshot was taken in the quarantine mode), +* whether the screenshot was taken because the test failed. + +If the test was run in the quarantine mode, you can also determine which attempts failed and passed. + +Refer to the [reportTestDone method description](https://devexpress.github.io/testcafe/documentation/extending-testcafe/reporter-plugin/reporter-methods.html#reporttestdone) for details on how to access this information. + +## Bug Fixes + +* HTML5 drag events are no longer simulated if `event.preventDefault` is called for the `mousedown` event ([#2529](https://github.com/DevExpress/testcafe/issues/2529)) +* File upload no longer causes an exception when there are several file inputs on the page ([#2642](https://github.com/DevExpress/testcafe/issues/2642)) +* File upload now works with inputs that have the `required` attribute ([#2509](https://github.com/DevExpress/testcafe/issues/2509)) +* The `load` event listener is no longer triggered when added to an image ([testcafe-hammerhead/#1688](https://github.com/DevExpress/testcafe-hammerhead/issues/1688)) diff --git a/docs/articles/blog/2018-09-03-testcafe-v0-22-0-released.md b/docs/articles/blog/2018-09-03-testcafe-v0-22-0-released.md new file mode 100644 index 00000000..773abb90 --- /dev/null +++ b/docs/articles/blog/2018-09-03-testcafe-v0-22-0-released.md @@ -0,0 +1,77 @@ +--- +layout: post +title: TestCafe v0.22.0 Released +permalink: /blog/:title.html +--- +# TestCafe v0.22.0 Released + +Write tests in CoffeeScript, view failed selector methods in test run reports and detect server-side errors and unhandled promise rejections. + + + +## Enhancements + +### ⚙ CoffeeScript Support ([#1556](https://github.com/DevExpress/testcafe/issues/1556)) by [@GeoffreyBooth](https://github.com/GeoffreyBooth) + +TestCafe now allows you to write tests in CoffeeScript. You do not need to compile CoffeeScript manually or make any customizations - everything works out of the box. + +```coffee +import { Selector } from 'testcafe' + +fixture 'CoffeeScript Example' + .page 'https://devexpress.github.io/testcafe/example/' + +nameInput = Selector '#developer-name' + +test 'Test', (t) => + await t + .typeText(nameInput, 'Peter') + .typeText(nameInput, 'Paker', { replace: true }) + .typeText(nameInput, 'r', { caretPos: 2 }) + .expect(nameInput.value).eql 'Parker'; +``` + +### ⚙ Failed Selector Method Pinpointed in the Report ([#2568](https://github.com/DevExpress/testcafe/issues/2568)) + +Now the test run report can identify which selector's method does not match any DOM element. + +![Failed Selector Report](../images/failed-selector-report.png) + +### ⚙ Fail on Uncaught Server Errors ([#2546](https://github.com/DevExpress/testcafe/issues/2546)) + +Previously, TestCafe ignored uncaught errors and unhandled promise rejections that occurred on the server. Whenever an error or a promise rejection happened, test execution continued. + +Starting from v0.22.0, tests fail if a server error or promise rejection is unhandled. To return to the previous behavior, we have introduced the `skipUncaughtErrors` option. Use the [--skip-uncaught-errors](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#-u---skip-uncaught-errors) flag in the command line or the [skipUncaughtErrors](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#run) option in the API. + +```sh +testcafe chrome tests/fixture.js --skipUncaughtErrors +``` + +```js +runner.run({skipUncaughtErrors:true}) +``` + +### ⚙ Use Glob Patterns in `runner.src` ([#980](https://github.com/DevExpress/testcafe/issues/980)) + +You can now use [glob patterns](https://github.com/isaacs/node-glob#glob-primer) in the [runner.src](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#src) method to specify a set of test files. + +```js +runner.src(['/home/user/tests/**/*.js', '!/home/user/tests/foo.js']); +``` + +## Bug Fixes + +* `RequestLogger` no longer fails when it tries to stringify a null request body ([#2718](https://github.com/DevExpress/testcafe/issues/2718)) +* Temporary directories are now correctly removed when the test run is finished ([#2735](https://github.com/DevExpress/testcafe/issues/2735)) +* TestCafe no longer throws `ECONNRESET` when run against a Webpack project ([#2711](https://github.com/DevExpress/testcafe/issues/2711)) +* An error is no longer thrown when TestCafe tests Sencha ExtJS applications in IE11 ([#2639](https://github.com/DevExpress/testcafe/issues/2639)) +* Firefox no longer waits for page elements to appear without necessity ([#2080](https://github.com/DevExpress/testcafe/issues/2080)) +* `${BROWSER}` in the screenshot pattern now correctly resolves to the browser name ([#2742](https://github.com/DevExpress/testcafe/issues/2742)) +* The `toString` function now returns a native string for overridden descriptor ancestors ([testcafe-hammerhead/#1713](https://github.com/DevExpress/testcafe-hammerhead/issues/1713)) +* The `iframe` flag is no longer added when a form with `target="_parent"` is submitted ([testcafe-hammerhead/#1680](https://github.com/DevExpress/testcafe-hammerhead/issues/1680)) +* Hammerhead no longer sends request headers in lower case ([testcafe-hammerhead/#1380](https://github.com/DevExpress/testcafe-hammerhead/issues/1380)) +* The overridden `createHTMLDocument` method has the right context now ([testcafe-hammerhead/#1722](https://github.com/DevExpress/testcafe-hammerhead/issues/1722)) +* Tests no longer lose connection ([testcafe-hammerhead/#1647](https://github.com/DevExpress/testcafe-hammerhead/issues/1647)) +* The case when both the `X-Frame-Options` header and a CSP with `frame-ancestors` are set is now handled correctly ([testcafe-hammerhead/#1666](https://github.com/DevExpress/testcafe-hammerhead/issues/1666)) +* The mechanism that resolves URLs on the client now works correctly ([testcafe-hammerhead/#1701](https://github.com/DevExpress/testcafe-hammerhead/issues/1701)) +* `LiveNodeListWrapper` now imitates the native behavior correctly ([testcafe-hammerhead/#1376](https://github.com/DevExpress/testcafe-hammerhead/issues/1376)) diff --git a/docs/articles/blog/2018-10-25-testcafe-v0-23-0-released.md b/docs/articles/blog/2018-10-25-testcafe-v0-23-0-released.md new file mode 100644 index 00000000..d6de7f34 --- /dev/null +++ b/docs/articles/blog/2018-10-25-testcafe-v0-23-0-released.md @@ -0,0 +1,63 @@ +--- +layout: post +title: TestCafe v0.23.0 Released +permalink: /blog/:title.html +--- +# TestCafe v0.23.0 Released + +Stop a test run after the first test fail, view JavaScript errors' stack trace in test run reports and let TestCafe restart browsers when they stop responding. + + + +## Enhancements + +### ⚙ Stop Test Run After the First Test Fail ([#1323](https://github.com/DevExpress/testcafe/issues/1323)) + +You can now configure TestCafe to stop the entire test run after the first test fail. This saves your time when you fix problems with your tests one by one. + +Specify the [--sf](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#--sf---stop-on-first-fail) flag to enable this feature when you run tests from the command line. + +```sh +testcafe chrome my-tests --sf +``` + +In the API, use the [stopOnFirstFail](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#run) option. + +```js +runner.run({ stopOnFirstFail: true }) +``` + +### ⚙ View the JavaScript Errors' Stack Traces in Reports ([#2043](https://github.com/DevExpress/testcafe/issues/2043)) + +Now when a JavaScript error occurs on the tested webpage, the test run report includes a stack trace for this error (only if the [--skip-js-errors](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#-e---skip-js-errors) option is disabled). + +![A report that contains a stack trace for a client JS error](../images/client-error-stack-report.png) + +### ⚙ Browsers are Automatically Restarted When They Stop Responding ([#1815](https://github.com/DevExpress/testcafe/issues/1815)) + +If a browser stops responding while it executes tests, TestCafe restarts the browser and reruns the current test in a new browser instance. +If the same problem occurs with this test two more times, the test run finishes and an error is thrown. + +## Bug Fixes + +* An error message about an unawaited call to an async function is no longer displayed when an uncaught error occurs ([#2557](https://github.com/DevExpress/testcafe/issues/2557)) +* A request hook is no longer added multiple times when a filter rule is used ([#2650](https://github.com/DevExpress/testcafe/issues/2650)) +* Screenshot links in test run reports now contain paths specified by the `--screenshot-pattern` option ([#2726](https://github.com/DevExpress/testcafe/issues/2726)) +* Assertion chains no longer produce unhandled promise rejections ([#2852](https://github.com/DevExpress/testcafe/issues/2852)) +* The `moment` loader now works correctly in the Jest environment ([#2500](https://github.com/DevExpress/testcafe/issues/2500)) +* TestCafe no longer hangs if the screenshot directory contains forbidden symbols ([#681](https://github.com/DevExpress/testcafe/issues/681)) +* The `--ssl` option's parameters are now parsed correctly ([#2924](https://github.com/DevExpress/testcafe/issues/2924)) +* TestCafe now throws a meaningful error if an assertion method is missing ([#1063](https://github.com/DevExpress/testcafe/issues/1063)) +* TestCafe no longer hangs when it clicks a custom element ([#2861](https://github.com/DevExpress/testcafe/issues/2861)) +* TestCafe now performs keyboard navigation between radio buttons/groups in a way that matches the native browser behavior ([#2067](https://github.com/DevExpress/testcafe/issues/2067), [#2045](https://github.com/DevExpress/testcafe/issues/2045)) +* The `fetch` method can now be used with data URLs ([#2865](https://github.com/DevExpress/testcafe/issues/2865)) +* The `switchToIframe` function no longer throws an error ([#2956](https://github.com/DevExpress/testcafe/issues/2956)) +* TestCafe can now scroll through fixed elements when the action has custom offsets ([#2978](https://github.com/DevExpress/testcafe/issues/2978)) +* You can now specify the current directory or its parent directories as the base path to store screenshots ([#2975](https://github.com/DevExpress/testcafe/issues/2975)) +* Tests no longer hang up when you try to debug in headless browsers ([#2846](https://github.com/DevExpress/testcafe/issues/2846)) +* The `removeEventListener` function now works correctly when an object is passed as its third argument ([testcafe-hammerhead/#1737](https://github.com/DevExpress/testcafe-hammerhead/issues/1737)) +* Hammerhead no longer adds the `event` property to a null `contentWindow` in IE11 ([testcafe-hammerhead/#1684](https://github.com/DevExpress/testcafe-hammerhead/issues/1684)) +* The browser no longer resets connection with the server for no reason ([testcafe-hammerhead/#1647](https://github.com/DevExpress/testcafe-hammerhead/issues/1647)) +* Hammerhead now stringifies values correctly before outputting them to the console ([testcafe-hammerhead/#1750](https://github.com/DevExpress/testcafe-hammerhead/issues/1750)) +* A document fragment from the top window can now be correctly appended to an iframe ([testcafe-hammerhead/#912](https://github.com/DevExpress/testcafe-hammerhead/issues/912)) +* Lifecycle callbacks that result from the `document.registerElement` method are no longer called twice ([testcafe-hammerhead/#695](https://github.com/DevExpress/testcafe-hammerhead/issues/695)) diff --git a/docs/articles/blog/2018-11-7-testcafe-v0-23-1-released.md b/docs/articles/blog/2018-11-7-testcafe-v0-23-1-released.md new file mode 100644 index 00000000..f7b8d7cf --- /dev/null +++ b/docs/articles/blog/2018-11-7-testcafe-v0-23-1-released.md @@ -0,0 +1,61 @@ +--- +layout: post +title: TestCafe v0.23.1 Released +permalink: /blog/:title.html +--- +# TestCafe v0.23.1 Released + +Select tests and fixtures to run by their metadata and run dynamically loaded tests. + + + +## Enhancements + +### ⚙ Select Tests and Fixtures to Run by Their Metadata ([#2527](https://github.com/DevExpress/testcafe/issues/2527)) by [@NickCis](https://github.com/NickCis) + +You can now run only those tests or fixtures whose [metadata](https://devexpress.github.io/testcafe/documentation/test-api/test-code-structure.html#specifying-testing-metadata) contains a specific set of values. + +Use the [--test-meta](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#--test-meta-keyvaluekey2value2) flag to specify values to look for in test metadata. + +```sh +testcafe chrome my-tests --test-meta device=mobile,env=production +``` + +To select fixtures by their metadata, use the [--fixture-meta](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#--fixture-meta-keyvaluekey2value2) flag. + +```sh +testcafe chrome my-tests --fixture-meta subsystem=payments,type=regression +``` + +In the API, test and fixture metadata is now passed to the [runner.filter](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#filter) method in the `testMeta` and `fixtureMeta` parameters. Use this metadata to decide whether to run the current test. + +```js +runner.filter((testName, fixtureName, fixturePath, testMeta, fixtureMeta) => { + return testMeta.mobile === 'true' && + fixtureMeta.env === 'staging'; +}); +``` + +### ⚙ Run Dynamically Loaded Tests ([#2074](https://github.com/DevExpress/testcafe/issues/2074)) + +You can now run tests imported from external libraries or generated dynamically even if the `.js` file you provide to TestCafe does not contain any tests. + +Previously, this was not possible because TestCafe required test files to contain the [fixture](https://devexpress.github.io/testcafe/documentation/test-api/test-code-structure.html#fixtures) and [test](https://devexpress.github.io/testcafe/documentation/test-api/test-code-structure.html#tests) directives. Now you can bypass this check. To do this, provide the [--disable-test-syntax-validation](https://devexpress.github.io/testcafe/documentation/using-testcafe/command-line-interface.html#--disable-test-syntax-validation) command line flag. + +```sh +testcafe safari test.js --disable-test-syntax-validation +``` + +In the API, use the [disableTestSyntaxValidation](https://devexpress.github.io/testcafe/documentation/using-testcafe/programming-interface/runner.html#run) option. + +```js +runner.run({ disableTestSyntaxValidation: true }) +``` + +## Bug Fixes + +* Touch events are now simulated with correct touch properties (`touches`, `targetTouches`, `changedTouches`) ([#2856](https://github.com/DevExpress/testcafe/issues/2856)) +* Google Chrome now closes correctly on macOS after tests are finished ([#2860](https://github.com/DevExpress/testcafe/issues/2860)) +* Internal attribute and node changes no longer provoke `MutationObserver` notifications ([testcafe-hammerhead/#1769](https://github.com/DevExpress/testcafe-hammerhead/issues/1769)) +* The `ECONNABORTED` error is no longer raised ([testcafe-hammerhead/#1744](https://github.com/DevExpress/testcafe-hammerhead/issues/1744)) +* Websites that use `Location.ancestorOrigins` are now proxied correctly ([testcafe-hammerhead/#1342](https://github.com/DevExpress/testcafe-hammerhead/issues/1342)) diff --git a/docs/articles/documentation/README.md b/docs/articles/documentation/README.md index 4e63203b..67333927 100644 --- a/docs/articles/documentation/README.md +++ b/docs/articles/documentation/README.md @@ -1,8 +1,3 @@ ---- -layout: docs -title: Documentation -permalink: /documentation/ ---- # Documentation * [Getting Started](getting-started/README.md) diff --git a/docs/articles/documentation/extending-testcafe/reporter-plugin/reporter-methods.md b/docs/articles/documentation/extending-testcafe/reporter-plugin/reporter-methods.md index f775e2bf..890b107c 100644 --- a/docs/articles/documentation/extending-testcafe/reporter-plugin/reporter-methods.md +++ b/docs/articles/documentation/extending-testcafe/reporter-plugin/reporter-methods.md @@ -6,7 +6,12 @@ checked: false --- # Reporter Methods -You should implement the following methods to create a [reporter](README.md#implementing-the-reporter). +You should implement the following methods to create a [reporter](README.md#implementing-the-reporter): + +* [reportTaskStart](#reporttaskstart) +* [reportFixtureStart](#reportfixturestart) +* [reportTestDone](#reporttestdone) +* [reportTaskDone](#reporttaskdone) > You can use the [helper methods and libraries](helpers.md) within the reporter methods to output the required data. @@ -81,19 +86,9 @@ reportTestDone (name, testRunInfo, meta) Parameter | Type | Description ------------- | ------ | ------------------------------------------------------------- `name` | String | The test name. -`testRunInfo` | Object | The object providing detailed information about the test run. +`testRunInfo` | Object | The [testRunInfo](#testruninfo-object) object. `meta` | Object | The test metadata. See [Specifying Testing Metadata](../../test-api/test-code-structure.md#specifying-testing-metadata) for more information. -The `testRunInfo` object has the following properties. - -Property | Type | Description ----------------- | ---------------- | -------------------------------------------------------- -`errs` | Array or Strings | An array of errors that occurred during a test run. -`durationMs` | Number | The time spent on test execution (in milliseconds). -`unstable` | Boolean | Specifies if the test is marked as unstable. -`screenshotPath` | String | The directory path where screenshots have been saved to. -`skipped` | Boolean | Specifies if the test was skipped. - **Example** ```js @@ -124,6 +119,40 @@ reportTestDone (name, testRunInfo, meta) { //=> skipped First fixture - Fourth test in first fixture ``` +### testRunInfo Object + +The `testRunInfo` object provides detailed information about the test run. The object has the following properties: + +Property | Type | Description +------------------- | ---------------- | -------------------------------------------------------- +`errs` | Array of Strings | An array of errors that occurred during the test run. +`durationMs` | Number | The time spent on test execution (in milliseconds). +`unstable` | Boolean | Specifies if the test is marked as unstable. +`screenshotPath` | String | The path where screenshots are saved. +`screenshots` | Array of Objects | An array of [screenshot](#screenshots-object) objects. +`quarantine` | Object | A [quarantine](#quarantine-object) object. +`skipped` | Boolean | Specifies if the test was skipped. + +### screenshots Object + +The `screenshot` object provides information about the screenshot captured during the test run. The object has the following properties: + +Property | Type | Description +------------------- | ---------------- | -------------------------------------------------------- +`screenshotPath` | String | The path where the screenshot was saved. +`thumbnailPath` | String | The path where the screenshot's thumbnail was saved. +`userAgent` | String | The user agent string of the browser where the screenshot was captured. +`quarantineAttempt` | Number | The [quarantine](../../using-testcafe/programming-interface/runner.md#quarantine-mode) attempt's number. +`takenOnFail` | Boolean | Specifies if the screenshot was captured when the test failed. + +### quarantine Object + +The `quarantine` object provides information about [quarantine](../../using-testcafe/programming-interface/runner.md#quarantine-mode)'s attempts in the form of key-value pairs. + +Key | Value +----------------------------------| ------------------------------------------------ +The quarantine attempt's number. | The object that provides information about the attempt. The object has the boolean `passed` property that specifies if the test passed in the current attempt. + ## reportTaskDone Fires when the task ends. diff --git a/docs/articles/documentation/getting-started/README.md b/docs/articles/documentation/getting-started/README.md index 42025381..dc9feca2 100644 --- a/docs/articles/documentation/getting-started/README.md +++ b/docs/articles/documentation/getting-started/README.md @@ -2,6 +2,8 @@ layout: docs title: Getting Started permalink: /documentation/getting-started/ +redirect_from: + - /documentation/ --- # Getting Started @@ -158,7 +160,7 @@ A functional test should also check the result of actions performed. For example, the article header on the "Thank you" page should address a user using the entered name. To check if the header is correct, you have to add an assertion to the test. -The following test demonstrates how to use [build-in assertions](../test-api/assertions/README.md). +The following test demonstrates how to use [built-in assertions](../test-api/assertions/README.md). ```js import { Selector } from 'testcafe'; diff --git a/docs/articles/documentation/recipes/README.md b/docs/articles/documentation/recipes/README.md index df329452..9da33b44 100644 --- a/docs/articles/documentation/recipes/README.md +++ b/docs/articles/documentation/recipes/README.md @@ -2,13 +2,15 @@ layout: docs title: Recipes permalink: /documentation/recipes/ +redirect_from: + - /documentation/recipes/running-tests-in-firefox-and-chrome-using-travis-ci.html --- # Recipes This section provides examples and recipes of how to use TestCafe in different scenarios. -* [Debugging with Chrome Developer Tools](debugging-with-chrome-dev-tools.md) -* [Debugging with Visual Studio Code](debugging-with-visual-studio-code.md) +* [Debug in Chrome Developer Tools](debug-in-chrome-dev-tools.md) +* [Debug in Visual Studio Code](debug-in-visual-studio-code.md) * [Finding Code Issues with Flow Type Checker](finding-code-issues-with-flow-type-checker.md) * [Integrating TestCafe with CI Systems](integrating-testcafe-with-ci-systems/README.md) * [Running Tests Using Travis CI and Sauce Labs](running-tests-using-travis-ci-and-sauce-labs.md) diff --git a/docs/articles/documentation/recipes/debugging-with-chrome-dev-tools.md b/docs/articles/documentation/recipes/debug-in-chrome-dev-tools.md similarity index 86% rename from docs/articles/documentation/recipes/debugging-with-chrome-dev-tools.md rename to docs/articles/documentation/recipes/debug-in-chrome-dev-tools.md index 7e71284e..1679068e 100644 --- a/docs/articles/documentation/recipes/debugging-with-chrome-dev-tools.md +++ b/docs/articles/documentation/recipes/debug-in-chrome-dev-tools.md @@ -1,9 +1,11 @@ --- layout: docs -title: Debugging with Chrome Developer Tools -permalink: /documentation/recipes/debugging-with-chrome-dev-tools.html +title: Debug in Chrome Developer Tools +permalink: /documentation/recipes/debug-in-chrome-dev-tools.html +redirect_from: + - /documentation/recipes/debugging-with-chrome-dev-tools.html --- -# Debugging with Chrome Developer Tools +# Debug in Chrome Developer Tools Starting with version 6.3.0, Node.js allows you to debug applications in Chrome Developer Tools. If you have Chrome and an appropriate version of Node.js installed on your machine, diff --git a/docs/articles/documentation/recipes/debug-in-visual-studio-code.md b/docs/articles/documentation/recipes/debug-in-visual-studio-code.md new file mode 100644 index 00000000..5d9504ea --- /dev/null +++ b/docs/articles/documentation/recipes/debug-in-visual-studio-code.md @@ -0,0 +1,72 @@ +--- +layout: docs +title: Debug in Visual Studio Code +permalink: /documentation/recipes/debug-in-visual-studio-code.html +redirect_from: + - /documentation/recipes/debugging-with-visual-studio-code.html +--- +# Debug in Visual Studio Code + +Before you debug in Visual Studio Code, ensure that your root test directory contains a `package.json` file that includes `testcafe` in the `devDependencies` section. + +```json +{ + "devDependencies": { + "testcafe": "x.y.z" + } +} +``` + +where `x.y.z` is the TestCafe version you use. + +Then you need to install TestCafe locally in the test directory. + +```sh +npm install +``` + +The next step adds a launch configuration used to run TestCafe tests. + +![Configuration File](../../images/recipe-vscode-configuration-file.png) + +See the [Visual Studio Code documentation](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) to learn how to create a configuration. + +You need to add the following configuration to the `launch.json` file. + +```json +{ + "type": "node", + "protocol": "inspector", + "request": "launch", + "name": "Launch test files with TestCafe", + "program": "${workspaceRoot}/node_modules/testcafe/bin/testcafe.js", + "args": [ + "firefox", + "${relativeFile}" + ], + "console": "integratedTerminal", + "cwd": "${workspaceRoot}" +} +``` + +This configuration contains the following attributes: + +* `type` - specifies the configuration type. Set to `node` for a Node.js configuration. +* `protocol` - specifies the Node.js [debugger wire protocol](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_supported-nodelike-runtimes). Note that the inspector protocol is supported in Node.js v6.3 (or v6.9 for Windows) or later. For early versions, omit this property. In that case, Node.js uses a legacy debugger protocol. The legacy protocol has issues with source map support, therefore newer versions of Node.js are recommended. +* `request` - specifies the request type. Set to `launch` since this configuration launches a program. +* `name` - specifies the configuration name. +* `program` - path to the executed JS file. In this case, this file is the TestCafe module. +* `args` - [command line arguments](../using-testcafe/command-line-interface.md) passed to the launched program. In this case, they specify the browser in which the tests should run and the relative path to the test file. +* `console` - the console where the test run report is printed. In this case, the report is output to the integrated terminal. You can learn about other available values in the [Launch.json attributes](https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes) topic. +* `cwd` - the current working directory. Set to the workspace root. + +Save the `launch.json` file. The new configuration then appears in the configuration drop-down. + +![Select Configuration](../../images/recipe-vscode-select-configuration.png) + +Now you can open a file with TestCafe tests, select the `"Launch test files with TestCafe"` configuration and click the **Run** button. +Tests run with the debugger attached. You can put breakpoints in test code and the debugger stops at them. + +![Stop at a Breakpoint](../../images/recipe-vscode-debugging-breakpoint.png) + +> Important! If you do not select the `"Launch test files with TestCafe"` configuration, Visual Studio Code tries to run the test file as a program and throws an error. diff --git a/docs/articles/documentation/recipes/debugging-with-visual-studio-code.md b/docs/articles/documentation/recipes/debugging-with-visual-studio-code.md deleted file mode 100644 index 4b2dfc0a..00000000 --- a/docs/articles/documentation/recipes/debugging-with-visual-studio-code.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -layout: docs -title: Debugging with Visual Studio Code -permalink: /documentation/recipes/debugging-with-visual-studio-code.html ---- -# Debugging with Visual Studio Code - -Before debugging in Visual Studio Code, ensure that your root test directory contains a `package.json` file that includes `testcafe` in the `devDependencies` section. - -```json -{ - "devDependencies": { - "testcafe": "x.y.z" - } -} -``` - -where `x.y.z` is the TestCafe version you use. - -Then you need to install TestCafe locally in the tests directory via `npm`. - -```sh -npm install -``` - -The next step is adding a launch configuration that runs TestCafe tests. See the [Visual Studio Code documentation](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) to learn how to create a configuration. - -You will need to add the following configuration to the `launch.json` file. - -```json -{ - "type": "node", - "protocol": "inspector", - "request": "launch", - "name": "Launch test files with TestCafe", - "program": "${workspaceRoot}/node_modules/testcafe/bin/testcafe.js", - "args": [ - "firefox", - "${file}" - ], - "cwd": "${workspaceRoot}" -} -``` - -This configuration contains the following attributes: - -* `type` - specifies the type of the configuration. Set to `node` for a Node.js configuration. -* `protocol` - specifies the Node.js [debugger wire protocol](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_supported-nodelike-runtimes). Note that the inspector protocol is supported in Node.js v6.3 (or v6.9 for Windows) or later. For early versions, omit this property. In that case, a legacy debugger protocol will be used. Legacy protocol is well known for its issues with source map support, therefore newer versions of Node.js are recommended. -* `request` - specifies the request type. Set to `launch` since this configuration launches a program. -* `name` - specifies the name of the configuration. -* `program` - path to a JS file that will be executed. In this case, it is the TestCafe module. -* `args` - [command line arguments](../using-testcafe/command-line-interface.md) passed to the launched program. In this case, they specify the browser in which the tests should run and the test file. -* `cwd` - the current working directory. Set to the workspace root. - -Save the `launch.json` file. The new configuration will appear in the configuration drop-down. - -Now you can open a file with TestCafe tests, select the configuration you have just created and click the Run button. -Tests will run with the debugger attached. You can put breakpoints in test code and the debugger will stop at them. diff --git a/docs/articles/documentation/recipes/using-page-model.md b/docs/articles/documentation/recipes/using-page-model.md index 2f3028b9..5ddabaf5 100644 --- a/docs/articles/documentation/recipes/using-page-model.md +++ b/docs/articles/documentation/recipes/using-page-model.md @@ -8,6 +8,18 @@ permalink: /documentation/recipes/using-page-model.html [Page Model](http://martinfowler.com/bliki/PageObject.html) is a test automation pattern that allows you to create an abstraction of the tested page and use it in test code to refer to page elements. +* [Why Use Page Model](#why-use-page-model) +* [Create a Page Model](#create-a-page-model) + * [Step 1 - Declare a Page Model Class](#step-1---declare-a-page-model-class) + * [Step 2 - Add a Page Element to the Page Model](#step-2---add-a-page-element-to-the-page-model) + * [Step 3 - Write a Test That Uses the Page Model](#step-3---write-a-test-that-uses-the-page-model) + * [Step 4 - Add a New Class for Check Boxes](#step-4---add-a-new-class-for-check-boxes) + * [Step 5 - Add a List of Check Boxes to the Page Model](#step-5---add-a-list-of-check-boxes-to-the-page-model) + * [Step 6 - Write a Test That Iterates Through Check Boxes](#step-6---write-a-test-that-iterates-through-check-boxes) + * [Step 7 - Add Actions to the Page Model](#step-7---add-actions-to-the-page-model) + * [Step 8 - Write a Test That Calls Actions From the Page Model](#step-8---write-a-test-that-calls-actions-from-the-page-model) +* [Page Model Example](#page-model-example) + ## Why Use Page Model Consider the following fixture with two tests: one that types and edits @@ -51,137 +63,230 @@ Generally speaking, the Page Model pattern allows you to follow the separation of concerns principle - you keep page representation in the Page Model, while tests remain focused on the behavior. -## Creating a Page Model +## Create a Page Model -1. Begin with a new `.js` file and declare the `Page` class there. +### Step 1 - Declare a Page Model Class - ```js - export default class Page { - constructor () { - } +Begin with a new `.js` file and declare the `Page` class there. + +```js +export default class Page { + constructor () { } - ``` +} +``` - This class will contain the Page Model, so name the file `page-model.js`. +This class will contain the Page Model, so name the file `page-model.js`. -2. Add the `Developer Name` input element to the model. To do this, - introduce the `nameInput` property and assign a [selector](../test-api/selecting-page-elements/selectors/README.md) to it. +### Step 2 - Add a Page Element to the Page Model - ```js - import { Selector } from 'testcafe'; +Add the `Developer Name` input element to the model. To do this, +introduce the `nameInput` property and assign a [selector](../test-api/selecting-page-elements/selectors/README.md) to it. + +```js +import { Selector } from 'testcafe'; - export default class Page { - constructor () { - this.nameInput = Selector('#developer-name'); - } +export default class Page { + constructor () { + this.nameInput = Selector('#developer-name'); } - ``` +} +``` -3. In the test file, import `page-model.js` and create an instance of the `Page` class. - After that, you can use the `page.nameInput` property to identify the `Developer Name` input element. +### Step 3 - Write a Test That Uses the Page Model - ```js - import Page from './page-model'; +In the test file, import `page-model.js` and create an instance of the `Page` class. +After that, you can use the `page.nameInput` property to identify the `Developer Name` input element. - const page = new Page(); +```js +import Page from './page-model'; - fixture `My fixture` - .page `https://devexpress.github.io/testcafe/example/`; +const page = new Page(); - test('Text typing basics', async t => { - await t - .typeText(page.nameInput, 'Peter') - .typeText(page.nameInput, 'Paker', { replace: true }) - .typeText(page.nameInput, 'r', { caretPos: 2 }) - .expect(page.nameInput.value).eql('Parker'); - }); - ``` +fixture `My fixture` + .page `https://devexpress.github.io/testcafe/example/`; -4. Add check boxes from the Features section to the Page Model. +test('Text typing basics', async t => { + await t + .typeText(page.nameInput, 'Peter') + .typeText(page.nameInput, 'Paker', { replace: true }) + .typeText(page.nameInput, 'r', { caretPos: 2 }) + .expect(page.nameInput.value).eql('Parker'); +}); +``` - As long as each item in the Features section contains a check box and a label, - introduce a new class `Feature` with two properties: `label` and `checkbox`. +### Step 4 - Add a New Class for Check Boxes - ```js - import { Selector } from 'testcafe'; +Add check boxes from the Features section to the Page Model. + +As long as each item in the Features section contains a check box and a label, +introduce a new class `Feature` with two properties: `label` and `checkbox`. - const label = Selector('label'); +```js +import { Selector } from 'testcafe'; + +const label = Selector('label'); - class Feature { - constructor (text) { - this.label = label.withText(text); - this.checkbox = this.label.find('input[type=checkbox]'); - } +class Feature { + constructor (text) { + this.label = label.withText(text); + this.checkbox = this.label.find('input[type=checkbox]'); } +} - export default class Page { - constructor () { - this.nameInput = Selector('#developer-name'); - } +export default class Page { + constructor () { + this.nameInput = Selector('#developer-name'); } - ``` +} +``` -5. In the `Page` class, add the `featureList` property with an array of `Feature` objects. +### Step 5 - Add a List of Check Boxes to the Page Model - ```js - import { Selector } from 'testcafe'; +In the `Page` class, add the `featureList` property with an array of `Feature` objects. + +```js +import { Selector } from 'testcafe'; - const label = Selector('label'); +const label = Selector('label'); - class Feature { - constructor (text) { - this.label = label.withText(text); - this.checkbox = this.label.find('input[type=checkbox]'); - } +class Feature { + constructor (text) { + this.label = label.withText(text); + this.checkbox = this.label.find('input[type=checkbox]'); } +} - export default class Page { - constructor () { - this.nameInput = Selector('#developer-name'); - this.featureList = [ - new Feature('Support for testing on remote devices'), - new Feature('Re-using existing JavaScript code for testing'), - new Feature('Easy embedding into a Continuous integration system') - ]; - } +export default class Page { + constructor () { + this.nameInput = Selector('#developer-name'); + this.featureList = [ + new Feature('Support for testing on remote devices'), + new Feature('Re-using existing JavaScript code for testing'), + new Feature('Easy embedding into a Continuous integration system') + ]; } - ``` +} +``` + +Organizing check boxes in an array makes the page model semantically correct and simplifies iterating through the check boxes. + +### Step 6 - Write a Test That Iterates Through Check Boxes + +The second test now boils down to a single loop. + +```js +import Page from './page-model'; - Organizing check boxes in an array makes the page model semantically correct and simplifies iterating through the check boxes. +fixture `My fixture` + .page `https://devexpress.github.io/testcafe/example/`; -6. The second test now boils down to a single loop. +const page = new Page(); + +test('Text typing basics', async t => { + await t + .typeText(page.nameInput, 'Peter') + .typeText(page.nameInput, 'Paker', { replace: true }) + .typeText(page.nameInput, 'r', { caretPos: 2 }) + .expect(page.nameInput.value).eql('Parker'); +}); + +test('Click check boxes and then verify their state', async t => { + for (const feature of page.featureList) { + await t + .click(feature.label) + .expect(feature.checkbox.checked).ok(); + } +}); +``` + +### Step 7 - Add Actions to the Page Model + +Add an action that enters the developer name and clicks the Submit button. + +1. Import `t`, a [test controller](../test-api/test-code-structure.md#test-controller), from the `testcafe` module. ```js - import Page from './page-model'; + import { Selector, t } from 'testcafe'; + ``` - fixture `My fixture` - .page `https://devexpress.github.io/testcafe/example/`; +2. Add a Submit button to the page model. - const page = new Page(); + ```js + this.submitButton = Selector('#submit-button'); + ``` + +3. Declare an [asynchronous function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) in the `Page` class. This function uses the test controller to perform several actions on the tested page: enter the developer name and click the Submit button. - test('Text typing basics', async t => { + ```js + async submitName (name) { await t - .typeText(page.nameInput, 'Peter') - .typeText(page.nameInput, 'Paker', { replace: true }) - .typeText(page.nameInput, 'r', { caretPos: 2 }) - .expect(page.nameInput.value).eql('Parker'); - }); - - test('Click check boxes and then verify their state', async t => { - for (const feature of page.featureList) { - await t - .click(feature.label) - .expect(feature.checkbox.checked).ok(); - } - }); + .typeText(this.nameInput, name) + .click(this.submitButton); + } ``` -**Example** +Here is how the page model looks now. -This sample shows a page model for the example page at [https://devexpress.github.io/testcafe/example/](https://devexpress.github.io/testcafe/example/). +```js +import { Selector, t } from 'testcafe'; + +const label = Selector('label'); + +class Feature { + constructor (text) { + this.label = label.withText(text); + this.checkbox = this.label.find('input[type=checkbox]'); + } +} + +export default class Page { + constructor () { + this.nameInput = Selector('#developer-name'); + + this.featureList = [ + new Feature('Support for testing on remote devices'), + new Feature('Re-using existing JavaScript code for testing'), + new Feature('Easy embedding into a Continuous integration system') + ]; + + this.submitButton = Selector('#submit-button'); + } + + async submitName (name) { + await t + .typeText(this.nameInput, name) + .click(this.submitButton); + } +} +``` + +### Step 8 - Write a Test That Calls Actions From the Page Model + +Now write a test that calls `page.submitName` and checks the message on the Thank You page. + +```js +test('Submit a developer name and check the header', async t => { + const header = Selector('#article-header'); + + await page.submitName('Peter'); + + await t.expect(header.innerText).eql('Thank you, Peter!'); +}); +``` + +This test works with a different page for which there is no page model. That is why it uses a selector. Don't forget to import it to the test file. ```js import { Selector } from 'testcafe'; +``` + +## Page Model Example + +This sample shows a page model for the example page at [https://devexpress.github.io/testcafe/example/](https://devexpress.github.io/testcafe/example/). + +```js +import { Selector, t } from 'testcafe'; const label = Selector('label'); @@ -229,6 +334,13 @@ export default class Page { this.interfaceSelect = Selector('#preferred-interface'); this.interfaceSelectOption = this.interfaceSelect.find('option'); + this.submitButton = Selector('#submit-button'); + } + + async submitName (name) { + await t + .typeText(this.nameInput, name) + .click(this.submitButton); } } ``` \ No newline at end of file diff --git a/docs/articles/documentation/test-api/README.md b/docs/articles/documentation/test-api/README.md index 96874e4f..52ebe3f0 100644 --- a/docs/articles/documentation/test-api/README.md +++ b/docs/articles/documentation/test-api/README.md @@ -6,12 +6,11 @@ checked: true --- # Test API -TestCafe allows you to write tests using JavaScript and TypeScript (see [TypeScript Support](typescript-support.md) for more information about writing tests in TypeScript). +TestCafe allows you to write tests using JavaScript, [TypeScript](typescript-support.md) and [CoffeeScript](coffeescript-support.md). The following topics demonstrate how to organize test code: * [Test Code Structure](test-code-structure.md) -* [TypeScript Support](typescript-support.md) The following topics describe the API used to manipulate the webpage and check its state: @@ -20,7 +19,7 @@ The following topics describe the API used to manipulate the webpage and check i * [Assertions](assertions/README.md) * [Obtaining Data From the Client](obtaining-data-from-the-client/README.md) * [Intercepting HTTP Requests](intercepting-http-requests/README.md) -* [Waiting for Page Elements to Appear](waiting-for-page-elements-to-appear.md) +* [Built-In Waiting Mechanisms](built-in-waiting-mechanisms.md) * [Authentication](authentication/README.md) * [Pausing the Test](pausing-the-test.md) * [Handling Native Dialogs](handling-native-dialogs.md) diff --git a/docs/articles/documentation/test-api/actions/navigate.md b/docs/articles/documentation/test-api/actions/navigate.md index 47f7efa8..9fff7af3 100644 --- a/docs/articles/documentation/test-api/actions/navigate.md +++ b/docs/articles/documentation/test-api/actions/navigate.md @@ -40,4 +40,7 @@ test('Navigate to local pages', async t => { .navigateTo('file:///user/my-website/index.html') .navigateTo('../my-project/index.html'); }); -``` \ No newline at end of file +``` + +TestCafe automatically waits for the server to respond after a redirect happens. +The test is resumed if the server does not respond within **15** seconds. \ No newline at end of file diff --git a/docs/articles/documentation/test-api/actions/resize-window.md b/docs/articles/documentation/test-api/actions/resize-window.md index ac8549cc..268cb40b 100644 --- a/docs/articles/documentation/test-api/actions/resize-window.md +++ b/docs/articles/documentation/test-api/actions/resize-window.md @@ -8,12 +8,14 @@ checked: true There are three ways of resizing a browser window. -**Note**: these actions require a [ICCCM/EWMH-compliant window manager](https://en.wikipedia.org/wiki/Comparison_of_X_window_managers) on Linux. - * [Setting the Window Size](#setting-the-window-size) * [Fitting the Window into a Particular Device](#fitting-the-window-into-a-particular-device) * [Maximizing the Window](#maximizing-the-window) +> Important! Window resize actions are not supported when you run tests in [remote browsers](../../using-testcafe/common-concepts/browsers/browser-support.md#browsers-on-remote-devices). + +**Note**: these actions require .NET 4.0 or newer installed on Windows machines and an [ICCCM/EWMH-compliant window manager](https://en.wikipedia.org/wiki/Comparison_of_X_window_managers) on Linux. + ## Setting the Window Size ```text diff --git a/docs/articles/documentation/test-api/actions/take-screenshot.md b/docs/articles/documentation/test-api/actions/take-screenshot.md index 4ce6007e..efe4b76a 100644 --- a/docs/articles/documentation/test-api/actions/take-screenshot.md +++ b/docs/articles/documentation/test-api/actions/take-screenshot.md @@ -6,12 +6,13 @@ checked: true --- # Take Screenshot -This topic describes how you can take screenshots of the tested page. +This topic describes how to take screenshots of the tested page. -**Note**: these actions require a [ICCCM/EWMH-compliant window manager](https://en.wikipedia.org/wiki/Comparison_of_X_window_managers) on Linux. +> Important! Screenshot actions are not supported when you run tests in [remote browsers](../../using-testcafe/common-concepts/browsers/browser-support.md#browsers-on-remote-devices). -> Important! If the screenshot directory is not specified with the [runner.screenshots](../../using-testcafe/programming-interface/runner.md#screenshots) API method or the [screenshots](../../using-testcafe/command-line-interface.md#-s-path---screenshots-path) command line option, -> the screenshot actions are ignored. +**Note**: these actions require .NET 4.0 or newer installed on Windows machines and an [ICCCM/EWMH-compliant window manager](https://en.wikipedia.org/wiki/Comparison_of_X_window_managers) on Linux. + +> Important! Screenshot actions are ignored if the screenshot directory is not specified with the [runner.screenshots](../../using-testcafe/programming-interface/runner.md#screenshots) API method or the [--screenshots](../../using-testcafe/command-line-interface.md#-s-path---screenshots-path) command line option. ## Take a Screenshot of the Entire Page @@ -21,14 +22,9 @@ t.takeScreenshot( [path] ) Parameter | Type | Description ------------------- | ------ | ----------------------------------------------------------------------------------------------------- -`path` *(optional)* | String | The relative path and the name of the screenshot file to be created. Resolved from the *screenshot directory* specified by using the [runner.screenshots](../../using-testcafe/programming-interface/runner.md#screenshots) API method or the [screenshots](../../using-testcafe/command-line-interface.md#-s-path---screenshots-path) command line option. - -By default, the path to which screenshots are saved is specified as: - -* `{currentDate}\test-{testIndex}\{userAgent}\{screenshotIndex}.png` if the [quarantine mode](../../using-testcafe/command-line-interface.md#-q---quarantine-mode) is disabled; -* `{currentDate}\test-{testIndex}\run-{quarantineAttempt}\{userAgent}\{screenshotIndex}.png` if the [quarantine mode](../../using-testcafe/command-line-interface.md#-q---quarantine-mode) is enabled. +`path` *(optional)* | String | The screenshot file's relative path and name. The path is relative to the base directory specified by the [runner.screenshots](../../using-testcafe/programming-interface/runner.md#screenshots) API method or the [screenshots](../../using-testcafe/command-line-interface.md#-s-path---screenshots-path) command line option. This path overrides the relative path the default or custom [path patterns](../../using-testcafe/command-line-interface.md#path-patterns) specify. -The following example shows how to use the `t.takeScreenshot` action. +The following example shows how to use the `t.takeScreenshot` action: ```js import { Selector } from 'testcafe'; @@ -54,9 +50,9 @@ Takes a screenshot of the specified page element. Parameter | Type | Description ------------------------ | ------ | ----------------------------------------------------------------------------------------------------- -`selector` | Function | String | Selector | Snapshot | Promise | Identifies the webpage element whose screenshot will be taken. See [Selecting Target Elements](README.md#selecting-target-elements). -`path` *(optional)* | String | The relative path and the name of the screenshot file to be created. Resolved from the *screenshot directory* specified by using the [runner.screenshots](../../using-testcafe/programming-interface/runner.md#screenshots) API method or the [screenshots](../../using-testcafe/command-line-interface.md#-s-path---screenshots-path) command line option. -`options` *(optional)* | Object | Options that define how the screenshot will be taken. See details below. +`selector` | Function | String | Selector | Snapshot | Promise | Identifies the webpage element whose screenshot should be taken. See [Selecting Target Elements](README.md#selecting-target-elements). +`path` *(optional)* | String | The screenshot file's relative path and name. The path is relative to the root directory specified by using the [runner.screenshots](../../using-testcafe/programming-interface/runner.md#screenshots) API method or the [screenshots](../../using-testcafe/command-line-interface.md#-s-path---screenshots-path) command line option. This path overrides the relative path the default or custom [path patterns](../../using-testcafe/command-line-interface.md#path-patterns) specify. +`options` *(optional)* | Object | Options that define how the screenshot is taken. See details below. ```js import { Selector } from 'testcafe'; @@ -72,16 +68,11 @@ test('Take a screenshot of a fieldset', async t => { }); ``` -By default, the path to which screenshots are saved is specified as: - -* `{currentDate}\test-{testIndex}\{userAgent}\{screenshotIndex}.png` if the [quarantine mode](../../using-testcafe/command-line-interface.md#-q---quarantine-mode) is disabled; -* `{currentDate}\test-{testIndex}\run-{quarantineAttempt}\{userAgent}\{screenshotIndex}.png` if the [quarantine mode](../../using-testcafe/command-line-interface.md#-q---quarantine-mode) is enabled. - -The `options` object contains the following properties. +The `options` object contains the following properties: Property | Type | Description | Default --------------- | ---- | ------------- | ---------- -`scrollTargetX`, `scrollTargetY` | Number | If the target element is too big to fit into the browser window, the page will be scrolled to put this point to the center of the viewport. The coordinates of this point are calculated relative to the target element. If the numbers are positive, the point is positioned relative to the top-left corner of the element. If the numbers are negative, the point is positioned relative to the bottom-right corner. If the target element fits into the browser window, these properties have no effect. | The center of the element. If the `crop` rectangle is specified, its center. If the `crop` rectangle is larger than the viewport, the center of the viewport. +`scrollTargetX`, `scrollTargetY` | Number | If the target element is too big to fit into the browser window, the page is scrolled to put this point to the center of the viewport. The coordinates of this point are calculated relative to the target element. If the numbers are positive, the point is positioned relative to the top-left corner of the element. If the numbers are negative, the point is positioned relative to the bottom-right corner. If the target element fits into the browser window, these properties have no effect. | The center of the element. If the `crop` rectangle is specified, its center. If the `crop` rectangle is larger than the viewport, the center of the viewport. `includeMargins` | Boolean | Specifies whether to include target element's margins in the screenshot. When it is enabled, the `scrollTargetX`, `scrollTargetY` and `crop` rectangle coordinates are calculated from the corners where top and left (or bottom and right) margins intersect. | `false` `includeBorders` | Boolean | Specifies whether to include target element's borders in the screenshot. When it is enabled, the `scrollTargetX`, `scrollTargetY` and `crop` rectangle coordinates are calculated from the corners where top and left (or bottom and right) internal edges of the element intersect. | `true` `includePaddings` | Boolean | Specifies whether to include target element's paddings in the screenshot. When it is enabled, the `scrollTargetX`, `scrollTargetY` and `crop` rectangle coordinates are calculated from the corners where top and left (or bottom and right) edges of the element's content area intersect. | `true` diff --git a/docs/articles/documentation/test-api/authentication/user-roles.md b/docs/articles/documentation/test-api/authentication/user-roles.md index 10534d82..890007a7 100644 --- a/docs/articles/documentation/test-api/authentication/user-roles.md +++ b/docs/articles/documentation/test-api/authentication/user-roles.md @@ -131,4 +131,26 @@ TestCafe will navigate to the saved URL each time after you switch to this role. This option is useful if you store session-related data (like session ID) in the URL. +```js +import { Role } from 'testcafe'; + +const role = Role('http://example.com/login', async t => { + await t + .typeText('#login', 'username') + .typeText('#password', 'password') + .click('#sign-in'); // Redirects to http://example.com?sessionId=abcdef +}, { preserveUrl: true }); + +fixture `My Fixture`; + +test('My test', async t => { + await t + .navigateTo('http://example.com/') + + // Does not return to http://example.com/ but + // stays at http://example.com?sessionId=abcdef instead + // because options.preserveUrl is enabled. + .useRole(role); +``` + **Default value**: `false` \ No newline at end of file diff --git a/docs/articles/documentation/test-api/waiting-for-page-elements-to-appear.md b/docs/articles/documentation/test-api/built-in-waiting-mechanisms.md similarity index 83% rename from docs/articles/documentation/test-api/waiting-for-page-elements-to-appear.md rename to docs/articles/documentation/test-api/built-in-waiting-mechanisms.md index 0578c918..893a65fc 100644 --- a/docs/articles/documentation/test-api/waiting-for-page-elements-to-appear.md +++ b/docs/articles/documentation/test-api/built-in-waiting-mechanisms.md @@ -1,14 +1,16 @@ --- layout: docs -title: Waiting for Page Elements to Appear -permalink: /documentation/test-api/waiting-for-page-elements-to-appear.html +title: Built-In Waiting Mechanisms +permalink: /documentation/test-api/built-in-waiting-mechanisms.html +redirect_from: + - /documentation/test-api/waiting-for-page-elements-to-appear.html --- -# Waiting for Page Elements to Appear +# Built-In Waiting Mechanisms -TestCafe has a built-in automatic waiting mechanism, so that it does not need dedicated API to wait for page elements to appear. +TestCafe has built-in automatic waiting mechanisms, so that it does not need dedicated API to wait for page elements to appear or redirects to happen. -This topic describes how the automatic waiting mechanism works with [test actions](actions/README.md), -[assertions](assertions/README.md) and [selectors](selecting-page-elements/selectors/README.md). +This topic describes how these mechanisms work with [test actions](actions/README.md), +[assertions](assertions/README.md), [selectors](selecting-page-elements/selectors/README.md) and navigation. ## Waiting for Action Target Elements @@ -109,4 +111,9 @@ test('My test', async t => { // or the timeout passes. .expect(nameInput.value).eql('Peter Parker'); }); -``` \ No newline at end of file +``` + +## Waiting for Redirects + +When an action triggers a redirect, TestCafe automatically waits for the server to respond. +The test is resumed if the server does not respond within **15** seconds. \ No newline at end of file diff --git a/docs/articles/documentation/test-api/coffeescript-support.md b/docs/articles/documentation/test-api/coffeescript-support.md new file mode 100644 index 00000000..14accc44 --- /dev/null +++ b/docs/articles/documentation/test-api/coffeescript-support.md @@ -0,0 +1,28 @@ +--- +layout: docs +title: CoffeeScript Support +permalink: /documentation/test-api/coffeescript-support.html +--- +# CoffeeScript Support + +TestCafe allows you to write tests with [CoffeeScript](https://coffeescript.org/). + +**Example** + +```coffee +import { Selector } from 'testcafe' + +fixture 'CoffeeScript Example' + .page 'https://devexpress.github.io/testcafe/example/' + +nameInput = Selector '#developer-name' + +test 'Test', (t) => + await t + .typeText(nameInput, 'Peter') + .typeText(nameInput, 'Paker', { replace: true }) + .typeText(nameInput, 'r', { caretPos: 2 }) + .expect(nameInput.value).eql 'Parker'; +``` + +You can run CoffeeScript tests in the same manner as JavaScript tests. TestCafe automatically compiles the CoffeeScript code, so you do not need to compile it manually. \ No newline at end of file diff --git a/docs/articles/documentation/test-api/debugging.md b/docs/articles/documentation/test-api/debugging.md index 47db5aa2..ef44e6fc 100644 --- a/docs/articles/documentation/test-api/debugging.md +++ b/docs/articles/documentation/test-api/debugging.md @@ -16,8 +16,8 @@ TestCafe allows you to debug server-side test code and test behavior on the clie You can debug test code in Chrome Developer Tools and popular IDEs. See the following recipes for details. -* [Debugging with Chrome Developer Tools](../recipes/debugging-with-chrome-dev-tools.md) -* [Debugging with Visual Studio Code](../recipes/debugging-with-visual-studio-code.md) +* [Debug in Chrome Developer Tools](../recipes/debug-in-chrome-dev-tools.md) +* [Debug in Visual Studio Code](../recipes/debug-in-visual-studio-code.md) ## Client-Side Debugging diff --git a/docs/articles/documentation/test-api/handling-native-dialogs.md b/docs/articles/documentation/test-api/handling-native-dialogs.md index 74886d09..78d57b1c 100644 --- a/docs/articles/documentation/test-api/handling-native-dialogs.md +++ b/docs/articles/documentation/test-api/handling-native-dialogs.md @@ -62,7 +62,7 @@ Dialog Type | Return Value | Defaul ------------ | -------------------------------------------------------- | -------------- alert | Ignored | 'OK' button. beforeunload | Ignored | 'Leave' button. There is no way to emulate 'Stay' programmatically. -confirm | `true` to answer 'Yes'; `false` to answer 'No'. | 'No' button. +confirm | `true` to answer 'OK'; `false` to answer 'Cancel'. | 'Cancel' button. prompt | A string that contains text to be typed into the prompt. | 'Cancel' button. The following example demonstrates how to handle an alert dialog. diff --git a/docs/articles/documentation/test-api/intercepting-http-requests/attaching-hooks-to-tests-and-fixtures.md b/docs/articles/documentation/test-api/intercepting-http-requests/attaching-hooks-to-tests-and-fixtures.md index f8914d28..3ced586f 100644 --- a/docs/articles/documentation/test-api/intercepting-http-requests/attaching-hooks-to-tests-and-fixtures.md +++ b/docs/articles/documentation/test-api/intercepting-http-requests/attaching-hooks-to-tests-and-fixtures.md @@ -9,20 +9,20 @@ checked: true To attach a hook to a test or fixture, use the `fixture.requestHooks` and `test.requestHooks` methods. A hook attached to a fixture handles requests from all tests in the fixture. ```text -fixture.requestHooks(...hook) -test.requestHooks(...hook) +fixture.requestHooks(...hooks) +test.requestHooks(...hooks) ``` You can also attach and detach hooks during test run using the `t.addRequestHooks` and `t.removeRequestHooks` methods. ```text -t.addRequestHooks(...hook) +t.addRequestHooks(...hooks) t.removeRequestHooks(...hooks) ``` Parameter | Type | Description --------- | ---- | ------------ -`hook` | RequestHook subclass | A `RequestLogger`, `RequestMock` or custom user-defined hook. +`hooks` | RequestHook subclass | A `RequestLogger`, `RequestMock` or custom user-defined hook. The `fixture.requestHooks`, `test.requestHooks` `t.addRequestHooks` and `t.removeRequestHooks` methods use the rest operator which allows you to pass multiple hooks as parameters or arrays of hooks. diff --git a/docs/articles/documentation/test-api/intercepting-http-requests/logging-http-requests.md b/docs/articles/documentation/test-api/intercepting-http-requests/logging-http-requests.md index 63dea196..a1d2318e 100644 --- a/docs/articles/documentation/test-api/intercepting-http-requests/logging-http-requests.md +++ b/docs/articles/documentation/test-api/intercepting-http-requests/logging-http-requests.md @@ -32,10 +32,10 @@ Option | Type | Description | Default ------ | ---- | ------------- | --------- `logRequestHeaders` | Boolean | Specifies whether the request headers should be logged. | `false` `logRequestBody` | Boolean | Specifies whether the request body should be logged. | `false` -`stringifyRequestBody` | Boolean | Specifies whether the request body should be stored as a String or a [Buffer](https://nodejs.org/api/buffer.html). | `false` +`stringifyRequestBody` | Boolean | Specifies whether the request body should be stored as a String or a [Buffer](https://nodejs.org/api/buffer.html). When you set `stringifyRequestBody` to `true`, make sure that the request body is logged (`logRequestBody` is also `true`). Otherwise, an error is thrown. | `false` `logResponseHeaders` | Boolean | Specifies whether the response headers should be logged. | `false` `logResponseBody` | Boolean | Specifies whether the response body should be logged. | `false` -`stringifyResponseBody` | Boolean | Specifies whether the response body should be stored as a string or a [Buffer](https://nodejs.org/api/buffer.html). | `false` +`stringifyResponseBody` | Boolean | Specifies whether the response body should be stored as a string or a [Buffer](https://nodejs.org/api/buffer.html). When you set `stringifyResponseBody` to `true`, make sure that the response body is logged (`logResponseBody` is also `true`). Otherwise, an error is thrown. | `false` ```js import { RequestLogger } from 'testcafe'; diff --git a/docs/articles/documentation/test-api/intercepting-http-requests/specifying-which-requests-are-handled-by-the-hook.md b/docs/articles/documentation/test-api/intercepting-http-requests/specifying-which-requests-are-handled-by-the-hook.md index 0ff5616c..fb72cd3d 100644 --- a/docs/articles/documentation/test-api/intercepting-http-requests/specifying-which-requests-are-handled-by-the-hook.md +++ b/docs/articles/documentation/test-api/intercepting-http-requests/specifying-which-requests-are-handled-by-the-hook.md @@ -39,7 +39,7 @@ const logger = RequestLogger(/.co.uk/); ```js const mock = RequestMock() - .onRequestTo('/\/api\/users\//') + .onRequestTo(/\/api\/users\//) .respond(/*...*/); ``` diff --git a/docs/articles/documentation/test-api/obtaining-data-from-the-client.md b/docs/articles/documentation/test-api/obtaining-data-from-the-client.md deleted file mode 100644 index cef4fd95..00000000 --- a/docs/articles/documentation/test-api/obtaining-data-from-the-client.md +++ /dev/null @@ -1,225 +0,0 @@ ---- -layout: docs -title: Obtaining Data from the Client -permalink: /documentation/test-api/obtaining-data-from-the-client.html -checked: true ---- -# Obtaining Data from the Client - -TestCafe allows you to create *client functions* that can return any -serializable value from the client side, like the current URL -or custom data calculated by a client script. - -> Important! Do not modify the tested webpage within client functions. -> To interact with the page, use [test actions](actions/README.md). - -This topic contains the following sections. - -* [Creating Client Functions](#creating-client-functions) - * [Running Asynchronous Client Code](#running-asynchronous-client-code) -* [Executing Client Functions](#executing-client-functions) -* [Options](#options) -* [One-Time Client Code Execution](#one-time-client-code-execution) -* [Calling Client Functions from Node.js Callbacks](#calling-client-functions-from-nodejs-callbacks) -* [Limitations](#limitations) - -## Creating Client Functions - -To create a client function, use the `ClientFunction` constructor. - -```text -ClientFunction( fn [, options] ) -``` - -Parameter | Type | Description ----------------------- | -------- | --------------------------------------------- -`fn` | Function | A function to be executed on the client side. -`options` *(optional)* | Object | See [Options](#options). - -> Important! Client functions cannot return DOM nodes. Use [selectors](selecting-page-elements/selectors/README.md) for this. - -The following example shows how to create a client function. - -```js -import { ClientFunction } from 'testcafe'; - -const getWindowLocation = ClientFunction(() => window.location); -``` - -### Running Asynchronous Client Code - -You can create client functions that run asynchronous code. -To this end, pass a function that returns a Promise to the `ClientFunction` constructor. -In this instance, the client function will complete only when this Promise resolves. - -```js -import { ClientFunction } from 'testcafe'; - -const performAsyncOperation = ClientFunction(() => { - return Promise(resolve => { - window.setTimeout(resolve, 500); // some async operations - }); -}); -``` - -## Executing Client Functions - -To execute a client function, call it with the `await` keyword. - -```js -import { ClientFunction } from 'testcafe'; - -const getWindowLocation = ClientFunction(() => window.location); - -fixture `My fixture` - .page `http://www.example.com/`; - -test('My Test', async t => { - const location = await getWindowLocation(); -}); -``` - -## Options - -You can pass the following options to the -[ClientFunction constructor](#creating-client-functions) and the -[t.eval](#one-time-client-code-execution) function. - -### options.dependencies - -**Type**: Object - -Contains functions, variables or objects used by the client function internally. -Properties of the `dependencies` object will be added to the client function's scope as variables. - -The following sample demonstrates a client function (`getArticleHeaderHTML`) that -calls a [selector](selecting-page-elements/selectors/README.md) (`articleHeader`) internally. -This selector is passed to `getArticleHeaderHTML` as a dependency. - -```js -import { Selector, ClientFunction } from 'testcafe'; - -const articleHeader = Selector('#article-header'); - -const getArticleHeaderHTML = ClientFunction(() => articleHeader().innerHTML, { - dependencies: { articleHeader } -}); -``` - -> When a client function calls a [selector](selecting-page-elements/selectors/README.md) internally, -> the selector does not wait for the element to appear in the DOM -> but is executed at once, like a client function. - -### options.boundTestRun - -**Type**: Object - -If you need to call a client function from a Node.js callback, -assign the current [test controller](test-code-structure.md#test-controller) -to the `boundTestRun` option. - -For details, see [Calling Client Functions from Node.js Callbacks](#calling-client-functions-from-nodejs-callbacks). - -### Overwriting Options - -You can overwrite client function options by using the ClientFunction's `with` function. - -```text -clientFunction.with( options ) → ClientFunction -``` - -`with` returns a new client function with a different set of options that includes options -from the original function and new `options` that overwrite the original ones. - -The sample below shows how to overwrite the client function options. - -```js -import { Selector, ClientFunction } from 'testcafe'; - -const option = Selector('option'); - -const thirdOption = option.nth(2); - -const getThirdOptionHTML = ClientFunction(() => option().innerHTML, { - dependencies: { option: thirdOption } -}); - -const fourthOption = option.nth(3); - -const getFourthOptionHTML = getThirdOptionHTML.with({ - dependencies: { option: fourthOption } -}); -``` - -## One-Time Client Code Execution - -To create a client function and immediately execute it without saving it, -use the `eval` method of the [test controller](test-code-structure.md#test-controller). - -```text -t.eval( fn [, options] ) -``` - -Parameter | Type | Description ----------------------- | -------- | -------------------------------------------------------------------------- -`fn` | Function | A function to be executed on the client side. -`options` *(optional)* | Object | See [Options](#options). - -The following example shows how to get the document's URI with `t.eval`. - -```js -fixture `My fixture` - .page `http://www.example.com/`; - -test('My Test', async t => { - const docURI = await t.eval(() => document.documentURI); -}); -``` - -## Calling Client Functions from Node.js Callbacks - -Client functions need access to the [test controller](test-code-structure.md#test-controller) to be executed. -When called right from the test function, they implicitly obtain the test controller. - -However, if you need to call a client function from a Node.js callback that fires during the test run, -you will have to manually bind this function to the test controller. - -Use the [boundTestRun](#optionsboundtestrun) option for this. - -```js -import fs from 'fs'; -import { ClientFunction } from 'testcafe'; - -fixture `My fixture` - .page `http://www.example.com/`; - -const getDataFromClient = ClientFunction(() => getSomeData()); - -test('Check client data', async t => { - const boundGetDataFromClient = getDataFromClient.with({ boundTestRun: t }); - - const equal = await new Promise(resolve => { - fs.readFile('/home/user/tests/reference/clientData.json', (err, data) => { - boundGetDataFromClient().then(clientData => { - resolve(JSON.stringify(clientData) === data); - }); - }); - }); - - await t.expect(equal).ok(); -}); -``` - -This approach only works for Node.js callbacks that fire during the test run. To ensure that the test function -does not finish before the callback is executed, suspend the test until the callback fires. You can do this -by introducing a promise and synchronously waiting for it to complete as shown in the example above. - -## Limitations - -* You cannot use generators or `async/await` syntax within client functions. - -* Client functions cannot access variables defined in the outer scope in test code. - However, you can use arguments to pass data inside these functions, except for self-invoking functions - that cannot take any parameters from the outside. - - Likewise, the return value is the only way to obtain data from client functions. diff --git a/docs/articles/documentation/test-api/obtaining-data-from-the-client/README.md b/docs/articles/documentation/test-api/obtaining-data-from-the-client/README.md index 0a428b29..70580116 100644 --- a/docs/articles/documentation/test-api/obtaining-data-from-the-client/README.md +++ b/docs/articles/documentation/test-api/obtaining-data-from-the-client/README.md @@ -2,7 +2,8 @@ layout: docs title: Obtaining Data from the Client permalink: /documentation/test-api/obtaining-data-from-the-client/ -checked: true +redirect_from: + - /documentation/test-api/obtaining-data-from-the-client.html --- # Obtaining Data from the Client @@ -20,6 +21,7 @@ This topic contains the following sections. * [Executing Client Functions](#executing-client-functions) * [Options](#options) * [One-Time Client Code Execution](#one-time-client-code-execution) +* [Import Functions to be Used as Client Function Dependencies](#import-functions-to-be-used-as-client-function-dependencies) * [Calling Client Functions from Node.js Callbacks](#calling-client-functions-from-nodejs-callbacks) * [Limitations](#limitations) @@ -178,6 +180,41 @@ test('My Test', async t => { > Since the `eval` method returns a value, not an object, you cannot call other methods of the test controller in the chain after calling 'eval'. +## Import Functions to be Used as Client Function Dependencies + +Assume you have a JS file `utils.js` with a function you need to use as a client function dependency in your test file. + +**utils.js** + +```js +export function getDocumentURI() { + return document.documentURI; +} +``` + +Note that TestCafe internally processes test files with [Babel](https://babeljs.io). To avoid issues caused by code transpiling, use the [require](https://nodejs.org/api/modules.html#modules_require) function instead of the `import` statement to import client function dependencies. + +**test.js** + +```js +import { ClientFunction } from 'testcafe'; + +const getDocumentURI = require('./utils.js').getDocumentURI; + +fixture `My fixture` + .page `http://devexpress.github.io/testcafe/example/`; + +test('My test', async t => { + const getUri = ClientFunction(() => { + return getDocumentURI(); + }, { dependencies: { getDocumentURI } }); + + const uri = await getUri(); + + await t.expect(uri).eql('http://devexpress.github.io/testcafe/example/'); +}); +``` + ## Calling Client Functions from Node.js Callbacks Client functions need access to the [test controller](../test-code-structure.md#test-controller) to be executed. diff --git a/docs/articles/documentation/test-api/obtaining-data-from-the-client/examples-of-using-client-functions.md b/docs/articles/documentation/test-api/obtaining-data-from-the-client/examples-of-using-client-functions.md index 242a2cb0..2001e73e 100644 --- a/docs/articles/documentation/test-api/obtaining-data-from-the-client/examples-of-using-client-functions.md +++ b/docs/articles/documentation/test-api/obtaining-data-from-the-client/examples-of-using-client-functions.md @@ -136,7 +136,7 @@ test('My Test', async t => { }); ``` -> Note that the `getChildNodeText` client function uses the `testedPage` selector that is passed to it as a dependency. See [options.dependencies](../obtaining-data-from-the-client.md#optionsdependencies) for more information. +> Note that the `getChildNodeText` client function uses the `testedPage` selector that is passed to it as a dependency. See [options.dependencies](README.md#optionsdependencies) for more information. ## Complex DOM Queries diff --git a/docs/articles/documentation/test-api/selecting-page-elements/framework-specific-selectors.md b/docs/articles/documentation/test-api/selecting-page-elements/framework-specific-selectors.md index e90622bf..7ebafc7a 100644 --- a/docs/articles/documentation/test-api/selecting-page-elements/framework-specific-selectors.md +++ b/docs/articles/documentation/test-api/selecting-page-elements/framework-specific-selectors.md @@ -11,6 +11,7 @@ For this purpose, the TestCafe team and community developed libraries of dedicat * [React](#react) * [Angular](#angular) +* [AngularJS](#angularjs) * [Vue](#vue) * [Aurelia](#aurelia) @@ -42,29 +43,6 @@ To learn more, see the [repository documentation](https://github.com/DevExpress/ ## Angular -### AngularJS - -`AngularJSSelector` contains a set of static methods to search for an HTML element by the specified binding (`byModel`, `byBinding`, etc.). - -```js -import { AngularJSSelector } from 'testcafe-angular-selectors'; -import { Selector } from 'testcafe'; - -fixture `TestFixture` - .page('http://todomvc.com/examples/angularjs/'); - -test('add new item', async t => { - await t - .typeText(AngularJSSelector.byModel('newTodo'), 'new item') - .pressKey('enter') - .expect(Selector('#todo-list').visible).ok(); -}); -``` - -To learn more, see the [angularJS-selector.md](https://github.com/DevExpress/testcafe-angular-selectors/blob/master/angularJS-selector.md) file in the plugin repository. - -### Angular v2+ - Use the `AngularSelector` class to select DOM elements by the component name. Call it without parameters to get a root element. You can also search through the nested components or elements. In addition, you can obtain the component state. ```js @@ -86,6 +64,27 @@ await t.expect(listAngular.testProp).eql(1); To learn more, refer to the [plugin repository](https://github.com/DevExpress/testcafe-angular-selectors/blob/master/angular-selector.md). +## AngularJS + +`AngularJSSelector` contains a set of static methods to search for an HTML element by the specified binding (`byModel`, `byBinding`, etc.). + +```js +import { AngularJSSelector } from 'testcafe-angular-selectors'; +import { Selector } from 'testcafe'; + +fixture `TestFixture` + .page('http://todomvc.com/examples/angularjs/'); + +test('add new item', async t => { + await t + .typeText(AngularJSSelector.byModel('newTodo'), 'new item') + .pressKey('enter') + .expect(Selector('#todo-list').visible).ok(); +}); +``` + +To learn more, refer to the [plugin repository](https://github.com/DevExpress/testcafe-angular-selectors/blob/master/angularJS-selector.md). + ## Vue Vue selectors allow you to pick DOM elements by the component name. You can also search through the nested components or elements. In addition, you can obtain the component props, state and computed props. diff --git a/docs/articles/documentation/test-api/selecting-page-elements/selectors/README.md b/docs/articles/documentation/test-api/selecting-page-elements/selectors/README.md index 2cbe9b12..b96f78dc 100644 --- a/docs/articles/documentation/test-api/selecting-page-elements/selectors/README.md +++ b/docs/articles/documentation/test-api/selecting-page-elements/selectors/README.md @@ -3,6 +3,8 @@ layout: docs title: Selectors permalink: /documentation/test-api/selecting-page-elements/selectors/ checked: false +redirect_from: + - /documentation/test-api/selecting-page-elements/selectors.html --- # Selectors diff --git a/docs/articles/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.md b/docs/articles/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.md index 6c8bf3bf..88986cb4 100644 --- a/docs/articles/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.md +++ b/docs/articles/documentation/test-api/selecting-page-elements/selectors/functional-style-selectors.md @@ -38,6 +38,14 @@ Method | Return Type | Description ------ | ----- | ----- `nth(index)` | Selector | Finds an element by its index in the matching set. The `index` parameter is zero-based. If `index` is negative, the index is counted from the end of the matching set. +```js +// Selects the third ul element. +Selector('ul').nth(2); + +// Selects the last div element. +Selector('div').nth(-1); +``` + ### withText Method | Type | Description @@ -45,12 +53,50 @@ Method | Type | Description `withText(text)` | Selector | Creates a selector that filters a matching set by the specified text. Selects elements that *contain* this text. To filter elements by *strict match*, use the `withExactText` method. The `text` parameter is case-sensitive. `withText(re)` | Selector | Creates a selector that filters a matching set using the specified regular expression. +```js +// Selects label elements that contain 'foo'. +// Matches 'foo', 'foobar'. Does not match 'bar', 'Foo'. +Selector('label').withText('foo'); + +// Selects div elements whose text matches +// the /a[b-e]/ regular expression. +// Matches 'ab', 'ac'. Does not match 'bb', 'aa'. +Selector('div').withText(/a[b-e]/); +``` + +Note that when `withText` filters the matching set, it leaves not only the element that immediately contains the specified text but also its ancestors. + +Assume the following markup. + +```html +
+
some text
+
+``` + +In this instance, a selector that targets `div` elements with the `'some text'` text will match both elements (first, the parent and then, the child). + +```js +// This selector matches the parent div (.container) +// and then the child div (.child) +Selector('div').withText('some text'); +``` + ### withExactText Method | Type | Description ------ | ----- | ----- `withExactText(text)` | Selector | Creates a selector that filters a matching set by the specified text. Selects elements whose text content *strictly matches* this text. To search for elements that *contain* a specific text, use the `withText` method. The `text` parameter is case-sensitive. +```js +// Selects elements of the 'container' class +// whose text exactly matches 'foo'. +// Does not match 'bar', 'foobar', 'Foo'. +Selector('.container').withExactText('foo'); +``` + +Note that when `withExactText` filters the matching set, it leaves not only the element that immediately contains the specified text but also its ancestors (if they do not contain any other text). See an example for [withText](#withtext). + ### withAttribute Method | Return Type | Description @@ -61,23 +107,55 @@ This method takes the following parameters. Parameter | Type | Description ----------------------------- | -------------------- | ------- -`attrName` | String | RegExp | The attribute name. -`attrValue` *(optional)* | String | RegExp | The attribute value. You can omit this parameter to select elements that have the `attrName` attribute regardless of the value. +`attrName` | String | RegExp | The attribute name. This parameter is case-sensitive. +`attrValue` *(optional)* | String | RegExp | The attribute value. This parameter is case-sensitive. You can omit it to select elements that have the `attrName` attribute regardless of the value. If `attrName` or `attrValue` is a String, `withAttribute` selects an element by strict match. +```js +// Selects div elements that have the 'myAttr' attribute. +// This attribute can have any value. +Selector('div').withAttribute('myAttr'); + +// Selects div elements whose 'attrName' attribute +// is set to 'foo'. Does not match +// the 'otherAttr' attribute, or the 'attrName' attribute +// with the 'foobar' value. +Selector('div').withAttribute('attrName', 'foo'); + +// Selects ul elements that have an attribute whose +// name matches the /[123]z/ regular expression. +// This attribute must have a value that matches +// the /a[0-9]/ regular expression. +// Matches the '1z' and '3z' attributes with the +// 'a0' and 'a7' values. +// Does not match the '4z' or '1b' attribute, +// as well as any attribute with the 'b0' or 'ab' value. +Selector('ul').withAttribute(/[123]z/, /a[0-9]/); +``` + ### filterVisible Method | Type | Description ----------------------------------- | -------- | ----------- `filterVisible()` | Selector | Creates a selector that filters a matching set leaving only visible elements. These are elements that *do not* have `display: none` or `visibility: hidden` CSS properties and have non-zero width and height. +```js +// Selects all visible div elements. +Selector('div').filterVisible(); +``` + ### filterHidden Method | Type | Description ----------------------------------- | -------- | ----------- `filterHidden()` | Selector | Creates a selector that filters a matching set leaving only hidden elements. These are elements that have a `display: none` or `visibility: hidden` CSS property or zero width or height. +```js +// Selects all hidden label elements. +Selector('label').filterVisible(); +``` + ### filter Method | Return Type | Description @@ -85,6 +163,12 @@ Method | Return Type | Description `filter(cssSelector)` | Selector | Finds elements that match the `cssSelector`. `filter(filterFn, dependencies)` | Selector | Finds elements that satisfy the `filterFn` predicate. Use an optional `dependencies` parameter to pass functions, variables or objects to the `filterFn` function. +```js +// Selects li elements that +// have the someClass class. +Selector('li').filter('.someClass') +``` + The `filterFn` predicate is executed on the client. It takes the following parameters. Parameter | Description @@ -150,6 +234,12 @@ Method | Description `find(cssSelector)` | Finds all descendants of all nodes in the matching set and filters them by `cssSelector`. `find(filterFn, dependencies)` | Finds all descendants of all nodes in the matching set and filters them using `filterFn` predicate. Use an optional `dependencies` parameter to pass functions, variables or objects to the `filterFn` function. See [Filtering DOM Elements by Predicates](#filtering-dom-elements-by-predicates). +```js +// Selects input elements that are descendants +// of div elements with the someClass class. +Selector('div.someClass').find('input'); +``` + ### parent Method | Description @@ -159,6 +249,20 @@ Method | Description `parent(cssSelector)` | Finds all parents of all nodes in the matching set and filters them by `cssSelector`. `parent(filterFn, dependencies)` | Finds all parents of all nodes in the matching set and filters them by the `filterFn` predicate. Use an optional `dependencies` parameter to pass functions, variables or objects to the `filterFn` function. See [Filtering DOM Elements by Predicates](#filtering-dom-elements-by-predicates). +```js +// Selects all ancestors of all ul elements. +Selector('ul').parent(); + +// Selects all closest parents of all input elements. +Selector('input').parent(0); + +// Selects all furthest ancestors of all labels. +Selector('label').parent(-1); + +// Selects all divs that are ancestors of an 'a' element. +Selector('a').parent('div'); +``` + ### child Method | Description @@ -168,35 +272,93 @@ Method | Description `child(cssSelector)` | Finds all child elements (not nodes) of all nodes in the matching set and filters them by `cssSelector`. `child(filterFn, dependencies)` | Finds all child elements (not nodes) of all nodes in the matching set and filters them by the `filterFn` predicate. Use an optional `dependencies` parameter to pass functions, variables or objects to the `filterFn` function. See [Filtering DOM Elements by Predicates](#filtering-dom-elements-by-predicates). -> Important! To know how to access child nodes, see [Accessing Child Nodes in the DOM Hierarchy](../../obtaining-data-from-the-client/examples-of-using-client-functions.md#accessing-child-nodes-in-the-dom-hierarchy). +> Important! To learn how to access child nodes, see [Accessing Child Nodes in the DOM Hierarchy](../../obtaining-data-from-the-client/examples-of-using-client-functions.md#accessing-child-nodes-in-the-dom-hierarchy). + +```js +// Selects all children of all ul elements. +Selector('ul').child(); + +// Selects all closest children of all div elements. +Selector('div').child(0); + +// Selects all furthest children of all table elements. +Selector('table').child(-1); + +// Selects all ul elements that are children of a nav element. +Selector('nav').child('ul'); +``` ### sibling Method | Description ------ | ----- `sibling()` | Finds all sibling elements (not nodes) of all nodes in the matching set. -`sibling(index)` | Finds all sibling elements (not nodes) of all nodes in the matching set and filters them by `index`. The `index` parameter is zero-based. If `index` is negative, the index is counted from the end of the matching set. +`sibling(index)` | Finds all sibling elements (not nodes) of all nodes in the matching set and filters them by `index`. Elements are indexed as they appear in their parents' `childNodes` collections. The `index` parameter is zero-based. If `index` is negative, the index is counted from the end of the matching set. `sibling(cssSelector)` | Finds all sibling elements (not nodes) of all nodes in the matching set and filters them by `cssSelector`. `sibling(filterFn, dependencies)` | Finds all sibling elements (not nodes) of all nodes in the matching set and filters them by the `filterFn` predicate. Use an optional `dependencies` parameter to pass functions, variables or objects to the `filterFn` function. See [Filtering DOM Elements by Predicates](#filtering-dom-elements-by-predicates). +```js +// Selects all siblings of all td elements. +Selector('td').sibling(); + +// Selects all li elements' siblings +// that go first in their parent's child lists. +Selector('li').sibling(0); + +// Selects all ul elements' siblings +// that go last in their parent's child lists. +Selector('ul').sibling(-1); + +// Selects all p elements that are siblings of an img element. +Selector('img').sibling('p'); +``` + ### nextSibling Method | Description ------ | ----- `nextSibling()` | Finds all succeeding sibling elements (not nodes) of all nodes in the matching set. -`nextSibling(index)` | Finds all succeeding sibling elements (not nodes) of all nodes in the matching set and filters them by `index`. The `index` parameter is zero-based. If `index` is negative, the index is counted from the end of the matching set. +`nextSibling(index)` | Finds all succeeding sibling elements (not nodes) of all nodes in the matching set and filters them by `index`. Elements are indexed beginning from the closest sibling. The `index` parameter is zero-based. If `index` is negative, the index is counted from the end of the matching set. `nextSibling(cssSelector)` | Finds all succeeding sibling elements (not nodes) of all nodes in the matching set and filters them by `cssSelector`. `nextSibling(filterFn, dependencies)` | Finds all succeeding sibling elements (not nodes) of all nodes in the matching set and filters them by the `filterFn` predicate. Use an optional `dependencies` parameter to pass functions, variables or objects to the `filterFn` function. See [Filtering DOM Elements by Predicates](#filtering-dom-elements-by-predicates). +```js +// Selects all succeeding siblings of all 'a' elements. +Selector('a').nextSibling(); + +// Selects all closest succeeding siblings of all div elements. +Selector('div').nextSibling(0); + +// Selects all furthest succeeding siblings of all pre elements. +Selector('pre').nextSibling(-1); + +// Selects all p elements that are succeeding siblings of an hr element. +Selector('hr').nextSibling('p'); +``` + ### prevSibling Method | Description ------ | ----- `prevSibling()` | Finds all preceding sibling elements (not nodes) of all nodes in the matching set. -`prevSibling(index)` | Finds all preceding sibling elements (not nodes) of all nodes in the matching set and filters them by `index`. The `index` parameter is zero-based. If `index` is negative, the index is counted from the end of the matching set. +`prevSibling(index)` | Finds all preceding sibling elements (not nodes) of all nodes in the matching set and filters them by `index`. Elements are indexed beginning from the closest sibling. The `index` parameter is zero-based. If `index` is negative, the index is counted from the end of the matching set. `prevSibling(cssSelector)` | Finds all preceding sibling elements (not nodes) of all nodes in the matching set and filters them by `cssSelector`. `prevSibling(filterFn, dependencies)` | Finds all preceding sibling elements (not nodes) of all nodes in the matching set and filters them by the `filterFn` predicate. Use an optional `dependencies` parameter to pass functions, variables or objects to the `filterFn` function. See [Filtering DOM Elements by Predicates](#filtering-dom-elements-by-predicates). +```js +// Selects all preceding siblings of all p elements. +Selector('p').prevSibling(); + +// Selects all closest preceding siblings of all figure elements. +Selector('figure').prevSibling(0); + +// Selects all furthest preceding siblings of all option elements. +Selector('option').prevSibling(-1); + +// Selects all p elements that are preceding siblings of a blockquote element. +Selector('blockquote').prevSibling('p'); +``` + ### Filtering DOM Elements by Predicates Functions that search for elements through the DOM tree allow you to filter the matching set by a `filterFn` predicate. diff --git a/docs/articles/documentation/test-api/selecting-page-elements/selectors/selector-options.md b/docs/articles/documentation/test-api/selecting-page-elements/selectors/selector-options.md index e232dc4a..a8c68871 100644 --- a/docs/articles/documentation/test-api/selecting-page-elements/selectors/selector-options.md +++ b/docs/articles/documentation/test-api/selecting-page-elements/selectors/selector-options.md @@ -3,6 +3,8 @@ layout: docs title: Selector Options permalink: /documentation/test-api/selecting-page-elements/selectors/selector-options.html checked: true +redirect_from: + - /documentation/test-api/selecting-page-elements/selector-options.html --- # Selector Options @@ -65,7 +67,7 @@ This option is in effect when TestCafe waits for the selector to return a page e If the target element is not visible, the selector throws an exception in all these cases. Note that when a selector is passed to a [test action](../../actions/README.md) as an identifier for the target element, -TestCafe [requires](../../waiting-for-page-elements-to-appear.md#waiting-for-action-target-elements) that the target element is visible regardless of the `visibilityCheck` option. +TestCafe [requires](../../built-in-waiting-mechanisms.md#waiting-for-action-target-elements) that the target element is visible regardless of the `visibilityCheck` option. Unlike filter functions, the `visibilityCheck` option does not change the matching set of the selector. diff --git a/docs/articles/documentation/test-api/selecting-page-elements/selectors/using-selectors.md b/docs/articles/documentation/test-api/selecting-page-elements/selectors/using-selectors.md index 60e66adc..2198e7b7 100644 --- a/docs/articles/documentation/test-api/selecting-page-elements/selectors/using-selectors.md +++ b/docs/articles/documentation/test-api/selecting-page-elements/selectors/using-selectors.md @@ -14,6 +14,7 @@ This topic describes how to identify DOM elements and obtain information about t * [Define Action Targets](#define-action-targets) * [Define Assertion Actual Value](#define-assertion-actual-value) * [Selector Timeout](#selector-timeout) +* [Debug Selectors](#debug-selectors) ## Check if an Element Exists @@ -161,9 +162,9 @@ test('Assertion with Selector', async t => { const developerNameInput = Selector('#developer-name'); await t - .expect(developerNameInput.innerText).eql('') + .expect(developerNameInput.value).eql('') .typeText(developerNameInput, 'Peter') - .expect(developerNameInput.innerText).eql('Peter'); + .expect(developerNameInput.value).eql('Peter'); }); ``` @@ -181,7 +182,20 @@ method if you use API or specify the [selector-timeout](../../../using-testcafe/ if you run TestCafe from the command line. Within the selector timeout, the selector is executed over and over again, until it returns a -DOM node or the timeout exceeds. +DOM node or the timeout exceeds. If TestCafe cannot find the corresponding node in the DOM, the test fails. -Note that you can additionally require that the node returned by the selector is visible. +> Note that you can require that the node returned by the selector is visible. To do this, use the [visibilityCheck](selector-options.md#optionsvisibilitycheck) option. + +## Debug Selectors + +TestCafe outputs information about failed selectors to test run reports. + +When you try to use a selector that does not match any DOM element, the test fails and an error is thrown. +The error message indicates which selector has failed. + +An error can also occur when you call [selector's methods](functional-style-selectors.md) in a chain. +These methods are applied to the selector one by one. TestCafe detects a method after which the selector no longer matches any DOM element. +This method is highlighted in the error message. + +![Selector methods in a report](../../../../images/failed-selector-report.png) \ No newline at end of file diff --git a/docs/articles/documentation/test-api/test-code-structure.md b/docs/articles/documentation/test-api/test-code-structure.md index db8acfc0..1dff3156 100644 --- a/docs/articles/documentation/test-api/test-code-structure.md +++ b/docs/articles/documentation/test-api/test-code-structure.md @@ -29,7 +29,7 @@ to avoid the `'fixture' is not defined` and `'test' is not defined` errors. ## Fixtures TestCafe tests must be organized into categories called *fixtures*. -A JavaScript or TypeScript file with TestCafe tests can contain one or more fixtures. +A JavaScript, TypeScript or CoffeeScript file with TestCafe tests can contain one or more fixtures. To declare a test fixture, use the `fixture` function. diff --git a/docs/articles/documentation/using-testcafe/command-line-interface.md b/docs/articles/documentation/using-testcafe/command-line-interface.md index 73c79b72..691cb2b6 100644 --- a/docs/articles/documentation/using-testcafe/command-line-interface.md +++ b/docs/articles/documentation/using-testcafe/command-line-interface.md @@ -26,26 +26,34 @@ testcafe [options] * [-r \, --reporter \](#-r-namefile---reporter-namefile) * [-s \, --screenshots \](#-s-path---screenshots-path) * [-S, --screenshots-on-fails](#-s---screenshots-on-fails) + * [-p, --screenshot-path-pattern](#-p---screenshot-path-pattern) * [-q, --quarantine-mode](#-q---quarantine-mode) + * [-d, --debug-mode](#-d---debug-mode) * [-e, --skip-js-errors](#-e---skip-js-errors) - * [-c \, --concurrency \](#-c-n---concurrency-n) + * [-u, --skip-uncaught-errors](#-u---skip-uncaught-errors) * [-t \, --test \](#-t-name---test-name) * [-T \, --test-grep \](#-t-pattern---test-grep-pattern) * [-f \, --fixture \](#-f-name---fixture-name) * [-F \, --fixture-grep \](#-f-pattern---fixture-grep-pattern) + * [--test-meta \](#--test-meta-keyvaluekey2value2) + * [--fixture-meta \](#--fixture-meta-keyvaluekey2value2) * [-a \, --app \](#-a-command---app-command) - * [-d, --debug-mode](#-d---debug-mode) + * [-c \, --concurrency \](#-c-n---concurrency-n) * [--debug-on-fail](#--debug-on-fail) * [--app-init-delay \](#--app-init-delay-ms) * [--selector-timeout \](#--selector-timeout-ms) * [--assertion-timeout \](#--assertion-timeout-ms) * [--page-load-timeout \](#--page-load-timeout-ms) - * [--proxy \](#--proxy-host) - * [--proxy-bypass \](#--proxy-bypass-rules) + * [--speed \](#--speed-factor) * [--ports \](#--ports-port1port2) * [--hostname \](#--hostname-name) - * [--speed \](#--speed-factor) + * [--proxy \](#--proxy-host) + * [--proxy-bypass \](#--proxy-bypass-rules) + * [--ssl \](#--ssl-options) + * [--dev](#--dev) * [--qr-code](#--qr-code) + * [--sf, --stop-on-first-fail](#--sf---stop-on-first-fail) + * [--disable-test-syntax-validation](#--disable-test-syntax-validation) * [--color](#--color) * [--no-color](#--no-color) @@ -53,6 +61,9 @@ testcafe [options] > Inactive tabs and minimized browser windows switch to a lower resource consumption mode > where tests do not always execute correctly. +If a browser stops responding while it executes tests, TestCafe restarts the browser and reruns the current test in a new browser instance. +If the same problem occurs with this test two more times, the test run finishes and an error is thrown. + ## Browser List The `browser-list-comma-separated` argument specifies the list of browsers (separated by commas) where tests are run. @@ -268,12 +279,26 @@ Note that only one reporter can write to `stdout`. All other reporters must outp ### -s \, --screenshots \ -Enables screenshot capturing and specifies the directory where screenshots are saved. +Enables screenshots and specifies the base directory where they are saved. ```sh testcafe all tests/sample-fixture.js -s screenshots ``` +#### Path Patterns + +The captured screenshots are organized into subdirectories within the base directory. The following path patterns are used to define a relative path and name for screenshots the [Take Screenshot](../test-api/actions/take-screenshot.md) actions take: + +* `${DATE}_${TIME}\test-${TEST_INDEX}\${USERAGENT}\${FILE_INDEX}.png` if the [quarantine mode](#-q---quarantine-mode) is disabled; +* `${DATE}_${TIME}\test-${TEST_INDEX}\run-${QUARANTINE_ATTEMPT}\${USERAGENT}\${FILE_INDEX}.png` if the [quarantine mode](#-q---quarantine-mode) is enabled. + +If TestCafe takes screenshots when a test fails (see [--screenshots-on-fails](#-s---screenshots-on-fails) option), the following path patterns are used: + +* `${DATE}_${TIME}\test-${TEST_INDEX}\${USERAGENT}\errors\${FILE_INDEX}.png`; +* `${DATE}_${TIME}\test-${TEST_INDEX}\run-${QUARANTINE_ATTEMPT}\${USERAGENT}\errors\${FILE_INDEX}.png` if the [quarantine mode](#-q---quarantine-mode) is enabled. + +You can also use the [--screenshot-path-pattern](#-p---screenshot-path-pattern) option to specify a custom pattern. + ### -S, --screenshots-on-fails Takes a screenshot whenever a test fails. Screenshots are saved to the directory specified in the **-screenshots \** option. @@ -286,22 +311,61 @@ For example, the following command runs tests from the testcafe all tests/sample-fixture.js -S -s screenshots ``` -### -q, --quarantine-mode +### -p, --screenshot-path-pattern -Enables the quarantine mode for tests that fail. -In this mode, a failed test is executed several times. -The test result depends on the outcome (*passed* or *failed*) that occurs most often. -That is, if the test fails on most attempts, the result is *failed*. +Specifies a custom pattern to compose screenshot files' relative path and name. This pattern overrides the default [path pattern](#path-patterns). -If the test result differs between test runs, the test is marked as unstable. +You can use the following placeholders in the pattern: + +Placeholder | Description +----------- | ------------ +`${DATE}` | The test run's start date (YYYY-MM-DD). +`${TIME}` | The test run's start time (HH-mm-ss). +`${TEST_INDEX}` | The test's index. +`${FILE_INDEX}` | The screenshot file's index. +`${QUARANTINE_ATTEMPT}` | The [quarantine](programming-interface/runner.md#quarantine-mode) attempt's number. If the quarantine mode is disabled, the `${QUARANTINE_ATTEMPT}` placeholder's value is 1. +`${FIXTURE}` | The fixture's name. +`${TEST}` | The test's name. +`${USERAGENT}` | The combination of `${BROWSER}`, `${BROWSER_VERSION}`, `${OS}`, and `${OS_VERSION}` (separated by underscores). +`${BROWSER}` | The browser's name. +`${BROWSER_VERSION}` | The browser's version. +`${OS}` | The operation system's name. +`${OS_VERSION}` | The operation system's version. + +```sh +testcafe all tests/sample-fixture.js -s screenshots -p '${DATE}_${TIME}/test-${TEST_INDEX}/${USERAGENT}/${FILE_INDEX}.png' +``` + +In Windows `cmd.exe` shell, use double quotes because single quotes do not escape spaces. + +```sh +testcafe all tests/sample-fixture.js -s screenshots -p "${DATE} ${TIME}/test ${TEST_INDEX}/${USERAGENT}/${FILE_INDEX}.png" +``` + +### -q, --quarantine-mode + +Enables the [quarantine mode](programming-interface/runner.md#quarantine-mode) for tests that fail. ```sh testcafe all tests/sample-fixture.js -q ``` +### -d, --debug-mode + +Specify this option to run tests in the debugging mode. In this mode, test execution is paused before the first action or assertion allowing you to invoke the developer tools and debug. + +The footer displays a status bar in which you can resume test execution or skip to the next action or assertion. + +![Debugging status bar](../../images/debugging/client-debugging-footer.png) + +> If the test you run in the debugging mode contains a [test hook](../test-api/test-code-structure.md#test-hooks), +> it is paused within this hook before the first action. + +You can also use the **Unlock page** switch in the footer to unlock the tested page and interact with its elements. + ### -e, --skip-js-errors -When a JavaScript error occurs on a tested web page, TestCafe stops test execution and posts an error message to a report. To ignore JavaScript errors, use the `-e`(`--skip-js-errors`) option. +When a JavaScript error occurs on a tested web page, TestCafe stops test execution and posts an error message and a stack trace to a report. To ignore JavaScript errors, use the `-e`(`--skip-js-errors`) option. For example, the following command runs tests from the specified file and forces TestCafe to ignore JavaScript errors: @@ -309,19 +373,16 @@ For example, the following command runs tests from the specified file and forces testcafe ie tests/sample-fixture.js -e ``` -### -c \, --concurrency \ - -Specifies that tests should run concurrently. +### -u, --skip-uncaught-errors -TestCafe opens `n` instances of the same browser and creates a pool of browser instances. -Tests are run concurrently against this pool, that is, each test is run in the first free instance. +When an uncaught error or unhandled promise rejection occurs on the server during test execution, TestCafe stops the test and posts an error message to a report. Note that if you run tests [concurrently](#-c-n---concurrency-n) and such an error occurs in any test, all tests that are running at this moment will fail. -See [Concurrent Test Execution](common-concepts/concurrent-test-execution.md) to learn more about concurrent test execution. +To ignore these errors, use the `-u`(`--skip-uncaught-errors`) option. -The following example shows how to run tests in three Chrome instances: +For example, the following command runs tests from the specified file and forces TestCafe to ignore uncaught errors and unhandled promise rejections: ```sh -testcafe -c 3 chrome tests/sample-fixture.js +testcafe ie tests/sample-fixture.js -u ``` ### -t \, --test \ @@ -362,6 +423,26 @@ For example, the following command runs fixtures whose names match `Page.*`. The testcafe ie my-tests -F "Page.*" ``` +### --test-meta \ + +TestCafe runs tests whose [metadata](../test-api/test-code-structure.md#specifying-testing-metadata) [matches](https://lodash.com/docs/#isMatch) the specified key-value pair. + +For example, the following command runs tests whose metadata has the `device` property set to the `mobile` value and the `env` property set to the `production` value. + +```sh +testcafe chrome my-tests --test-meta device=mobile,env=production +``` + +### --fixture-meta \ + +TestCafe runs tests whose fixture's [metadata](../test-api/test-code-structure.md#specifying-testing-metadata) [matches](https://lodash.com/docs/#isMatch) the specified key-value pair. + +For example, the following command runs tests whose fixture's metadata has the `device` property set to the `mobile` value and the `env` property set to the `production` value. + +```sh +testcafe chrome my-tests --fixture-meta device=mobile,env=production +``` + ### -a \, --app \ Executes the specified shell command before running tests. Use it to launch or deploy the application you are going to test. @@ -376,24 +457,26 @@ testcafe chrome my-tests --app "node server.js" Use the [--app-init-delay](#--app-init-delay-ms) option to specify the amount of time allowed for this command to initialize the tested application. -### -d, --debug-mode +### -c \, --concurrency \ -Specify this option to run tests in the debugging mode. In this mode, test execution is paused before the first action or assertion allowing you to invoke the developer tools and debug. +Specifies that tests should run concurrently. -The footer displays a status bar in which you can resume test execution or skip to the next action or assertion. +TestCafe opens `n` instances of the same browser and creates a pool of browser instances. +Tests are run concurrently against this pool, that is, each test is run in the first free instance. -![Debugging status bar](../../images/debugging/client-debugging-footer.png) +See [Concurrent Test Execution](common-concepts/concurrent-test-execution.md) for more information about concurrent test execution. -> If the test you run in the debugging mode contains a [test hook](../test-api/test-code-structure.md#test-hooks), -> it is paused within this hook before the first action. +The following example shows how to run tests in three Chrome instances: -You can also use the **Unlock page** switch in the footer to unlock the tested page and interact with its elements. +```sh +testcafe -c 3 chrome tests/sample-fixture.js +``` ### --debug-on-fail Specifies whether to automatically enter the [debug mode](#-d---debug-mode) when a test fails. -If this option is enabled, TestCafe pauses the test at the moment it fails. This allows you to view the tested page and determine the cause of the fail. +If this option is enabled, TestCafe pauses the test when it fails. This allows you to view the tested page and determine the cause of the fail. When you are done, click the **Finish** button in the footer to end test execution. @@ -411,7 +494,7 @@ testcafe chrome my-tests --app "node server.js" --app-init-delay 4000 ### --selector-timeout \ -Specifies the time (in milliseconds) within which [selectors](../test-api/selecting-page-elements/selectors/README.md) attempts to obtain a node to be returned. See [Selector Timeout](../test-api/selecting-page-elements/selectors/using-selectors.md#selector-timeout). +Specifies the time (in milliseconds) within which [selectors](../test-api/selecting-page-elements/selectors/README.md) attempt to obtain a node to be returned. See [Selector Timeout](../test-api/selecting-page-elements/selectors/using-selectors.md#selector-timeout). **Default value**: `10000` @@ -421,7 +504,7 @@ testcafe ie my-tests --selector-timeout 500000 ### --assertion-timeout \ -Specifies the time (in milliseconds) within which TestCafe makes attempts to successfully execute an [assertion](../test-api/assertions/README.md) +Specifies the time (in milliseconds) TestCafe attempts to successfully execute an [assertion](../test-api/assertions/README.md) if a [selector property](../test-api/selecting-page-elements/selectors/using-selectors.md#define-assertion-actual-value) or a [client function](../test-api/obtaining-data-from-the-client/README.md) was passed as an actual value. See [Smart Assertion Query Mechanism](../test-api/assertions/README.md#smart-assertion-query-mechanism). @@ -448,6 +531,35 @@ You can set the page load timeout to `0` to skip waiting for the `window.load` e testcafe ie my-tests --page-load-timeout 0 ``` +### --speed \ + +Specifies the test execution speed. + +Tests are run at the maximum speed by default. You can use this option +to slow the test down. + +`factor` should be a number between `1` (the fastest) and `0.01` (the slowest). + +```sh +testcafe chrome my-tests --speed 0.1 +``` + +If the speed is also specified for an [individual action](../test-api/actions/action-options.md#basic-action-options), the action's speed setting overrides the test speed. + +**Default value**: `1` + +### --ports \ + +Specifies custom port numbers TestCafe uses to perform testing. The number range is [0-65535]. + +TestCafe automatically selects ports if ports are not specified. + +### --hostname \ + +Specifies your computer's hostname. It is used when running tests in [remote browsers](#remote-browsers). + +If the hostname is not specified, TestCafe uses the operating system's hostname or the current machine's network IP address. + ### --proxy \ Specifies the proxy server used in your local network to access the Internet. @@ -472,7 +584,7 @@ Specifies the resources accessed bypassing the proxy server. When you access the Internet through a proxy server specified using the [--proxy](#--proxy-host) option, you may still need some local or external resources to be accessed directly. In this instance, provide their URLs to the `--proxy-bypass` option. -The `rules` parameter takes a comma-separated list (without spaces) of URLs that require direct access. You can replace parts of the URL with wildcards `*`. TestCafe will correspond these symbols to any number of characters in the URL. Wildcards at the beginning and end of the rules can be omitted (`*.mycompany.com` and `.mycompany.com` have the same effect). +The `rules` parameter takes a comma-separated list (without spaces) of URLs that require direct access. You can replace parts of the URL with the `*` wildcard that matches any number of characters. Wildcards at the beginning and end of the rules can be omitted (`*.mycompany.com` and `.mycompany.com` have the same effect). The following example uses the proxy server at `proxy.corp.mycompany.com` with the `localhost:8080` address accessed directly: @@ -492,47 +604,86 @@ The `*.mycompany.com` value means that all URLs in the `mycompany.com` subdomain testcafe chrome my-tests/**/*.js --proxy proxy.corp.mycompany.com --proxy-bypass *.mycompany.com ``` -### --ports \ +### --ssl \ -Specifies custom port numbers TestCafe uses to perform testing. The number range is [0-65535]. +Provides options that allow you to establish an HTTPS connection between the client browser and the TestCafe server. -TestCafe automatically selects ports if ports are not specified. +The `options` parameter contains options required to initialize +[a Node.js HTTPS server](https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener). +The most commonly used SSL options are described in the [TLS topic](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) in Node.js documentation. +Options are specified in a semicolon-separated string. -### --hostname \ +```sh +testcafe --ssl pfx=path/to/file.pfx;rejectUnauthorized=true;... +``` -Specifies your computer's hostname. It is used when running tests in [remote browsers](#remote-browsers). +Provide the `--ssl` flag if the tested webpage uses browser features that require +secure origin ([Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API), [Geolocation API](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API), [ApplePaySession](https://developer.apple.com/documentation/apple_pay_on_the_web/applepaysession), [SubtleCrypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto), etc). +See [Connect to the TestCafe Server over HTTPS](common-concepts/connect-to-the-testcafe-server-over-https.md) for more information. -If the hostname is not specified, TestCafe uses the operating system hostname or network IP address of the current machine. +### --dev -### --speed \ +Enables mechanisms to log and diagnose errors. You should enable this option if you are going to contact TestCafe Support to report an issue. -Specifies the test execution speed. +```sh +testcafe chrome my-tests --dev +``` -Tests are run at the maximum speed by default. You can use this option -to slow the test down. +### --qr-code -`factor` should be a number between `1` (the fastest) and `0.01` (the slowest). +Outputs a QR-code that represents URLs used to connect the [remote browsers](#remote-browsers). ```sh -testcafe chrome my-tests --speed 0.1 +testcafe remote my-tests --qr-code ``` -If the speed is also specified for an [individual action](../test-api/actions/action-options.md#basic-action-options), the action's speed setting overrides the test speed. +### --sf, --stop-on-first-fail -**Default value**: `1` +Stops an entire test run if any test fails. This allows you not to wait for all the tests included in the test task to finish and allows focusing on the first error. -### --qr-code +```sh +testcafe chrome my-tests --sf +``` -Outputs a QR-code that represents URLs used to connect the [remote browsers](#remote-browsers). +### --disable-test-syntax-validation + +Disables checks for `test` and `fixture` directives in test files. Use this flag to run dynamically loaded tests. + +TestCafe requires test files to have the [fixture](../test-api/test-code-structure.md#fixtures) and [test](../test-api/test-code-structure.md#tests) directives. Otherwise, an error is thrown. + +However, when you import tests from external libraries or generate them dynamically, the `.js` file provided to TestCafe may not contain any tests. + +**external-lib.js** + +```js +export default function runTests () { + fixture `External tests` + .page `http:///example.com`; + + test('My Test', async t => { + // ... + }); +} +``` + +**test.js** + +```js +import runTests from './external-lib'; + +runTests(); +``` + +In this instance, specify the `--disable-test-syntax-validation` flag to bypass checks for test syntax. ```sh -testcafe remote my-tests --qr-code +testcafe safari test.js --disable-test-syntax-validation ``` ### --color -Enables colors on the command line. +Enables colors in the command line. ### --no-color -Disables colors on the command line. +Disables colors in the command line. diff --git a/docs/articles/documentation/using-testcafe/common-concepts/README.md b/docs/articles/documentation/using-testcafe/common-concepts/README.md index a5245524..1b5dffaa 100644 --- a/docs/articles/documentation/using-testcafe/common-concepts/README.md +++ b/docs/articles/documentation/using-testcafe/common-concepts/README.md @@ -8,3 +8,4 @@ permalink: /documentation/using-testcafe/common-concepts/ * [Browsers](browsers/README.md) * [Concurrent Test Execution](concurrent-test-execution.md) * [Reporters](reporters.md) +* [Connect to the TestCafe Server over HTTPS](connect-to-the-testcafe-server-over-https.md) diff --git a/docs/articles/documentation/using-testcafe/common-concepts/browsers/browser-support.md b/docs/articles/documentation/using-testcafe/common-concepts/browsers/browser-support.md index 9eb43860..4c7ae9ca 100644 --- a/docs/articles/documentation/using-testcafe/common-concepts/browsers/browser-support.md +++ b/docs/articles/documentation/using-testcafe/common-concepts/browsers/browser-support.md @@ -74,6 +74,8 @@ First, you will need to create a remote browser connection. After that, TestCafe will provide a URL to open on the remote device in the browser against which you want to test. As you open this URL, the browser connects to the TestCafe server and starts testing. +> You cannot [take screenshots](../../../test-api/actions/take-screenshot.md) or [resize the browser window](../../../test-api/actions/resize-window.md) when you run tests in remote browsers. + ## Browsers in Cloud Testing Services TestCafe allows you to use browsers from cloud testing services. You can access them through [browser provider plugins](../../../extending-testcafe/browser-provider-plugin/). diff --git a/docs/articles/documentation/using-testcafe/common-concepts/connect-to-the-testcafe-server-over-https.md b/docs/articles/documentation/using-testcafe/common-concepts/connect-to-the-testcafe-server-over-https.md new file mode 100644 index 00000000..35c85fa0 --- /dev/null +++ b/docs/articles/documentation/using-testcafe/common-concepts/connect-to-the-testcafe-server-over-https.md @@ -0,0 +1,60 @@ +--- +layout: docs +title: Connect to the TestCafe Server over HTTPS +permalink: /documentation/using-testcafe/common-concepts/connect-to-the-testcafe-server-over-https.html +--- +# Connect to TestCafe Server over HTTPS + +TestCafe is a proxy-based testing tool. Browser requests are sent via the TestCafe proxy server to the tested website. All requests between the browser and the TestCafe server are sent over the HTTP protocol. + +![Connection Protocols](../../../images/proxy-connection-protocols.svg) + +Some browser features (like +[Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API), +[Geolocation API](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API), +[ApplePaySession](https://developer.apple.com/documentation/apple_pay_on_the_web/applepaysession), or +[SubtleCrypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto)) +require a secure origin. This means that the website should use the HTTPS protocol. If TestCafe proxies such websites through HTTP, tests fail because of JavaScript errors. + +TestCafe can serve the proxied tested page over the HTTPS protocol. When this option is enabled, the client browser uses HTTPS to connect to the TestCafe proxy server. This allows you to test web pages with browser features that require a secure origin. + +To enable HTTPS, use the [--ssl](../command-line-interface.md#--ssl-options) flag when you run tests from the command line. Specify options required to initialize +[a Node.js HTTPS server](https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener) after this flag in a semicolon-separated string. The most commonly used SSL options are described in the [TLS topic](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) in Node.js documentation. + +The example below uses the PFX encoded private key and certificate chain to create an HTTPS server. + +```sh +testcafe --ssl pfx=path/to/file.pfx;rejectUnauthorized=true;... +``` + +When you use the programming interface, pass the HTTPS server options to the [createTestCafe](../programming-interface/createtestcafe.md) method. + +The following example uses the [openssl-self-signed-certificate](https://www.npmjs.com/package/openssl-self-signed-certificate) module to generate a self-signed certificate: + +```js +'use strict'; + +const createTestCafe = require('testcafe'); +const selfSignedSertificate = require('openssl-self-signed-certificate'); +let runner = null; + +const sslOptions = { + key: selfSignedSertificate.key, + cert: selfSignedSertificate.cert +}; + +createTestCafe('localhost', 1337, 1338, sslOptions) + .then(testcafe => { + runner = testcafe.createRunner(); + }) + .then(() => { + return runner + .src('test.js') + + // Browsers restrict self-signed certificate usage unless you + // explicitly set a flag specific to each browser. + // For Chrome, this is '--allow-insecure-localhost'. + .browsers('chrome --allow-insecure-localhost') + .run(); + }); +``` \ No newline at end of file diff --git a/docs/articles/documentation/using-testcafe/common-concepts/reporters.md b/docs/articles/documentation/using-testcafe/common-concepts/reporters.md index 8940725a..20906372 100644 --- a/docs/articles/documentation/using-testcafe/common-concepts/reporters.md +++ b/docs/articles/documentation/using-testcafe/common-concepts/reporters.md @@ -16,7 +16,7 @@ TestCafe ships with the following reporters: * [xUnit](https://github.com/DevExpress/testcafe-reporter-xunit) * [JSON](https://github.com/DevExpress/testcafe-reporter-json) -You can also create a [custom reporter](/testcafe/documentation/extending-testcafe/reporter-plugin/) that will fulfill your needs. +You can also create a [custom reporter](/testcafe/documentation/extending-testcafe/reporter-plugin/) that fulfills your needs. Here are some custom reporters developed by the community. @@ -44,5 +44,5 @@ You can install reporter packages from npm as you would install any other plugin When running tests, you can select a reporter to generate test reports. You can do this by using the -[reporter](../command-line-interface.md#-r-namefile---reporter-namefile) command line option or the +[-r (--reporter)](../command-line-interface.md#-r-namefile---reporter-namefile) command line option or the [runner.reporter](../programming-interface/runner.md#reporter) API method. \ No newline at end of file diff --git a/docs/articles/documentation/using-testcafe/programming-interface/createtestcafe.md b/docs/articles/documentation/using-testcafe/programming-interface/createtestcafe.md index cb3c84e8..5bfff45e 100644 --- a/docs/articles/documentation/using-testcafe/programming-interface/createtestcafe.md +++ b/docs/articles/documentation/using-testcafe/programming-interface/createtestcafe.md @@ -9,25 +9,60 @@ checked: true Creates a [TestCafe](testcafe.md) server instance. ```text -async createTestCafe([hostname], [port1], [port2]) → Promise +async createTestCafe([hostname], [port1], [port2], [sslOptions], [developmentMode]) → Promise ``` Parameter | Type | Description | Default ----------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- -`hostname` *(optional)* | String | The hostname or IP you will use to address the TestCafe server. Must resolve to the current machine. To test on external devices, use the hostname that is visible in the network shared with these devices. | Hostname of the OS. If the hostname does not resolve to the current machine - its network IP address. +`hostname` *(optional)* | String | The hostname or IP on which the TestCafe server is running. Must resolve to the current machine. To test on external devices, use the hostname that is visible in the network shared with these devices. | Hostname of the OS. If the hostname does not resolve to the current machine - its network IP address. `port1`, `port2` *(optional)* | Number | Ports that will be used to serve tested webpages. | Free ports selected automatically. +`sslOptions` *(optional)* | Object | Options that allow you to establish an HTTPS connection between the TestCafe server and the client browser. This object should contain options required to initialize [a Node.js HTTPS server](https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener). The most commonly used SSL options are described in the [TLS topic](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) in Node.js documentation. See [Connect to the TestCafe Server over HTTPS](../common-concepts/connect-to-the-testcafe-server-over-https.md) for more information. +`developmentMode` *(optional)* | Boolean | Enables/disables mechanisms to log and diagnose errors. You should enable this option if you are going to contact TestCafe Support to report an issue. | `false` **Example** +Create a `TestCafe` instance with the `createTestCafe` function. + ```js const createTestCafe = require('testcafe'); createTestCafe('localhost', 1337, 1338) .then(testcafe => { + runner = testcafe.createRunner(); /* ... */ }); ``` +Establish an HTTPS connection with the TestCafe server. The [openssl-self-signed-certificate](https://www.npmjs.com/package/openssl-self-signed-certificate) module is used to generate a self-signed certificate for development use. + +```js +'use strict'; + +const createTestCafe = require('testcafe'); +const selfSignedSertificate = require('openssl-self-signed-certificate'); +let runner = null; + +const sslOptions = { + key: selfSignedSertificate.key, + cert: selfSignedSertificate.cert +}; + +createTestCafe('localhost', 1337, 1338, sslOptions) + .then(testcafe => { + runner = testcafe.createRunner(); + }) + .then(() => { + return runner + .src('test.js') + + // Browsers restrict self-signed certificate usage unless you + // explicitly set a flag specific to each browser. + // For Chrome, this is '--allow-insecure-localhost'. + .browsers('chrome --allow-insecure-localhost') + .run(); + }); +``` + ## See Also -* [TestCafe Class](testcafe.md) \ No newline at end of file +* [TestCafe Class](testcafe.md) diff --git a/docs/articles/documentation/using-testcafe/programming-interface/runner.md b/docs/articles/documentation/using-testcafe/programming-interface/runner.md index 925fc2d7..f30722b5 100644 --- a/docs/articles/documentation/using-testcafe/programming-interface/runner.md +++ b/docs/articles/documentation/using-testcafe/programming-interface/runner.md @@ -65,16 +65,20 @@ src(source) → this Parameter | Type | Description --------- | ------------------- | ---------------------------------------------------------------------------- -`source` | String | Array | The relative or absolute path to a test fixture file, or several such paths. +`source` | String | Array | The relative or absolute path to a test fixture file, or several such paths. You can use [glob patterns](https://github.com/isaacs/node-glob#glob-primer) to include (or exclude) multiple files. If you call the method several times, all the specified sources are added to the test runner. -**Example** +**Examples** ```js runner.src(['/home/user/tests/fixture1.js', 'fixture5.js']); ``` +```js +runner.src(['/home/user/tests/**/*.js', '!/home/user/tests/foo.js']); +``` + ### filter Allows you to select which tests should be run. @@ -83,9 +87,9 @@ Allows you to select which tests should be run. filter(callback) → this ``` -Parameter | Type | Description ----------- | ---------------------------------------------- | ---------------------------------------------------------------- -`callback` | `function(testName, fixtureName, fixturePath)` | The callback that determines if a particular test should be run. +Parameter | Type | Description +---------- | --------------------------------------------------------------------- | ---------------------------------------------------------------- +`callback` | `function(testName, fixtureName, fixturePath, testMeta, fixtureMeta)` | The callback that determines if a particular test should be run. The callback function is called for each test in the files specified using the [src](#src) method. @@ -93,19 +97,23 @@ Return `true` from the callback to include the current test or `false` to exclud The callback function accepts the following arguments: -Parameter | Type | Description -------------- | ------ | ---------------------------------- -`testName` | String | The name of the test. -`fixtureName` | String | The name of the test fixture. -`fixturePath` | String | The path to the test fixture file. +Parameter | Type | Description +------------- | ------------------------ | ---------------------------------- +`testName` | String | The name of the test. +`fixtureName` | String | The name of the test fixture. +`fixturePath` | String | The path to the test fixture file. +`testMeta` | Object\ | The test metadata. +`fixtureMeta` | Object\ | The fixture metadata. **Example** ```js -runner.filter((testName, fixtureName, fixturePath) => { +runner.filter((testName, fixtureName, fixturePath, testMeta, fixtureMeta) => { return fixturePath.startsWith('D') && testName.match(someRe) && - fixtureName.match(anotherRe); + fixtureName.match(anotherRe) && + testMeta.mobile === 'true' && + fixtureMeta.env === 'staging'; }); ``` @@ -201,13 +209,14 @@ createTestCafe('localhost', 1337, 1338) Enables TestCafe to take screenshots of the tested webpages. ```text -screenshots(path [, takeOnFails]) → this +screenshots(path [, takeOnFails, pathPattern]) → this ``` Parameter | Type | Description | Default -------------------------- | ------- | ----------------------------------------------------------------------------- | ------- -`path` | String | The path to which the screenshots are saved. +`path` | String | The base path where the screenshots are saved. Note that to construct a complete path to these screenshots, TestCafe uses default [path patterns](../command-line-interface.md#path-patterns). You can override these patterns using the method's `screenshotPathPattern` parameter. `takeOnFails` *(optional)* | Boolean | Specifies if screenshots should be taken automatically when a test fails. | `false` +`sceenshotPathPattern` *(optional)* | String | The pattern to compose screenshot files' relative path and name. See [--screenshot-path-pattern](../command-line-interface.md#-p---screenshot-path-pattern) for information about the available placeholders. The `screenshots` function should be called to allow TestCafe to take screenshots when the [t.takeScreenshot](../../test-api/actions/take-screenshot.md) action is called from test code. @@ -219,7 +228,7 @@ Set the `takeOnFails` parameter to `true` to take a screenshot when a test fails **Example** ```js -runner.screenshots('reports/screenshots/', true); +runner.screenshots('reports/screenshots/', true, '${DATE}_${TIME}/test-${TEST_INDEX}/${USERAGENT}/${FILE_INDEX}.png'); ``` ### reporter @@ -299,7 +308,7 @@ concurrency(n) → this ``` TestCafe opens `n` instances of the same browser and creates a pool of browser instances. -Tests are run concurrently against this pool, that is, each test is run in the first free instance. +Tests are run concurrently against this pool, that is, each test is run in the first available instance. The `concurrency` function takes the following parameters: @@ -402,15 +411,18 @@ async run(options) → Promise You can pass the following options to the `runner.run` function. Parameter | Type | Description | Default ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- +----------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- `skipJsErrors` | Boolean | Defines whether to continue running a test after a JavaScript error occurs on a page (`true`), or consider such a test failed (`false`). | `false` +`skipUncaughtErrors` | Boolean | Defines whether to continue running a test after an uncaught error or unhandled promise rejection occurs on the server (`true`), or consider such a test failed (`false`). | `false` `quarantineMode` | Boolean | Defines whether to enable the [quarantine mode](#quarantine-mode). | `false` +`debugMode` | Boolean | Specifies if tests run in the debug mode. If this option is enabled, test execution is paused before the first action or assertion allowing you to invoke the developer tools and debug. In the debug mode, you can execute the test step-by-step to reproduce its incorrect behavior. You can also use the **Unlock page** switch in the footer to unlock the tested page and interact with its elements. | `false` +`debugOnFail` | Boolean | Specifies whether to enter the debug mode when a test fails. If enabled, the test is paused at the moment it fails, so that you can explore the tested page to determine what caused the failure. | `false` `selectorTimeout` | Number | Specifies the time (in milliseconds) within which [selectors](../../test-api/selecting-page-elements/selectors/README.md) make attempts to obtain a node to be returned. See [Selector Timeout](../../test-api/selecting-page-elements/selectors/using-selectors.md#selector-timeout). | `10000` `assertionTimeout` | Number | Specifies the time (in milliseconds) within which TestCafe makes attempts to successfully execute an [assertion](../../test-api/assertions/README.md) if [a selector property](../../test-api/selecting-page-elements/selectors/using-selectors.md#define-assertion-actual-value) or a [client function](../../test-api/obtaining-data-from-the-client/README.md) was passed as an actual value. See [Smart Assertion Query Mechanism](../../test-api/assertions/README.md#smart-assertion-query-mechanism). | `3000` `pageLoadTimeout` | Number | Specifies the time (in milliseconds) passed after the `DOMContentLoaded` event, within which TestCafe waits for the `window.load` event to fire. After the timeout passes or the `window.load` event is raised (whichever happens first), TestCafe starts the test. You can set this timeout to `0` to skip waiting for `window.load`. | `3000` `speed` | Number | Specifies the test execution speed. Should be a number between `1` (the fastest) and `0.01` (the slowest). If speed is also specified for an [individual action](../../test-api/actions/action-options.md#basic-action-options), the action speed setting overrides test speed. | `1` -`debugMode` | Boolean | Specifies if tests run in the debug mode. If this option is enabled, test execution is paused before the first action or assertion allowing you to invoke the developer tools and debug. In the debug mode, you can execute the test step-by-step to reproduce its incorrect behavior. You can also use the **Unlock page** switch in the footer to unlock the tested page and interact with its elements. | `false` -`debugOnFail` | Boolean | Specifies whether to enter the debug mode when a test fails. If enabled, the test is paused at the moment it fails, so that you can explore the tested page to determine what caused the failure. | `false` +`stopOnFirstFail` | Boolean | Defines whether to stop an entire test run if any test fails. This allows you not to wait for all the tests included in the test task to finish and allows focusing on the first error. | `false` +`disableTestSyntaxValidation` | Boolean | Defines whether to disable checks for [test](../../test-api/test-code-structure.md#tests) and [fixture](../../test-api/test-code-structure.md#fixtures) directives in test files. Use this option to run dynamically loaded tests. See details in the [--disable-test-syntax-validation](../command-line-interface.md#--disable-test-syntax-validation) command line option description. | `false` After all tests are finished, call the [testcafe.close](testcafe.md#close) function to stop the TestCafe server. @@ -431,7 +443,8 @@ createTestCafe('localhost', 1337, 1338) selectorTimeout: 50000, assertionTimeout: 7000, pageLoadTimeout: 8000, - speed: 0.1 + speed: 0.1, + stopOnFirstFail: true }); }) .then(failed => { @@ -441,6 +454,9 @@ createTestCafe('localhost', 1337, 1338) .catch(error => { /* ... */ }); ``` +If a browser stops responding while it executes tests, TestCafe restarts the browser and reruns the current test in a new browser instance. +If the same problem occurs with this test two more times, the test run finishes and an error is thrown. + #### Cancelling Test Tasks You can stop an individual test task at any moment by cancelling the corresponding promise. @@ -459,18 +475,22 @@ You can also cancel all pending tasks at once using the [runner.stop](#stop) fun #### Quarantine Mode -The quarantine mode is designed to isolate *non-deterministic* tests (that is, tests that sometimes pass and fail without a clear reason) -from the rest of the test base (*healthy* tests). +The quarantine mode is designed to isolate *non-deterministic* tests (that is, tests that pass and fail without any apparent reason) from the other tests. + +When the quarantine mode is enabled, tests run according to the following logic: + +1. A test runs at the first time. If it passes, TestCafe proceeds to the next test. +2. If the test fails, it runs again until it passes or fails three times. +3. The most frequent outcome is recorded as the test result. +4. If the test result differs between test runs, the test is marked as unstable. -When quarantine mode is enabled, tests are not marked as *failed* after the first unsuccessful run but rather sent to quarantine. -After that, these tests are run again several times. The outcome of the most runs (*passed* or *failed*) is recorded as the test result. -A test is separately marked *unstable* if the outcome varies between runs. The test counts the run that was quarantined. +> Note that it increases the test task's duration if you enable quarantine mode on your test machine because failed tests are executed three to five times. -To learn more about non-deterministic tests, see Martin Fowler's [Eradicating Non-Determinism in Tests](http://martinfowler.com/articles/nonDeterminism.html) article. +See Martin Fowler's [Eradicating Non-Determinism in Tests](http://martinfowler.com/articles/nonDeterminism.html) article for more information about non-deterministic tests. ### stop -Stops all pending test tasks. +Stops all the pending test tasks. ```text async stop() diff --git a/docs/articles/documentation/using-testcafe/using-testcafe-docker-image.md b/docs/articles/documentation/using-testcafe/using-testcafe-docker-image.md index 03152315..a852a80f 100644 --- a/docs/articles/documentation/using-testcafe/using-testcafe-docker-image.md +++ b/docs/articles/documentation/using-testcafe/using-testcafe-docker-image.md @@ -47,21 +47,49 @@ This command takes the following parameters: You can run tests in the Chromium and Firefox browsers preinstalled to the Docker image. Add the `--no-sandbox` flag to Chromium if the container is run in the unprivileged mode. - Use the [remote browser](command-line-interface.md#remote-browsers) to run tests on a mobile device. To do this, you need to add the `-P` flag to the `docker run` command and specify the `remote` keyword as the browser name. - - `docker run -v //d/tests:/tests -P -it testcafe/testcafe remote /tests/test.js` - You can pass a glob instead of the directory path in TestCafe parameters. `docker run -v //d/tests:/tests -it testcafe/testcafe firefox /tests/**/*.js` > If tests use other Node.js modules, these modules should be located in the tests directory or its child directories. TestCafe will not be able to find modules in the parent directories. +## Testing in Remote Browsers + +You need to add the following options to the `docker run` command to run tests in a [remote browser](command-line-interface.md#remote-browsers), e.g. on a mobile device. + +* add the `-P` flag to publish all exposed ports; +* use the [--hostname](command-line-interface.md#--hostname-name) TestCafe flag to specify the host machine name; +* specify the `remote` keyword as the browser name. + +The example below shows a command that runs tests in a remote browser. + +```sh +docker run -v /d/tests:/tests -P -it testcafe/testcafe --hostname $EXTERNAL_HOSTNAME remote /tests/test.js +``` + +where `$EXTERNAL_HOSTNAME` is the host machine name. + +If Docker reports that the default ports `1337` and `1338` are occupied by some other process, kill this process or choose different ports. Use the `netstat` command to determine which process occupies the default ports. + +OS | Command +------- | --------- +Windows | `netstat -ano` +macOS | `netstat -anv` +Linux | `netstat -anp` + +If you choose to use different ports, publish them in the Docker container (use the `-p` flag) and specify them to TestCafe (use the [--ports](command-line-interface.md#--ports-port1port2) option). + +```sh +docker run -v /d/tests:/tests -p $PORT1 -p $PORT2 -it testcafe/testcafe --hostname $EXTERNAL_HOSTNAME --ports $PORT1,$PORT2 remote /tests/test.js +``` + +where `$PORT1` and `$PORT2` are vacant container's ports, `$EXTERNAL_HOSTNAME` is the host machine name. + ## Testing Heavy Websites If you are testing a heavy website, you may need to allocate extra resources for the Docker image. -The most common case is when the temporary file storage `/dev/shm` runs out of free space. It usually happens when you run tests in Chromium. The following example shows how to allow additional space (1GB) for this storage using the `--shm-size` option. +The most common case is when the temporary file storage `/dev/shm` runs out of free space. The following example shows how to allow additional space (1GB) for this storage using the `--shm-size` option. ```sh docker run --shm-size=1g -v ${TEST_FOLDER}:/tests -it testcafe/testcafe ${TESTCAFE_ARGS} diff --git a/docs/articles/faq/README.md b/docs/articles/faq/README.md index 523fb071..68b6e416 100644 --- a/docs/articles/faq/README.md +++ b/docs/articles/faq/README.md @@ -7,7 +7,7 @@ permalink: /faq/ * [General Questions](#general-questions) * [I have heard that TestCafe does not use Selenium. How does it operate?](#i-have-heard-that-testcafe-does-not-use-selenium-how-does-it-operate) - * [What is the difference between a paid and an open-source TestCafe version?](#what-is-the-difference-between-a-paid-and-an-open-source-testcafe-version) + * [What is the difference between a paid and an open-source TestCafe version? What is TestCafe Studio?](#what-is-the-difference-between-a-paid-and-an-open-source-testcafe-version-what-is-testcafe-studio) * [Which browsers does TestCafe support? What are the exact supported versions?](#which-browsers-does-testcafe-support-what-are-the-exact-supported-versions) * [Can I use third-party modules in tests?](#can-i-use-third-party-modules-in-tests) * [How do I work with configuration files and environment variables?](#how-do-i-work-with-configuration-files-and-environment-variables) @@ -32,32 +32,36 @@ This proxy injects the driver script that emulates user actions into the tested You can read about this in our [forum](https://testcafe-discuss.devexpress.com/t/why-not-use-selenium/47). Feel free to ask for more details. -### What is the difference between a [paid](https://testcafe.devexpress.com) and an [open-source](https://devexpress.github.io/testcafe) TestCafe version? +### What is the difference between a [paid](https://testcafe.devexpress.com) and an [open-source](https://devexpress.github.io/testcafe) TestCafe version? What is [TestCafe Studio](https://testcafe-studio.devexpress.com/)? -TestCafe first appeared as a paid, standalone tool. It had several features besides the test runner: the -[Control Panel](https://testcafe.devexpress.com/Documentation/Using_TestCafe/Control_Panel/), -a visual interface for creating, modifying and running your tests, and the -[Visual Test Recorder](https://testcafe.devexpress.com/Documentation/Using_TestCafe/Visual_Test_Recorder/) -for recording tests by pointing and clicking through the test scenario in the browser. +All three versions share the same core features: -In 2015 we decided to release the TestCafe core as an open-source project. -The last major paid version release (v15.1) was in summer 2015. -After that, it switched to a maintenance-only mode. -You can find the latest paid version at [https://testcafe.devexpress.com](https://testcafe.devexpress.com). +* No need for WebDriver, browser plugins or other tools. +* Cross-platform and cross-browser out of the box. -We released the open-source TestCafe in late 2016. -It has the same name but contains lots of new functionality and improvements: +[TestCafe](https://testcafe.devexpress.com) +*first released in 2013, commercial web application* -* a new API and offers a new approach to writing tests; -* no UI to manage and run tests. You can run tests from the CLI or node.js module; -* it is more convenient to integrate into node.js development workflow; -* es6 support, flexible selectors and smart assertions, authentication features, etc. +* Visual Test Recorder and web GUI to create, edit and run tests. +* You can record tests or edit them as JavaScript code. -You can learn more about the open-source version at [https://devexpress.github.io/testcafe](https://devexpress.github.io/testcafe). +[TestCafe](https://devexpress.github.io/testcafe) +*first released in 2016, free and open-source node.js application* -We have not abandoned our aspirations to create a competitive proprietary product. -We plan to release a new commercial version called ***TestCafe Studio***, based on the revised open-source TestCafe. -Follow us on [Twitter](https://twitter.com/DXTestCafe) for the news about TestCafe Studio. +* You can write tests in the latest JavaScript or TypeScript. +* Clearer and more flexible [API](https://devexpress.github.io/testcafe/documentation/test-api/) supports ES6 and [PageModel pattern](https://devexpress.github.io/testcafe/documentation/recipes/using-page-model.html). +* More stable tests due to the [Smart Assertion Query Mechanism](https://devexpress.github.io/testcafe/documentation/test-api/assertions/#smart-assertion-query-mechanism). +* Tests run faster due to improved [Automatic Waiting Mechanism](https://devexpress.github.io/testcafe/documentation/test-api/waiting-for-page-elements-to-appear.html) and [Concurrent Test Execution](https://devexpress.github.io/testcafe/documentation/using-testcafe/common-concepts/concurrent-test-execution.html). +* Easy integration: it is a node.js solution with CLI and reporters for popular CI systems. +* You can extend it with [plugins](https://github.com/DevExpress/testcafe#plugins) and other Node.js modules. + +[TestCafe Studio](https://testcafe-studio.devexpress.com) +*Preview released in 2018, commercial desktop application* + +* Based on the open-source TestCafe, and supports its major features. +* You can record tests or edit them as JavaScript or TypeScript code. +* New [Visual Test Recorder](https://testcafe-studio.devexpress.com/documentation/guides/record-tests/) and [IDE-like GUI](https://testcafe-studio.devexpress.com/documentation/guides/write-test-code.html) to record, edit, run and debug tests. +* Currently available as a free preview version. ### Which browsers does TestCafe support? What are the exact supported versions? @@ -228,9 +232,17 @@ npm install -g {pluginName} ### My test fails because TestCafe could not find the required webpage element. Why does this happen? -First, try debugging the tested page with the TestCafe's built-in debugger by adding -the [t.debug()](../documentation/test-api/debugging.md) method to test code. -Then run the test and wait until the browser stops at the breakpoint. +This happens because either: + +* one of the [selectors](../documentation/test-api/selecting-page-elements/selectors/README.md) you used in test code does not match any DOM element, or +* you have tried to specify an [action's target element](https://devexpress.github.io/testcafe/documentation/test-api/actions/#selecting-target-elements) using a wrong CSS selector or a client-side function that returns no element. + +To determine the cause of this issue, do the following: + +1. Look at the error message in the test run report [to learn which selector has failed](../documentation/test-api/selecting-page-elements/selectors/using-selectors.md#debug-selectors). +2. Add the [t.debug()](../documentation/test-api/debugging.md) method before this selector to stop test execution before it reaches this point. +3. Run the test and wait until the browser stops at the breakpoint. + After this, use the browser's development tools to check that: * the element is present on the page; diff --git a/docs/articles/images/client-error-stack-report.png b/docs/articles/images/client-error-stack-report.png new file mode 100644 index 00000000..8486bafb Binary files /dev/null and b/docs/articles/images/client-error-stack-report.png differ diff --git a/docs/articles/images/failed-selector-report.png b/docs/articles/images/failed-selector-report.png new file mode 100644 index 00000000..c5467393 Binary files /dev/null and b/docs/articles/images/failed-selector-report.png differ diff --git a/docs/articles/images/landing-page/all-environments.png b/docs/articles/images/landing-page/all-environments.png deleted file mode 100644 index e5831074..00000000 Binary files a/docs/articles/images/landing-page/all-environments.png and /dev/null differ diff --git a/docs/articles/images/landing-page/all-environments.svg b/docs/articles/images/landing-page/all-environments.svg new file mode 100644 index 00000000..76f7f1cd --- /dev/null +++ b/docs/articles/images/landing-page/all-environments.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/articles/images/landing-page/banner-image.png b/docs/articles/images/landing-page/banner-image.png index fa769ad1..bb74f716 100644 Binary files a/docs/articles/images/landing-page/banner-image.png and b/docs/articles/images/landing-page/banner-image.png differ diff --git a/docs/articles/images/landing-page/banner-image@2x.png b/docs/articles/images/landing-page/banner-image@2x.png new file mode 100644 index 00000000..95b62f6b Binary files /dev/null and b/docs/articles/images/landing-page/banner-image@2x.png differ diff --git a/docs/articles/images/landing-page/close-icon.png b/docs/articles/images/landing-page/close-icon.png deleted file mode 100644 index ec40f6c8..00000000 Binary files a/docs/articles/images/landing-page/close-icon.png and /dev/null differ diff --git a/docs/articles/images/landing-page/close.svg b/docs/articles/images/landing-page/close.svg new file mode 100644 index 00000000..aa74ddfc --- /dev/null +++ b/docs/articles/images/landing-page/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/articles/images/landing-page/concurrent-tests.png b/docs/articles/images/landing-page/concurrent-tests.png deleted file mode 100644 index 257cf966..00000000 Binary files a/docs/articles/images/landing-page/concurrent-tests.png and /dev/null differ diff --git a/docs/articles/images/landing-page/concurrent-tests.svg b/docs/articles/images/landing-page/concurrent-tests.svg new file mode 100644 index 00000000..ffed8d22 --- /dev/null +++ b/docs/articles/images/landing-page/concurrent-tests.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/articles/images/landing-page/continuous-integration.png b/docs/articles/images/landing-page/continuous-integration.png deleted file mode 100644 index eafd80ed..00000000 Binary files a/docs/articles/images/landing-page/continuous-integration.png and /dev/null differ diff --git a/docs/articles/images/landing-page/continuous-integration.svg b/docs/articles/images/landing-page/continuous-integration.svg new file mode 100644 index 00000000..00e6a9da --- /dev/null +++ b/docs/articles/images/landing-page/continuous-integration.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/articles/images/landing-page/detect-errors.png b/docs/articles/images/landing-page/detect-errors.png deleted file mode 100644 index de5872a9..00000000 Binary files a/docs/articles/images/landing-page/detect-errors.png and /dev/null differ diff --git a/docs/articles/images/landing-page/detect-errors.svg b/docs/articles/images/landing-page/detect-errors.svg new file mode 100644 index 00000000..cf912f9d --- /dev/null +++ b/docs/articles/images/landing-page/detect-errors.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/articles/images/landing-page/find-icon-black.png b/docs/articles/images/landing-page/find-icon-black.png deleted file mode 100644 index 1dbae080..00000000 Binary files a/docs/articles/images/landing-page/find-icon-black.png and /dev/null differ diff --git a/docs/articles/images/landing-page/find-icon-black.svg b/docs/articles/images/landing-page/find-icon-black.svg new file mode 100644 index 00000000..2b0072d8 --- /dev/null +++ b/docs/articles/images/landing-page/find-icon-black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/articles/images/landing-page/find-icon.png b/docs/articles/images/landing-page/find-icon.png deleted file mode 100644 index 57ebc49d..00000000 Binary files a/docs/articles/images/landing-page/find-icon.png and /dev/null differ diff --git a/docs/articles/images/landing-page/follow-on-github.png b/docs/articles/images/landing-page/follow-on-github.png deleted file mode 100644 index 6b9ca995..00000000 Binary files a/docs/articles/images/landing-page/follow-on-github.png and /dev/null differ diff --git a/docs/articles/images/landing-page/follow-on-github.svg b/docs/articles/images/landing-page/follow-on-github.svg new file mode 100644 index 00000000..e37a9603 --- /dev/null +++ b/docs/articles/images/landing-page/follow-on-github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/articles/images/landing-page/follow-on-twitter.png b/docs/articles/images/landing-page/follow-on-twitter.png deleted file mode 100644 index c7191870..00000000 Binary files a/docs/articles/images/landing-page/follow-on-twitter.png and /dev/null differ diff --git a/docs/articles/images/landing-page/follow-on-twitter.svg b/docs/articles/images/landing-page/follow-on-twitter.svg new file mode 100644 index 00000000..8cd165f1 --- /dev/null +++ b/docs/articles/images/landing-page/follow-on-twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/articles/images/landing-page/free-open-source.png b/docs/articles/images/landing-page/free-open-source.png deleted file mode 100644 index 90d240c2..00000000 Binary files a/docs/articles/images/landing-page/free-open-source.png and /dev/null differ diff --git a/docs/articles/images/landing-page/free-open-source.svg b/docs/articles/images/landing-page/free-open-source.svg new file mode 100644 index 00000000..68d37478 --- /dev/null +++ b/docs/articles/images/landing-page/free-open-source.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/articles/images/landing-page/js-ts.png b/docs/articles/images/landing-page/js-ts.png deleted file mode 100644 index 35addbe6..00000000 Binary files a/docs/articles/images/landing-page/js-ts.png and /dev/null differ diff --git a/docs/articles/images/landing-page/js-ts.svg b/docs/articles/images/landing-page/js-ts.svg new file mode 100644 index 00000000..cd0d0135 --- /dev/null +++ b/docs/articles/images/landing-page/js-ts.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/articles/images/landing-page/page-object.png b/docs/articles/images/landing-page/page-object.png deleted file mode 100644 index 10997449..00000000 Binary files a/docs/articles/images/landing-page/page-object.png and /dev/null differ diff --git a/docs/articles/images/landing-page/page-object.svg b/docs/articles/images/landing-page/page-object.svg new file mode 100644 index 00000000..3f328514 --- /dev/null +++ b/docs/articles/images/landing-page/page-object.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/articles/images/landing-page/quick-setup.png b/docs/articles/images/landing-page/quick-setup.png deleted file mode 100644 index 8efd3e13..00000000 Binary files a/docs/articles/images/landing-page/quick-setup.png and /dev/null differ diff --git a/docs/articles/images/landing-page/quick-setup.svg b/docs/articles/images/landing-page/quick-setup.svg new file mode 100644 index 00000000..955a693c --- /dev/null +++ b/docs/articles/images/landing-page/quick-setup.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/articles/images/landing-page/rapid-dev-icon-small.svg b/docs/articles/images/landing-page/rapid-dev-icon-small.svg new file mode 100644 index 00000000..66c15060 --- /dev/null +++ b/docs/articles/images/landing-page/rapid-dev-icon-small.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/articles/images/landing-page/rapid-dev-icon.svg b/docs/articles/images/landing-page/rapid-dev-icon.svg index 8d9ef8ca..88da5b6d 100644 --- a/docs/articles/images/landing-page/rapid-dev-icon.svg +++ b/docs/articles/images/landing-page/rapid-dev-icon.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/docs/articles/images/landing-page/stable-tests.png b/docs/articles/images/landing-page/stable-tests.png deleted file mode 100644 index 59e132f6..00000000 Binary files a/docs/articles/images/landing-page/stable-tests.png and /dev/null differ diff --git a/docs/articles/images/landing-page/stable-tests.svg b/docs/articles/images/landing-page/stable-tests.svg new file mode 100644 index 00000000..3d23b5cf --- /dev/null +++ b/docs/articles/images/landing-page/stable-tests.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/articles/images/landing-page/studio-promo.png b/docs/articles/images/landing-page/studio-promo.png new file mode 100644 index 00000000..362c06db Binary files /dev/null and b/docs/articles/images/landing-page/studio-promo.png differ diff --git a/docs/articles/images/landing-page/studio-promo@2x.png b/docs/articles/images/landing-page/studio-promo@2x.png new file mode 100644 index 00000000..ac4c9642 Binary files /dev/null and b/docs/articles/images/landing-page/studio-promo@2x.png differ diff --git a/docs/articles/images/landing-page/testcafe-logo.png b/docs/articles/images/landing-page/testcafe-logo.png deleted file mode 100644 index 9b71ce2a..00000000 Binary files a/docs/articles/images/landing-page/testcafe-logo.png and /dev/null differ diff --git a/docs/articles/images/landing-page/twitter-icon.png b/docs/articles/images/landing-page/twitter-icon.png deleted file mode 100644 index bfa4f5f4..00000000 Binary files a/docs/articles/images/landing-page/twitter-icon.png and /dev/null differ diff --git a/docs/articles/images/proxy-connection-protocols.svg b/docs/articles/images/proxy-connection-protocols.svg new file mode 100644 index 00000000..04bdb1d4 --- /dev/null +++ b/docs/articles/images/proxy-connection-protocols.svg @@ -0,0 +1,413 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + Browser + + TestCafe Server + + HTTP + + Tested Website + + + HTTPS + HTTP + + diff --git a/docs/articles/images/recipe-vscode-configuration-file.png b/docs/articles/images/recipe-vscode-configuration-file.png new file mode 100644 index 00000000..870f2382 Binary files /dev/null and b/docs/articles/images/recipe-vscode-configuration-file.png differ diff --git a/docs/articles/images/recipe-vscode-debugging-breakpoint.png b/docs/articles/images/recipe-vscode-debugging-breakpoint.png new file mode 100644 index 00000000..397ed4b3 Binary files /dev/null and b/docs/articles/images/recipe-vscode-debugging-breakpoint.png differ diff --git a/docs/articles/images/recipe-vscode-select-configuration.png b/docs/articles/images/recipe-vscode-select-configuration.png new file mode 100644 index 00000000..48e0f397 Binary files /dev/null and b/docs/articles/images/recipe-vscode-select-configuration.png differ diff --git a/docs/articles/index.html b/docs/articles/index.html index c367f79c..f1258d5c 100644 --- a/docs/articles/index.html +++ b/docs/articles/index.html @@ -22,7 +22,9 @@
@@ -38,7 +40,7 @@

1 minute to set up

You do not need WebDriver or any other testing software. Install TestCafe with one command, and you are ready to test.

- npm install -g testcafe +
npm install -g testcafe
@@ -61,6 +63,68 @@

How it works

+ @@ -115,6 +179,10 @@

How it works

The TestCafe's Test API includes a high-level selector library, assertions, etc. You can combine them to implement readable tests with the PageObject pattern.

const el = Selector('.column').find('label')
.withText('MacOS').child('input');
+
const el = Selector('.column') + .find('label') + .withText('MacOS') + .child('input');
@@ -144,6 +212,19 @@

How it works

+
+
+
+
+
IDE for end-to-end web testing
+
How about e2e automation without coding?
Meet TestCafe Studio: all the perks of TestCafe + GUI + Visual Test Recorder.
+ Check it out! +
+
+
+
@@ -159,7 +240,7 @@

How it works

diff --git a/src/client/browser/idle-page/index.js b/src/client/browser/idle-page/index.js index 0b0e4946..b9150651 100644 --- a/src/client/browser/idle-page/index.js +++ b/src/client/browser/idle-page/index.js @@ -5,14 +5,17 @@ import COMMAND from '../../../browser/connection/command'; const CHECK_STATUS_DELAY = 1000; -var createXHR = () => new XMLHttpRequest(); +const createXHR = () => new XMLHttpRequest(); class IdlePage { - constructor (statusUrl, heartbeatUrl, initScriptUrl) { + constructor (statusUrl, heartbeatUrl, initScriptUrl, options = {}) { this.statusUrl = statusUrl; this.statusIndicator = new StatusIndicator(); + if (options.retryTestPages) + browser.enableRetryingTestPages(); + browser.startHeartbeat(heartbeatUrl, createXHR); browser.startInitScriptExecution(initScriptUrl, createXHR); diff --git a/src/client/browser/idle-page/status-indicator.js b/src/client/browser/idle-page/status-indicator.js index 1fed088f..799f4708 100644 --- a/src/client/browser/idle-page/status-indicator.js +++ b/src/client/browser/idle-page/status-indicator.js @@ -38,7 +38,7 @@ function convertToString (value) { } function rotateAxes (point, rotationAngle) { - var angle = convertToRadian(rotationAngle); + const angle = convertToRadian(rotationAngle); return { x: Math.round(point.x * Math.cos(angle) - point.y * Math.sin(angle)), @@ -87,7 +87,7 @@ export default class StatusIndicator { } static _createStatusMessage (connected) { - var statusSpan = StatusIndicator._getStatusElementSpan(); + const statusSpan = StatusIndicator._getStatusElementSpan(); // eslint-disable-next-line no-restricted-properties statusSpan.textContent = connected ? CONNECTED_TEXT : DISCONNECTED_TEXT; @@ -95,10 +95,10 @@ export default class StatusIndicator { } static _alignContainerVertically () { - var background = document.getElementsByClassName(PAGE_BACKGROUND_CLASS_NAME)[0]; - var container = StatusIndicator._getContainer(); + const background = document.getElementsByClassName(PAGE_BACKGROUND_CLASS_NAME)[0]; + const container = StatusIndicator._getContainer(); - var topMargin = Math.ceil((background.offsetHeight - container.offsetHeight) / 2); + const topMargin = Math.ceil((background.offsetHeight - container.offsetHeight) / 2); if (topMargin > 0) container.style.marginTop = convertToString(topMargin); @@ -106,10 +106,10 @@ export default class StatusIndicator { _setSize () { - var documentElement = window.document.documentElement; - var minResolution = Math.min(documentElement.clientWidth, documentElement.clientHeight); - var container = StatusIndicator._getContainer(); - var newSize = Math.round(Math.min(MAXIMUM_SPINNER_SIZE, minResolution * RELATED_SPINNER_SIZE)); + const documentElement = window.document.documentElement; + const minResolution = Math.min(documentElement.clientWidth, documentElement.clientHeight); + const container = StatusIndicator._getContainer(); + const newSize = Math.round(Math.min(MAXIMUM_SPINNER_SIZE, minResolution * RELATED_SPINNER_SIZE)); if (newSize === this.size) return; @@ -124,15 +124,15 @@ export default class StatusIndicator { } _setFontSize () { - var userAgentSpan = document.getElementsByClassName(USER_AGENT_ELEMENT_CLASS_NAME)[0].children[0]; - var statusSpan = StatusIndicator._getStatusElementSpan(); + const userAgentSpan = document.getElementsByClassName(USER_AGENT_ELEMENT_CLASS_NAME)[0].children[0]; + const statusSpan = StatusIndicator._getStatusElementSpan(); // NOTE: We have established proportions for two edge cases: // the maximum spinner size of 400px corresponds to the 16px font, // the minimum spinner size of 240px corresponds to the 11px font. // Actual sizes are calculated from these proportions. - var fontSize = Math.round(FONT_SIZE_EQUATION_SLOPE * this.size + FONT_SIZE_EQUATION_Y_INTERCEPT); - var lineHeight = fontSize + LINE_HEIGHT_INDENT; + const fontSize = Math.round(FONT_SIZE_EQUATION_SLOPE * this.size + FONT_SIZE_EQUATION_Y_INTERCEPT); + const lineHeight = fontSize + LINE_HEIGHT_INDENT; userAgentSpan.style.fontSize = convertToString(fontSize); userAgentSpan.style.lineHeight = convertToString(lineHeight); @@ -144,7 +144,7 @@ export default class StatusIndicator { _watchWindowResize () { window.onresize = () => { - var oldSize = this.size; + const oldSize = this.size; this._setSize(); this._setFontSize(); @@ -180,7 +180,7 @@ export default class StatusIndicator { } _drawCircle (strokeStyle, centralAngle, startAngle) { - var radius = this.spinnerCenter - SPINNER_WIDTH / 2; + const radius = this.spinnerCenter - SPINNER_WIDTH / 2; this.canvasContext.beginPath(); @@ -204,7 +204,7 @@ export default class StatusIndicator { } _getRotatedGradientPoints (point) { - var changedPoint = moveAxes(point, this.spinnerCenter); + let changedPoint = moveAxes(point, this.spinnerCenter); changedPoint = rotateAxes(changedPoint, this.rotationAngle); changedPoint = moveAxes(changedPoint, -this.spinnerCenter); @@ -213,12 +213,12 @@ export default class StatusIndicator { } _setSpinnerGradient () { - var startGradientPoint = { + let startGradientPoint = { x: Math.round(this.size * START_GRADIENT_POINT_OFFSET.x), y: Math.round(this.size * START_GRADIENT_POINT_OFFSET.y) }; - var endGradientPoint = { + let endGradientPoint = { x: Math.round(this.size * END_GRADIENT_POINT_OFFSET.x), y: Math.round(this.size * END_GRADIENT_POINT_OFFSET.y) }; @@ -228,7 +228,7 @@ export default class StatusIndicator { endGradientPoint = this._getRotatedGradientPoints(endGradientPoint); } - var gradient = this.canvasContext.createLinearGradient(startGradientPoint.x, startGradientPoint.y, + const gradient = this.canvasContext.createLinearGradient(startGradientPoint.x, startGradientPoint.y, endGradientPoint.x, endGradientPoint.y); gradient.addColorStop(0, CONNECTED_SPINNER_COLOR); diff --git a/src/client/browser/index.js b/src/client/browser/index.js index 3e7a5342..79af664f 100644 --- a/src/client/browser/index.js +++ b/src/client/browser/index.js @@ -2,24 +2,43 @@ import Promise from 'pinkie'; import COMMAND from '../../browser/connection/command'; import STATUS from '../../browser/connection/status'; +import { UNSTABLE_NETWORK_MODE_HEADER } from '../../browser/connection/unstable-network-mode'; const HEARTBEAT_INTERVAL = 2 * 1000; -var allowInitScriptExecution = false; +let allowInitScriptExecution = false; +let retryTestPages = false; + +const noop = () => void 0; +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +const FETCH_PAGE_TO_CACHE_RETRY_DELAY = 300; +const FETCH_PAGE_TO_CACHE_RETRY_COUNT = 5; //Utils // NOTE: the window.XMLHttpRequest may have been wrapped by Hammerhead, while we should send a request to // the original URL. That's why we need the XMLHttpRequest argument to send the request via native methods. -function sendXHR (url, createXHR, method = 'GET', data = null) { +export function sendXHR (url, createXHR, { method = 'GET', data = null, parseResponse = true } = {}) { return new Promise((resolve, reject) => { - var xhr = createXHR(); + const xhr = createXHR(); xhr.open(method, url, true); + if (isRetryingTestPagesEnabled()) { + xhr.setRequestHeader(UNSTABLE_NETWORK_MODE_HEADER, 'true'); + xhr.setRequestHeader('accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'); + } + xhr.onreadystatechange = () => { if (xhr.readyState === 4) { - if (xhr.status === 200) - resolve(xhr.responseText ? JSON.parse(xhr.responseText) : ''); //eslint-disable-line no-restricted-globals + if (xhr.status === 200) { + let responseText = xhr.responseText || ''; + + if (responseText && parseResponse) + responseText = JSON.parse(xhr.responseText); //eslint-disable-line no-restricted-globals + + resolve(responseText); + } else reject('disconnected'); } @@ -63,7 +82,7 @@ function executeInitScript (initScriptUrl, createXHR) { return null; /* eslint-disable no-eval, no-restricted-globals*/ - return sendXHR(initScriptUrl, createXHR, 'POST', JSON.stringify(eval(res.code))); + return sendXHR(initScriptUrl, createXHR, { method: 'POST', data: JSON.stringify(eval(res.code)) }); /* eslint-enable no-eval, no-restricted-globals */ }) .then(() => { @@ -86,17 +105,48 @@ export function redirect (command) { document.location = command.url; } +export function fetchPageToCache (pageUrl, createXHR) { + const requestAttempt = () => sendXHR(pageUrl, createXHR, { parseResponse: false }); + const retryRequest = () => delay(FETCH_PAGE_TO_CACHE_RETRY_DELAY).then(requestAttempt); + + let fetchPagePromise = requestAttempt(); + + for (let i = 0; i < FETCH_PAGE_TO_CACHE_RETRY_COUNT; i++) + fetchPagePromise = fetchPagePromise.catch(retryRequest); + + return fetchPagePromise.catch(noop); +} + export function checkStatus (statusUrl, createXHR, opts) { const { manualRedirect } = opts || {}; return sendXHR(statusUrl, createXHR) - .then(res => { - const redirecting = (res.cmd === COMMAND.run || res.cmd === COMMAND.idle) && !isCurrentLocation(res.url); + .then(result => { + let ensurePagePromise = Promise.resolve(); + + if (result.url && isRetryingTestPagesEnabled()) + ensurePagePromise = fetchPageToCache(result.url, createXHR); + + return ensurePagePromise.then(() => result); + }) + .then(result => { + const redirecting = (result.cmd === COMMAND.run || result.cmd === COMMAND.idle) && !isCurrentLocation(result.url); if (redirecting && !manualRedirect) - redirect(res); + redirect(result); - return { command: res, redirecting }; + return { command: result, redirecting }; }); } +export function enableRetryingTestPages () { + retryTestPages = true; +} + +export function disableRetryingTestPages () { + retryTestPages = false; +} + +export function isRetryingTestPagesEnabled () { + return retryTestPages; +} diff --git a/src/client/core/index.js b/src/client/core/index.js index f3f5a194..049d3b48 100644 --- a/src/client/core/index.js +++ b/src/client/core/index.js @@ -18,6 +18,7 @@ import * as promiseUtils from './utils/promise'; import * as textSelection from './utils/text-selection'; import waitFor from './utils/wait-for'; import delay from './utils/delay'; +import getTimeLimitedPromise from './utils/get-time-limited-promise'; import noop from './utils/noop'; import getKeyArray from './utils/get-key-array'; import getSanitizedKey from './utils/get-sanitized-key'; @@ -46,6 +47,7 @@ exports.promiseUtils = promiseUtils; exports.textSelection = textSelection; exports.waitFor = waitFor; exports.delay = delay; +exports.getTimeLimitedPromise = getTimeLimitedPromise; exports.noop = noop; exports.getKeyArray = getKeyArray; exports.getSanitizedKey = getSanitizedKey; diff --git a/src/client/core/page-unload-barrier.js b/src/client/core/page-unload-barrier.js index 4d5e3d4a..8ed4da36 100644 --- a/src/client/core/page-unload-barrier.js +++ b/src/client/core/page-unload-barrier.js @@ -4,10 +4,10 @@ import delay from './utils/delay'; import MESSAGE from '../../test-run/client-messages'; import { isAnchorElement } from './utils/dom'; -var Promise = hammerhead.Promise; -var browserUtils = hammerhead.utils.browser; -var nativeMethods = hammerhead.nativeMethods; -var transport = hammerhead.transport; +const Promise = hammerhead.Promise; +const browserUtils = hammerhead.utils.browser; +const nativeMethods = hammerhead.nativeMethods; +const transport = hammerhead.transport; const DEFAULT_BARRIER_TIMEOUT = 400; @@ -16,13 +16,13 @@ const FILE_DOWNLOAD_CHECK_DELAY = 500; const MAX_UNLOADING_TIMEOUT = 15 * 1000; -var waitingForUnload = false; -var waitingForUnloadTimeoutId = null; -var waitingPromiseResolvers = []; -var unloading = false; +let waitingForUnload = false; +let waitingForUnloadTimeoutId = null; +let waitingPromiseResolvers = []; +let unloading = false; -var pageNavigationTriggeredListener = null; -var pageNavigationTriggered = false; +let pageNavigationTriggeredListener = null; +let pageNavigationTriggered = false; function onBeforeUnload () { if (!browserUtils.isIE) { @@ -36,7 +36,7 @@ function onBeforeUnload () { .then(() => { // NOTE: except file downloading if (document.readyState === 'loading') { - var activeElement = nativeMethods.documentActiveElementGetter.call(document); + const activeElement = nativeMethods.documentActiveElementGetter.call(document); if (!activeElement || !isAnchorElement(activeElement) || !activeElement.hasAttribute('download')) unloading = true; @@ -93,7 +93,7 @@ export function watchForPageNavigationTriggers () { } export function wait (timeout) { - var waitForUnloadingPromise = new Promise(resolve => { + const waitForUnloadingPromise = new Promise(resolve => { if (timeout === void 0) timeout = !pageNavigationTriggeredListener || pageNavigationTriggered ? DEFAULT_BARRIER_TIMEOUT : 0; @@ -125,7 +125,7 @@ export function wait (timeout) { // fires (see issues #664, #437). To avoid test hanging, we resolve the unload // barrier waiting promise in MAX_UNLOADING_TIMEOUT. We can improve this logic when // the https://github.com/DevExpress/testcafe-hammerhead/issues/667 issue is fixed. - var watchdog = delay(MAX_UNLOADING_TIMEOUT) + const watchdog = delay(MAX_UNLOADING_TIMEOUT) .then(() => { unloading = false; }); diff --git a/src/client/core/prevent-real-events.js b/src/client/core/prevent-real-events.js index bfb04744..9b690678 100644 --- a/src/client/core/prevent-real-events.js +++ b/src/client/core/prevent-real-events.js @@ -6,9 +6,9 @@ import { get, hasDimensions } from './utils/style'; import { filter } from './utils/array'; import { isShadowUIElement, isWindow, getParents } from './utils/dom'; -var browserUtils = utils.browser; -var listeners = eventSandbox.listeners; -var eventSimulator = eventSandbox.eventSimulator; +const browserUtils = utils.browser; +const listeners = eventSandbox.listeners; +const eventSimulator = eventSandbox.eventSimulator; const PREVENTED_EVENTS = [ 'click', 'mousedown', 'mouseup', 'dblclick', 'contextmenu', 'mousemove', 'mouseover', 'mouseout', @@ -29,7 +29,7 @@ function checkBrowserHotkey (e) { // NOTE: when tests are running, we should block real events (from mouse // or keyboard), because they may lead to unexpected test result. function preventRealEventHandler (e, dispatched, preventDefault, cancelHandlers, stopEventPropagation) { - var target = e.target || e.srcElement; + const target = e.target || e.srcElement; if (!dispatched && !isShadowUIElement(target)) { // NOTE: this will allow pressing hotkeys to open developer tools. @@ -44,9 +44,9 @@ function preventRealEventHandler (e, dispatched, preventDefault, cancelHandlers, // invisible don't lead to blurring (in MSEdge, focus/blur are sync). if (e.type === 'blur') { if (browserUtils.isIE && browserUtils.version < 12) { - var isElementInvisible = !isWindow(target) && get(target, 'display') === 'none'; - var elementParents = null; - var invisibleParents = false; + const isElementInvisible = !isWindow(target) && get(target, 'display') === 'none'; + let elementParents = null; + let invisibleParents = false; if (!isElementInvisible) { elementParents = getParents(target); diff --git a/src/client/core/request-barrier.js b/src/client/core/request-barrier.js index abbd57eb..69cba02c 100644 --- a/src/client/core/request-barrier.js +++ b/src/client/core/request-barrier.js @@ -3,8 +3,8 @@ import delay from './utils/delay'; import { remove as removeItem, indexOf } from './utils/array'; import { EventEmitter } from './utils/service'; -var Promise = hammerhead.Promise; -var nativeMethods = hammerhead.nativeMethods; +const Promise = hammerhead.Promise; +const nativeMethods = hammerhead.nativeMethods; const REQUESTS_COLLECTION_DELAY_DEFAULT = 50; @@ -39,10 +39,10 @@ export default class RequestBarrier extends EventEmitter { } _init () { - var onXhrSend = e => this._onXhrSend(e.xhr); - var onXhrCompleted = e => this._onRequestCompleted(e.xhr); - var onXhrError = e => this._onRequestError(e.xhr, e.err); - var onFetchSend = e => this._onFetchSend(e); + const onXhrSend = e => this._onXhrSend(e.xhr); + const onXhrCompleted = e => this._onRequestCompleted(e.xhr); + const onXhrError = e => this._onRequestError(e.xhr, e.err); + const onFetchSend = e => this._onFetchSend(e); hammerhead.on(hammerhead.EVENTS.beforeXhrSend, onXhrSend); hammerhead.on(hammerhead.EVENTS.xhrCompleted, onXhrCompleted); @@ -98,7 +98,7 @@ export default class RequestBarrier extends EventEmitter { .then(() => { this.collectingReqs = false; - var onRequestsFinished = () => { + const onRequestsFinished = () => { if (this.watchdog) nativeMethods.clearTimeout.call(window, this.watchdog); diff --git a/src/client/core/scroll-controller.js b/src/client/core/scroll-controller.js index cda2b360..8b37ec4b 100644 --- a/src/client/core/scroll-controller.js +++ b/src/client/core/scroll-controller.js @@ -32,9 +32,9 @@ class ScrollController { } waitForScroll () { - var promiseResolver = null; + let promiseResolver = null; - var promise = new Promise(resolve => { + const promise = new Promise(resolve => { promiseResolver = resolve; }); diff --git a/src/client/core/utils/array.js b/src/client/core/utils/array.js index 1d21c85c..1baeca1e 100644 --- a/src/client/core/utils/array.js +++ b/src/client/core/utils/array.js @@ -1,19 +1,19 @@ import hammerhead from '../deps/hammerhead'; -var nativeIndexOf = Array.prototype.indexOf; -var nativeForEach = Array.prototype.forEach; -var nativeSome = Array.prototype.some; -var nativeMap = Array.prototype.map; -var nativeFilter = Array.prototype.filter; -var nativeReverse = Array.prototype.reverse; -var nativeReduce = Array.prototype.reduce; -var nativeSplice = Array.prototype.splice; +const nativeIndexOf = Array.prototype.indexOf; +const nativeForEach = Array.prototype.forEach; +const nativeSome = Array.prototype.some; +const nativeMap = Array.prototype.map; +const nativeFilter = Array.prototype.filter; +const nativeReverse = Array.prototype.reverse; +const nativeReduce = Array.prototype.reduce; +const nativeSplice = Array.prototype.splice; export function toArray (arg) { - var arr = []; - var length = arg.length; + const arr = []; + const length = arg.length; - for (var i = 0; i < length; i++) + for (let i = 0; i < length; i++) arr.push(arg[i]); return arr; @@ -28,9 +28,9 @@ export function isArray (arg) { } export function find (arr, callback) { - var length = arr.length; + const length = arr.length; - for (var i = 0; i < length; i++) { + for (let i = 0; i < length; i++) { if (callback(arr[i], i, arr)) return arr[i]; } @@ -63,7 +63,7 @@ export function reduce (arr, callback, initialValue) { } export function remove (arr, item) { - var index = indexOf(arr, item); + const index = indexOf(arr, item); if (index > -1) nativeSplice.call(arr, index, 1); @@ -73,7 +73,7 @@ export function equals (arr1, arr2) { if (arr1.length !== arr2.length) return false; - for (var i = 0, l = arr1.length; i < l; i++) { + for (let i = 0, l = arr1.length; i < l; i++) { if (arr1[i] !== arr2[i]) return false; } @@ -82,8 +82,8 @@ export function equals (arr1, arr2) { } export function getCommonElement (arr1, arr2) { - for (var i = 0; i < arr1.length; i++) { - for (var t = 0; t < arr2.length; t++) { + for (let i = 0; i < arr1.length; i++) { + for (let t = 0; t < arr2.length; t++) { if (arr1[i] === arr2[t]) return arr1[i]; } diff --git a/src/client/core/utils/content-editable.js b/src/client/core/utils/content-editable.js index 6bb63b3b..9babbb01 100644 --- a/src/client/core/utils/content-editable.js +++ b/src/client/core/utils/content-editable.js @@ -4,8 +4,8 @@ import * as styleUtils from './style'; //nodes utils function getOwnFirstVisibleTextNode (el) { - var children = el.childNodes; - var childrenLength = domUtils.getChildNodesLength(children); + const children = el.childNodes; + const childrenLength = domUtils.getChildNodesLength(children); if (!childrenLength && isVisibleTextNode(el)) return el; @@ -19,8 +19,8 @@ function getOwnFirstVisibleNode (el) { } function getOwnPreviousVisibleSibling (el) { - var sibling = null; - var current = el; + let sibling = null; + let current = el; while (!sibling) { current = current.previousSibling; @@ -55,8 +55,8 @@ function hasSelectableChildren (node) { // this line breaks is not contained in node values //so we should take it into account manually function isNodeBlockWithBreakLine (parent, node) { - var parentFirstVisibleChild = null; - var firstVisibleChild = null; + let parentFirstVisibleChild = null; + let firstVisibleChild = null; if (domUtils.isShadowUIElement(parent) || domUtils.isShadowUIElement(node)) return false; @@ -78,10 +78,10 @@ function isNodeBlockWithBreakLine (parent, node) { } function isNodeAfterNodeBlockWithBreakLine (parent, node) { - var isRenderedNode = domUtils.isRenderedNode(node); - var parentFirstVisibleChild = null; - var firstVisibleChild = null; - var previousSibling = null; + const isRenderedNode = domUtils.isRenderedNode(node); + let parentFirstVisibleChild = null; + let firstVisibleChild = null; + let previousSibling = null; if (domUtils.isShadowUIElement(parent) || domUtils.isShadowUIElement(node)) return false; @@ -111,17 +111,17 @@ function isNodeAfterNodeBlockWithBreakLine (parent, node) { } function getFirstTextNode (el, onlyVisible) { - var children = el.childNodes; - var childrenLength = domUtils.getChildNodesLength(children); - var curNode = null; - var child = null; - var isNotContentEditableElement = null; - var checkTextNode = onlyVisible ? isVisibleTextNode : domUtils.isTextNode; + const children = el.childNodes; + const childrenLength = domUtils.getChildNodesLength(children); + let curNode = null; + let child = null; + let isNotContentEditableElement = null; + const checkTextNode = onlyVisible ? isVisibleTextNode : domUtils.isTextNode; if (!childrenLength && checkTextNode(el)) return el; - for (var i = 0; i < childrenLength; i++) { + for (let i = 0; i < childrenLength; i++) { curNode = children[i]; isNotContentEditableElement = domUtils.isElementNode(curNode) && !domUtils.isContentEditableElement(curNode); @@ -143,17 +143,17 @@ export function getFirstVisibleTextNode (el) { } export function getLastTextNode (el, onlyVisible) { - var children = el.childNodes; - var childrenLength = domUtils.getChildNodesLength(children); - var curNode = null; - var child = null; - var isNotContentEditableElement = null; - var visibleTextNode = null; + const children = el.childNodes; + const childrenLength = domUtils.getChildNodesLength(children); + let curNode = null; + let child = null; + let isNotContentEditableElement = null; + let visibleTextNode = null; if (!childrenLength && isVisibleTextNode(el)) return el; - for (var i = childrenLength - 1; i >= 0; i--) { + for (let i = childrenLength - 1; i >= 0; i--) { curNode = children[i]; isNotContentEditableElement = domUtils.isElementNode(curNode) && !domUtils.isContentEditableElement(curNode); visibleTextNode = domUtils.isTextNode(curNode) && @@ -176,10 +176,10 @@ export function getFirstNonWhitespaceSymbolIndex (nodeValue, startFrom) { if (!nodeValue || !nodeValue.length) return 0; - var valueLength = nodeValue.length; - var index = startFrom || 0; + const valueLength = nodeValue.length; + let index = startFrom || 0; - for (var i = index; i < valueLength; i++) { + for (let i = index; i < valueLength; i++) { if (nodeValue.charCodeAt(i) === 10 || nodeValue.charCodeAt(i) === 32) index++; else @@ -192,10 +192,10 @@ export function getLastNonWhitespaceSymbolIndex (nodeValue) { if (!nodeValue || !nodeValue.length) return 0; - var valueLength = nodeValue.length; - var index = valueLength; + const valueLength = nodeValue.length; + let index = valueLength; - for (var i = valueLength - 1; i >= 0; i--) { + for (let i = valueLength - 1; i >= 0; i--) { if (nodeValue.charCodeAt(i) === 10 || nodeValue.charCodeAt(i) === 32) index--; else @@ -208,9 +208,9 @@ export function isInvisibleTextNode (node) { if (!domUtils.isTextNode(node)) return false; - var nodeValue = node.nodeValue; - var firstVisibleIndex = getFirstNonWhitespaceSymbolIndex(nodeValue); - var lastVisibleIndex = getLastNonWhitespaceSymbolIndex(nodeValue); + const nodeValue = node.nodeValue; + const firstVisibleIndex = getFirstNonWhitespaceSymbolIndex(nodeValue); + const lastVisibleIndex = getLastNonWhitespaceSymbolIndex(nodeValue); return firstVisibleIndex === nodeValue.length && lastVisibleIndex === 0; } @@ -225,18 +225,18 @@ function isSkippableNode (node) { //dom utils function hasContentEditableAttr (el) { - var attrValue = el.getAttribute ? el.getAttribute('contenteditable') : null; + const attrValue = el.getAttribute ? el.getAttribute('contenteditable') : null; return attrValue === '' || attrValue === 'true'; } export function findContentEditableParent (element) { - var elParents = domUtils.getParents(element); + const elParents = domUtils.getParents(element); if (hasContentEditableAttr(element) && domUtils.isContentEditableElement(element)) return element; - var currentDocument = domUtils.findDocument(element); + const currentDocument = domUtils.findDocument(element); if (currentDocument.designMode === 'on') return currentDocument.body; @@ -252,9 +252,9 @@ export function getNearestCommonAncestor (node1, node2) { return node1.parentNode; } - var ancestors = []; - var contentEditableParent = findContentEditableParent(node1); - var curNode = null; + const ancestors = []; + const contentEditableParent = findContentEditableParent(node1); + let curNode = null; if (!domUtils.isElementContainsNode(contentEditableParent, node2)) return null; @@ -272,10 +272,10 @@ export function getNearestCommonAncestor (node1, node2) { //selection utils function getSelectedPositionInParentByOffset (node, offset) { - var currentNode = null; - var currentOffset = null; - var childCount = domUtils.getChildNodesLength(node.childNodes); - var isSearchForLastChild = offset >= childCount; + let currentNode = null; + let currentOffset = null; + const childCount = domUtils.getChildNodesLength(node.childNodes); + let isSearchForLastChild = offset >= childCount; // NOTE: we get a child element by its offset index in the parent if (domUtils.isShadowUIElement(node)) @@ -327,10 +327,10 @@ function getSelectedPositionInParentByOffset (node, offset) { } function getSelectionStart (el, selection, inverseSelection) { - var startNode = inverseSelection ? selection.focusNode : selection.anchorNode; - var startOffset = inverseSelection ? selection.focusOffset : selection.anchorOffset; + const startNode = inverseSelection ? selection.focusNode : selection.anchorNode; + const startOffset = inverseSelection ? selection.focusOffset : selection.anchorOffset; - var correctedStartPosition = { + let correctedStartPosition = { node: startNode, offset: startOffset }; @@ -346,10 +346,10 @@ function getSelectionStart (el, selection, inverseSelection) { } function getSelectionEnd (el, selection, inverseSelection) { - var endNode = inverseSelection ? selection.anchorNode : selection.focusNode; - var endOffset = inverseSelection ? selection.anchorOffset : selection.focusOffset; + const endNode = inverseSelection ? selection.anchorNode : selection.focusNode; + const endOffset = inverseSelection ? selection.anchorOffset : selection.focusOffset; - var correctedEndPosition = { + let correctedEndPosition = { node: endNode, offset: endOffset }; @@ -372,13 +372,13 @@ export function getSelection (el, selection, inverseSelection) { } export function getSelectionStartPosition (el, selection, inverseSelection) { - var correctedSelectionStart = getSelectionStart(el, selection, inverseSelection); + const correctedSelectionStart = getSelectionStart(el, selection, inverseSelection); return calculatePositionByNodeAndOffset(el, correctedSelectionStart); } export function getSelectionEndPosition (el, selection, inverseSelection) { - var correctedSelectionEnd = getSelectionEnd(el, selection, inverseSelection); + const correctedSelectionEnd = getSelectionEnd(el, selection, inverseSelection); return calculatePositionByNodeAndOffset(el, correctedSelectionEnd); } @@ -415,14 +415,14 @@ function isNodeSelectable (node, includeDescendants) { } export function calculateNodeAndOffsetByPosition (el, offset) { - var point = { + let point = { node: null, offset: offset }; function checkChildNodes (target) { - var childNodes = target.childNodes; - var childNodesLength = domUtils.getChildNodesLength(childNodes); + const childNodes = target.childNodes; + const childNodesLength = domUtils.getChildNodesLength(childNodes); if (point.node) return point; @@ -469,12 +469,12 @@ export function calculateNodeAndOffsetByPosition (el, offset) { } export function calculatePositionByNodeAndOffset (el, { node, offset }) { - var currentOffset = 0; - var find = false; + let currentOffset = 0; + let find = false; function checkChildNodes (target) { - var childNodes = target.childNodes; - var childNodesLength = domUtils.getChildNodesLength(childNodes); + const childNodes = target.childNodes; + const childNodesLength = domUtils.getChildNodesLength(childNodes); if (find) return currentOffset; @@ -514,7 +514,7 @@ export function calculatePositionByNodeAndOffset (el, { node, offset }) { } export function getElementBySelection (selection) { - var el = getNearestCommonAncestor(selection.anchorNode, selection.focusNode); + const el = getNearestCommonAncestor(selection.anchorNode, selection.focusNode); return domUtils.isTextNode(el) ? el.parentElement : el; } @@ -523,9 +523,9 @@ export function getElementBySelection (selection) { // so we should create a range and select all text contents of the node. // Then range object will contain information about node's the first and last visible symbol. export function getFirstVisiblePosition (el) { - var firstVisibleTextChild = domUtils.isTextNode(el) ? el : getFirstVisibleTextNode(el); - var curDocument = domUtils.findDocument(el); - var range = curDocument.createRange(); + const firstVisibleTextChild = domUtils.isTextNode(el) ? el : getFirstVisibleTextNode(el); + const curDocument = domUtils.findDocument(el); + const range = curDocument.createRange(); if (firstVisibleTextChild) { range.selectNodeContents(firstVisibleTextChild); @@ -537,13 +537,13 @@ export function getFirstVisiblePosition (el) { } export function getLastVisiblePosition (el) { - var lastVisibleTextChild = domUtils.isTextNode(el) ? el : getLastTextNode(el, true); + const lastVisibleTextChild = domUtils.isTextNode(el) ? el : getLastTextNode(el, true); if (!lastVisibleTextChild || isResetAnchorOffsetRequired(lastVisibleTextChild, el)) return 0; - var curDocument = domUtils.findDocument(el); - var range = curDocument.createRange(); + const curDocument = domUtils.findDocument(el); + const range = curDocument.createRange(); range.selectNodeContents(lastVisibleTextChild); @@ -572,9 +572,9 @@ function hasWhiteSpacePreStyle (el, container) { } function getContentEditableNodes (target) { - var result = []; - var childNodes = target.childNodes; - var childNodesLength = domUtils.getChildNodesLength(childNodes); + let result = []; + const childNodes = target.childNodes; + const childNodesLength = domUtils.getChildNodesLength(childNodes); if (!isSkippableNode(target) && !childNodesLength && domUtils.isTextNode(target)) result.push(target); diff --git a/src/client/core/utils/delay.js b/src/client/core/utils/delay.js index 5ccb580d..70d433ae 100644 --- a/src/client/core/utils/delay.js +++ b/src/client/core/utils/delay.js @@ -1,7 +1,7 @@ import hammerhead from '../deps/hammerhead'; -var Promise = hammerhead.Promise; -var nativeMethods = hammerhead.nativeMethods; +const Promise = hammerhead.Promise; +const nativeMethods = hammerhead.nativeMethods; export default function (ms) { diff --git a/src/client/core/utils/dom.js b/src/client/core/utils/dom.js index a070e100..9447a0fd 100644 --- a/src/client/core/utils/dom.js +++ b/src/client/core/utils/dom.js @@ -2,53 +2,54 @@ import hammerhead from '../deps/hammerhead'; import * as styleUtils from './style'; import * as arrayUtils from './array'; - -var browserUtils = hammerhead.utils.browser; -var nativeMethods = hammerhead.nativeMethods; - -export var getActiveElement = hammerhead.utils.dom.getActiveElement; -export var findDocument = hammerhead.utils.dom.findDocument; -export var isElementInDocument = hammerhead.utils.dom.isElementInDocument; -export var isElementInIframe = hammerhead.utils.dom.isElementInIframe; -export var getIframeByElement = hammerhead.utils.dom.getIframeByElement; -export var isCrossDomainWindows = hammerhead.utils.dom.isCrossDomainWindows; -export var getSelectParent = hammerhead.utils.dom.getSelectParent; -export var getChildVisibleIndex = hammerhead.utils.dom.getChildVisibleIndex; -export var getSelectVisibleChildren = hammerhead.utils.dom.getSelectVisibleChildren; -export var isElementNode = hammerhead.utils.dom.isElementNode; -export var isTextNode = hammerhead.utils.dom.isTextNode; -export var isRenderedNode = hammerhead.utils.dom.isRenderedNode; -export var isIframeElement = hammerhead.utils.dom.isIframeElement; -export var isInputElement = hammerhead.utils.dom.isInputElement; -export var isButtonElement = hammerhead.utils.dom.isButtonElement; -export var isFileInput = hammerhead.utils.dom.isFileInput; -export var isTextAreaElement = hammerhead.utils.dom.isTextAreaElement; -export var isAnchorElement = hammerhead.utils.dom.isAnchorElement; -export var isImgElement = hammerhead.utils.dom.isImgElement; -export var isFormElement = hammerhead.utils.dom.isFormElement; -export var isSelectElement = hammerhead.utils.dom.isSelectElement; -export var isOptionElement = hammerhead.utils.dom.isOptionElement; -export var isSVGElement = hammerhead.utils.dom.isSVGElement; -export var isMapElement = hammerhead.utils.dom.isMapElement; -export var isBodyElement = hammerhead.utils.dom.isBodyElement; -export var isHtmlElement = hammerhead.utils.dom.isHtmlElement; -export var isDocument = hammerhead.utils.dom.isDocument; -export var isWindow = hammerhead.utils.dom.isWindow; -export var isTextEditableInput = hammerhead.utils.dom.isTextEditableInput; -export var isTextEditableElement = hammerhead.utils.dom.isTextEditableElement; -export var isTextEditableElementAndEditingAllowed = hammerhead.utils.dom.isTextEditableElementAndEditingAllowed; -export var isContentEditableElement = hammerhead.utils.dom.isContentEditableElement; -export var isDomElement = hammerhead.utils.dom.isDomElement; -export var isShadowUIElement = hammerhead.utils.dom.isShadowUIElement; -export var isElementFocusable = hammerhead.utils.dom.isElementFocusable; -export var isHammerheadAttr = hammerhead.utils.dom.isHammerheadAttr; -export var isElementReadOnly = hammerhead.utils.dom.isElementReadOnly; -export var getScrollbarSize = hammerhead.utils.dom.getScrollbarSize; -export var getMapContainer = hammerhead.utils.dom.getMapContainer; -export var getTagName = hammerhead.utils.dom.getTagName; -export var closest = hammerhead.utils.dom.closest; -export var getParents = hammerhead.utils.dom.getParents; -export var getTopSameDomainWindow = hammerhead.utils.dom.getTopSameDomainWindow; +const browserUtils = hammerhead.utils.browser; +const nativeMethods = hammerhead.nativeMethods; + +export const getActiveElement = hammerhead.utils.dom.getActiveElement; +export const findDocument = hammerhead.utils.dom.findDocument; +export const isElementInDocument = hammerhead.utils.dom.isElementInDocument; +export const isElementInIframe = hammerhead.utils.dom.isElementInIframe; +export const getIframeByElement = hammerhead.utils.dom.getIframeByElement; +export const isCrossDomainWindows = hammerhead.utils.dom.isCrossDomainWindows; +export const getSelectParent = hammerhead.utils.dom.getSelectParent; +export const getChildVisibleIndex = hammerhead.utils.dom.getChildVisibleIndex; +export const getSelectVisibleChildren = hammerhead.utils.dom.getSelectVisibleChildren; +export const isElementNode = hammerhead.utils.dom.isElementNode; +export const isTextNode = hammerhead.utils.dom.isTextNode; +export const isRenderedNode = hammerhead.utils.dom.isRenderedNode; +export const isIframeElement = hammerhead.utils.dom.isIframeElement; +export const isInputElement = hammerhead.utils.dom.isInputElement; +export const isButtonElement = hammerhead.utils.dom.isButtonElement; +export const isFileInput = hammerhead.utils.dom.isFileInput; +export const isTextAreaElement = hammerhead.utils.dom.isTextAreaElement; +export const isAnchorElement = hammerhead.utils.dom.isAnchorElement; +export const isImgElement = hammerhead.utils.dom.isImgElement; +export const isFormElement = hammerhead.utils.dom.isFormElement; +export const isSelectElement = hammerhead.utils.dom.isSelectElement; +export const isOptionElement = hammerhead.utils.dom.isOptionElement; +export const isSVGElement = hammerhead.utils.dom.isSVGElement; +export const isMapElement = hammerhead.utils.dom.isMapElement; +export const isBodyElement = hammerhead.utils.dom.isBodyElement; +export const isHtmlElement = hammerhead.utils.dom.isHtmlElement; +export const isDocument = hammerhead.utils.dom.isDocument; +export const isWindow = hammerhead.utils.dom.isWindow; +export const isTextEditableInput = hammerhead.utils.dom.isTextEditableInput; +export const isTextEditableElement = hammerhead.utils.dom.isTextEditableElement; +export const isTextEditableElementAndEditingAllowed = hammerhead.utils.dom.isTextEditableElementAndEditingAllowed; +export const isContentEditableElement = hammerhead.utils.dom.isContentEditableElement; +export const isDomElement = hammerhead.utils.dom.isDomElement; +export const isShadowUIElement = hammerhead.utils.dom.isShadowUIElement; +export const isElementFocusable = hammerhead.utils.dom.isElementFocusable; +export const isHammerheadAttr = hammerhead.utils.dom.isHammerheadAttr; +export const isElementReadOnly = hammerhead.utils.dom.isElementReadOnly; +export const getScrollbarSize = hammerhead.utils.dom.getScrollbarSize; +export const getMapContainer = hammerhead.utils.dom.getMapContainer; +export const getTagName = hammerhead.utils.dom.getTagName; +export const closest = hammerhead.utils.dom.closest; +export const getParents = hammerhead.utils.dom.getParents; +export const getTopSameDomainWindow = hammerhead.utils.dom.getTopSameDomainWindow; + +export const isRadioButtonElement = el => isInputElement(el) && el.type === 'radio'; function getElementsWithTabIndex (elements) { return arrayUtils.filter(elements, el => el.tabIndex > 0); @@ -62,44 +63,44 @@ function sortElementsByFocusingIndex (elements) { if (!elements || !elements.length) return []; - var elementsWithTabIndex = getElementsWithTabIndex(elements); + let elementsWithTabIndex = getElementsWithTabIndex(elements); - //iFrames - var iFrames = arrayUtils.filter(elements, el => isIframeElement(el)); + //iframes + const iframes = arrayUtils.filter(elements, el => isIframeElement(el)); if (!elementsWithTabIndex.length) { - if (iFrames.length) - elements = insertIFramesContentElements(elements, iFrames); + if (iframes.length) + elements = insertIframesContentElements(elements, iframes); return elements; } - elementsWithTabIndex = elementsWithTabIndex.sort(sortBy('tabIndex')); - var elementsWithoutTabIndex = getElementsWithoutTabIndex(elements); + elementsWithTabIndex = elementsWithTabIndex.sort(sortBy('tabIndex')); + const elementsWithoutTabIndex = getElementsWithoutTabIndex(elements); - if (iFrames.length) - return insertIFramesContentElements(elementsWithTabIndex, iFrames).concat(insertIFramesContentElements(elementsWithoutTabIndex, iFrames)); + if (iframes.length) + return insertIframesContentElements(elementsWithTabIndex, iframes).concat(insertIframesContentElements(elementsWithoutTabIndex, iframes)); return elementsWithTabIndex.concat(elementsWithoutTabIndex); } -function insertIFramesContentElements (elements, iFrames) { - var sortedIFrames = sortElementsByTabIndex(iFrames); - var results = []; - var iFramesElements = []; - var iframeFocusedElements = []; - var i = 0; +function insertIframesContentElements (elements, iframes) { + const sortedIframes = sortElementsByTabIndex(iframes); + let results = []; + const iframesElements = []; + let iframeFocusedElements = []; + let i = 0; - for (i = 0; i < sortedIFrames.length; i++) { + for (i = 0; i < sortedIframes.length; i++) { //NOTE: We can get elements of the same domain iframe only try { - iframeFocusedElements = getFocusableElements(sortedIFrames[i].contentDocument); + iframeFocusedElements = getFocusableElements(sortedIframes[i].contentDocument); } catch (e) { iframeFocusedElements = []; } - iFramesElements.push(sortElementsByFocusingIndex(iframeFocusedElements)); + iframesElements.push(sortElementsByFocusingIndex(iframeFocusedElements)); } for (i = 0; i < elements.length; i++) { @@ -109,9 +110,9 @@ function insertIFramesContentElements (elements, iFrames) { if (browserUtils.isIE) { results.pop(); - var iFrameElements = iFramesElements[arrayUtils.indexOf(iFrames, elements[i])]; - var elementsWithTabIndex = getElementsWithTabIndex(iFrameElements); - var elementsWithoutTabIndexArray = getElementsWithoutTabIndex(iFrameElements); + const iFrameElements = iframesElements[arrayUtils.indexOf(iframes, elements[i])]; + let elementsWithTabIndex = getElementsWithTabIndex(iFrameElements); + const elementsWithoutTabIndexArray = getElementsWithoutTabIndex(iFrameElements); elementsWithTabIndex = elementsWithTabIndex.sort(sortBy('tabIndex')); results = results.concat(elementsWithTabIndex); @@ -119,10 +120,10 @@ function insertIFramesContentElements (elements, iFrames) { results = results.concat(elementsWithoutTabIndexArray); } else { - if (browserUtils.isWebKit && iFramesElements[arrayUtils.indexOf(iFrames, elements[i])].length) + if (browserUtils.isWebKit && iframesElements[arrayUtils.indexOf(iframes, elements[i])].length) results.pop(); - results = results.concat(iFramesElements[arrayUtils.indexOf(iFrames, elements[i])]); + results = results.concat(iframesElements[arrayUtils.indexOf(iframes, elements[i])]); } } } @@ -131,7 +132,7 @@ function insertIFramesContentElements (elements, iFrames) { } function sortElementsByTabIndex (elements) { - var elementsWithTabIndex = getElementsWithTabIndex(elements); + const elementsWithTabIndex = getElementsWithTabIndex(elements); if (!elementsWithTabIndex.length) return elements; @@ -150,20 +151,20 @@ function sortBy (property) { }; } -function getFocusableElements (doc) { +export function getFocusableElements (doc, sort = false) { // NOTE: We don't take into account the case of embedded contentEditable // elements and specify the contentEditable attribute for focusable elements - var allElements = doc.querySelectorAll('*'); - var invisibleElements = getInvisibleElements(allElements); - var inputElementsRegExp = /^(input|button|select|textarea)$/; - var focusableElements = []; - var element = null; - var tagName = null; - var tabIndex = null; + const allElements = doc.querySelectorAll('*'); + const invisibleElements = getInvisibleElements(allElements); + const inputElementsRegExp = /^(input|button|select|textarea)$/; + const focusableElements = []; + let element = null; + let tagName = null; + let tabIndex = null; - var needPush = false; + let needPush = false; - for (var i = 0; i < allElements.length; i++) { + for (let i = 0; i < allElements.length; i++) { element = allElements[i]; tagName = getTagName(element); tabIndex = getTabIndexAttributeIntValue(element); @@ -188,7 +189,7 @@ function getFocusableElements (doc) { else if (isAnchorElement(element) && element.hasAttribute('href')) needPush = element.getAttribute('href') !== '' || !browserUtils.isIE || tabIndex !== null; - var contentEditableAttr = element.getAttribute('contenteditable'); + const contentEditableAttr = element.getAttribute('contenteditable'); if (contentEditableAttr === '' || contentEditableAttr === 'true') needPush = true; @@ -201,13 +202,18 @@ function getFocusableElements (doc) { } //NOTE: remove children of invisible elements - return arrayUtils.filter(focusableElements, el => !containsElement(invisibleElements, el)); + let result = arrayUtils.filter(focusableElements, el => !containsElement(invisibleElements, el)); + + if (sort) + result = sortElementsByFocusingIndex(result); + + return result; } function getInvisibleElements (elements) { - var invisibleElements = []; + const invisibleElements = []; - for (var i = 0; i < elements.length; i++) { + for (let i = 0; i < elements.length; i++) { if (styleUtils.get(elements[i], 'display') === 'none') invisibleElements.push(elements[i]); } @@ -216,7 +222,7 @@ function getInvisibleElements (elements) { } function getTabIndexAttributeIntValue (el) { - var tabIndex = el.getAttribute('tabIndex'); + let tabIndex = el.getAttribute('tabIndex'); if (tabIndex !== null) { tabIndex = parseInt(tabIndex, 10); @@ -234,24 +240,24 @@ export function containsElement (elements, element) { } export function getTextareaIndentInLine (textarea, position) { - var textareaValue = getTextAreaValue(textarea); + const textareaValue = getTextAreaValue(textarea); if (!textareaValue) return 0; - var topPart = textareaValue.substring(0, position); - var linePosition = topPart.lastIndexOf('\n') === -1 ? 0 : topPart.lastIndexOf('\n') + 1; + const topPart = textareaValue.substring(0, position); + const linePosition = topPart.lastIndexOf('\n') === -1 ? 0 : topPart.lastIndexOf('\n') + 1; return position - linePosition; } export function getTextareaLineNumberByPosition (textarea, position) { - var textareaValue = getTextAreaValue(textarea); - var lines = textareaValue.split('\n'); - var topPartLength = 0; - var line = 0; + const textareaValue = getTextAreaValue(textarea); + const lines = textareaValue.split('\n'); + let topPartLength = 0; + let line = 0; - for (var i = 0; topPartLength <= position; i++) { + for (let i = 0; topPartLength <= position; i++) { if (position <= topPartLength + lines[i].length) { line = i; @@ -265,11 +271,11 @@ export function getTextareaLineNumberByPosition (textarea, position) { } export function getTextareaPositionByLineAndOffset (textarea, line, offset) { - var textareaValue = getTextAreaValue(textarea); - var lines = textareaValue.split('\n'); - var lineIndex = 0; + const textareaValue = getTextAreaValue(textarea); + const lines = textareaValue.split('\n'); + let lineIndex = 0; - for (var i = 0; i < line; i++) + for (let i = 0; i < line; i++) lineIndex += lines[i].length + 1; return lineIndex + offset; @@ -279,7 +285,7 @@ export function getTextareaPositionByLineAndOffset (textarea, line, offset) { // types (referred to as types that block implicit submission in the HTML5 standard) on the // form and this input is focused (http://www.w3.org/TR/html5/forms.html#implicit-submission) export function blocksImplicitSubmission (el) { - var inputTypeRegExp = null; + let inputTypeRegExp = null; if (browserUtils.isSafari) inputTypeRegExp = /^(text|password|color|date|time|datetime|datetime-local|email|month|number|search|tel|url|week|image)$/i; @@ -300,13 +306,13 @@ export function isEditableElement (el, checkEditingAllowed) { } export function isElementContainsNode (parentElement, childNode) { - var contains = false; + let contains = false; function checkChildNodes (el, node) { if (contains || isTheSameNode(node, el)) contains = true; - for (var i = 0; i < el.childNodes.length; i++) { + for (let i = 0; i < el.childNodes.length; i++) { contains = checkChildNodes(el.childNodes[i], node); if (contains) @@ -324,7 +330,7 @@ export function isOptionGroupElement (element) { } export function getElementIndexInParent (parent, child) { - var children = parent.querySelectorAll(getTagName(child)); + const children = parent.querySelectorAll(getTagName(child)); return arrayUtils.indexOf(children, child); @@ -339,20 +345,20 @@ export function isTheSameNode (node1, node2) { } export function getElementDescription (el) { - var attributes = { + const attributes = { id: 'id', name: 'name', 'class': 'className' }; - var res = []; + const res = []; res.push('<'); res.push(getTagName(el)); - for (var attr in attributes) { + for (const attr in attributes) { if (attributes.hasOwnProperty(attr)) { - var val = el[attributes[attr]]; + const val = el[attributes[attr]]; if (val) res.push(' ' + attr + '="' + val + '"'); @@ -364,34 +370,10 @@ export function getElementDescription (el) { return res.join(''); } -export function getNextFocusableElement (element, reverse) { - var offset = reverse ? -1 : 1; - var allFocusable = sortElementsByFocusingIndex(getFocusableElements(findDocument(element))); - - //NOTE: in all browsers except Mozilla and Opera focus sets on one radio set from group only. - // in Mozilla and Opera focus sets on any radio set. - if (isInputElement(element) && element.type === 'radio' && element.name !== '' && !browserUtils.isFirefox) { - allFocusable = arrayUtils.filter(allFocusable, item => { - return !item.name || item === element || item.name !== element.name; - }); - } - - var currentIndex = arrayUtils.indexOf(allFocusable, element); - var isLastElementFocused = reverse ? currentIndex === 0 : currentIndex === allFocusable.length - 1; - - if (isLastElementFocused) - return document.body; - - if (reverse && currentIndex === -1) - return allFocusable[allFocusable.length - 1]; - - return allFocusable[currentIndex + offset]; -} - export function getFocusableParent (el) { - var parents = getParents(el); + const parents = getParents(el); - for (var i = 0; i < parents.length; i++) { + for (let i = 0; i < parents.length; i++) { if (isElementFocusable(parents[i])) return parents[i]; } @@ -410,7 +392,7 @@ export function isIFrameWindowInDOM (win) { if (!win.setTimeout) return false; - var frameElement = null; + let frameElement = null; try { //NOTE: This may raise a cross-domain policy error in some browsers. @@ -439,9 +421,9 @@ export function isTopWindow (win) { } export function findIframeByWindow (iframeWindow, iframeDestinationWindow) { - var iframes = (iframeDestinationWindow || window).document.getElementsByTagName('iframe'); + const iframes = (iframeDestinationWindow || window).document.getElementsByTagName('iframe'); - for (var i = 0; i < iframes.length; i++) { + for (let i = 0; i < iframes.length; i++) { if (iframes[i].contentWindow === iframeWindow) return iframes[i]; } @@ -457,8 +439,8 @@ export function getCommonAncestor (element1, element2) { if (isTheSameNode(element1, element2)) return element1; - var el1Parents = [element1].concat(getParents(element1)); - var commonAncestor = element2; + const el1Parents = [element1].concat(getParents(element1)); + let commonAncestor = element2; while (commonAncestor) { if (arrayUtils.indexOf(el1Parents, commonAncestor) > -1) @@ -517,3 +499,17 @@ export function setElementValue (element, value) { return value; } + +export function findParent (node, includeSelf = false, predicate) { + if (!includeSelf) + node = node.parentNode; + + while (node) { + if (typeof predicate !== 'function' || predicate(node)) + return node; + + node = node.parentNode; + } + + return null; +} diff --git a/src/client/core/utils/event.js b/src/client/core/utils/event.js index 82bbfa12..b3f0b125 100644 --- a/src/client/core/utils/event.js +++ b/src/client/core/utils/event.js @@ -3,9 +3,9 @@ import delay from './delay'; import * as domUtils from './dom'; -var Promise = hammerhead.Promise; -var nativeMethods = hammerhead.nativeMethods; -var listeners = hammerhead.eventSandbox.listeners; +const Promise = hammerhead.Promise; +const nativeMethods = hammerhead.nativeMethods; +const listeners = hammerhead.eventSandbox.listeners; export const RECORDING_LISTENED_EVENTS = [ 'click', 'mousedown', 'mouseup', 'dblclick', 'contextmenu', 'mousemove', 'mouseover', 'mouseout', @@ -19,7 +19,7 @@ export const BUTTONS_PARAMETER = hammerhead.utils.event.BUTTONS_PARAMETER; export const DOM_EVENTS = hammerhead.utils.event.DOM_EVENTS; export const WHICH_PARAMETER = hammerhead.utils.event.WHICH_PARAMETER; -export var preventDefault = hammerhead.utils.event.preventDefault; +export const preventDefault = hammerhead.utils.event.preventDefault; export function bind (el, event, handler, useCapture) { if (domUtils.isWindow(el)) @@ -38,37 +38,44 @@ export function unbind (el, event, handler, useCapture) { // Document ready const waitForDomContentLoaded = () => { - return new Promise(resolve => { - var isReady = false; + // NOTE: We can't use a regular Promise here, because window.load event can happen in the same event loop pass + // The default Promise will call resolve handlers in the next pass, and load event will be lost. + const resolveHandlers = []; - function ready () { - if (isReady) - return; + function createPromiseResolver (resolveHandler) { + return new Promise(resolve => resolveHandlers.push(() => resolve(resolveHandler()))); + } - if (!document.body) { - nativeMethods.setTimeout.call(window, ready, 1); - return; - } + let isReady = false; - isReady = true; + function ready () { + if (isReady) + return; - resolve(); + if (!document.body) { + nativeMethods.setTimeout.call(window, ready, 1); + return; } - function onContentLoaded () { - if (!domUtils.isIFrameWindowInDOM(window) && !domUtils.isTopWindow(window)) - return; + isReady = true; - unbind(document, 'DOMContentLoaded', onContentLoaded); - ready(); - } + resolveHandlers.forEach(handler => handler()); + } + + function onContentLoaded () { + if (!domUtils.isIFrameWindowInDOM(window) && !domUtils.isTopWindow(window)) + return; + unbind(document, 'DOMContentLoaded', onContentLoaded); + ready(); + } + + if (document.readyState === 'complete') + nativeMethods.setTimeout.call(window, onContentLoaded, 1); + else + bind(document, 'DOMContentLoaded', onContentLoaded); - if (document.readyState === 'complete') - nativeMethods.setTimeout.call(window, onContentLoaded, 1); - else - bind(document, 'DOMContentLoaded', onContentLoaded); - }); + return { then: handler => createPromiseResolver(handler) }; }; const waitForWindowLoad = () => new Promise(resolve => bind(window, 'load', resolve)); diff --git a/src/client/core/utils/get-key-array.js b/src/client/core/utils/get-key-array.js index aa2747e9..13c2cb73 100644 --- a/src/client/core/utils/get-key-array.js +++ b/src/client/core/utils/get-key-array.js @@ -3,7 +3,7 @@ import { map } from './array'; export default function getKeyArray (keyCombination) { // NOTE: we should separate the '+' symbol that concats other // keys and the '+' key to support commands like the 'ctrl++' - var keys = keyCombination.replace(/^\+/g, 'plus').replace(/\+\+/g, '+plus').split('+'); + const keys = keyCombination.replace(/^\+/g, 'plus').replace(/\+\+/g, '+plus').split('+'); return map(keys, key => key.replace('plus', '+')); } diff --git a/src/client/core/utils/get-sanitized-key.js b/src/client/core/utils/get-sanitized-key.js index 463036bf..d98e6182 100644 --- a/src/client/core/utils/get-sanitized-key.js +++ b/src/client/core/utils/get-sanitized-key.js @@ -1,8 +1,8 @@ import KEY_MAPS from './key-maps'; export default function getSanitizedKey (key) { - var isChar = key.length === 1 || key === 'space'; - var sanitizedKey = isChar ? key : key.toLowerCase(); + const isChar = key.length === 1 || key === 'space'; + let sanitizedKey = isChar ? key : key.toLowerCase(); if (KEY_MAPS.modifiersMap[sanitizedKey]) sanitizedKey = KEY_MAPS.modifiersMap[sanitizedKey]; diff --git a/src/client/core/utils/get-time-limited-promise.js b/src/client/core/utils/get-time-limited-promise.js new file mode 100644 index 00000000..9d934df2 --- /dev/null +++ b/src/client/core/utils/get-time-limited-promise.js @@ -0,0 +1,8 @@ +import { Promise } from '../deps/hammerhead'; +import delay from './delay'; +import { timeLimitedPromiseTimeoutExpired } from '../../../errors/runtime/message'; + + +export default function (promise, ms) { + return Promise.race([promise, delay(ms).then(() => Promise.reject(new Error(timeLimitedPromiseTimeoutExpired)))]); +} diff --git a/src/client/core/utils/key-maps.js b/src/client/core/utils/key-maps.js index 8d40ec55..52d7bbd6 100644 --- a/src/client/core/utils/key-maps.js +++ b/src/client/core/utils/key-maps.js @@ -1,6 +1,6 @@ import hammerhead from '../deps/hammerhead'; -var browserUtils = hammerhead.utils.browser; +const browserUtils = hammerhead.utils.browser; const MODIFIERS = { @@ -55,9 +55,9 @@ const SPECIAL_KEYS = { }; function reverseMap (map) { - var reversed = {}; + const reversed = {}; - for (var key in map) { + for (const key in map) { if (map.hasOwnProperty(key)) reversed[map[key]] = key; } diff --git a/src/client/core/utils/parse-key-sequence.js b/src/client/core/utils/parse-key-sequence.js index db323a91..fbe3d253 100644 --- a/src/client/core/utils/parse-key-sequence.js +++ b/src/client/core/utils/parse-key-sequence.js @@ -4,7 +4,7 @@ import { some } from './array'; import getKeyArray from './get-key-array'; import getSanitizedKey from './get-sanitized-key'; -var trim = hammerhead.utils.trim; +const trim = hammerhead.utils.trim; export default function (keyString) { @@ -13,24 +13,24 @@ export default function (keyString) { keyString = trim(keyString).replace(/\s+/g, ' '); - var keyStringLength = keyString.length; - var lastChar = keyString.charAt(keyStringLength - 1); - var charBeforeLast = keyString.charAt(keyStringLength - 2); + const keyStringLength = keyString.length; + const lastChar = keyString.charAt(keyStringLength - 1); + const charBeforeLast = keyString.charAt(keyStringLength - 2); // NOTE: trim last connecting '+' if (keyStringLength > 1 && lastChar === '+' && !/[+ ]/.test(charBeforeLast)) keyString = keyString.substring(0, keyString.length - 1); - var combinations = keyString.split(' '); + const combinations = keyString.split(' '); - var error = some(combinations, combination => { - var keyArray = getKeyArray(combination); + const error = some(combinations, combination => { + const keyArray = getKeyArray(combination); return some(keyArray, key => { - var isChar = key.length === 1 || key === 'space'; - var sanitizedKey = getSanitizedKey(key); - var modifierKeyCode = KEY_MAPS.modifiers[sanitizedKey]; - var specialKeyCode = KEY_MAPS.specialKeys[sanitizedKey]; + const isChar = key.length === 1 || key === 'space'; + const sanitizedKey = getSanitizedKey(key); + const modifierKeyCode = KEY_MAPS.modifiers[sanitizedKey]; + const specialKeyCode = KEY_MAPS.specialKeys[sanitizedKey]; return !(isChar || modifierKeyCode || specialKeyCode); }); diff --git a/src/client/core/utils/position.js b/src/client/core/utils/position.js index b158e5d6..56cff724 100644 --- a/src/client/core/utils/position.js +++ b/src/client/core/utils/position.js @@ -4,17 +4,17 @@ import * as styleUtils from './style'; import * as domUtils from './dom'; -export var getElementRectangle = hammerhead.utils.position.getElementRectangle; -export var getOffsetPosition = hammerhead.utils.position.getOffsetPosition; -export var offsetToClientCoords = hammerhead.utils.position.offsetToClientCoords; +export const getElementRectangle = hammerhead.utils.position.getElementRectangle; +export const getOffsetPosition = hammerhead.utils.position.getOffsetPosition; +export const offsetToClientCoords = hammerhead.utils.position.offsetToClientCoords; export function getIframeClientCoordinates (iframe) { - var { left, top } = getOffsetPosition(iframe); - var clientPosition = offsetToClientCoords({ x: left, y: top }); - var iframeBorders = styleUtils.getBordersWidth(iframe); - var iframePadding = styleUtils.getElementPadding(iframe); - var iframeRectangleLeft = clientPosition.x + iframeBorders.left + iframePadding.left; - var iframeRectangleTop = clientPosition.y + iframeBorders.top + iframePadding.top; + const { left, top } = getOffsetPosition(iframe); + const clientPosition = offsetToClientCoords({ x: left, y: top }); + const iframeBorders = styleUtils.getBordersWidth(iframe); + const iframePadding = styleUtils.getElementPadding(iframe); + const iframeRectangleLeft = clientPosition.x + iframeBorders.left + iframePadding.left; + const iframeRectangleTop = clientPosition.y + iframeBorders.top + iframePadding.top; return { left: iframeRectangleLeft, @@ -28,7 +28,7 @@ export function isElementVisible (el) { if (domUtils.isTextNode(el)) return !styleUtils.isNotVisibleNode(el); - var elementRectangle = getElementRectangle(el); + const elementRectangle = getElementRectangle(el); if (!domUtils.isContentEditableElement(el)) { if (elementRectangle.width === 0 || elementRectangle.height === 0) @@ -36,18 +36,18 @@ export function isElementVisible (el) { } if (domUtils.isMapElement(el)) { - var mapContainer = domUtils.getMapContainer(domUtils.closest(el, 'map')); + const mapContainer = domUtils.getMapContainer(domUtils.closest(el, 'map')); return mapContainer ? isElementVisible(mapContainer) : false; } if (styleUtils.isSelectVisibleChild(el)) { - var select = domUtils.getSelectParent(el); - var childRealIndex = domUtils.getChildVisibleIndex(select, el); - var realSelectSizeValue = styleUtils.getSelectElementSize(select); - var topVisibleIndex = Math.max(styleUtils.getScrollTop(select) / styleUtils.getOptionHeight(select), 0); - var bottomVisibleIndex = topVisibleIndex + realSelectSizeValue - 1; - var optionVisibleIndex = Math.max(childRealIndex - topVisibleIndex, 0); + const select = domUtils.getSelectParent(el); + const childRealIndex = domUtils.getChildVisibleIndex(select, el); + const realSelectSizeValue = styleUtils.getSelectElementSize(select); + const topVisibleIndex = Math.max(styleUtils.getScrollTop(select) / styleUtils.getOptionHeight(select), 0); + const bottomVisibleIndex = topVisibleIndex + realSelectSizeValue - 1; + const optionVisibleIndex = Math.max(childRealIndex - topVisibleIndex, 0); return optionVisibleIndex >= topVisibleIndex && optionVisibleIndex <= bottomVisibleIndex; } @@ -60,7 +60,7 @@ export function isElementVisible (el) { export function getClientDimensions (target) { if (!domUtils.isDomElement(target)) { - var clientPoint = offsetToClientCoords(target); + const clientPoint = offsetToClientCoords(target); return { width: 0, @@ -84,17 +84,17 @@ export function getClientDimensions (target) { }; } - var isHtmlElement = /html/i.test(target.tagName); - var body = isHtmlElement ? target.getElementsByTagName('body')[0] : null; - var elementBorders = styleUtils.getBordersWidth(target); - var elementRect = target.getBoundingClientRect(); - var elementScroll = styleUtils.getElementScroll(target); - var isElementInIframe = domUtils.isElementInIframe(target); - var elementLeftPosition = isHtmlElement ? 0 : elementRect.left; - var elementTopPosition = isHtmlElement ? 0 : elementRect.top; - var elementHeight = isHtmlElement ? target.clientHeight : elementRect.height; - var elementWidth = isHtmlElement ? target.clientWidth : elementRect.width; - var isCompatMode = target.ownerDocument.compatMode === 'BackCompat'; + const isHtmlElement = /html/i.test(target.tagName); + const body = isHtmlElement ? target.getElementsByTagName('body')[0] : null; + const elementBorders = styleUtils.getBordersWidth(target); + const elementRect = target.getBoundingClientRect(); + const elementScroll = styleUtils.getElementScroll(target); + const isElementInIframe = domUtils.isElementInIframe(target); + let elementLeftPosition = isHtmlElement ? 0 : elementRect.left; + let elementTopPosition = isHtmlElement ? 0 : elementRect.top; + let elementHeight = isHtmlElement ? target.clientHeight : elementRect.height; + let elementWidth = isHtmlElement ? target.clientWidth : elementRect.width; + const isCompatMode = target.ownerDocument.compatMode === 'BackCompat'; if (isHtmlElement && body && (typeof isIFrameWithoutSrc === 'boolean' && isIFrameWithoutSrc || isCompatMode)) { elementHeight = body.clientHeight; @@ -102,15 +102,15 @@ export function getClientDimensions (target) { } if (isElementInIframe) { - var iframeElement = domUtils.getIframeByElement(target); + const iframeElement = domUtils.getIframeByElement(target); if (iframeElement) { - var iframeOffset = getOffsetPosition(iframeElement); - var clientOffset = offsetToClientCoords({ + const iframeOffset = getOffsetPosition(iframeElement); + const clientOffset = offsetToClientCoords({ x: iframeOffset.left, y: iframeOffset.top }); - var iframeBorders = styleUtils.getBordersWidth(iframeElement); + const iframeBorders = styleUtils.getBordersWidth(iframeElement); elementLeftPosition += clientOffset.x + iframeBorders.left; elementTopPosition += clientOffset.y + iframeBorders.top; @@ -152,29 +152,29 @@ export function getClientDimensions (target) { } export function containsOffset (el, offsetX, offsetY) { - var dimensions = getClientDimensions(el); - var width = Math.max(el.scrollWidth, dimensions.width); - var height = Math.max(el.scrollHeight, dimensions.height); - var maxX = dimensions.scrollbar.right + dimensions.border.left + dimensions.border.right + width; - var maxY = dimensions.scrollbar.bottom + dimensions.border.top + dimensions.border.bottom + height; + const dimensions = getClientDimensions(el); + const width = Math.max(el.scrollWidth, dimensions.width); + const height = Math.max(el.scrollHeight, dimensions.height); + const maxX = dimensions.scrollbar.right + dimensions.border.left + dimensions.border.right + width; + const maxY = dimensions.scrollbar.bottom + dimensions.border.top + dimensions.border.bottom + height; return (typeof offsetX === 'undefined' || offsetX >= 0 && maxX >= offsetX) && (typeof offsetY === 'undefined' || offsetY >= 0 && maxY >= offsetY); } export function getEventAbsoluteCoordinates (ev) { - var el = ev.target || ev.srcElement; - var pageCoordinates = getEventPageCoordinates(ev); - var curDocument = domUtils.findDocument(el); - var xOffset = 0; - var yOffset = 0; + const el = ev.target || ev.srcElement; + const pageCoordinates = getEventPageCoordinates(ev); + const curDocument = domUtils.findDocument(el); + let xOffset = 0; + let yOffset = 0; if (domUtils.isElementInIframe(curDocument.documentElement)) { - var currentIframe = domUtils.getIframeByElement(curDocument); + const currentIframe = domUtils.getIframeByElement(curDocument); if (currentIframe) { - var iframeOffset = getOffsetPosition(currentIframe); - var iframeBorders = styleUtils.getBordersWidth(currentIframe); + const iframeOffset = getOffsetPosition(currentIframe); + const iframeBorders = styleUtils.getBordersWidth(currentIframe); xOffset = iframeOffset.left + iframeBorders.left; yOffset = iframeOffset.top + iframeBorders.top; @@ -188,16 +188,16 @@ export function getEventAbsoluteCoordinates (ev) { } export function getEventPageCoordinates (ev) { - var curCoordObject = /^touch/.test(ev.type) && ev.targetTouches ? ev.targetTouches[0] || ev.changedTouches[0] : ev; + const curCoordObject = /^touch/.test(ev.type) && ev.targetTouches ? ev.targetTouches[0] || ev.changedTouches[0] : ev; - var bothPageCoordinatesAreZero = curCoordObject.pageX === 0 && curCoordObject.pageY === 0; - var notBothClientCoordinatesAreZero = curCoordObject.clientX !== 0 || curCoordObject.clientY !== 0; + const bothPageCoordinatesAreZero = curCoordObject.pageX === 0 && curCoordObject.pageY === 0; + const notBothClientCoordinatesAreZero = curCoordObject.clientX !== 0 || curCoordObject.clientY !== 0; if ((curCoordObject.pageX === null || bothPageCoordinatesAreZero && notBothClientCoordinatesAreZero) && curCoordObject.clientX !== null) { - var currentDocument = domUtils.findDocument(ev.target || ev.srcElement); - var html = currentDocument.documentElement; - var body = currentDocument.body; + const currentDocument = domUtils.findDocument(ev.target || ev.srcElement); + const html = currentDocument.documentElement; + const body = currentDocument.body; return { x: Math.round(curCoordObject.clientX + (html && html.scrollLeft || body && body.scrollLeft || 0) - @@ -213,8 +213,8 @@ export function getEventPageCoordinates (ev) { } export function getElementFromPoint (x, y) { - var el = null; - var func = document.getElementFromPoint || document.elementFromPoint; + let el = null; + const func = document.getElementFromPoint || document.elementFromPoint; try { // Permission denied to access property 'getElementFromPoint' error in iframe @@ -229,9 +229,9 @@ export function getElementFromPoint (x, y) { el = func.call(document, x - 1, y - 1); while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) { - var shadowEl = el.shadowRoot.elementFromPoint(x, y); + const shadowEl = el.shadowRoot.elementFromPoint(x, y); - if (!shadowEl) + if (!shadowEl || el === shadowEl) break; el = shadowEl; @@ -241,10 +241,10 @@ export function getElementFromPoint (x, y) { } export function getIframePointRelativeToParentFrame (pos, iframeWin) { - var iframe = domUtils.findIframeByWindow(iframeWin); - var iframeOffset = getOffsetPosition(iframe); - var iframeBorders = styleUtils.getBordersWidth(iframe); - var iframePadding = styleUtils.getElementPadding(iframe); + const iframe = domUtils.findIframeByWindow(iframeWin); + const iframeOffset = getOffsetPosition(iframe); + const iframeBorders = styleUtils.getBordersWidth(iframe); + const iframePadding = styleUtils.getElementPadding(iframe); return offsetToClientCoords({ x: pos.x + iframeOffset.left + iframeBorders.left + iframePadding.left, @@ -253,7 +253,7 @@ export function getIframePointRelativeToParentFrame (pos, iframeWin) { } export function clientToOffsetCoord (coords, currentDocument) { - var doc = currentDocument || document; + const doc = currentDocument || document; return { x: coords.x + styleUtils.getScrollLeft(doc), @@ -262,7 +262,7 @@ export function clientToOffsetCoord (coords, currentDocument) { } export function findCenter (el) { - var rectangle = getElementRectangle(el); + const rectangle = getElementRectangle(el); return { x: Math.round(rectangle.left + rectangle.width / 2), @@ -271,9 +271,9 @@ export function findCenter (el) { } export function getClientPosition (el) { - var { left, top } = getOffsetPosition(el); + const { left, top } = getOffsetPosition(el); - var clientCoords = offsetToClientCoords({ x: left, y: top }); + const clientCoords = offsetToClientCoords({ x: left, y: top }); clientCoords.x = Math.round(clientCoords.x); clientCoords.y = Math.round(clientCoords.y); @@ -282,8 +282,8 @@ export function getClientPosition (el) { } export function getElementClientRectangle (el) { - var rect = getElementRectangle(el); - var clientPos = offsetToClientCoords({ + const rect = getElementRectangle(el); + const clientPos = offsetToClientCoords({ x: rect.left, y: rect.top }); @@ -318,9 +318,9 @@ export function getLineYByXCoord (startLinePoint, endLinePoint, x) { if (endLinePoint.x - startLinePoint.x === 0) return null; - var equationSlope = (endLinePoint.y - startLinePoint.y) / (endLinePoint.x - startLinePoint.x); + const equationSlope = (endLinePoint.y - startLinePoint.y) / (endLinePoint.x - startLinePoint.x); - var equationYIntercept = startLinePoint.x * (startLinePoint.y - endLinePoint.y) / + const equationYIntercept = startLinePoint.x * (startLinePoint.y - endLinePoint.y) / (endLinePoint.x - startLinePoint.x) + startLinePoint.y; return Math.round(equationSlope * x + equationYIntercept); @@ -330,9 +330,9 @@ export function getLineXByYCoord (startLinePoint, endLinePoint, y) { if (endLinePoint.y - startLinePoint.y === 0) return null; - var equationSlope = (endLinePoint.x - startLinePoint.x) / (endLinePoint.y - startLinePoint.y); + const equationSlope = (endLinePoint.x - startLinePoint.x) / (endLinePoint.y - startLinePoint.y); - var equationXIntercept = startLinePoint.y * (startLinePoint.x - endLinePoint.x) / + const equationXIntercept = startLinePoint.y * (startLinePoint.x - endLinePoint.x) / (endLinePoint.y - startLinePoint.y) + startLinePoint.x; return Math.round(equationSlope * y + equationXIntercept); diff --git a/src/client/core/utils/promise.js b/src/client/core/utils/promise.js index c7edb0b7..5e50825d 100644 --- a/src/client/core/utils/promise.js +++ b/src/client/core/utils/promise.js @@ -1,7 +1,7 @@ import hammerhead from '../deps/hammerhead'; import { reduce } from './array'; -var Promise = hammerhead.Promise; +const Promise = hammerhead.Promise; export function whilst (condition, iterator) { diff --git a/src/client/core/utils/send-request-to-frame.js b/src/client/core/utils/send-request-to-frame.js index b65381c8..4b622340 100644 --- a/src/client/core/utils/send-request-to-frame.js +++ b/src/client/core/utils/send-request-to-frame.js @@ -1,7 +1,7 @@ import hammerhead from '../deps/hammerhead'; -var Promise = hammerhead.Promise; -var messageSandbox = hammerhead.eventSandbox.message; +const Promise = hammerhead.Promise; +const messageSandbox = hammerhead.eventSandbox.message; export default function sendRequestToFrame (msg, responseCmd, receiverWindow) { return new Promise(resolve => { diff --git a/src/client/core/utils/service.js b/src/client/core/utils/service.js index ba125c4d..825fbcee 100644 --- a/src/client/core/utils/service.js +++ b/src/client/core/utils/service.js @@ -2,7 +2,7 @@ import hammerhead from '../deps/hammerhead'; import { filter } from './array'; export function inherit (Child, Parent) { - var Func = function () { + const Func = function () { }; Func.prototype = Parent.prototype; @@ -12,15 +12,15 @@ export function inherit (Child, Parent) { Child.base = Parent.prototype; } -export var EventEmitter = function () { +export const EventEmitter = function () { this.eventsListeners = []; }; EventEmitter.prototype.emit = function (evt) { - var listeners = this.eventsListeners[evt]; + const listeners = this.eventsListeners[evt]; if (listeners) { - for (var i = 0; i < listeners.length; i++) { + for (let i = 0; i < listeners.length; i++) { try { if (listeners[i]) listeners[i].apply(this, Array.prototype.slice.apply(arguments, [1])); @@ -39,7 +39,7 @@ EventEmitter.prototype.emit = function (evt) { }; EventEmitter.prototype.off = function (evt, listener) { - var listeners = this.eventsListeners[evt]; + const listeners = this.eventsListeners[evt]; if (listeners) this.eventsListeners[evt] = filter(listeners, item => item !== listener); diff --git a/src/client/core/utils/style.js b/src/client/core/utils/style.js index 1c24f89e..03ac0735 100644 --- a/src/client/core/utils/style.js +++ b/src/client/core/utils/style.js @@ -1,56 +1,64 @@ import hammerhead from '../deps/hammerhead'; import * as domUtils from './dom'; -import { filter, some } from './array'; - - -var styleUtils = hammerhead.utils.style; - -export var getBordersWidth = hammerhead.utils.style.getBordersWidth; -export var getComputedStyle = hammerhead.utils.style.getComputedStyle; -export var getElementMargin = hammerhead.utils.style.getElementMargin; -export var getElementPadding = hammerhead.utils.style.getElementPadding; -export var getElementScroll = hammerhead.utils.style.getElementScroll; -export var getOptionHeight = hammerhead.utils.style.getOptionHeight; -export var getSelectElementSize = hammerhead.utils.style.getSelectElementSize; -export var isElementVisible = hammerhead.utils.style.isElementVisible; -export var isSelectVisibleChild = hammerhead.utils.style.isVisibleChild; -export var getWidth = hammerhead.utils.style.getWidth; -export var getHeight = hammerhead.utils.style.getHeight; -export var getInnerWidth = hammerhead.utils.style.getInnerWidth; -export var getInnerHeight = hammerhead.utils.style.getInnerHeight; -export var getScrollLeft = hammerhead.utils.style.getScrollLeft; -export var getScrollTop = hammerhead.utils.style.getScrollTop; -export var setScrollLeft = hammerhead.utils.style.setScrollLeft; -export var setScrollTop = hammerhead.utils.style.setScrollTop; -export var get = hammerhead.utils.style.get; - -const SCROLLABLE_OVERFLOW_STYLE_RE = /auto|scroll/i; - -var getAncestors = function (node) { - var ancestors = []; - - while (node.parentNode) { - ancestors.unshift(node.parentNode); - node = node.parentNode; +import { filter } from './array'; + + +const styleUtils = hammerhead.utils.style; +const browserUtils = hammerhead.utils.browser; + +export const getBordersWidth = hammerhead.utils.style.getBordersWidth; +export const getComputedStyle = hammerhead.utils.style.getComputedStyle; +export const getElementMargin = hammerhead.utils.style.getElementMargin; +export const getElementPadding = hammerhead.utils.style.getElementPadding; +export const getElementScroll = hammerhead.utils.style.getElementScroll; +export const getOptionHeight = hammerhead.utils.style.getOptionHeight; +export const getSelectElementSize = hammerhead.utils.style.getSelectElementSize; +export const isElementVisible = hammerhead.utils.style.isElementVisible; +export const isSelectVisibleChild = hammerhead.utils.style.isVisibleChild; +export const getWidth = hammerhead.utils.style.getWidth; +export const getHeight = hammerhead.utils.style.getHeight; +export const getInnerWidth = hammerhead.utils.style.getInnerWidth; +export const getInnerHeight = hammerhead.utils.style.getInnerHeight; +export const getScrollLeft = hammerhead.utils.style.getScrollLeft; +export const getScrollTop = hammerhead.utils.style.getScrollTop; +export const setScrollLeft = hammerhead.utils.style.setScrollLeft; +export const setScrollTop = hammerhead.utils.style.setScrollTop; +export const get = hammerhead.utils.style.get; + +const SCROLLABLE_OVERFLOW_STYLE_RE = /auto|scroll/i; +const DEFAULT_IE_SCROLLABLE_OVERFLOW_STYLE_VALUE = 'visible'; + +const getScrollable = function (el) { + const overflowX = get(el, 'overflowX'); + const overflowY = get(el, 'overflowY'); + let scrollableHorizontally = SCROLLABLE_OVERFLOW_STYLE_RE.test(overflowX); + let scrollableVertically = SCROLLABLE_OVERFLOW_STYLE_RE.test(overflowY); + + // IE11 and MS Edge bug: There are two properties: overflow-x and overflow-y. + // If one property is set so that the browser may show scrollbars (`auto` or `scroll`) and the second one is set to 'visible', + // then the second one will work as if it had the 'auto' value. + if (browserUtils.isIE) { + scrollableHorizontally = scrollableHorizontally || scrollableVertically && overflowX === DEFAULT_IE_SCROLLABLE_OVERFLOW_STYLE_VALUE; + scrollableVertically = scrollableVertically || scrollableHorizontally && overflowY === DEFAULT_IE_SCROLLABLE_OVERFLOW_STYLE_VALUE; } - return ancestors; + return { scrollableHorizontally, scrollableVertically }; }; -var getAncestorsAndSelf = function (node) { - return getAncestors(node).concat([node]); -}; - -var isVisibilityHiddenNode = function (node) { - var ancestors = getAncestorsAndSelf(node); +const isVisibilityHiddenNode = function (node) { + node = domUtils.findParent(node, true, ancestor => { + return domUtils.isElementNode(ancestor) && get(ancestor, 'visibility') === 'hidden'; + }); - return some(ancestors, ancestor => domUtils.isElementNode(ancestor) && get(ancestor, 'visibility') === 'hidden'); + return !!node; }; -var isHiddenNode = function (node) { - var ancestors = getAncestorsAndSelf(node); +const isHiddenNode = function (node) { + node = domUtils.findParent(node, true, ancestor => { + return domUtils.isElementNode(ancestor) && get(ancestor, 'display') === 'none'; + }); - return some(ancestors, ancestor => domUtils.isElementNode(ancestor) && get(ancestor, 'display') === 'none'); + return !!node; }; export function isFixedElement (node) { @@ -62,10 +70,10 @@ export function isNotVisibleNode (node) { } export function getScrollableParents (element) { - var parentsArray = domUtils.getParents(element); + const parentsArray = domUtils.getParents(element); if (domUtils.isElementInIframe(element)) { - var iFrameParents = domUtils.getParents(domUtils.getIframeByElement(element)); + const iFrameParents = domUtils.getParents(domUtils.getIframeByElement(element)); parentsArray.concat(iFrameParents); } @@ -74,29 +82,29 @@ export function getScrollableParents (element) { } function hasBodyScroll (el) { - var overflowX = get(el, 'overflowX'); - var overflowY = get(el, 'overflowY'); - var scrollableHorizontally = SCROLLABLE_OVERFLOW_STYLE_RE.test(overflowX); - var scrollableVertically = SCROLLABLE_OVERFLOW_STYLE_RE.test(overflowY); + const overflowX = get(el, 'overflowX'); + const overflowY = get(el, 'overflowY'); + const scrollableHorizontally = SCROLLABLE_OVERFLOW_STYLE_RE.test(overflowX); + const scrollableVertically = SCROLLABLE_OVERFLOW_STYLE_RE.test(overflowY); - var documentElement = domUtils.findDocument(el).documentElement; + const documentElement = domUtils.findDocument(el).documentElement; return (scrollableHorizontally || scrollableVertically) && el.scrollHeight > documentElement.scrollHeight; } function hasHTMLElementScroll (el) { - var overflowX = get(el, 'overflowX'); - var overflowY = get(el, 'overflowY'); + const overflowX = get(el, 'overflowX'); + const overflowY = get(el, 'overflowY'); //T174562 - wrong scrolling in iframes without src and others iframes - var body = el.getElementsByTagName('body')[0]; + const body = el.getElementsByTagName('body')[0]; //T303226 if (overflowX === 'hidden' && overflowY === 'hidden') return false; - var hasHorizontalScroll = el.scrollHeight > el.clientHeight; - var hasVerticalScroll = el.scrollWidth > el.clientWidth; + const hasHorizontalScroll = el.scrollHeight > el.clientHeight; + const hasVerticalScroll = el.scrollWidth > el.clientWidth; if (hasHorizontalScroll || hasVerticalScroll) return true; @@ -105,8 +113,8 @@ function hasHTMLElementScroll (el) { if (hasBodyScroll(body)) return false; - var clientWidth = Math.min(el.clientWidth, body.clientWidth); - var clientHeight = Math.min(el.clientHeight, body.clientHeight); + const clientWidth = Math.min(el.clientWidth, body.clientWidth); + const clientHeight = Math.min(el.clientHeight, body.clientHeight); return body.scrollHeight > clientHeight || body.scrollWidth > clientWidth; } @@ -115,10 +123,7 @@ function hasHTMLElementScroll (el) { } export function hasScroll (el) { - var overflowX = get(el, 'overflowX'); - var overflowY = get(el, 'overflowY'); - var scrollableHorizontally = SCROLLABLE_OVERFLOW_STYLE_RE.test(overflowX); - var scrollableVertically = SCROLLABLE_OVERFLOW_STYLE_RE.test(overflowY); + const { scrollableHorizontally, scrollableVertically } = getScrollable(el); if (domUtils.isBodyElement(el)) return hasBodyScroll(el); @@ -129,8 +134,8 @@ export function hasScroll (el) { if (!scrollableHorizontally && !scrollableVertically) return false; - var hasHorizontalScroll = scrollableVertically && el.scrollHeight > el.clientHeight; - var hasVerticalScroll = scrollableHorizontally && el.scrollWidth > el.clientWidth; + const hasVerticalScroll = scrollableVertically && el.scrollHeight > el.clientHeight; + const hasHorizontalScroll = scrollableHorizontally && el.scrollWidth > el.clientWidth; return hasHorizontalScroll || hasVerticalScroll; } @@ -144,7 +149,7 @@ export function set (el, style, value) { if (typeof style === 'string') styleUtils.set(el, style, value); - for (var property in style) { + for (const property in style) { if (style.hasOwnProperty(property)) styleUtils.set(el, property, style[property]); } diff --git a/src/client/core/utils/text-selection.js b/src/client/core/utils/text-selection.js index cbaff29d..b47ad12a 100644 --- a/src/client/core/utils/text-selection.js +++ b/src/client/core/utils/text-selection.js @@ -4,9 +4,9 @@ import * as contentEditable from './content-editable'; import * as eventUtils from './event'; -var browserUtils = hammerhead.utils.browser; -var nativeMethods = hammerhead.nativeMethods; -var selectionSandbox = hammerhead.eventSandbox.selection; +const browserUtils = hammerhead.utils.browser; +const nativeMethods = hammerhead.nativeMethods; +const selectionSandbox = hammerhead.eventSandbox.selection; //NOTE: we can't determine selection direction in ie from dom api. Therefore we should listen selection changes, @@ -16,13 +16,13 @@ const FORWARD_SELECTION_DIRECTION = 'forward'; const NONE_SELECTION_DIRECTION = 'none'; -var selectionDirection = NONE_SELECTION_DIRECTION; -var initialLeft = 0; -var initialTop = 0; -var lastSelectionHeight = 0; -var lastSelectionLeft = 0; -var lastSelectionLength = 0; -var lastSelectionTop = 0; +let selectionDirection = NONE_SELECTION_DIRECTION; +let initialLeft = 0; +let initialTop = 0; +let lastSelectionHeight = 0; +let lastSelectionLeft = 0; +let lastSelectionLength = 0; +let lastSelectionTop = 0; function stateChanged (left, top, height, width, selectionLength) { if (!selectionLength) { @@ -69,11 +69,11 @@ function stateChanged (left, top, height, width, selectionLength) { } function onSelectionChange () { - var activeElement = null; - var endSelection = null; - var range = null; - var rect = null; - var startSelection = null; + let activeElement = null; + let endSelection = null; + let range = null; + let rect = null; + let startSelection = null; try { if (this.selection) @@ -101,7 +101,7 @@ function onSelectionChange () { //NOTE: for MSEdge range = document.createRange(); - var textNode = hammerhead.nativeMethods.nodeFirstChildGetter.call(activeElement); + const textNode = hammerhead.nativeMethods.nodeFirstChildGetter.call(activeElement); range.setStart(textNode, startSelection); range.setEnd(textNode, endSelection); @@ -116,12 +116,12 @@ function onSelectionChange () { return; } - var rangeLeft = rect ? Math.ceil(rect.left) : range.offsetLeft; - var rangeTop = rect ? Math.ceil(rect.top) : range.offsetTop; - var rangeHeight = rect ? Math.ceil(rect.height) : range.boundingHeight; - var rangeWidth = rect ? Math.ceil(rect.width) : range.boundingWidth; - var rangeHTMLTextLength = range.htmlText ? range.htmlText.length : 0; - var rangeTextLength = rect ? range.toString().length : rangeHTMLTextLength; + const rangeLeft = rect ? Math.ceil(rect.left) : range.offsetLeft; + const rangeTop = rect ? Math.ceil(rect.top) : range.offsetTop; + const rangeHeight = rect ? Math.ceil(rect.height) : range.boundingHeight; + const rangeWidth = rect ? Math.ceil(rect.width) : range.boundingWidth; + const rangeHTMLTextLength = range.htmlText ? range.htmlText.length : 0; + const rangeTextLength = rect ? range.toString().length : rangeHTMLTextLength; stateChanged(rangeLeft, rangeTop, rangeHeight, rangeWidth, rangeTextLength); } @@ -131,12 +131,12 @@ if (browserUtils.isIE) //utils for contentEditable function selectContentEditable (el, from, to, needFocus) { - var endPosition = null; - var firstTextNodeChild = null; - var latestTextNodeChild = null; - var startPosition = null; - var temp = null; - var inverse = false; + let endPosition = null; + let firstTextNodeChild = null; + let latestTextNodeChild = null; + let startPosition = null; + let temp = null; + let inverse = false; if (typeof from !== 'undefined' && typeof to !== 'undefined' && from > to) { temp = from; @@ -177,22 +177,22 @@ function selectContentEditable (el, from, to, needFocus) { } function correctContentEditableSelectionBeforeDelete (el) { - var selection = getSelectionByElement(el); + const selection = getSelectionByElement(el); - var startNode = selection.anchorNode; - var endNode = selection.focusNode; + const startNode = selection.anchorNode; + const endNode = selection.focusNode; - var startOffset = selection.anchorOffset; - var endOffset = selection.focusOffset; + const startOffset = selection.anchorOffset; + const endOffset = selection.focusOffset; - var startNodeFirstNonWhitespaceSymbol = contentEditable.getFirstNonWhitespaceSymbolIndex(startNode.nodeValue); - var startNodeLastNonWhitespaceSymbol = contentEditable.getLastNonWhitespaceSymbolIndex(startNode.nodeValue); + const startNodeFirstNonWhitespaceSymbol = contentEditable.getFirstNonWhitespaceSymbolIndex(startNode.nodeValue); + const startNodeLastNonWhitespaceSymbol = contentEditable.getLastNonWhitespaceSymbolIndex(startNode.nodeValue); - var endNodeFirstNonWhitespaceSymbol = contentEditable.getFirstNonWhitespaceSymbolIndex(endNode.nodeValue); - var endNodeLastNonWhitespaceSymbol = contentEditable.getLastNonWhitespaceSymbolIndex(endNode.nodeValue); + const endNodeFirstNonWhitespaceSymbol = contentEditable.getFirstNonWhitespaceSymbolIndex(endNode.nodeValue); + const endNodeLastNonWhitespaceSymbol = contentEditable.getLastNonWhitespaceSymbolIndex(endNode.nodeValue); - var newStartOffset = null; - var newEndOffset = null; + let newStartOffset = null; + let newEndOffset = null; if (domUtils.isTextNode(startNode)) { if (startOffset < startNodeFirstNonWhitespaceSymbol && startOffset !== 0) @@ -239,8 +239,8 @@ function correctContentEditableSelectionBeforeDelete (el) { else newEndOffset = endOffset; - var startPos = { node: startNode, offset: newStartOffset }; - var endPos = { node: endNode, offset: newEndOffset }; + const startPos = { node: startNode, offset: newStartOffset }; + const endPos = { node: endNode, offset: newEndOffset }; selectByNodesAndOffsets(startPos, endPos); } @@ -248,10 +248,10 @@ function correctContentEditableSelectionBeforeDelete (el) { //API export function hasInverseSelectionContentEditable (el) { - var curDocument = el ? domUtils.findDocument(el) : document; - var selection = curDocument.getSelection(); - var range = null; - var backward = false; + const curDocument = el ? domUtils.findDocument(el) : document; + const selection = curDocument.getSelection(); + let range = null; + let backward = false; if (selection) { if (!selection.isCollapsed) { @@ -267,14 +267,14 @@ export function hasInverseSelectionContentEditable (el) { } export function isInverseSelectionContentEditable (element, startPos, endPos) { - var startPosition = contentEditable.calculatePositionByNodeAndOffset(element, startPos); - var endPosition = contentEditable.calculatePositionByNodeAndOffset(element, endPos); + const startPosition = contentEditable.calculatePositionByNodeAndOffset(element, startPos); + const endPosition = contentEditable.calculatePositionByNodeAndOffset(element, endPos); return startPosition > endPosition; } export function getSelectionStart (el) { - var selection = null; + let selection = null; if (!domUtils.isContentEditableElement(el)) return selectionSandbox.getSelection(el).start; @@ -289,7 +289,7 @@ export function getSelectionStart (el) { } export function getSelectionEnd (el) { - var selection = null; + let selection = null; if (!domUtils.isContentEditableElement(el)) return selectionSandbox.getSelection(el).end; @@ -311,7 +311,7 @@ export function hasInverseSelection (el) { } export function getSelectionByElement (el) { - var currentDocument = domUtils.findDocument(el); + const currentDocument = domUtils.findDocument(el); return currentDocument ? currentDocument.getSelection() : window.getSelection(); } @@ -323,10 +323,10 @@ export function select (el, from, to) { return; } - var start = from || 0; - var end = typeof to === 'undefined' ? domUtils.getElementValue(el).length : to; - var inverse = false; - var temp = null; + let start = from || 0; + let end = typeof to === 'undefined' ? domUtils.getElementValue(el).length : to; + let inverse = false; + let temp = null; if (start > end) { temp = start; @@ -344,13 +344,13 @@ export function select (el, from, to) { } export function selectByNodesAndOffsets (startPos, endPos, needFocus) { - var startNode = startPos.node; - var endNode = endPos.node; + const startNode = startPos.node; + const endNode = endPos.node; - var startNodeLength = startNode.nodeValue ? startNode.length : 0; - var endNodeLength = endNode.nodeValue ? endNode.length : 0; - var startOffset = startPos.offset; - var endOffset = endPos.offset; + const startNodeLength = startNode.nodeValue ? startNode.length : 0; + const endNodeLength = endNode.nodeValue ? endNode.length : 0; + let startOffset = startPos.offset; + let endOffset = endPos.offset; if (!domUtils.isElementNode(startNode) || !startOffset) startOffset = Math.min(startNodeLength, startPos.offset); @@ -358,15 +358,15 @@ export function selectByNodesAndOffsets (startPos, endPos, needFocus) { if (!domUtils.isElementNode(endNode) || !endOffset) endOffset = Math.min(endNodeLength, endPos.offset); - var parentElement = contentEditable.findContentEditableParent(startNode); - var inverse = isInverseSelectionContentEditable(parentElement, startPos, endPos); + const parentElement = contentEditable.findContentEditableParent(startNode); + const inverse = isInverseSelectionContentEditable(parentElement, startPos, endPos); - var selection = getSelectionByElement(parentElement); - var curDocument = domUtils.findDocument(parentElement); - var range = curDocument.createRange(); + const selection = getSelectionByElement(parentElement); + const curDocument = domUtils.findDocument(parentElement); + const range = curDocument.createRange(); - var selectionSetter = function () { + const selectionSetter = function () { selection.removeAllRanges(); //NOTE: For IE we can't create inverse selection @@ -385,9 +385,9 @@ export function selectByNodesAndOffsets (startPos, endPos, needFocus) { range.setEnd(startNode, startOffset); selection.addRange(range); - var shouldCutEndOffset = browserUtils.isSafari || browserUtils.isChrome && browserUtils.version < 58; + const shouldCutEndOffset = browserUtils.isSafari || browserUtils.isChrome && browserUtils.version < 58; - var extendSelection = (node, offset) => { + const extendSelection = (node, offset) => { // NODE: in some cases in Firefox extend method raises error so we use try-catch try { selection.extend(node, offset); @@ -412,19 +412,19 @@ export function selectByNodesAndOffsets (startPos, endPos, needFocus) { } function deleteSelectionRanges (el) { - var selection = getSelectionByElement(el); - var rangeCount = selection.rangeCount; + const selection = getSelectionByElement(el); + const rangeCount = selection.rangeCount; if (!rangeCount) return; - for (var i = 0; i < rangeCount; i++) + for (let i = 0; i < rangeCount; i++) selection.getRangeAt(i).deleteContents(); } export function deleteSelectionContents (el, selectAll) { - var startSelection = getSelectionStart(el); - var endSelection = getSelectionEnd(el); + const startSelection = getSelectionStart(el); + const endSelection = getSelectionEnd(el); if (selectAll) @@ -439,8 +439,8 @@ export function deleteSelectionContents (el, selectAll) { deleteSelectionRanges(el); - var selection = getSelectionByElement(el); - var range = null; + const selection = getSelectionByElement(el); + let range = null; //NOTE: We should try to do selection collapsed if (selection.rangeCount && !selection.getRangeAt(0).collapsed) { @@ -450,13 +450,13 @@ export function deleteSelectionContents (el, selectAll) { } export function setCursorToLastVisiblePosition (el) { - var position = contentEditable.getLastVisiblePosition(el); + const position = contentEditable.getLastVisiblePosition(el); selectContentEditable(el, position, position); } export function hasElementContainsSelection (el) { - var selection = getSelectionByElement(el); + const selection = getSelectionByElement(el); return selection.anchorNode && selection.focusNode ? domUtils.isElementContainsNode(el, selection.anchorNode) && diff --git a/src/client/core/utils/wait-for.js b/src/client/core/utils/wait-for.js index 3603d2dc..4d8e80a9 100644 --- a/src/client/core/utils/wait-for.js +++ b/src/client/core/utils/wait-for.js @@ -1,19 +1,19 @@ import hammerhead from '../deps/hammerhead'; -var Promise = hammerhead.Promise; -var nativeMethods = hammerhead.nativeMethods; +const Promise = hammerhead.Promise; +const nativeMethods = hammerhead.nativeMethods; export default function (fn, delay, timeout) { return new Promise((resolve, reject) => { - var result = fn(); + let result = fn(); if (result) { resolve(result); return; } - var intervalId = nativeMethods.setInterval.call(window, () => { + const intervalId = nativeMethods.setInterval.call(window, () => { result = fn(); if (result) { @@ -23,7 +23,7 @@ export default function (fn, delay, timeout) { } }, delay); - var timeoutId = nativeMethods.setTimeout.call(window, () => { + const timeoutId = nativeMethods.setTimeout.call(window, () => { nativeMethods.clearInterval.call(window, intervalId); reject(); }, timeout); diff --git a/src/client/driver/command-executors/browser-manipulation/ensure-crop-options.js b/src/client/driver/command-executors/browser-manipulation/ensure-crop-options.js index 0a4c0e29..d8c5e954 100644 --- a/src/client/driver/command-executors/browser-manipulation/ensure-crop-options.js +++ b/src/client/driver/command-executors/browser-manipulation/ensure-crop-options.js @@ -5,9 +5,9 @@ import { ActionInvalidScrollTargetError, InvalidElementScreenshotDimensionsError function determineDimensionBounds (bounds, maximum) { - var hasMin = typeof bounds.min === 'number'; - var hasMax = typeof bounds.max === 'number'; - var hasLength = typeof bounds.length === 'number'; + const hasMin = typeof bounds.min === 'number'; + const hasMax = typeof bounds.max === 'number'; + const hasLength = typeof bounds.length === 'number'; if (hasLength) bounds.length = limitNumber(bounds.length, 0, maximum); @@ -36,23 +36,23 @@ function determineScrollPoint (cropStart, cropEnd, viewportBound) { } export default function ensureCropOptions (element, options) { - var elementRectangle = element.getBoundingClientRect(); + const elementRectangle = element.getBoundingClientRect(); - var elementBounds = { + const elementBounds = { left: elementRectangle.left, right: elementRectangle.right, top: elementRectangle.top, bottom: elementRectangle.bottom }; - var elementMargin = styleUtils.getElementMargin(element); - var elementPadding = styleUtils.getElementPadding(element); - var elementBordersWidth = styleUtils.getBordersWidth(element); + const elementMargin = styleUtils.getElementMargin(element); + const elementPadding = styleUtils.getElementPadding(element); + const elementBordersWidth = styleUtils.getBordersWidth(element); options.originOffset = { x: 0, y: 0 }; - var scrollRight = elementBounds.left + element.scrollWidth + elementBordersWidth.left + elementBordersWidth.right; - var scrollBottom = elementBounds.top + element.scrollHeight + elementBordersWidth.top + elementBordersWidth.bottom; + const scrollRight = elementBounds.left + element.scrollWidth + elementBordersWidth.left + elementBordersWidth.right; + const scrollBottom = elementBounds.top + element.scrollHeight + elementBordersWidth.top + elementBordersWidth.bottom; elementBounds.right = Math.max(elementBounds.right, scrollRight); elementBounds.bottom = Math.max(elementBounds.bottom, scrollBottom); @@ -89,8 +89,8 @@ export default function ensureCropOptions (element, options) { elementBounds.width = elementBounds.right - elementBounds.left; elementBounds.height = elementBounds.bottom - elementBounds.top; - var horizontalCropBounds = determineDimensionBounds({ min: options.crop.left, max: options.crop.right, length: options.crop.width }, elementBounds.width); - var verticalCropBounds = determineDimensionBounds({ min: options.crop.top, max: options.crop.bottom, length: options.crop.height }, elementBounds.height); + const horizontalCropBounds = determineDimensionBounds({ min: options.crop.left, max: options.crop.right, length: options.crop.width }, elementBounds.width); + const verticalCropBounds = determineDimensionBounds({ min: options.crop.top, max: options.crop.bottom, length: options.crop.height }, elementBounds.height); options.crop.left = horizontalCropBounds.min; options.crop.right = horizontalCropBounds.max; @@ -103,13 +103,13 @@ export default function ensureCropOptions (element, options) { if (options.crop.width <= 0 || options.crop.height <= 0) throw new InvalidElementScreenshotDimensionsError(options.crop.width, options.crop.height); - var viewportDimensions = styleUtils.getViewportDimensions(); + const viewportDimensions = styleUtils.getViewportDimensions(); if (elementBounds.width > viewportDimensions.width || elementBounds.height > viewportDimensions.height) options.scrollToCenter = true; - var hasScrollTargetX = typeof options.scrollTargetX === 'number'; - var hasScrollTargetY = typeof options.scrollTargetY === 'number'; + const hasScrollTargetX = typeof options.scrollTargetX === 'number'; + const hasScrollTargetY = typeof options.scrollTargetY === 'number'; if (!hasScrollTargetX) options.scrollTargetX = determineScrollPoint(options.crop.left, options.crop.right, viewportDimensions.width); @@ -117,13 +117,13 @@ export default function ensureCropOptions (element, options) { if (!hasScrollTargetY) options.scrollTargetY = determineScrollPoint(options.crop.top, options.crop.bottom, viewportDimensions.height); - var { offsetX, offsetY } = getOffsetOptions(element, options.scrollTargetX, options.scrollTargetY); + const { offsetX, offsetY } = getOffsetOptions(element, options.scrollTargetX, options.scrollTargetY); options.scrollTargetX = offsetX; options.scrollTargetY = offsetY; - var isScrollTargetXValid = !hasScrollTargetX || options.scrollTargetX >= options.crop.left && options.scrollTargetX <= options.crop.right; - var isScrollTargetYValid = !hasScrollTargetY || options.scrollTargetY >= options.crop.top && options.scrollTargetY <= options.crop.bottom; + const isScrollTargetXValid = !hasScrollTargetX || options.scrollTargetX >= options.crop.left && options.scrollTargetX <= options.crop.right; + const isScrollTargetYValid = !hasScrollTargetY || options.scrollTargetY >= options.crop.top && options.scrollTargetY <= options.crop.bottom; if (!isScrollTargetXValid || !isScrollTargetYValid) throw new ActionInvalidScrollTargetError(isScrollTargetXValid, isScrollTargetYValid); diff --git a/src/client/driver/command-executors/browser-manipulation/index.js b/src/client/driver/command-executors/browser-manipulation/index.js index 0b44d463..fd9f3175 100644 --- a/src/client/driver/command-executors/browser-manipulation/index.js +++ b/src/client/driver/command-executors/browser-manipulation/index.js @@ -22,14 +22,14 @@ const MANIPULATION_RESPONSE_CMD = 'driver|browser-manipulation|response'; // Setup cross-iframe interaction messageSandbox.on(messageSandbox.SERVICE_MSG_RECEIVED_EVENT, e => { if (e.message.cmd === MANIPULATION_REQUEST_CMD) { - var element = domUtils.findIframeByWindow(e.source); + const element = domUtils.findIframeByWindow(e.source); - var { command, cropDimensions } = e.message; + const { command, cropDimensions } = e.message; if (cropDimensions) command.options = new ElementScreenshotOptions({ crop: cropDimensions, includePaddings: false }); - var manipulation = new ManipulationExecutor(command); + const manipulation = new ManipulationExecutor(command); manipulation.element = element; @@ -48,13 +48,13 @@ class ManipulationExecutor { } _getAbsoluteCropValues () { - var { top, left } = this.element.getBoundingClientRect(); + let { top, left } = this.element.getBoundingClientRect(); left += this.command.options.originOffset.x; top += this.command.options.originOffset.y; - var right = left + this.command.options.crop.right; - var bottom = top + this.command.options.crop.bottom; + const right = left + this.command.options.crop.right; + const bottom = top + this.command.options.crop.bottom; top += this.command.options.crop.top; left += this.command.options.crop.left; @@ -63,9 +63,9 @@ class ManipulationExecutor { } _createManipulationReadyMessage () { - var dpr = window.devicePixelRatio || 1; + const dpr = window.devicePixelRatio || 1; - var message = { + const message = { cmd: MESSAGE.readyForBrowserManipulation, pageDimensions: { @@ -94,9 +94,9 @@ class ManipulationExecutor { if (this.element || !this.command.selector) return Promise.resolve(); - var selectorTimeout = this.command.selector.timeout; + const selectorTimeout = this.command.selector.timeout; - var specificSelectorTimeout = typeof selectorTimeout === 'number' ? selectorTimeout : this.globalSelectorTimeout; + const specificSelectorTimeout = typeof selectorTimeout === 'number' ? selectorTimeout : this.globalSelectorTimeout; this.statusBar.showWaitingElementStatus(specificSelectorTimeout); @@ -115,9 +115,9 @@ class ManipulationExecutor { .then(() => { ensureCropOptions(this.element, this.command.options); - var { scrollTargetX, scrollTargetY, scrollToCenter } = this.command.options; + const { scrollTargetX, scrollTargetY, scrollToCenter } = this.command.options; - var scrollAutomation = new ScrollAutomation(this.element, new ScrollOptions({ + const scrollAutomation = new ScrollAutomation(this.element, new ScrollOptions({ offsetX: scrollTargetX, offsetY: scrollTargetY, scrollToCenter: scrollToCenter, @@ -148,9 +148,9 @@ class ManipulationExecutor { if (window.top === window) return transport.queuedAsyncServiceMsg(this._createManipulationReadyMessage()); - var cropDimensions = this._getAbsoluteCropValues(); + const cropDimensions = this._getAbsoluteCropValues(); - var iframeRequestPromise = sendRequestToFrame({ + const iframeRequestPromise = sendRequestToFrame({ cmd: MANIPULATION_REQUEST_CMD, command: this.command, cropDimensions: cropDimensions @@ -161,7 +161,7 @@ class ManipulationExecutor { if (!message.result) return { result: null }; - var { result, executionError } = message.result; + const { result, executionError } = message.result; if (executionError) throw executionError; @@ -171,7 +171,7 @@ class ManipulationExecutor { } _runManipulation () { - var manipulationResult = null; + let manipulationResult = null; return Promise .resolve() @@ -212,14 +212,14 @@ class ManipulationExecutor { } execute () { - var { barriersPromise } = runWithBarriers(() => this._runManipulation()); + const { barriersPromise } = runWithBarriers(() => this._runManipulation()); return barriersPromise; } } export default function (command, globalSelectorTimeout, statusBar) { - var manipulationExecutor = new ManipulationExecutor(command, globalSelectorTimeout, statusBar); + const manipulationExecutor = new ManipulationExecutor(command, globalSelectorTimeout, statusBar); return manipulationExecutor.execute(); } diff --git a/src/client/driver/command-executors/client-functions/client-function-executor.js b/src/client/driver/command-executors/client-functions/client-function-executor.js index b495d091..a95721ab 100644 --- a/src/client/driver/command-executors/client-functions/client-function-executor.js +++ b/src/client/driver/command-executors/client-functions/client-function-executor.js @@ -16,7 +16,7 @@ export default class ClientFunctionExecutor { getResult () { return Promise.resolve() .then(() => { - var args = this.replicator.decode(this.command.args); + const args = this.replicator.decode(this.command.args); return this._executeFn(args); }) diff --git a/src/client/driver/command-executors/client-functions/element-utils.js b/src/client/driver/command-executors/client-functions/element-utils.js deleted file mode 100644 index 16e114f9..00000000 --- a/src/client/driver/command-executors/client-functions/element-utils.js +++ /dev/null @@ -1,16 +0,0 @@ -import { domUtils, positionUtils } from '../../deps/testcafe-core'; -import { selectElement as selectElementUI } from '../../deps/testcafe-ui'; - -export function exists (el) { - return !!el; -} - -export function visible (el) { - if (!domUtils.isDomElement(el) && !domUtils.isTextNode(el)) - return false; - - if (domUtils.isOptionElement(el) || domUtils.getTagName(el) === 'optgroup') - return selectElementUI.isOptionElementVisible(el); - - return positionUtils.isElementVisible(el); -} diff --git a/src/client/driver/command-executors/client-functions/eval-function.js b/src/client/driver/command-executors/client-functions/eval-function.js index eba661c8..b1a2b590 100644 --- a/src/client/driver/command-executors/client-functions/eval-function.js +++ b/src/client/driver/command-executors/client-functions/eval-function.js @@ -2,7 +2,7 @@ import hammerhead from '../../deps/hammerhead'; // NOTE: expose Promise to the function code /* eslint-disable no-unused-vars */ -var Promise = hammerhead.Promise; +const Promise = hammerhead.Promise; /* eslint-enable no-unused-vars */ // NOTE: evalFunction is isolated into a separate module to diff --git a/src/client/driver/command-executors/client-functions/replicator.js b/src/client/driver/command-executors/client-functions/replicator.js index b8df8ce5..202e029b 100644 --- a/src/client/driver/command-executors/client-functions/replicator.js +++ b/src/client/driver/command-executors/client-functions/replicator.js @@ -5,15 +5,15 @@ import { DomNodeClientFunctionResultError, UncaughtErrorInCustomDOMPropertyCode import hammerhead from '../../deps/hammerhead'; // NOTE: save original ctors because they may be overwritten by page code -var Node = window.Node; -var identity = val => val; +const Node = window.Node; +const identity = val => val; export function createReplicator (transforms) { // NOTE: we will serialize replicator results // to JSON with a command or command result. // Therefore there is no need to do additional job here, // so we use identity functions for serialization. - var replicator = new Replicator({ + const replicator = new Replicator({ serialize: identity, deserialize: identity }); @@ -61,7 +61,7 @@ export class SelectorNodeTransform { } toSerializable (node) { - var snapshot = node.nodeType === 1 ? new ElementSnapshot(node) : new NodeSnapshot(node); + const snapshot = node.nodeType === 1 ? new ElementSnapshot(node) : new NodeSnapshot(node); this._extend(snapshot, node); diff --git a/src/client/driver/command-executors/client-functions/selector-executor/filter.js b/src/client/driver/command-executors/client-functions/selector-executor/filter.js index 1bdb27d8..048ae863 100644 --- a/src/client/driver/command-executors/client-functions/selector-executor/filter.js +++ b/src/client/driver/command-executors/client-functions/selector-executor/filter.js @@ -1,75 +1,117 @@ import { InvalidSelectorResultError } from '../../../../../errors/test-run'; -import { exists, visible } from '../element-utils'; +import { exists, visible, IsNodeCollection } from '../../../utils/element-utils'; import testCafeCore from '../../../deps/testcafe-core'; import hammerhead from '../../../deps/hammerhead'; -// NOTE: save original ctors and methods because they may be overwritten by page code -var isArray = Array.isArray; -var Node = window.Node; -var HTMLCollection = window.HTMLCollection; -var NodeList = window.NodeList; -var arrayUtils = testCafeCore.arrayUtils; +const arrayUtils = testCafeCore.arrayUtils; +const typeUtils = hammerhead.utils.types; +const nativeMethods = hammerhead.nativeMethods; + +const SELECTOR_FILTER_ERROR = { + filterVisible: 1, + filterHidden: 2, + nth: 3 +}; + +const FILTER_ERROR_TO_API_RE = { + [SELECTOR_FILTER_ERROR.filterVisible]: /^\.filterVisible\(\)$/, + [SELECTOR_FILTER_ERROR.filterHidden]: /^\.filterHidden\(\)$/, + [SELECTOR_FILTER_ERROR.nth]: /^\.nth\(\d+\)$/ +}; + +class SelectorFilter { + constructor () { + this.err = null; + } -function isArrayOfNodes (obj) { - if (!isArray(obj)) - return false; + get error () { + return this.err; + } - for (var i = 0; i < obj.length; i++) { - if (!(obj[i] instanceof Node)) - return false; + set error (message) { + if (this.err === null) + this.err = message; } - return true; -} + filter (node, options, apiInfo) { + let filtered = arrayUtils.filter(node, exists); -function getNodeByIndex (collection, index) { - return index < 0 ? collection[collection.length + index] : collection[index]; -} + if (options.filterVisible) { + filtered = filtered.filter(visible); + this.assertFilterError(filtered, apiInfo, SELECTOR_FILTER_ERROR.filterVisible); + } -// Selector filter -hammerhead.nativeMethods.objectDefineProperty.call(window, window, '%testCafeSelectorFilter%', { - value: (node, options) => { - var filtered = []; + if (options.filterHidden) { + filtered = filtered.filter(n => !visible(n)); - if (node === null || node === void 0) - filtered = []; + this.assertFilterError(filtered, apiInfo, SELECTOR_FILTER_ERROR.filterHidden); + } - else if (node instanceof Node) - filtered = [node]; + if (options.counterMode) { + if (options.index !== null) + filtered = this.getNodeByIndex(filtered, options.index) ? 1 : 0; + else + filtered = filtered.length; + } + else { + if (options.collectionMode) { + if (options.index !== null) { + const nodeOnIndex = this.getNodeByIndex(filtered, options.index); + + filtered = nodeOnIndex ? [nodeOnIndex] : []; + } + } + else + filtered = this.getNodeByIndex(filtered, options.index || 0); - else if (node instanceof HTMLCollection || node instanceof NodeList || isArrayOfNodes(node)) - filtered = node; + if (typeof options.index === 'number') + this.assertFilterError(filtered, apiInfo, SELECTOR_FILTER_ERROR.nth); + } - else - throw new InvalidSelectorResultError(); + return filtered; + } - filtered = arrayUtils.filter(filtered, n => exists(n)); + cast (node) { + let result = null; - if (options.filterVisible) - filtered = filtered.filter(n => visible(n)); + if (typeUtils.isNullOrUndefined(node)) + result = []; - if (options.filterHidden) - filtered = filtered.filter(n => !visible(n)); + else if (node instanceof Node) + result = [node]; - if (options.counterMode) { - if (options.index !== null) - return getNodeByIndex(filtered, options.index) ? 1 : 0; + else if (IsNodeCollection(node)) + result = node; - return filtered.length; - } + else + throw new InvalidSelectorResultError(); - if (options.collectionMode) { - if (options.index !== null) { - var nodeOnIndex = getNodeByIndex(filtered, options.index); + return result; + } - return nodeOnIndex ? [nodeOnIndex] : []; - } + assertFilterError (filtered, apiInfo, filterError) { + if (!filtered || filtered.length === 0) + this.error = this.getErrorItem(apiInfo, filterError); + } - return filtered; + getErrorItem ({ apiFnChain, apiFnID }, err) { + if (err) { + for (let i = apiFnID; i < apiFnChain.length; i++) { + if (FILTER_ERROR_TO_API_RE[err].test(apiFnChain[i])) + return i; + } } + return null; + } - return getNodeByIndex(filtered, options.index || 0); - }, + getNodeByIndex (collection, index) { + return index < 0 ? collection[collection.length + index] : collection[index]; + } +} + +// Selector filter +nativeMethods.objectDefineProperty.call(window, window, '%testCafeSelectorFilter%', { + value: new SelectorFilter(), configurable: true }); diff --git a/src/client/driver/command-executors/client-functions/selector-executor/index.js b/src/client/driver/command-executors/client-functions/selector-executor/index.js index 38a8d718..b5e095c5 100644 --- a/src/client/driver/command-executors/client-functions/selector-executor/index.js +++ b/src/client/driver/command-executors/client-functions/selector-executor/index.js @@ -1,7 +1,7 @@ import { Promise } from '../../../deps/hammerhead'; import { delay } from '../../../deps/testcafe-core'; import ClientFunctionExecutor from '../client-function-executor'; -import { exists, visible } from '../element-utils'; +import { exists, visible } from '../../../utils/element-utils'; import { createReplicator, FunctionTransform, SelectorNodeTransform } from '../replicator'; import './filter'; @@ -18,12 +18,12 @@ export default class SelectorExecutor extends ClientFunctionExecutor { this.counterMode = this.dependencies.filterOptions.counterMode; if (startTime) { - var elapsed = new Date() - startTime; + const elapsed = new Date() - startTime; this.timeout = Math.max(this.timeout - elapsed, 0); } - var customDOMProperties = this.dependencies && this.dependencies.customDOMProperties; + const customDOMProperties = this.dependencies && this.dependencies.customDOMProperties; this.replicator.addTransforms([new SelectorNodeTransform(customDOMProperties)]); } @@ -34,6 +34,16 @@ export default class SelectorExecutor extends ClientFunctionExecutor { ]); } + _getTimeoutErrorParams () { + const apiFnIndex = window['%testCafeSelectorFilter%'].error; + const apiFnChain = this.command.apiFnChain; + + if (typeof apiFnIndex !== 'undefined') + return { apiFnIndex, apiFnChain }; + + return null; + } + _validateElement (args, startTime) { return Promise.resolve() .then(() => this.fn.apply(window, args)) @@ -50,7 +60,7 @@ export default class SelectorExecutor extends ClientFunctionExecutor { return delay(CHECK_ELEMENT_DELAY).then(() => this._validateElement(args, startTime)); if (createTimeoutError) - throw createTimeoutError(); + throw createTimeoutError(this._getTimeoutErrorParams()); return null; }); @@ -60,9 +70,9 @@ export default class SelectorExecutor extends ClientFunctionExecutor { if (this.counterMode) return super._executeFn(args); - var startTime = new Date(); - var error = null; - var element = null; + const startTime = new Date(); + let error = null; + let element = null; return this ._validateElement(args, startTime) diff --git a/src/client/driver/command-executors/client-functions/selector-executor/node-snapshots.js b/src/client/driver/command-executors/client-functions/selector-executor/node-snapshots.js index 74adbc0e..949324ed 100644 --- a/src/client/driver/command-executors/client-functions/selector-executor/node-snapshots.js +++ b/src/client/driver/command-executors/client-functions/selector-executor/node-snapshots.js @@ -7,7 +7,7 @@ import { // Node -var nodeSnapshotPropertyInitializers = { +const nodeSnapshotPropertyInitializers = { // eslint-disable-next-line no-restricted-properties textContent: node => node.textContent, childNodeCount: node => node.childNodes.length, @@ -15,17 +15,17 @@ var nodeSnapshotPropertyInitializers = { childElementCount: node => { /*eslint-disable no-restricted-properties*/ - var children = node.children; + const children = node.children; if (children) return children.length; // NOTE: IE doesn't have `children` for non-element nodes =/ - var childElementCount = 0; - var childNodeCount = node.childNodes.length; + let childElementCount = 0; + const childNodeCount = node.childNodes.length; /*eslint-enable no-restricted-properties*/ - for (var i = 0; i < childNodeCount; i++) { + for (let i = 0; i < childNodeCount; i++) { if (node.childNodes[i].nodeType === 1) childElementCount++; } @@ -44,9 +44,9 @@ export class NodeSnapshot { } _initializeProperties (node, properties, initializers) { - for (var i = 0; i < properties.length; i++) { - var property = properties[i]; - var initializer = initializers[property]; + for (let i = 0; i < properties.length; i++) { + const property = properties[i]; + const initializer = initializers[property]; this[property] = initializer ? initializer(node) : node[property]; } @@ -55,17 +55,17 @@ export class NodeSnapshot { // Element -var elementSnapshotPropertyInitializers = { +const elementSnapshotPropertyInitializers = { tagName: element => element.tagName.toLowerCase(), visible: positionUtils.isElementVisible, focused: element => domUtils.getActiveElement() === element, attributes: element => { // eslint-disable-next-line no-restricted-properties - var attrs = element.attributes; - var result = {}; + const attrs = element.attributes; + const result = {}; - for (var i = attrs.length - 1; i >= 0; i--) + for (let i = attrs.length - 1; i >= 0; i--) // eslint-disable-next-line no-restricted-properties result[attrs[i].name] = attrs[i].value; @@ -73,7 +73,7 @@ var elementSnapshotPropertyInitializers = { }, boundingClientRect: element => { - var rect = element.getBoundingClientRect(); + const rect = element.getBoundingClientRect(); return { left: rect.left, @@ -86,7 +86,7 @@ var elementSnapshotPropertyInitializers = { }, classNames: element => { - var className = element.className; + let className = element.className; className = typeof className.animVal === 'string' ? className.animVal : className; @@ -96,11 +96,11 @@ var elementSnapshotPropertyInitializers = { }, style: element => { - var result = {}; - var computed = window.getComputedStyle(element); + const result = {}; + const computed = window.getComputedStyle(element); - for (var i = 0; i < computed.length; i++) { - var prop = computed[i]; + for (let i = 0; i < computed.length; i++) { + const prop = computed[i]; result[prop] = computed[prop]; } diff --git a/src/client/driver/command-executors/execute-action.js b/src/client/driver/command-executors/execute-action.js index 81028351..5fb2ba36 100644 --- a/src/client/driver/command-executors/execute-action.js +++ b/src/client/driver/command-executors/execute-action.js @@ -75,7 +75,7 @@ function ensureFileInput (element) { } function ensureOffsetOptions (element, options) { - var { offsetX, offsetY } = getOffsetOptions(element, options.offsetX, options.offsetY); + const { offsetX, offsetY } = getOffsetOptions(element, options.offsetX, options.offsetY); options.offsetX = offsetX; options.offsetY = offsetY; @@ -101,7 +101,7 @@ class ActionExecutor { } _getSpecificTimeout () { - var hasSpecificTimeout = this.command.selector && typeof this.command.selector.timeout === 'number'; + const hasSpecificTimeout = this.command.selector && typeof this.command.selector.timeout === 'number'; return hasSpecificTimeout ? this.command.selector.timeout : this.globalSelectorTimeout; } @@ -119,7 +119,7 @@ class ActionExecutor { _ensureCommandArguments () { if (this.command.type === COMMAND_TYPE.pressKey) { - var parsedKeySequence = parseKeySequence(this.command.keys); + const parsedKeySequence = parseKeySequence(this.command.keys); if (parsedKeySequence.error) throw new ActionIncorrectKeysError('keys'); @@ -127,7 +127,7 @@ class ActionExecutor { } _ensureCommandElements () { - var elementDescriptors = []; + const elementDescriptors = []; if (this.command.selector) elementDescriptors.push(createElementDescriptor(this.command.selector)); @@ -168,7 +168,7 @@ class ActionExecutor { } _createAutomation () { - var selectArgs = null; + let selectArgs = null; switch (this.command.type) { case COMMAND_TYPE.click : @@ -227,7 +227,7 @@ class ActionExecutor { .then(() => { this._ensureCommandOptions(); - var automation = this._createAutomation(); + const automation = this._createAutomation(); if (automation.TARGET_ELEMENT_FOUND_EVENT) { automation.on(automation.TARGET_ELEMENT_FOUND_EVENT, () => { @@ -246,8 +246,8 @@ class ActionExecutor { } _runRecursively () { - var actionFinished = false; - var strictElementCheck = true; + let actionFinished = false; + let strictElementCheck = true; return promiseUtils.whilst(() => !actionFinished, () => { return this @@ -278,11 +278,11 @@ class ActionExecutor { if (this.command.options && !this.command.options.speed) this.command.options.speed = this.testSpeed; - var startPromise = new Promise(resolve => { + const startPromise = new Promise(resolve => { this.executionStartedHandler = resolve; }); - var completionPromise = new Promise(resolve => { + const completionPromise = new Promise(resolve => { this.executionStartTime = new Date(); try { @@ -297,7 +297,7 @@ class ActionExecutor { this.statusBar.showWaitingElementStatus(this.commandSelectorTimeout); - var { actionPromise, barriersPromise } = runWithBarriers(() => this._runRecursively()); + const { actionPromise, barriersPromise } = runWithBarriers(() => this._runRecursively()); actionPromise .then(() => Promise.all([ @@ -316,7 +316,7 @@ class ActionExecutor { } export default function executeAction (command, globalSelectorTimeout, statusBar, testSpeed) { - var actionExecutor = new ActionExecutor(command, globalSelectorTimeout, statusBar, testSpeed); + const actionExecutor = new ActionExecutor(command, globalSelectorTimeout, statusBar, testSpeed); return actionExecutor.execute(); } diff --git a/src/client/driver/command-executors/execute-navigate-to.js b/src/client/driver/command-executors/execute-navigate-to.js index 9ab2708c..3dacbe0d 100644 --- a/src/client/driver/command-executors/execute-navigate-to.js +++ b/src/client/driver/command-executors/execute-navigate-to.js @@ -1,19 +1,26 @@ import hammerhead from '../deps/hammerhead'; -import testCafeCore from '../deps/testcafe-core'; +import { RequestBarrier, pageUnloadBarrier, browser } from '../deps/testcafe-core'; import DriverStatus from '../status'; -var Promise = hammerhead.Promise; - -var RequestBarrier = testCafeCore.RequestBarrier; -var pageUnloadBarrier = testCafeCore.pageUnloadBarrier; +const { createNativeXHR, utils } = hammerhead; export default function executeNavigateTo (command) { - var requestBarrier = new RequestBarrier(); + const navigationUrl = utils.url.getNavigationUrl(command.url, window); + + let ensurePagePromise = hammerhead.Promise.resolve(); + + if (navigationUrl && browser.isRetryingTestPagesEnabled()) + ensurePagePromise = browser.fetchPageToCache(navigationUrl, createNativeXHR); + + return ensurePagePromise + .then(() => { + const requestBarrier = new RequestBarrier(); - hammerhead.navigateTo(command.url); + hammerhead.navigateTo(command.url); - return Promise.all([requestBarrier.wait(), pageUnloadBarrier.wait()]) + return hammerhead.Promise.all([requestBarrier.wait(), pageUnloadBarrier.wait()]); + }) .then(() => new DriverStatus({ isCommandResult: true })) .catch(err => new DriverStatus({ isCommandResult: true, executionError: err })); } diff --git a/src/client/driver/command-executors/execute-selector.js b/src/client/driver/command-executors/execute-selector.js index 9792b319..e3d17048 100644 --- a/src/client/driver/command-executors/execute-selector.js +++ b/src/client/driver/command-executors/execute-selector.js @@ -1,7 +1,7 @@ import SelectorExecutor from './client-functions/selector-executor'; export function getResult (command, globalTimeout, startTime, createNotFoundError, createIsInvisibleError, statusBar) { - var selectorExecutor = new SelectorExecutor(command, globalTimeout, startTime, createNotFoundError, createIsInvisibleError); + const selectorExecutor = new SelectorExecutor(command, globalTimeout, startTime, createNotFoundError, createIsInvisibleError); statusBar.showWaitingElementStatus(selectorExecutor.timeout); @@ -19,7 +19,7 @@ export function getResult (command, globalTimeout, startTime, createNotFoundErro } export function getResultDriverStatus (command, globalTimeout, startTime, createNotFoundError, createIsInvisibleError, statusBar) { - var selectorExecutor = new SelectorExecutor(command, globalTimeout, startTime, createNotFoundError, createIsInvisibleError); + const selectorExecutor = new SelectorExecutor(command, globalTimeout, startTime, createNotFoundError, createIsInvisibleError); statusBar.showWaitingElementStatus(selectorExecutor.timeout); diff --git a/src/client/driver/driver-link/child.js b/src/client/driver/driver-link/child.js index b267b815..63fdb1fa 100644 --- a/src/client/driver/driver-link/child.js +++ b/src/client/driver/driver-link/child.js @@ -55,9 +55,9 @@ export default class ChildDriverLink { } _waitForCommandResult () { - var onMessage = null; + let onMessage = null; - var waitForResultMessage = () => new Promise(resolve => { + const waitForResultMessage = () => new Promise(resolve => { onMessage = e => { if (e.message.type === MESSAGE_TYPE.commandExecuted) resolve(e.message.driverStatus); @@ -77,7 +77,7 @@ export default class ChildDriverLink { } confirmConnectionEstablished (requestMsgId) { - var msg = new ConfirmationMessage(requestMsgId, { id: this.driverId }); + const msg = new ConfirmationMessage(requestMsgId, { id: this.driverId }); eventSandbox.message.sendServiceMsg(msg, this.driverWindow); } @@ -88,7 +88,7 @@ export default class ChildDriverLink { return this ._ensureIframe() .then(() => { - var msg = new ExecuteCommandMessage(command, testSpeed); + const msg = new ExecuteCommandMessage(command, testSpeed); return Promise.all([ sendMessageToDriver(msg, this.driverWindow, this.iframeAvailabilityTimeout, CurrentIframeIsNotLoadedError), diff --git a/src/client/driver/driver-link/messages.js b/src/client/driver/driver-link/messages.js index cd6b39f7..256012de 100644 --- a/src/client/driver/driver-link/messages.js +++ b/src/client/driver/driver-link/messages.js @@ -1,6 +1,6 @@ import generateId from '../generate-id'; -export var TYPE = { +export const TYPE = { establishConnection: 'driver|establish-connection', commandExecuted: 'driver|command-executed', executeCommand: 'driver|execute-command', diff --git a/src/client/driver/driver-link/parent.js b/src/client/driver/driver-link/parent.js index dea5c8eb..4e90741a 100644 --- a/src/client/driver/driver-link/parent.js +++ b/src/client/driver/driver-link/parent.js @@ -12,20 +12,20 @@ export default class ParentDriverLink { } establishConnection () { - var msg = new EstablishConnectionMessage(); + const msg = new EstablishConnectionMessage(); return sendMessageToDriver(msg, this.driverWindow, WAIT_FOR_PARENT_DRIVER_RESPONSE_TIMEOUT, CurrentIframeIsNotLoadedError) .then(response => response.result.id); } confirmMessageReceived (requestMsgId) { - var msg = new ConfirmationMessage(requestMsgId); + const msg = new ConfirmationMessage(requestMsgId); eventSandbox.message.sendServiceMsg(msg, this.driverWindow); } onCommandExecuted (status) { - var msg = new CommandExecutedMessage(status); + const msg = new CommandExecutedMessage(status); eventSandbox.message.sendServiceMsg(msg, this.driverWindow); } diff --git a/src/client/driver/driver-link/send-message-to-driver.js b/src/client/driver/driver-link/send-message-to-driver.js index 6457c751..cb001364 100644 --- a/src/client/driver/driver-link/send-message-to-driver.js +++ b/src/client/driver/driver-link/send-message-to-driver.js @@ -8,13 +8,13 @@ const RESEND_MESSAGE_INTERVAL = 1000; export default function sendMessageToDriver (msg, driverWindow, timeout, NotLoadedErrorCtor) { - var sendMsgInterval = null; - var sendMsgTimeout = null; - var onResponse = null; + let sendMsgInterval = null; + const sendMsgTimeout = null; + let onResponse = null; timeout = Math.max(timeout || 0, MIN_RESPONSE_WAITING_TIMEOUT); - var sendAndWaitForResponse = () => { + const sendAndWaitForResponse = () => { return new Promise(resolve => { onResponse = e => { if (e.message.type === MESSAGE_TYPE.confirmation && e.message.requestMessageId === msg.id) diff --git a/src/client/driver/driver.js b/src/client/driver/driver.js index 59b06084..6e47bd24 100644 --- a/src/client/driver/driver.js +++ b/src/client/driver/driver.js @@ -7,6 +7,8 @@ import { preventRealEvents, disableRealEventsPreventing, waitFor, + delay, + getTimeLimitedPromise, browser } from './deps/testcafe-core'; import { StatusBar } from './deps/testcafe-ui'; @@ -27,7 +29,8 @@ import { ActionElementIsInvisibleError, CurrentIframeIsNotLoadedError, CurrentIframeNotFoundError, - CurrentIframeIsInvisibleError + CurrentIframeIsInvisibleError, + CantObtainInfoForElementSpecifiedBySelectorError } from '../../errors/test-run'; import BrowserConsoleMessages from '../../test-run/browser-console-messages'; @@ -48,11 +51,11 @@ import { } from './command-executors/execute-selector'; import ClientFunctionExecutor from './command-executors/client-functions/client-function-executor'; -var transport = hammerhead.transport; -var Promise = hammerhead.Promise; -var messageSandbox = hammerhead.eventSandbox.message; -var storages = hammerhead.storages; - +const transport = hammerhead.transport; +const Promise = hammerhead.Promise; +const messageSandbox = hammerhead.eventSandbox.message; +const storages = hammerhead.storages; +const DateCtor = hammerhead.nativeMethods.date; const TEST_DONE_SENT_FLAG = 'testcafe|driver|test-done-sent-flag'; const PENDING_STATUS = 'testcafe|driver|pending-status'; @@ -65,6 +68,10 @@ const ASSERTION_RETRIES_TIMEOUT = 'testcafe|driver|assertion-retries- const ASSERTION_RETRIES_START_TIME = 'testcafe|driver|assertion-retries-start-time'; const CONSOLE_MESSAGES = 'testcafe|driver|console-messages'; const CHECK_IFRAME_DRIVER_LINK_DELAY = 500; +const SEND_STATUS_REQUEST_TIME_LIMIT = 1000; +const SEND_STATUS_REQUEST_RETRY_DELAY = 300; +const SEND_STATUS_REQUEST_RETRY_COUNT = 5; +const CHECK_STATUS_RETRY_DELAY = 1000; const ACTION_IFRAME_ERROR_CTORS = { NotLoadedError: ActionIframeIsNotLoadedError, @@ -107,6 +114,9 @@ export default class Driver { this.statusBar = null; + if (options.retryTestPages) + browser.enableRetryingTestPages(); + this.pageInitialRequestBarrier = new RequestBarrier(); this.readyPromise = eventUtils @@ -146,7 +156,7 @@ export default class Driver { if (this.skipJsErrors || this.contextStorage.getItem(TEST_DONE_SENT_FLAG)) return Promise.resolve(); - var error = new UncaughtErrorOnPage(err.msg || err.message, err.pageUrl); + const error = new UncaughtErrorOnPage(err.stack, err.pageUrl); if (!this.contextStorage.getItem(PENDING_PAGE_ERROR)) this.contextStorage.setItem(PENDING_PAGE_ERROR, error); @@ -157,7 +167,7 @@ export default class Driver { _failIfClientCodeExecutionIsInterrupted () { // NOTE: ClientFunction should be used primarily for observation. We raise // an error if the page was reloaded during ClientFunction execution. - var executingClientFnDescriptor = this.contextStorage.getItem(EXECUTING_CLIENT_FUNCTION_DESCRIPTOR); + const executingClientFnDescriptor = this.contextStorage.getItem(EXECUTING_CLIENT_FUNCTION_DESCRIPTOR); if (executingClientFnDescriptor) { this._onReady(new DriverStatus({ @@ -182,7 +192,7 @@ export default class Driver { // Status _addPendingErrorToStatus (status) { - var pendingPageError = this.contextStorage.getItem(PENDING_PAGE_ERROR); + const pendingPageError = this.contextStorage.getItem(PENDING_PAGE_ERROR); if (pendingPageError) { this.contextStorage.setItem(PENDING_PAGE_ERROR, null); @@ -191,7 +201,7 @@ export default class Driver { } _addUnexpectedDialogErrorToStatus (status) { - var dialogError = this.nativeDialogsTracker.getUnexpectedDialogError(); + const dialogError = this.nativeDialogsTracker.getUnexpectedDialogError(); status.pageError = status.pageError || dialogError; } @@ -201,6 +211,25 @@ export default class Driver { this.consoleMessages = null; } + _sendStatusRequest (status) { + const statusRequestOptions = { + cmd: TEST_RUN_MESSAGES.ready, + status: status, + disableResending: true, + allowRejecting: true + }; + + const requestAttempt = () => getTimeLimitedPromise(transport.asyncServiceMsg(statusRequestOptions), SEND_STATUS_REQUEST_TIME_LIMIT); + const retryRequest = () => delay(SEND_STATUS_REQUEST_RETRY_DELAY).then(requestAttempt); + + let statusPromise = requestAttempt(); + + for (let i = 0; i < SEND_STATUS_REQUEST_RETRY_COUNT; i++) + statusPromise = statusPromise.catch(retryRequest); + + return statusPromise; + } + _sendStatus (status) { // NOTE: We should not modify the status if it is resent after // the page load because the server has cached the response @@ -212,17 +241,12 @@ export default class Driver { this.contextStorage.setItem(PENDING_STATUS, status); - var readyCommandResponse = null; + let readyCommandResponse = null; // NOTE: postpone status sending if the page is unloading return pageUnloadBarrier .wait(0) - .then(() => transport.queuedAsyncServiceMsg({ - cmd: TEST_RUN_MESSAGES.ready, - status: status, - disableResending: true - })) - + .then(() => this._sendStatusRequest(status)) //NOTE: do not execute the next command if the page is unloading .then(res => { readyCommandResponse = res; @@ -240,14 +264,14 @@ export default class Driver { // Iframes interaction _initChildDriverListening () { messageSandbox.on(messageSandbox.SERVICE_MSG_RECEIVED_EVENT, e => { - var msg = e.message; - var iframeWindow = e.source; + const msg = e.message; + const iframeWindow = e.source; if (msg.type === MESSAGE_TYPE.establishConnection) { - var childDriverLink = this._getChildDriverLinkByWindow(iframeWindow); + let childDriverLink = this._getChildDriverLinkByWindow(iframeWindow); if (!childDriverLink) { - var driverId = `${this.testRunId}-${generateId()}`; + const driverId = `${this.testRunId}-${generateId()}`; childDriverLink = new ChildDriverLink(iframeWindow, driverId); this.childDriverLinks.push(childDriverLink); @@ -263,8 +287,8 @@ export default class Driver { } _runInActiveIframe (command) { - var runningChain = Promise.resolve(); - var activeIframeSelector = this.contextStorage.getItem(ACTIVE_IFRAME_SELECTOR); + let runningChain = Promise.resolve(); + const activeIframeSelector = this.contextStorage.getItem(ACTIVE_IFRAME_SELECTOR); // NOTE: if the page was reloaded we restore the active child driver link via the iframe selector if (!this.activeChildDriverLink && activeIframeSelector) @@ -298,11 +322,11 @@ export default class Driver { } _switchToIframe (selector, iframeErrorCtors) { - var hasSpecificTimeout = typeof selector.timeout === 'number'; - var commandSelectorTimeout = hasSpecificTimeout ? selector.timeout : this.selectorTimeout; + const hasSpecificTimeout = typeof selector.timeout === 'number'; + const commandSelectorTimeout = hasSpecificTimeout ? selector.timeout : this.selectorTimeout; return getExecuteSelectorResult(selector, commandSelectorTimeout, null, - () => new iframeErrorCtors.NotFoundError(), () => iframeErrorCtors.IsInvisibleError(), this.statusBar) + fn => new iframeErrorCtors.NotFoundError(fn), () => iframeErrorCtors.IsInvisibleError(), this.statusBar) .then(iframe => { if (!domUtils.isIframeElement(iframe)) throw new ActionElementNotIframeError(); @@ -325,16 +349,16 @@ export default class Driver { } _setNativeDialogHandlerInIframes (dialogHandler) { - var msg = new SetNativeDialogHandlerMessage(dialogHandler); + const msg = new SetNativeDialogHandlerMessage(dialogHandler); - for (var i = 0; i < this.childDriverLinks.length; i++) + for (let i = 0; i < this.childDriverLinks.length; i++) messageSandbox.sendServiceMsg(msg, this.childDriverLinks[i].driverWindow); } // Commands handling _onActionCommand (command) { - var { startPromise, completionPromise } = executeActionCommand(command, this.selectorTimeout, this.statusBar, this.speed); + const { startPromise, completionPromise } = executeActionCommand(command, this.selectorTimeout, this.statusBar, this.speed); startPromise.then(() => this.contextStorage.setItem(this.COMMAND_EXECUTING_FLAG, true)); @@ -378,7 +402,7 @@ export default class Driver { _onExecuteClientFunctionCommand (command) { this.contextStorage.setItem(EXECUTING_CLIENT_FUNCTION_DESCRIPTOR, { instantiationCallsiteName: command.instantiationCallsiteName }); - var executor = new ClientFunctionExecutor(command); + const executor = new ClientFunctionExecutor(command); executor.getResultDriverStatus() .then(driverStatus => { @@ -388,9 +412,16 @@ export default class Driver { } _onExecuteSelectorCommand (command) { - var startTime = this.contextStorage.getItem(SELECTOR_EXECUTION_START_TIME) || new Date(); - - getExecuteSelectorResultDriverStatus(command, this.selectorTimeout, startTime, null, null, this.statusBar) + const startTime = this.contextStorage.getItem(SELECTOR_EXECUTION_START_TIME) || new DateCtor(); + const elementNotFoundOrNotVisible = fn => new CantObtainInfoForElementSpecifiedBySelectorError(null, fn); + const createError = command.needError ? elementNotFoundOrNotVisible : null; + + getExecuteSelectorResultDriverStatus(command, + this.selectorTimeout, + startTime, + createError, + createError, + this.statusBar) .then(driverStatus => { this.contextStorage.setItem(SELECTOR_EXECUTION_START_TIME, null); this._onReady(driverStatus); @@ -469,11 +500,14 @@ export default class Driver { browser.redirect(command); else this._onReady({ isCommandResult: false }); + }) + .catch(() => { + return delay(CHECK_STATUS_RETRY_DELAY); }); } _onCustomCommand (command) { - var handler = this.customCommandHandlers[command.type].handler; + const handler = this.customCommandHandlers[command.type].handler; handler(command).then(result => { this._onReady(new DriverStatus({ isCommandResult: true, result })); @@ -578,8 +612,8 @@ export default class Driver { .then(() => { // NOTE: we should not execute a command if we already have a pending page error and this command is // rejectable by page errors. In this case, we immediately send status with this error to the server. - var isCommandRejectableByError = isCommandRejectableByPageError(command); - var pendingPageError = this.contextStorage.getItem(PENDING_PAGE_ERROR); + const isCommandRejectableByError = isCommandRejectableByPageError(command); + const pendingPageError = this.contextStorage.getItem(PENDING_PAGE_ERROR); if (pendingPageError && isCommandRejectableByError) { this._onReady(new DriverStatus({ isCommandResult: true })); @@ -588,7 +622,7 @@ export default class Driver { // NOTE: we should execute a command in an iframe if the current execution context belongs to // this iframe and the command is not one of those that can be executed only in the top window. - var isThereActiveIframe = this.activeChildDriverLink || + const isThereActiveIframe = this.activeChildDriverLink || this.contextStorage.getItem(ACTIVE_IFRAME_SELECTOR); if (!this._isExecutableInTopWindowOnly(command) && isThereActiveIframe) { @@ -625,18 +659,18 @@ export default class Driver { this.readyPromise.then(() => { this.statusBar.hidePageLoadingStatus(); - var assertionRetriesTimeout = this.contextStorage.getItem(ASSERTION_RETRIES_TIMEOUT); + const assertionRetriesTimeout = this.contextStorage.getItem(ASSERTION_RETRIES_TIMEOUT); if (assertionRetriesTimeout) { - var startTime = this.contextStorage.getItem(ASSERTION_RETRIES_START_TIME); - var timeLeft = assertionRetriesTimeout - (new Date() - startTime); + const startTime = this.contextStorage.getItem(ASSERTION_RETRIES_START_TIME); + const timeLeft = assertionRetriesTimeout - (new Date() - startTime); if (timeLeft > 0) this.statusBar.showWaitingAssertionRetriesStatus(assertionRetriesTimeout, startTime); } }); - var pendingStatus = this.contextStorage.getItem(PENDING_STATUS); + const pendingStatus = this.contextStorage.getItem(PENDING_STATUS); if (pendingStatus) pendingStatus.resent = true; @@ -655,10 +689,10 @@ export default class Driver { if (this._failIfClientCodeExecutionIsInterrupted()) return; - var inCommandExecution = this.contextStorage.getItem(this.COMMAND_EXECUTING_FLAG) || + const inCommandExecution = this.contextStorage.getItem(this.COMMAND_EXECUTING_FLAG) || this.contextStorage.getItem(this.EXECUTING_IN_IFRAME_FLAG); - var status = pendingStatus || new DriverStatus({ isCommandResult: inCommandExecution }); + const status = pendingStatus || new DriverStatus({ isCommandResult: inCommandExecution }); this.contextStorage.setItem(this.COMMAND_EXECUTING_FLAG, false); this.contextStorage.setItem(this.EXECUTING_IN_IFRAME_FLAG, false); diff --git a/src/client/driver/embedding-utils.js b/src/client/driver/embedding-utils.js index e466d864..9744fd40 100644 --- a/src/client/driver/embedding-utils.js +++ b/src/client/driver/embedding-utils.js @@ -1,6 +1,8 @@ import { ElementSnapshot, NodeSnapshot } from './command-executors/client-functions/selector-executor/node-snapshots'; +import SelectorExecutor from './command-executors/client-functions/selector-executor'; export default { NodeSnapshot, - ElementSnapshot + ElementSnapshot, + SelectorExecutor }; diff --git a/src/client/driver/iframe-driver.js b/src/client/driver/iframe-driver.js index 564565b5..9db875ab 100644 --- a/src/client/driver/iframe-driver.js +++ b/src/client/driver/iframe-driver.js @@ -30,7 +30,7 @@ export default class IframeDriver extends Driver { // Messaging between drivers _initParentDriverListening () { eventSandbox.message.on(eventSandbox.message.SERVICE_MSG_RECEIVED_EVENT, e => { - var msg = e.message; + const msg = e.message; pageUnloadBarrier .wait(0) @@ -77,7 +77,7 @@ export default class IframeDriver extends Driver { this.nativeDialogsTracker = new IframeNativeDialogTracker(this.dialogHandler); this.statusBar = new IframeStatusBar(); - var initializePromise = this.parentDriverLink + const initializePromise = this.parentDriverLink .establishConnection() .then(id => { this.contextStorage = new ContextStorage(window, id); @@ -85,7 +85,7 @@ export default class IframeDriver extends Driver { if (this._failIfClientCodeExecutionIsInterrupted()) return; - var inCommandExecution = this.contextStorage.getItem(this.COMMAND_EXECUTING_FLAG) || + const inCommandExecution = this.contextStorage.getItem(this.COMMAND_EXECUTING_FLAG) || this.contextStorage.getItem(this.EXECUTING_IN_IFRAME_FLAG); if (inCommandExecution) { diff --git a/src/client/driver/native-dialog-tracker/iframe.js b/src/client/driver/native-dialog-tracker/iframe.js index d7babbb2..0139d0a3 100644 --- a/src/client/driver/native-dialog-tracker/iframe.js +++ b/src/client/driver/native-dialog-tracker/iframe.js @@ -2,7 +2,7 @@ import hammerhead from '../deps/hammerhead'; import MESSAGE_TYPE from './messages'; import NativeDialogTracker from './index'; -var messageSandbox = hammerhead.eventSandbox.message; +const messageSandbox = hammerhead.eventSandbox.message; export default class IframeNativeDialogTracker extends NativeDialogTracker { diff --git a/src/client/driver/native-dialog-tracker/index.js b/src/client/driver/native-dialog-tracker/index.js index 42be33c5..cc6096a3 100644 --- a/src/client/driver/native-dialog-tracker/index.js +++ b/src/client/driver/native-dialog-tracker/index.js @@ -4,9 +4,9 @@ import ClientFunctionExecutor from '../command-executors/client-functions/client import MESSAGE_TYPE from './messages'; -var messageSandbox = hammerhead.eventSandbox.message; -var processScript = hammerhead.processScript; -var nativeMethods = hammerhead.nativeMethods; +const messageSandbox = hammerhead.eventSandbox.message; +const processScript = hammerhead.processScript; +const nativeMethods = hammerhead.nativeMethods; const APPEARED_DIALOGS = 'testcafe|native-dialog-tracker|appeared-dialogs'; const UNEXPECTED_DIALOG = 'testcafe|native-dialog-tracker|unexpected-dialog'; @@ -27,7 +27,7 @@ export default class NativeDialogTracker { } get appearedDialogs () { - var dialogs = this.contextStorage.getItem(APPEARED_DIALOGS); + let dialogs = this.contextStorage.getItem(APPEARED_DIALOGS); if (!dialogs) { dialogs = []; @@ -63,7 +63,7 @@ export default class NativeDialogTracker { _initListening () { messageSandbox.on(messageSandbox.SERVICE_MSG_RECEIVED_EVENT, e => { - var msg = e.message; + const msg = e.message; if (msg.type === MESSAGE_TYPE.appearedDialog) // eslint-disable-next-line no-restricted-properties @@ -81,7 +81,7 @@ export default class NativeDialogTracker { hammerhead.on(hammerhead.EVENTS.beforeUnload, e => { if (e.prevented && !e.isFakeIEEvent) { if (this.dialogHandler) { - var handler = this._createDialogHandler('beforeunload'); + const handler = this._createDialogHandler('beforeunload'); handler(e.returnValue || ''); } @@ -101,12 +101,12 @@ export default class NativeDialogTracker { _createDialogHandler (type) { return text => { - var url = NativeDialogTracker._getPageUrl(); + const url = NativeDialogTracker._getPageUrl(); this._addAppearedDialogs(type, text, url); - var executor = new ClientFunctionExecutor(this.dialogHandler); - var result = null; + const executor = new ClientFunctionExecutor(this.dialogHandler); + let result = null; try { result = executor.fn.apply(window, [type, text, url]); @@ -121,7 +121,7 @@ export default class NativeDialogTracker { // Overridable methods _defaultDialogHandler (type) { - var url = NativeDialogTracker._getPageUrl(); + const url = NativeDialogTracker._getPageUrl(); this.unexpectedDialog = this.unexpectedDialog || { type, url }; } @@ -146,8 +146,8 @@ export default class NativeDialogTracker { } getUnexpectedDialogError () { - var unexpectedDialog = this.unexpectedDialog; - var handlerError = this.handlerError; + const unexpectedDialog = this.unexpectedDialog; + const handlerError = this.handlerError; this.unexpectedDialog = null; this.handlerError = null; diff --git a/src/client/driver/script-execution-barrier.js b/src/client/driver/script-execution-barrier.js index 6e3ee383..b918ca35 100644 --- a/src/client/driver/script-execution-barrier.js +++ b/src/client/driver/script-execution-barrier.js @@ -1,8 +1,8 @@ import hammerhead from './deps/hammerhead'; import { delay } from './deps/testcafe-core'; -var Promise = hammerhead.Promise; -var nativeMethods = hammerhead.nativeMethods; +const Promise = hammerhead.Promise; +const nativeMethods = hammerhead.nativeMethods; const WAIT_FOR_NEW_SCRIPTS_DELAY = 25; @@ -23,16 +23,16 @@ export default class ScriptExecutionBarrier { } _onScriptElementAdded (el) { - var scriptSrc = nativeMethods.scriptSrcGetter.call(el); + const scriptSrc = nativeMethods.scriptSrcGetter.call(el); if (scriptSrc === void 0 || scriptSrc === '') return; this.scriptsCount++; - var loadingTimeout = null; + let loadingTimeout = null; - var done = () => { + const done = () => { nativeMethods.removeEventListener.call(el, 'load', done); nativeMethods.removeEventListener.call(el, 'error', done); @@ -65,7 +65,7 @@ export default class ScriptExecutionBarrier { wait () { return new Promise(resolve => { - var done = () => { + const done = () => { nativeMethods.clearTimeout.call(window, this.watchdog); hammerhead.off(hammerhead.EVENTS.scriptElementAdded, this.scriptElementAddedHandler); diff --git a/src/client/driver/storage.js b/src/client/driver/storage.js index fffce563..7719e1e4 100644 --- a/src/client/driver/storage.js +++ b/src/client/driver/storage.js @@ -1,7 +1,7 @@ import hammerhead from './deps/hammerhead'; -var JSON = hammerhead.json; -var nativeMethods = hammerhead.nativeMethods; +const JSON = hammerhead.json; +const nativeMethods = hammerhead.nativeMethods; const STORAGE_KEY_PREFIX = 'testcafe|driver|'; @@ -15,7 +15,7 @@ export default class Storage { } _loadFromStorage () { - var savedData = this.storage.getItem(this.storageKey); + const savedData = this.storage.getItem(this.storageKey); if (savedData) { this.data = JSON.parse(savedData); diff --git a/src/client/driver/utils/element-utils.js b/src/client/driver/utils/element-utils.js new file mode 100644 index 00000000..c88372d8 --- /dev/null +++ b/src/client/driver/utils/element-utils.js @@ -0,0 +1,38 @@ +import { domUtils, positionUtils } from '../deps/testcafe-core'; +import { selectElement as selectElementUI } from '../deps/testcafe-ui'; + +// NOTE: save original ctors and methods because they may be overwritten by page code +const isArray = Array.isArray; +const Node = window.Node; +const HTMLCollection = window.HTMLCollection; +const NodeList = window.NodeList; + +export function exists (el) { + return !!el; +} + +export function visible (el) { + if (!domUtils.isDomElement(el) && !domUtils.isTextNode(el)) + return false; + + if (domUtils.isOptionElement(el) || domUtils.getTagName(el) === 'optgroup') + return selectElementUI.isOptionElementVisible(el); + + return positionUtils.isElementVisible(el); +} + +export function IsNodeCollection (obj) { + return obj instanceof HTMLCollection || obj instanceof NodeList || isArrayOfNodes(obj); +} + +function isArrayOfNodes (obj) { + if (!isArray(obj)) + return false; + + for (let i = 0; i < obj.length; i++) { + if (!(obj[i] instanceof Node)) + return false; + } + + return true; +} diff --git a/src/client/driver/utils/ensure-elements.js b/src/client/driver/utils/ensure-elements.js index abd3ddbe..1cd94cd9 100644 --- a/src/client/driver/utils/ensure-elements.js +++ b/src/client/driver/utils/ensure-elements.js @@ -27,7 +27,7 @@ class ElementsRetriever { _ensureElement ({ selector, createNotFoundError, createIsInvisibleError, createHasWrongNodeTypeError }) { this.ensureElementsPromise = this.ensureElementsPromise .then(() => { - var selectorExecutor = new SelectorExecutor(selector, this.globalSelectorTimeout, this.ensureElementsStartTime, + const selectorExecutor = new SelectorExecutor(selector, this.globalSelectorTimeout, this.ensureElementsStartTime, createNotFoundError, createIsInvisibleError); return selectorExecutor.getResult(); @@ -47,7 +47,7 @@ class ElementsRetriever { } export function ensureElements (elementDescriptors, globalSelectorTimeout) { - var elementsRetriever = new ElementsRetriever(elementDescriptors, globalSelectorTimeout); + const elementsRetriever = new ElementsRetriever(elementDescriptors, globalSelectorTimeout); return elementsRetriever.getElements(); } @@ -55,7 +55,7 @@ export function ensureElements (elementDescriptors, globalSelectorTimeout) { export function createElementDescriptor (selector) { return { selector: selector, - createNotFoundError: () => new ActionElementNotFoundError(), + createNotFoundError: fn => new ActionElementNotFoundError(fn), createIsInvisibleError: () => new ActionElementIsInvisibleError(), createHasWrongNodeTypeError: nodeDescription => new ActionSelectorMatchesWrongNodeTypeError(nodeDescription) }; @@ -64,7 +64,7 @@ export function createElementDescriptor (selector) { export function createAdditionalElementDescriptor (selector, elementName) { return { selector: selector, - createNotFoundError: () => new ActionAdditionalElementNotFoundError(elementName), + createNotFoundError: fn => new ActionAdditionalElementNotFoundError(elementName, fn), createIsInvisibleError: () => new ActionAdditionalElementIsInvisibleError(elementName), createHasWrongNodeTypeError: nodeDescription => new ActionAdditionalSelectorMatchesWrongNodeTypeError(elementName, nodeDescription) }; diff --git a/src/client/driver/utils/run-with-barriers.js b/src/client/driver/utils/run-with-barriers.js index 7f5f1491..9c71924a 100644 --- a/src/client/driver/utils/run-with-barriers.js +++ b/src/client/driver/utils/run-with-barriers.js @@ -6,15 +6,15 @@ import ScriptExecutionBarrier from '../script-execution-barrier'; export default function (action, ...args) { - var requestBarrier = new RequestBarrier(); - var scriptExecutionBarrier = new ScriptExecutionBarrier(); + const requestBarrier = new RequestBarrier(); + const scriptExecutionBarrier = new ScriptExecutionBarrier(); pageUnloadBarrier.watchForPageNavigationTriggers(); - var actionResult = null; - var actionPromise = action(...args); + let actionResult = null; + const actionPromise = action(...args); - var barriersPromise = actionPromise + const barriersPromise = actionPromise .then(result => { actionResult = result; diff --git a/src/client/test-run/iframe.js.mustache b/src/client/test-run/iframe.js.mustache index 81bf3ae1..0a7a0756 100644 --- a/src/client/test-run/iframe.js.mustache +++ b/src/client/test-run/iframe.js.mustache @@ -1,10 +1,11 @@ (function () { var IframeDriver = window['%testCafeIframeDriver%']; var driver = new IframeDriver({{{testRunId}}}, { - selectorTimeout: {{{selectorTimeout}}}, - pageLoadTimeout: {{{pageLoadTimeout}}}, - dialogHandler: {{{dialogHandler}}}, - speed: {{{speed}}} + selectorTimeout: {{{selectorTimeout}}}, + pageLoadTimeout: {{{pageLoadTimeout}}}, + dialogHandler: {{{dialogHandler}}}, + unstableNetworkMode: {{{retryTestPages}}}, + speed: {{{speed}}} }); driver.start(); diff --git a/src/client/test-run/index.js.mustache b/src/client/test-run/index.js.mustache index cf5f7125..ed4eea4e 100644 --- a/src/client/test-run/index.js.mustache +++ b/src/client/test-run/index.js.mustache @@ -4,10 +4,15 @@ var origin = location.origin; + // NOTE: location.origin doesn't exist in IE11 on Windows 10.10240 LTSB + if (!origin) + origin = location.protocol + '//' + location.hostname + (location.port ? ':' + location.port : ''); + var testRunId = {{{testRunId}}}; var browserId = {{{browserId}}}; var selectorTimeout = {{{selectorTimeout}}}; var pageLoadTimeout = {{{pageLoadTimeout}}}; + var retryTestPages = {{{retryTestPages}}}; var speed = {{{speed}}}; var browserHeartbeatUrl = origin + {{{browserHeartbeatRelativeUrl}}}; var browserStatusUrl = origin + {{{browserStatusRelativeUrl}}}; @@ -27,6 +32,7 @@ pageLoadTimeout: pageLoadTimeout, skipJsErrors: skipJsErrors, dialogHandler: dialogHandler, + retryTestPages: retryTestPages, speed: speed } ); diff --git a/src/client/ui/cursor/iframe-cursor.js b/src/client/ui/cursor/iframe-cursor.js index 647e2b19..4a3f53e4 100644 --- a/src/client/ui/cursor/iframe-cursor.js +++ b/src/client/ui/cursor/iframe-cursor.js @@ -2,7 +2,7 @@ import hammerhead from '../deps/hammerhead'; import { sendRequestToFrame } from '../deps/testcafe-core'; import CURSOR_UI_MESSAGES from './messages'; -var browserUtils = hammerhead.utils.browser; +const browserUtils = hammerhead.utils.browser; // HACK: In most browsers, the iframe's getElementFromPoint function ignores elements // from the parent frame. But in IE it doesn't, and our cursor overlaps the target @@ -11,7 +11,7 @@ const RECOGNITION_INCREMENT = browserUtils.isIE ? 1 : 0; export default { move (x, y) { - var msg = { + const msg = { cmd: CURSOR_UI_MESSAGES.moveRequest, x: x + RECOGNITION_INCREMENT, y: y + RECOGNITION_INCREMENT diff --git a/src/client/ui/cursor/index.js b/src/client/ui/cursor/index.js index 409838be..2d0f5225 100644 --- a/src/client/ui/cursor/index.js +++ b/src/client/ui/cursor/index.js @@ -4,14 +4,14 @@ import uiRoot from '../ui-root'; import CURSOR_UI_MESSAGES from './messages'; -var Promise = hammerhead.Promise; -var shadowUI = hammerhead.shadowUI; -var browserUtils = hammerhead.utils.browser; -var featureDetection = hammerhead.utils.featureDetection; -var messageSandbox = hammerhead.eventSandbox.message; +const Promise = hammerhead.Promise; +const shadowUI = hammerhead.shadowUI; +const browserUtils = hammerhead.utils.browser; +const featureDetection = hammerhead.utils.featureDetection; +const messageSandbox = hammerhead.eventSandbox.message; -var styleUtils = testCafeCore.styleUtils; -var positionUtils = testCafeCore.positionUtils; +const styleUtils = testCafeCore.styleUtils; +const positionUtils = testCafeCore.positionUtils; const CURSOR_CLASS = 'cursor'; const TOUCH_CLASS = 'touch'; @@ -21,11 +21,13 @@ const STATE_CLASSES = [L_MOUSE_DOWN_CLASS, R_MOUSE_DOWN_CLASS].join(' '); // Setup cross-iframe interaction messageSandbox.on(messageSandbox.SERVICE_MSG_RECEIVED_EVENT, e => { - var msg = e.message; + const msg = e.message; + + let position = null; switch (msg.cmd) { case CURSOR_UI_MESSAGES.moveRequest: - var position = positionUtils.getIframePointRelativeToParentFrame({ x: msg.x, y: msg.y }, e.source); + position = positionUtils.getIframePointRelativeToParentFrame({ x: msg.x, y: msg.y }, e.source); CursorUI .move(position.x, position.y) @@ -50,7 +52,7 @@ messageSandbox.on(messageSandbox.SERVICE_MSG_RECEIVED_EVENT, e => { } }); -var CursorUI = { +const CursorUI = { cursorElement: null, x: 50, y: 50, diff --git a/src/client/ui/index.js b/src/client/ui/index.js index bbd5a632..0abb4def 100644 --- a/src/client/ui/index.js +++ b/src/client/ui/index.js @@ -12,10 +12,10 @@ import screenshotMark from './screenshot-mark'; import uiRoot from './ui-root'; -var Promise = hammerhead.Promise; -var messageSandbox = hammerhead.eventSandbox.message; +const Promise = hammerhead.Promise; +const messageSandbox = hammerhead.eventSandbox.message; -var sendRequestToFrame = testCafeCore.sendRequestToFrame; +const sendRequestToFrame = testCafeCore.sendRequestToFrame; const HIDE_REQUEST_CMD = 'ui|hide|request'; const HIDE_RESPONSE_CMD = 'ui|hide|response'; diff --git a/src/client/ui/modal-background.js b/src/client/ui/modal-background.js index 97feb1f8..1f11c577 100644 --- a/src/client/ui/modal-background.js +++ b/src/client/ui/modal-background.js @@ -13,14 +13,14 @@ const BACKGROUND_OPACITY_WITH_LOADING_TEXT = 0.8; const LOADING_ICON_CLASS = 'loading-icon'; //Globals -var backgroundDiv = null; -var loadingTextDiv = null; -var loadingIconDiv = null; -var initialized = false; +let backgroundDiv = null; +let loadingTextDiv = null; +let loadingIconDiv = null; +let initialized = false; //Markup function createBackground () { - var root = uiRoot.element(); + const root = uiRoot.element(); backgroundDiv = document.createElement('div'); root.appendChild(backgroundDiv); @@ -40,9 +40,9 @@ function createBackground () { //Behavior function adjustLoadingTextPos () { - var wHeight = styleUtils.getHeight(window); - var wWidth = styleUtils.getWidth(window); - var loadingTextHidden = !styleUtils.hasDimensions(loadingTextDiv); + const wHeight = styleUtils.getHeight(window); + const wWidth = styleUtils.getWidth(window); + const loadingTextHidden = !styleUtils.hasDimensions(loadingTextDiv); if (loadingTextHidden) { styleUtils.set(loadingTextDiv, 'visibility', 'hidden'); @@ -61,9 +61,9 @@ function adjustLoadingTextPos () { } function initSizeAdjustments () { - var adjust = function () { - var wHeight = styleUtils.getHeight(window); - var wWidth = styleUtils.getWidth(window); + const adjust = function () { + const wHeight = styleUtils.getHeight(window); + const wWidth = styleUtils.getWidth(window); styleUtils.set(backgroundDiv, 'width', wWidth + 'px'); styleUtils.set(backgroundDiv, 'height', wHeight + 'px'); @@ -88,10 +88,10 @@ function init () { } export function initAndShowLoadingText () { - var shown = false; + let shown = false; //NOTE: init and show modal background as soon as possible - var initAndShow = function () { + const initAndShow = function () { init(); styleUtils.set(backgroundDiv, 'opacity', BACKGROUND_OPACITY_WITH_LOADING_TEXT); @@ -101,7 +101,7 @@ export function initAndShowLoadingText () { shown = true; }; - var tryShowBeforeReady = function () { + const tryShowBeforeReady = function () { if (!shown) { if (document.body) initAndShow(); diff --git a/src/client/ui/progress-panel/index.js b/src/client/ui/progress-panel/index.js index be7cbbfc..ca8b756e 100644 --- a/src/client/ui/progress-panel/index.js +++ b/src/client/ui/progress-panel/index.js @@ -3,11 +3,11 @@ import testCafeCore from '../deps/testcafe-core'; import ProgressBar from './progress-bar'; import uiRoot from '../ui-root'; -var shadowUI = hammerhead.shadowUI; -var nativeMethods = hammerhead.nativeMethods; +const shadowUI = hammerhead.shadowUI; +const nativeMethods = hammerhead.nativeMethods; -var eventUtils = testCafeCore.eventUtils; -var styleUtils = testCafeCore.styleUtils; +const eventUtils = testCafeCore.eventUtils; +const styleUtils = testCafeCore.styleUtils; const PANEL_CLASS = 'progress-panel'; @@ -50,12 +50,12 @@ export default class ProgressPanel { } static _getInvisibleElementProperty (element, property) { - var needShowElement = styleUtils.get(element, 'display') === 'none'; + const needShowElement = styleUtils.get(element, 'display') === 'none'; if (needShowElement) styleUtils.set(element, 'display', 'block'); - var value = element[property]; + const value = element[property]; if (needShowElement) styleUtils.set(element, 'display', 'none'); @@ -64,10 +64,10 @@ export default class ProgressPanel { } static _showAtWindowCenter (element) { - var elementHeight = ProgressPanel._getInvisibleElementProperty(element, 'offsetHeight'); - var elementWidth = ProgressPanel._getInvisibleElementProperty(element, 'offsetWidth'); - var top = Math.round(styleUtils.getHeight(window) / 2 - elementHeight / 2); - var left = Math.round(styleUtils.getWidth(window) / 2 - elementWidth / 2); + const elementHeight = ProgressPanel._getInvisibleElementProperty(element, 'offsetHeight'); + const elementWidth = ProgressPanel._getInvisibleElementProperty(element, 'offsetWidth'); + const top = Math.round(styleUtils.getHeight(window) / 2 - elementHeight / 2); + const left = Math.round(styleUtils.getWidth(window) / 2 - elementWidth / 2); styleUtils.set(element, { left: left + 'px', @@ -76,7 +76,7 @@ export default class ProgressPanel { } _setCurrentProgress () { - var progress = Math.round((Date.now() - this.startTime) / this.maxTimeout * 100); + const progress = Math.round((Date.now() - this.startTime) / this.maxTimeout * 100); this.progressBar.setValue(progress); } @@ -90,11 +90,11 @@ export default class ProgressPanel { } _animate (el, duration, show, complete) { - var startTime = Date.now(); - var startOpacityValue = show ? 0 : 1; - var passedTime = 0; - var progress = 0; - var delta = 0; + const startTime = Date.now(); + const startOpacityValue = show ? 0 : 1; + let passedTime = 0; + let progress = 0; + let delta = 0; if (show) { styleUtils.set(el, 'opacity', startOpacityValue); diff --git a/src/client/ui/progress-panel/progress-bar.js b/src/client/ui/progress-panel/progress-bar.js index a8ec9cf7..f5bafee4 100644 --- a/src/client/ui/progress-panel/progress-bar.js +++ b/src/client/ui/progress-panel/progress-bar.js @@ -1,9 +1,9 @@ import hammerhead from '../deps/hammerhead'; import testCafeCore from '../deps/testcafe-core'; -var shadowUI = hammerhead.shadowUI; +const shadowUI = hammerhead.shadowUI; -var styleUtils = testCafeCore.styleUtils; +const styleUtils = testCafeCore.styleUtils; const CONTAINER_CLASS = 'progress-bar'; diff --git a/src/client/ui/select-element.js b/src/client/ui/select-element.js index dd4af72e..95832deb 100644 --- a/src/client/ui/select-element.js +++ b/src/client/ui/select-element.js @@ -4,18 +4,18 @@ import hammerhead from './deps/hammerhead'; import testCafeCore from './deps/testcafe-core'; import uiRoot from './ui-root'; -var shadowUI = hammerhead.shadowUI; -var browserUtils = hammerhead.utils.browser; -var featureDetection = hammerhead.utils.featureDetection; -var nativeMethods = hammerhead.nativeMethods; -var eventSimulator = hammerhead.eventSandbox.eventSimulator; -var listeners = hammerhead.eventSandbox.listeners; +const shadowUI = hammerhead.shadowUI; +const browserUtils = hammerhead.utils.browser; +const featureDetection = hammerhead.utils.featureDetection; +const nativeMethods = hammerhead.nativeMethods; +const eventSimulator = hammerhead.eventSandbox.eventSimulator; +const listeners = hammerhead.eventSandbox.listeners; -var positionUtils = testCafeCore.positionUtils; -var domUtils = testCafeCore.domUtils; -var styleUtils = testCafeCore.styleUtils; -var eventUtils = testCafeCore.eventUtils; -var arrayUtils = testCafeCore.arrayUtils; +const positionUtils = testCafeCore.positionUtils; +const domUtils = testCafeCore.domUtils; +const styleUtils = testCafeCore.styleUtils; +const eventUtils = testCafeCore.eventUtils; +const arrayUtils = testCafeCore.arrayUtils; const OPTION_LIST_CLASS = 'tcOptionList'; @@ -25,10 +25,10 @@ const DISABLED_CLASS = 'disabled'; const MAX_OPTION_LIST_LENGTH = browserUtils.isIE ? 30 : 20; -var curSelectEl = null; -var optionList = null; -var groups = []; -var options = []; +let curSelectEl = null; +let optionList = null; +let groups = []; +let options = []; function onDocumentMouseDown (e) { //NOTE: only in Mozilla 'mousedown' raises for option @@ -54,9 +54,9 @@ function onWindowClick (e, dispatched, preventDefault) { } function clickOnOption (optionIndex, isOptionDisabled) { - var curSelectIndex = curSelectEl.selectedIndex; - var realOption = curSelectEl.getElementsByTagName('option')[optionIndex]; - var clickLeadChanges = !isOptionDisabled && optionIndex !== curSelectIndex; + const curSelectIndex = curSelectEl.selectedIndex; + const realOption = curSelectEl.getElementsByTagName('option')[optionIndex]; + const clickLeadChanges = !isOptionDisabled && optionIndex !== curSelectIndex; if (clickLeadChanges && !browserUtils.isIE) curSelectEl.selectedIndex = optionIndex; @@ -90,8 +90,8 @@ function clickOnOption (optionIndex, isOptionDisabled) { } function createOption (realOption, parent) { - var option = document.createElement('div'); - var isOptionDisabled = realOption.disabled || domUtils.getTagName(realOption.parentElement) === 'optgroup' && + const option = document.createElement('div'); + const isOptionDisabled = realOption.disabled || domUtils.getTagName(realOption.parentElement) === 'optgroup' && realOption.parentElement.disabled; // eslint-disable-next-line no-restricted-properties @@ -109,7 +109,7 @@ function createOption (realOption, parent) { } function createGroup (realGroup, parent) { - var group = document.createElement('div'); + const group = document.createElement('div'); nativeMethods.nodeTextContentSetter.call(group, realGroup.label || ' '); parent.appendChild(group); @@ -128,9 +128,9 @@ function createGroup (realGroup, parent) { } function createChildren (children, parent) { - var childrenLength = domUtils.getChildrenLength(children); + const childrenLength = domUtils.getChildrenLength(children); - for (var i = 0; i < childrenLength; i++) { + for (let i = 0; i < childrenLength; i++) { if (domUtils.isOptionElement(children[i])) createOption(children[i], parent); else if (domUtils.getTagName(children[i]) === 'optgroup') @@ -139,14 +139,14 @@ function createChildren (children, parent) { } export function expandOptionList (select) { - var selectChildren = select.children; + const selectChildren = select.children; if (!selectChildren.length) return; //NOTE: check is option list expanded if (curSelectEl) { - var isSelectExpanded = select === curSelectEl; + const isSelectExpanded = select === curSelectEl; collapseOptionList(); @@ -178,12 +178,12 @@ export function expandOptionList (select) { styleUtils.getOptionHeight(select) * MAX_OPTION_LIST_LENGTH : '' }); - var selectTopPosition = positionUtils.getOffsetPosition(curSelectEl).top; - var optionListHeight = styleUtils.getHeight(optionList); - var optionListTopPosition = selectTopPosition + styleUtils.getHeight(curSelectEl) + 2; + const selectTopPosition = positionUtils.getOffsetPosition(curSelectEl).top; + const optionListHeight = styleUtils.getHeight(optionList); + let optionListTopPosition = selectTopPosition + styleUtils.getHeight(curSelectEl) + 2; if (optionListTopPosition + optionListHeight > styleUtils.getScrollTop(window) + styleUtils.getHeight(window)) { - var topPositionAboveSelect = selectTopPosition - 3 - optionListHeight; + const topPositionAboveSelect = selectTopPosition - 3 - optionListHeight; if (topPositionAboveSelect >= styleUtils.getScrollTop(window)) optionListTopPosition = topPositionAboveSelect; @@ -207,8 +207,8 @@ export function isOptionListExpanded (select) { } export function getEmulatedChildElement (element) { - var isGroup = domUtils.getTagName(element) === 'optgroup'; - var elementIndex = isGroup ? domUtils.getElementIndexInParent(curSelectEl, element) : + const isGroup = domUtils.getTagName(element) === 'optgroup'; + const elementIndex = isGroup ? domUtils.getElementIndexInParent(curSelectEl, element) : domUtils.getElementIndexInParent(curSelectEl, element); if (!isGroup) @@ -218,19 +218,19 @@ export function getEmulatedChildElement (element) { } export function scrollOptionListByChild (child) { - var select = domUtils.getSelectParent(child); + const select = domUtils.getSelectParent(child); if (!select) return; - var realSizeValue = styleUtils.getSelectElementSize(select); - var optionHeight = styleUtils.getOptionHeight(select); - var scrollIndent = 0; + const realSizeValue = styleUtils.getSelectElementSize(select); + const optionHeight = styleUtils.getOptionHeight(select); + let scrollIndent = 0; - var topVisibleIndex = Math.max(styleUtils.getScrollTop(select) / optionHeight, 0); - var bottomVisibleIndex = topVisibleIndex + realSizeValue - 1; + const topVisibleIndex = Math.max(styleUtils.getScrollTop(select) / optionHeight, 0); + const bottomVisibleIndex = topVisibleIndex + realSizeValue - 1; - var childIndex = domUtils.getChildVisibleIndex(select, child); + const childIndex = domUtils.getChildVisibleIndex(select, child); if (childIndex < topVisibleIndex) { scrollIndent = optionHeight * (topVisibleIndex - childIndex); @@ -243,7 +243,7 @@ export function scrollOptionListByChild (child) { } export function getSelectChildCenter (child) { - var select = domUtils.getSelectParent(child); + const select = domUtils.getSelectParent(child); if (!select) { return { @@ -252,8 +252,8 @@ export function getSelectChildCenter (child) { }; } - var optionHeight = styleUtils.getOptionHeight(select); - var childRectangle = positionUtils.getElementRectangle(child); + const optionHeight = styleUtils.getOptionHeight(select); + const childRectangle = positionUtils.getElementRectangle(child); return { x: Math.round(childRectangle.left + childRectangle.width / 2), @@ -262,24 +262,24 @@ export function getSelectChildCenter (child) { } export function switchOptionsByKeys (element, command) { - var selectSize = styleUtils.getSelectElementSize(element); - var optionListHidden = !styleUtils.hasDimensions(shadowUI.select('.' + OPTION_LIST_CLASS)[0]); + const selectSize = styleUtils.getSelectElementSize(element); + const optionListHidden = !styleUtils.hasDimensions(shadowUI.select('.' + OPTION_LIST_CLASS)[0]); if (/down|up/.test(command) || !browserUtils.isIE && (selectSize <= 1 || browserUtils.isFirefox) && (optionListHidden || browserUtils.isFirefox) && /left|right/.test(command)) { - var realOptions = element.querySelectorAll('option'); - var enabledOptions = []; + const realOptions = element.querySelectorAll('option'); + const enabledOptions = []; - for (var i = 0; i < realOptions.length; i++) { - var parent = realOptions[i].parentElement; + for (let i = 0; i < realOptions.length; i++) { + const parent = realOptions[i].parentElement; if (!realOptions[i].disabled && !(domUtils.getTagName(parent) === 'optgroup' && parent.disabled)) enabledOptions.push(realOptions[i]); } - var curSelectedOptionIndex = arrayUtils.indexOf(enabledOptions, realOptions[element.selectedIndex]); - var nextIndex = curSelectedOptionIndex + (/down|right/.test(command) ? 1 : -1); + const curSelectedOptionIndex = arrayUtils.indexOf(enabledOptions, realOptions[element.selectedIndex]); + const nextIndex = curSelectedOptionIndex + (/down|right/.test(command) ? 1 : -1); if (nextIndex >= 0 && nextIndex < enabledOptions.length) { element.selectedIndex = arrayUtils.indexOf(realOptions, enabledOptions[nextIndex]); @@ -293,13 +293,13 @@ export function switchOptionsByKeys (element, command) { } export function isOptionElementVisible (el) { - var parentSelect = domUtils.getSelectParent(el); + const parentSelect = domUtils.getSelectParent(el); if (!parentSelect) return true; - var expanded = isOptionListExpanded(parentSelect); - var selectSizeValue = styleUtils.getSelectElementSize(parentSelect); + const expanded = isOptionListExpanded(parentSelect); + const selectSizeValue = styleUtils.getSelectElementSize(parentSelect); return expanded || selectSizeValue > 1; } diff --git a/src/client/ui/status-bar/iframe-status-bar.js b/src/client/ui/status-bar/iframe-status-bar.js index 6f9d0b69..c3e23e9c 100644 --- a/src/client/ui/status-bar/iframe-status-bar.js +++ b/src/client/ui/status-bar/iframe-status-bar.js @@ -3,8 +3,8 @@ import testCafeCore from './../deps/testcafe-core'; import MESSAGES from './messages'; import StatusBar from './index'; -var sendRequestToFrame = testCafeCore.sendRequestToFrame; -var messageSandbox = hammerhead.eventSandbox.message; +const sendRequestToFrame = testCafeCore.sendRequestToFrame; +const messageSandbox = hammerhead.eventSandbox.message; export default class IframeStatusBar extends StatusBar { @@ -18,7 +18,7 @@ export default class IframeStatusBar extends StatusBar { } hideWaitingElementStatus (waitingSuccess) { - var msg = { cmd: MESSAGES.endWaitingElementRequest, waitingSuccess }; + const msg = { cmd: MESSAGES.endWaitingElementRequest, waitingSuccess }; return sendRequestToFrame(msg, MESSAGES.endWaitingElementResponse, window.top); } @@ -28,7 +28,7 @@ export default class IframeStatusBar extends StatusBar { } hideWaitingAssertionRetriesStatus (waitingSuccess) { - var msg = { cmd: MESSAGES.endWaitingAssertionRetriesRequest, waitingSuccess }; + const msg = { cmd: MESSAGES.endWaitingAssertionRetriesRequest, waitingSuccess }; return sendRequestToFrame(msg, MESSAGES.endWaitingAssertionRetriesResponse, window.top); } diff --git a/src/client/ui/status-bar/index.js b/src/client/ui/status-bar/index.js index 7f4ea312..a4981e46 100644 --- a/src/client/ui/status-bar/index.js +++ b/src/client/ui/status-bar/index.js @@ -5,19 +5,19 @@ import uiRoot from '../ui-root'; import MESSAGES from './messages'; -var Promise = hammerhead.Promise; -var shadowUI = hammerhead.shadowUI; -var nativeMethods = hammerhead.nativeMethods; -var messageSandbox = hammerhead.eventSandbox.message; -var browserUtils = hammerhead.utils.browser; -var featureDetection = hammerhead.utils.featureDetection; -var listeners = hammerhead.eventSandbox.listeners; +const Promise = hammerhead.Promise; +const shadowUI = hammerhead.shadowUI; +const nativeMethods = hammerhead.nativeMethods; +const messageSandbox = hammerhead.eventSandbox.message; +const browserUtils = hammerhead.utils.browser; +const featureDetection = hammerhead.utils.featureDetection; +const listeners = hammerhead.eventSandbox.listeners; -var styleUtils = testCafeCore.styleUtils; -var eventUtils = testCafeCore.eventUtils; -var domUtils = testCafeCore.domUtils; -var serviceUtils = testCafeCore.serviceUtils; -var arrayUtils = testCafeCore.arrayUtils; +const styleUtils = testCafeCore.styleUtils; +const eventUtils = testCafeCore.eventUtils; +const domUtils = testCafeCore.domUtils; +const serviceUtils = testCafeCore.serviceUtils; +const arrayUtils = testCafeCore.arrayUtils; const STATUS_BAR_CLASS = 'status-bar'; @@ -119,13 +119,13 @@ export default class StatusBar extends serviceUtils.EventEmitter { shadowUI.addClass(this.fixtureContainer, FIXTURE_CONTAINER_CLASS); this.infoContainer.appendChild(this.fixtureContainer); - var fixtureDiv = document.createElement('div'); + const fixtureDiv = document.createElement('div'); nativeMethods.nodeTextContentSetter.call(fixtureDiv, `${this.fixtureName} - ${this.testName}`); shadowUI.addClass(fixtureDiv, FIXTURE_DIV_CLASS); this.fixtureContainer.appendChild(fixtureDiv); - var userAgentDiv = document.createElement('div'); + const userAgentDiv = document.createElement('div'); nativeMethods.nodeTextContentSetter.call(userAgentDiv, this.userAgent); shadowUI.addClass(userAgentDiv, USER_AGENT_DIV_CLASS); @@ -133,11 +133,11 @@ export default class StatusBar extends serviceUtils.EventEmitter { } _createUnlockPageArea (container) { - var unlockPageArea = document.createElement('div'); - var unlockPageContainer = document.createElement('div'); - var unlockIcon = document.createElement('div'); - var iconSeparator = document.createElement('div'); - var unlockText = document.createElement('span'); + const unlockPageArea = document.createElement('div'); + const unlockPageContainer = document.createElement('div'); + const unlockIcon = document.createElement('div'); + const iconSeparator = document.createElement('div'); + const unlockText = document.createElement('span'); nativeMethods.nodeTextContentSetter.call(unlockText, UNLOCK_PAGE_TEXT); @@ -175,7 +175,7 @@ export default class StatusBar extends serviceUtils.EventEmitter { } _createStatusArea () { - var statusContainer = document.createElement('div'); + const statusContainer = document.createElement('div'); shadowUI.addClass(statusContainer, STATUS_CONTAINER_CLASS); this.container.appendChild(statusContainer); @@ -207,9 +207,9 @@ export default class StatusBar extends serviceUtils.EventEmitter { } _createButton (text, className) { - var button = document.createElement('div'); - var icon = document.createElement('div'); - var span = document.createElement('span'); + const button = document.createElement('div'); + const icon = document.createElement('div'); + const span = document.createElement('span'); nativeMethods.nodeTextContentSetter.call(span, text); @@ -277,7 +277,7 @@ export default class StatusBar extends serviceUtils.EventEmitter { } _calculateActualView (windowWidth) { - var hideStatusMaxSize = this.state.debugging ? VIEWS.hideStatusDebugging.maxSize : VIEWS.hideStatus.maxSize; + const hideStatusMaxSize = this.state.debugging ? VIEWS.hideStatusDebugging.maxSize : VIEWS.hideStatus.maxSize; if (windowWidth >= VIEWS.hideFixture.maxSize) return VIEWS.all; @@ -302,10 +302,10 @@ export default class StatusBar extends serviceUtils.EventEmitter { if (styleUtils.get(this.fixtureContainer, 'display') === 'none') return; - var infoContainerWidth = styleUtils.getWidth(this.infoContainer); - var iconWidth = styleUtils.getWidth(this.icon); - var iconMargin = styleUtils.getElementMargin(this.icon); - var fixtureContainerWidth = infoContainerWidth - iconWidth - iconMargin.left - iconMargin.right - 1; + const infoContainerWidth = styleUtils.getWidth(this.infoContainer); + const iconWidth = styleUtils.getWidth(this.icon); + const iconMargin = styleUtils.getElementMargin(this.icon); + const fixtureContainerWidth = infoContainerWidth - iconWidth - iconMargin.left - iconMargin.right - 1; styleUtils.set(this.fixtureContainer, 'width', fixtureContainerWidth + 'px'); } @@ -314,27 +314,27 @@ export default class StatusBar extends serviceUtils.EventEmitter { if (!this.statusDiv.parentNode || styleUtils.get(this.statusDiv.parentNode, 'display') === 'none') return; - var statusDivHidden = styleUtils.get(this.statusDiv, 'display') === 'none'; + const statusDivHidden = styleUtils.get(this.statusDiv, 'display') === 'none'; - var infoContainerWidth = styleUtils.getWidth(this.infoContainer); - var containerWidth = styleUtils.getWidth(this.container); - var statusDivWidth = statusDivHidden ? 0 : styleUtils.getWidth(this.statusDiv); + const infoContainerWidth = styleUtils.getWidth(this.infoContainer); + const containerWidth = styleUtils.getWidth(this.container); + const statusDivWidth = statusDivHidden ? 0 : styleUtils.getWidth(this.statusDiv); - var marginLeft = containerWidth / 2 - statusDivWidth / 2 - infoContainerWidth; + let marginLeft = containerWidth / 2 - statusDivWidth / 2 - infoContainerWidth; if (this.state.debugging) { marginLeft -= styleUtils.getWidth(this.buttons) / 2; marginLeft -= styleUtils.getWidth(this.unlockPageArea) / 2; } - var marginLeftStr = Math.max(Math.round(marginLeft), 0) + 'px'; + const marginLeftStr = Math.max(Math.round(marginLeft), 0) + 'px'; styleUtils.set(this.statusDiv, 'marginLeft', statusDivHidden ? 0 : marginLeftStr); styleUtils.set(this.statusDiv.parentNode, 'marginLeft', statusDivHidden ? marginLeftStr : 0); } _recalculateSizes () { - var windowWidth = styleUtils.getWidth(window); + const windowWidth = styleUtils.getWidth(window); this.windowHeight = styleUtils.getHeight(window); @@ -346,11 +346,11 @@ export default class StatusBar extends serviceUtils.EventEmitter { } _animate (show) { - var startTime = Date.now(); - var startOpacityValue = parseInt(styleUtils.get(this.statusBar, 'opacity'), 10) || 0; - var passedTime = 0; - var progress = 0; - var delta = 0; + const startTime = Date.now(); + const startOpacityValue = parseInt(styleUtils.get(this.statusBar, 'opacity'), 10) || 0; + let passedTime = 0; + let progress = 0; + let delta = 0; this._stopAnimation(); @@ -424,10 +424,10 @@ export default class StatusBar extends serviceUtils.EventEmitter { } _bindClickOnce (elements, handler) { - var eventName = featureDetection.isTouchDevice ? 'touchstart' : 'mousedown'; + const eventName = featureDetection.isTouchDevice ? 'touchstart' : 'mousedown'; - var downHandler = e => { - var isTargetElement = !!arrayUtils.find(elements, el => domUtils.containsElement(el, e.target)); + const downHandler = e => { + const isTargetElement = !!arrayUtils.find(elements, el => domUtils.containsElement(el, e.target)); if (isTargetElement) { eventUtils.preventDefault(e); @@ -444,7 +444,7 @@ export default class StatusBar extends serviceUtils.EventEmitter { _initChildListening () { messageSandbox.on(messageSandbox.SERVICE_MSG_RECEIVED_EVENT, e => { - var msg = e.message; + const msg = e.message; if (msg.cmd === MESSAGES.startWaitingElement) this.showWaitingElementStatus(msg.timeout); @@ -472,7 +472,7 @@ export default class StatusBar extends serviceUtils.EventEmitter { } _showWaitingStatus () { - var waitingStatusText = this.state.assertionRetries ? WAITING_FOR_ASSERTION_EXECUTION_TEXT : WAITING_FOR_ELEMENT_TEXT; + const waitingStatusText = this.state.assertionRetries ? WAITING_FOR_ASSERTION_EXECUTION_TEXT : WAITING_FOR_ELEMENT_TEXT; nativeMethods.nodeTextContentSetter.call(this.statusDiv, waitingStatusText); this._setStatusDivLeftMargin(); @@ -520,7 +520,7 @@ export default class StatusBar extends serviceUtils.EventEmitter { this._recalculateSizes(); this._bindClickOnce([this.resumeButton, this.stepButton, this.finishButton], e => { - var isStepButton = domUtils.containsElement(this.stepButton, e.target); + const isStepButton = domUtils.containsElement(this.stepButton, e.target); this._resetState(); resolve(isStepButton); @@ -548,7 +548,7 @@ export default class StatusBar extends serviceUtils.EventEmitter { else shadowUI.addClass(this.statusBar, WAITING_FAILED_CLASS); - var forceReset = this.showingTimeout && waitingSuccess; + const forceReset = this.showingTimeout && waitingSuccess; if (this.showingTimeout) { nativeMethods.clearTimeout.call(window, this.showingTimeout); diff --git a/src/client/ui/status-bar/progress-bar/determinate-indicator.js b/src/client/ui/status-bar/progress-bar/determinate-indicator.js index 5103c547..7e4abe36 100644 --- a/src/client/ui/status-bar/progress-bar/determinate-indicator.js +++ b/src/client/ui/status-bar/progress-bar/determinate-indicator.js @@ -1,9 +1,9 @@ import hammerhead from '../../deps/hammerhead'; import testCafeCore from '../../deps/testcafe-core'; -var shadowUI = hammerhead.shadowUI; -var nativeMethods = hammerhead.nativeMethods; -var styleUtils = testCafeCore.styleUtils; +const shadowUI = hammerhead.shadowUI; +const nativeMethods = hammerhead.nativeMethods; +const styleUtils = testCafeCore.styleUtils; const DETERMINATE_STYLE_CLASS = 'determinate'; @@ -21,10 +21,10 @@ export default class DeterminateIndicator { } _setCurrentProgress () { - var progress = (Date.now() - this.startTime) / this.maxTimeout; - var percent = Math.min(Math.max(progress, 0), 1); - var progressBarWidth = styleUtils.getWidth(this.progressBar); - var newWidth = Math.round(progressBarWidth * percent); + const progress = (Date.now() - this.startTime) / this.maxTimeout; + const percent = Math.min(Math.max(progress, 0), 1); + const progressBarWidth = styleUtils.getWidth(this.progressBar); + const newWidth = Math.round(progressBarWidth * percent); styleUtils.set(this.firstValueElement, 'width', newWidth + 'px'); } diff --git a/src/client/ui/status-bar/progress-bar/indeterminate-indicator.js b/src/client/ui/status-bar/progress-bar/indeterminate-indicator.js index 5b0f6da3..68eac509 100644 --- a/src/client/ui/status-bar/progress-bar/indeterminate-indicator.js +++ b/src/client/ui/status-bar/progress-bar/indeterminate-indicator.js @@ -1,11 +1,11 @@ import hammerhead from '../../deps/hammerhead'; import testCafeCore from '../../deps/testcafe-core'; -var shadowUI = hammerhead.shadowUI; -var nativeMethods = hammerhead.nativeMethods; +const shadowUI = hammerhead.shadowUI; +const nativeMethods = hammerhead.nativeMethods; -var styleUtils = testCafeCore.styleUtils; -var positionUtils = testCafeCore.positionUtils; +const styleUtils = testCafeCore.styleUtils; +const positionUtils = testCafeCore.positionUtils; const FIRST_VALUE_ANIMATION_OPTIONS = { @@ -48,20 +48,20 @@ function getCompletePercent (time, y1, y2) { } function getNewPosition (completePercent, positions) { - var isFirstAnimationPart = completePercent < ANIMATION_PERCENTS.middle; - var startPercent = isFirstAnimationPart ? ANIMATION_PERCENTS.start : ANIMATION_PERCENTS.middle; - var endPercent = isFirstAnimationPart ? ANIMATION_PERCENTS.middle : ANIMATION_PERCENTS.end; - var startPosition = positions[startPercent]; - var endPosition = positions[endPercent]; - var startPoint = { x: startPercent, y: startPosition.left }; - var endPoint = { x: endPercent, y: endPosition.left }; + const isFirstAnimationPart = completePercent < ANIMATION_PERCENTS.middle; + const startPercent = isFirstAnimationPart ? ANIMATION_PERCENTS.start : ANIMATION_PERCENTS.middle; + const endPercent = isFirstAnimationPart ? ANIMATION_PERCENTS.middle : ANIMATION_PERCENTS.end; + const startPosition = positions[startPercent]; + const endPosition = positions[endPercent]; + let startPoint = { x: startPercent, y: startPosition.left }; + let endPoint = { x: endPercent, y: endPosition.left }; - var left = positionUtils.getLineYByXCoord(startPoint, endPoint, completePercent); + const left = positionUtils.getLineYByXCoord(startPoint, endPoint, completePercent); startPoint = { x: startPercent, y: startPosition.right }; endPoint = { x: endPercent, y: endPosition.right }; - var right = positionUtils.getLineYByXCoord(startPoint, endPoint, completePercent); + const right = positionUtils.getLineYByXCoord(startPoint, endPoint, completePercent); return { left, right }; } @@ -79,14 +79,14 @@ export default class IndeterminateIndicator { } static _updateValueAnimation (startTime, valueElement, animationOptions) { - var animationTime = animationOptions.time; - var animationPoints = animationOptions.points; - var positions = animationOptions.positionByCompletePercent; - var currentTime = Date.now() - startTime; - var timePercent = currentTime / animationTime; + const animationTime = animationOptions.time; + const animationPoints = animationOptions.points; + const positions = animationOptions.positionByCompletePercent; + const currentTime = Date.now() - startTime; + const timePercent = currentTime / animationTime; - var completePercent = getCompletePercent(timePercent, animationPoints[0], animationPoints[1]); - var { left, right } = getNewPosition(completePercent, positions); + const completePercent = getCompletePercent(timePercent, animationPoints[0], animationPoints[1]); + const { left, right } = getNewPosition(completePercent, positions); styleUtils.set(valueElement, 'left', Math.round(left) + '%'); styleUtils.set(valueElement, 'right', Math.round(right) + '%'); @@ -115,7 +115,7 @@ export default class IndeterminateIndicator { _startFirstValueAnimation () { this._clearFirstValueAnimation(); - var startTime = Date.now(); + const startTime = Date.now(); this.animationInterval = nativeMethods.setInterval.call(window, () => { IndeterminateIndicator._updateValueAnimation(startTime, this.firstValue, FIRST_VALUE_ANIMATION_OPTIONS); @@ -125,7 +125,7 @@ export default class IndeterminateIndicator { _startSecondValueAnimation () { this._clearSecondValueAnimation(); - var startTime = Date.now(); + const startTime = Date.now(); this.secondValueAnimationInterval = nativeMethods.setInterval.call(window, () => { IndeterminateIndicator._updateValueAnimation(startTime, this.secondValue, SECOND_VALUE_ANIMATION_OPTIONS); diff --git a/src/client/ui/status-bar/progress-bar/index.js b/src/client/ui/status-bar/progress-bar/index.js index 72a06f94..86bcd7d3 100644 --- a/src/client/ui/status-bar/progress-bar/index.js +++ b/src/client/ui/status-bar/progress-bar/index.js @@ -3,8 +3,8 @@ import testCafeCore from '../../deps/testcafe-core'; import DeterminateIndicator from './determinate-indicator'; import IndeterminateIndicator from './indeterminate-indicator'; -var shadowUI = hammerhead.shadowUI; -var styleUtils = testCafeCore.styleUtils; +const shadowUI = hammerhead.shadowUI; +const styleUtils = testCafeCore.styleUtils; const PROGRESS_BAR_CLASS = 'progress-bar'; @@ -29,7 +29,7 @@ export default class ProgressBar { shadowUI.addClass(this.progressBar, PROGRESS_BAR_CLASS); containerElement.appendChild(this.progressBar); - var container = document.createElement('div'); + const container = document.createElement('div'); shadowUI.addClass(container, CONTAINER_CLASS); this.progressBar.appendChild(container); diff --git a/src/client/ui/ui-root.js b/src/client/ui/ui-root.js index de1c64ab..36bad53a 100644 --- a/src/client/ui/ui-root.js +++ b/src/client/ui/ui-root.js @@ -29,7 +29,7 @@ export default { }, remove () { - var shadowRoot = shadowUI.getRoot(); + const shadowRoot = shadowUI.getRoot(); shadowRoot.parentNode.removeChild(shadowRoot); } diff --git a/src/compiler/compile-client-function.js b/src/compiler/compile-client-function.js index d2b535cc..1e99556c 100644 --- a/src/compiler/compile-client-function.js +++ b/src/compiler/compile-client-function.js @@ -1,6 +1,6 @@ import hammerhead from 'testcafe-hammerhead'; import asyncToGenerator from 'babel-runtime/helpers/asyncToGenerator'; -import { noop, escapeRegExp as escapeRe } from 'lodash'; +import { noop } from 'lodash'; import loadBabelLibs from './load-babel-libs'; import { ClientFunctionAPIError } from '../errors/runtime'; import MESSAGE from '../errors/runtime/message'; @@ -12,7 +12,7 @@ const TRAILING_SEMICOLON_RE = /;\s*$/; const REGENERATOR_FOOTPRINTS_RE = /(_index\d+\.default|_regenerator\d+\.default|regeneratorRuntime)\.wrap\(function _callee\$\(_context\)/; const ASYNC_TO_GENERATOR_OUTPUT_CODE = asyncToGenerator(noop).toString(); -var babelArtifactPolyfills = { +const babelArtifactPolyfills = { 'Promise': { re: /_promise(\d+)\.default/, getCode: match => `var _promise${match[1]} = { default: Promise };`, @@ -29,27 +29,15 @@ var babelArtifactPolyfills = { re: /_stringify(\d+)\.default/, getCode: match => `var _stringify${match[1]} = { default: JSON.stringify };`, removeMatchingCode: false - }, - - 'typeof': { - re: new RegExp(escapeRe( - 'var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? ' + - 'function (obj) {return typeof obj;} : ' + - 'function (obj) {return obj && typeof Symbol === "function" && obj.constructor === Symbol ' + - '&& obj !== Symbol.prototype ? "symbol" : typeof obj;};' - ), 'g'), - - getCode: () => 'var _typeof = function(obj) { return typeof obj; };', - removeMatchingCode: true } }; function getBabelOptions () { - var { presetFallback } = loadBabelLibs(); + const { presetFallback, transformForOfAsArray } = loadBabelLibs(); return { - presets: [presetFallback], + presets: [{ plugins: [transformForOfAsArray] }, presetFallback], sourceMaps: false, retainLines: true, ast: false, @@ -59,10 +47,10 @@ function getBabelOptions () { } function downgradeES (fnCode) { - var { babel } = loadBabelLibs(); + const { babel } = loadBabelLibs(); - var opts = getBabelOptions(); - var compiled = babel.transform(fnCode, opts); + const opts = getBabelOptions(); + const compiled = babel.transform(fnCode, opts); return compiled.code .replace(USE_STRICT_RE, '') @@ -70,12 +58,12 @@ function downgradeES (fnCode) { } function addBabelArtifactsPolyfills (fnCode, dependenciesDefinition) { - var modifiedFnCode = fnCode; + let modifiedFnCode = fnCode; - var polyfills = Object + const polyfills = Object .values(babelArtifactPolyfills) .reduce((polyfillsCode, polyfill) => { - var match = fnCode.match(polyfill.re); + const match = fnCode.match(polyfill.re); if (match) { if (polyfill.removeMatchingCode) @@ -104,7 +92,7 @@ function makeFnCodeSuitableForParsing (fnCode) { return `(${fnCode})`; // NOTE: 'myFn () {}' -> 'function myFn() {}' - var match = fnCode.match(ES6_OBJ_METHOD_NAME_RE); + const match = fnCode.match(ES6_OBJ_METHOD_NAME_RE); if (match && match[1] !== 'function') return `function ${fnCode}`; @@ -130,7 +118,7 @@ export default function compileClientFunction (fnCode, dependencies, instantiati if (!TRAILING_SEMICOLON_RE.test(fnCode)) fnCode += ';'; - var dependenciesDefinition = dependencies ? getDependenciesDefinition(dependencies) : ''; + const dependenciesDefinition = dependencies ? getDependenciesDefinition(dependencies) : ''; return addBabelArtifactsPolyfills(fnCode, dependenciesDefinition); } diff --git a/src/compiler/index.js b/src/compiler/index.js index bedbdd7c..318050c4 100644 --- a/src/compiler/index.js +++ b/src/compiler/index.js @@ -1,11 +1,11 @@ import Promise from 'pinkie'; import { flattenDeep as flatten, find, chunk, uniq } from 'lodash'; import stripBom from 'strip-bom'; -import sourceMapSupport from 'source-map-support'; import { Compiler as LegacyTestFileCompiler } from 'testcafe-legacy-api'; import hammerhead from 'testcafe-hammerhead'; import EsNextTestFileCompiler from './test-file/formats/es-next/compiler'; import TypeScriptTestFileCompiler from './test-file/formats/typescript/compiler'; +import CoffeeScriptTestFileCompiler from './test-file/formats/coffeescript/compiler'; import RawTestFileCompiler from './test-file/formats/raw'; import { readFile } from '../utils/promisified-functions'; import { GeneralError } from '../errors/runtime'; @@ -14,34 +14,27 @@ import MESSAGE from '../errors/runtime/message'; const SOURCE_CHUNK_LENGTH = 1000; -var testFileCompilers = [ +const testFileCompilers = [ new LegacyTestFileCompiler(hammerhead.processScript), new EsNextTestFileCompiler(), new TypeScriptTestFileCompiler(), + new CoffeeScriptTestFileCompiler(), new RawTestFileCompiler() ]; export default class Compiler { - constructor (sources) { + constructor (sources, disableTestSyntaxValidation) { this.sources = sources; - Compiler._setupSourceMapsSupport(); + this.disableTestSyntaxValidation = disableTestSyntaxValidation; } static getSupportedTestFileExtensions () { return uniq(testFileCompilers.map(c => c.getSupportedExtension())); } - static _setupSourceMapsSupport () { - sourceMapSupport.install({ - hookRequire: true, - handleUncaughtExceptions: false, - environment: 'node' - }); - } - async _compileTestFile (filename) { - var code = null; + let code = null; try { code = await readFile(filename); @@ -52,15 +45,15 @@ export default class Compiler { code = stripBom(code).toString(); - var compiler = find(testFileCompilers, c => c.canCompile(code, filename)); + const compiler = find(testFileCompilers, c => c.canCompile(code, filename, this.disableTestSyntaxValidation)); return compiler ? await compiler.compile(code, filename) : null; } async getTests () { - var sourceChunks = chunk(this.sources, SOURCE_CHUNK_LENGTH); - var tests = []; - var compileUnits = []; + const sourceChunks = chunk(this.sources, SOURCE_CHUNK_LENGTH); + let tests = []; + let compileUnits = []; // NOTE: split sources into chunks because the fs module can't read all files // simultaneously if the number of them is too large (several thousands). diff --git a/src/compiler/load-babel-libs.js b/src/compiler/load-babel-libs.js index ea2bdd69..5ed02982 100644 --- a/src/compiler/load-babel-libs.js +++ b/src/compiler/load-babel-libs.js @@ -6,6 +6,13 @@ function getOptsForPresetEnv () { }; } +function getOptsForPresetFallback () { + return { + loose: true, + exclude: ['transform-es2015-typeof-symbol'] + }; +} + // NOTE: lazy load heavy dependencies export default function loadBabelLibs () { return { @@ -14,7 +21,8 @@ export default function loadBabelLibs () { presetFlow: require('babel-preset-flow'), transformClassProperties: require('babel-plugin-transform-class-properties'), transformRuntime: require('babel-plugin-transform-runtime'), - presetFallback: require('babel-preset-env').default(null, { loose: true }), + transformForOfAsArray: require('babel-plugin-transform-for-of-as-array').default, + presetFallback: require('babel-preset-env').default(null, getOptsForPresetFallback()), presetEnv: require('babel-preset-env').default(null, getOptsForPresetEnv()) }; } diff --git a/src/compiler/test-file/api-based.js b/src/compiler/test-file/api-based.js index a8438301..f3124706 100644 --- a/src/compiler/test-file/api-based.js +++ b/src/compiler/test-file/api-based.js @@ -15,7 +15,7 @@ const EXPORTABLE_LIB_PATH = join(__dirname, '../../api/exportable-lib'); const FIXTURE_RE = /(^|;|\s+)fixture\s*(\.|\(|`)/; const TEST_RE = /(^|;|\s+)test\s*(\.|\()/; -var Module = module.constructor; +const Module = module.constructor; export default class APIBasedTestFileCompilerBase extends TestFileCompilerBase { constructor () { @@ -30,7 +30,7 @@ export default class APIBasedTestFileCompilerBase extends TestFileCompilerBase { } static _getNodeModulesLookupPath (filename) { - var dir = dirname(filename); + const dir = dirname(filename); return Module._nodeModulePaths(dir); } @@ -42,7 +42,7 @@ export default class APIBasedTestFileCompilerBase extends TestFileCompilerBase { } static _execAsModule (code, filename) { - var mod = new Module(filename, module.parent); + const mod = new Module(filename, module.parent); mod.filename = filename; mod.paths = APIBasedTestFileCompilerBase._getNodeModulesLookupPath(filename); @@ -59,12 +59,12 @@ export default class APIBasedTestFileCompilerBase extends TestFileCompilerBase { } _setupRequireHook (testFile) { - var requireCompilers = this._getRequireCompilers(); + const requireCompilers = this._getRequireCompilers(); this.origRequireExtensions = Object.create(null); Object.keys(requireCompilers).forEach(ext => { - var origExt = require.extensions[ext]; + const origExt = require.extensions[ext]; this.origRequireExtensions[ext] = origExt; @@ -72,12 +72,12 @@ export default class APIBasedTestFileCompilerBase extends TestFileCompilerBase { // NOTE: remove global API so that it will be unavailable for the dependencies this._removeGlobalAPI(); - if (APIBasedTestFileCompilerBase._isNodeModulesDep(filename)) + if (APIBasedTestFileCompilerBase._isNodeModulesDep(filename) && origExt) origExt(mod, filename); else { - var code = readFileSync(filename).toString(); - var compiledCode = requireCompilers[ext](stripBom(code), filename); + const code = readFileSync(filename).toString(); + const compiledCode = requireCompilers[ext](stripBom(code), filename); mod.paths = APIBasedTestFileCompilerBase._getNodeModulesLookupPath(filename); @@ -96,7 +96,7 @@ export default class APIBasedTestFileCompilerBase extends TestFileCompilerBase { } _compileCodeForTestFile (code, filename) { - var compiledCode = null; + let compiledCode = null; stackCleaningHook.enabled = true; @@ -131,8 +131,8 @@ export default class APIBasedTestFileCompilerBase extends TestFileCompilerBase { } compile (code, filename) { - var compiledCode = this._compileCodeForTestFile(code, filename); - var testFile = new TestFile(filename); + const compiledCode = this._compileCodeForTestFile(code, filename); + const testFile = new TestFile(filename); this._addGlobalAPI(testFile); diff --git a/src/compiler/test-file/base.js b/src/compiler/test-file/base.js index 02ddb00b..7a7421d9 100644 --- a/src/compiler/test-file/base.js +++ b/src/compiler/test-file/base.js @@ -2,7 +2,7 @@ import { escapeRegExp as escapeRe } from 'lodash'; export default class TestFileCompilerBase { constructor () { - var escapedExt = escapeRe(this.getSupportedExtension()); + const escapedExt = escapeRe(this.getSupportedExtension()); this.supportedExtensionRe = new RegExp(`${escapedExt}$`); } @@ -19,8 +19,8 @@ export default class TestFileCompilerBase { throw new Error('Not implemented'); } - canCompile (code, filename) { - return this.supportedExtensionRe.test(filename) && this._hasTests(code); + canCompile (code, filename, disableTestSyntaxValidation) { + return this.supportedExtensionRe.test(filename) && (disableTestSyntaxValidation || this._hasTests(code)); } cleanUp () { diff --git a/src/compiler/test-file/formats/coffeescript/compiler.js b/src/compiler/test-file/formats/coffeescript/compiler.js new file mode 100644 index 00000000..33f0683a --- /dev/null +++ b/src/compiler/test-file/formats/coffeescript/compiler.js @@ -0,0 +1,41 @@ +import CoffeeScript from 'coffeescript'; +import loadBabelLibs from '../../../load-babel-libs'; +import ESNextTestFileCompiler from '../es-next/compiler.js'; + +const FIXTURE_RE = /(^|;|\s+)fixture\s*(\.|\(|'|")/; +const TEST_RE = /(^|;|\s+)test\s*/; + +export default class CoffeeScriptTestFileCompiler extends ESNextTestFileCompiler { + _hasTests (code) { + return FIXTURE_RE.test(code) && TEST_RE.test(code); + } + + _compileCode (code, filename) { + if (this.cache[filename]) + return this.cache[filename]; + + const transpiled = CoffeeScript.compile(code, { + filename, + bare: true, + sourceMap: true, + inlineMap: true, + header: false + }); + + const { babel } = loadBabelLibs(); + const babelOptions = ESNextTestFileCompiler.getBabelOptions(filename, code); + const compiled = babel.transform(transpiled.js, babelOptions); + + this.cache[filename] = compiled.code; + + return compiled.code; + } + + _getRequireCompilers () { + return { '.coffee': (code, filename) => this._compileCode(code, filename) }; + } + + getSupportedExtension () { + return '.coffee'; + } +} diff --git a/src/compiler/test-file/formats/coffeescript/get-test-list.js b/src/compiler/test-file/formats/coffeescript/get-test-list.js new file mode 100644 index 00000000..9a7a1caa --- /dev/null +++ b/src/compiler/test-file/formats/coffeescript/get-test-list.js @@ -0,0 +1,29 @@ +import CoffeeScript from 'coffeescript'; +import { transform } from 'babel-core'; +import ESNextTestFileCompiler from '../es-next/compiler.js'; +import { EsNextTestFileParser } from '../es-next/get-test-list'; + +export class CoffeeScriptTestFileParser extends EsNextTestFileParser { + parse (code) { + const babelOptions = ESNextTestFileCompiler.getBabelOptions(null, code); + + delete babelOptions.filename; + babelOptions.ast = true; + + code = CoffeeScript.compile(code, { + bare: true, + sourceMap: false, + inlineMap: false, + header: false + }); + + const ast = transform(code, babelOptions).ast; + + return this.analyze(ast.program.body); + } +} + +const parser = new CoffeeScriptTestFileParser(); + +export const getCoffeeScriptTestList = parser.getTestList.bind(parser); +export const getCoffeeScriptTestListFromCode = parser.getTestListFromCode.bind(parser); diff --git a/src/compiler/test-file/formats/es-next/compiler.js b/src/compiler/test-file/formats/es-next/compiler.js index 2949a92a..069002d3 100644 --- a/src/compiler/test-file/formats/es-next/compiler.js +++ b/src/compiler/test-file/formats/es-next/compiler.js @@ -6,7 +6,7 @@ const FLOW_MARKER_RE = /^\s*\/\/\s*@flow\s*\n|^\s*\/\*\s*@flow\s*\*\//; export default class ESNextTestFileCompiler extends APIBasedTestFileCompilerBase { static getBabelOptions (filename, code) { - var { presetStage2, presetFlow, transformRuntime, transformClassProperties, presetEnv } = loadBabelLibs(); + const { presetStage2, presetFlow, transformRuntime, transformClassProperties, presetEnv } = loadBabelLibs(); // NOTE: passPrePreset and complex presets is a workaround for https://github.com/babel/babel/issues/2877 // Fixes https://github.com/DevExpress/testcafe/issues/969 @@ -48,13 +48,13 @@ export default class ESNextTestFileCompiler extends APIBasedTestFileCompilerBase } _compileCode (code, filename) { - var { babel } = loadBabelLibs(); + const { babel } = loadBabelLibs(); if (this.cache[filename]) return this.cache[filename]; - var opts = ESNextTestFileCompiler.getBabelOptions(filename, code); - var compiled = babel.transform(code, opts); + const opts = ESNextTestFileCompiler.getBabelOptions(filename, code); + const compiled = babel.transform(code, opts); this.cache[filename] = compiled.code; diff --git a/src/compiler/test-file/formats/es-next/get-test-list.js b/src/compiler/test-file/formats/es-next/get-test-list.js index 6fdbb691..50bda341 100644 --- a/src/compiler/test-file/formats/es-next/get-test-list.js +++ b/src/compiler/test-file/formats/es-next/get-test-list.js @@ -1,4 +1,4 @@ -import { assign } from 'lodash'; +import { assign, merge } from 'lodash'; import { transform } from 'babel-core'; import ESNextTestFileCompiler from './compiler'; import { TestFileParserBase } from '../../test-file-parser-base'; @@ -13,11 +13,14 @@ const TOKEN_TYPE = { ArrowFunctionExpression: 'ArrowFunctionExpression', FunctionExpression: 'FunctionExpression', ExpressionStatement: 'ExpressionStatement', + ReturnStatement: 'ReturnStatement', FunctionDeclaration: 'FunctionDeclaration', - VariableDeclaration: 'VariableDeclaration' + VariableStatement: 'VariableStatement', + VariableDeclaration: 'VariableDeclaration', + ObjectLiteralExpression: 'ObjectExpression' }; -class EsNextTestFileParser extends TestFileParserBase { +export class EsNextTestFileParser extends TestFileParserBase { constructor () { super(TOKEN_TYPE); } @@ -39,17 +42,44 @@ class EsNextTestFileParser extends TestFileParserBase { return token.declarations[0].init; } + getStringValue (token) { + const stringTypes = [this.tokenType.StringLiteral, this.tokenType.TemplateLiteral, this.tokenType.Identifier]; + + if (stringTypes.indexOf(token.type) > -1) + return this.formatFnArg(token); + + return null; + } + getFunctionBody (token) { return token.body && token.body.body ? token.body.body : []; } - formatFnData (name, value, token) { + getCalleeToken (token) { + return token.callee; + } + + getMemberFnName (token) { + return token.callee.property.name; + } + + formatFnData (name, value, token, meta = [{}]) { return { fnName: name, value: value, loc: token.loc, start: token.start, - end: token.end + end: token.end, + meta: merge({}, ...meta) + }; + } + + getKeyValue (prop) { + const { key, value } = prop; + + return { + key: key.name || this.formatFnArg(key), + value: this.getStringValue(value) }; } @@ -77,13 +107,15 @@ class EsNextTestFileParser extends TestFileParserBase { if (!this.isApiFn(exp.name)) return null; + const meta = this.getMetaInfo(callStack.slice()); + let parentExp = callStack.pop(); if (parentExp.type === tokenType.CallExpression) - return this.formatFnData(exp.name, this.formatFnArg(parentExp.arguments[0]), token); + return this.formatFnData(exp.name, this.formatFnArg(parentExp.arguments[0]), token, meta); if (parentExp.type === tokenType.TaggedTemplateExpression) - return this.formatFnData(exp.name, EsNextTestFileParser.getTagStrValue(parentExp.quasi), token); + return this.formatFnData(exp.name, EsNextTestFileParser.getTagStrValue(parentExp.quasi), token, meta); if (parentExp.type === tokenType.PropertyAccessExpression) { while (parentExp) { @@ -92,7 +124,7 @@ class EsNextTestFileParser extends TestFileParserBase { const calleeMemberFn = parentExp.callee.property && parentExp.callee.property.name; if (this.checkExpDefineTargetName(calleeType, calleeMemberFn)) - return this.formatFnData(exp.name, this.formatFnArg(parentExp.arguments[0]), token); + return this.formatFnData(exp.name, this.formatFnArg(parentExp.arguments[0]), token, meta); } if (parentExp.type === tokenType.TaggedTemplateExpression && parentExp.tag) { @@ -100,7 +132,7 @@ class EsNextTestFileParser extends TestFileParserBase { const tagMemberFn = parentExp.tag.property && parentExp.tag.property.name; if (this.checkExpDefineTargetName(tagType, tagMemberFn)) - return this.formatFnData(exp.name, EsNextTestFileParser.getTagStrValue(parentExp.quasi), token); + return this.formatFnData(exp.name, EsNextTestFileParser.getTagStrValue(parentExp.quasi), token, meta); } parentExp = callStack.pop(); diff --git a/src/compiler/test-file/formats/raw.js b/src/compiler/test-file/formats/raw.js index a72962ce..17a098a4 100644 --- a/src/compiler/test-file/formats/raw.js +++ b/src/compiler/test-file/formats/raw.js @@ -9,12 +9,13 @@ import createCommandFromObject from '../../../test-run/commands/from-object'; export default class RawTestFileCompiler extends TestFileCompilerBase { static _createTestFn (commands) { return async t => { - for (var i = 0; i < commands.length; i++) { - var callsite = commands[i] && commands[i].callsite; - var command = null; + for (let i = 0; i < commands.length; i++) { + const callsite = commands[i] && commands[i].callsite; + let command = null; try { - command = createCommandFromObject(commands[i]); + command = createCommandFromObject(commands[i], t.testRun); + await t.testRun.executeCommand(command, callsite); } catch (err) { @@ -48,7 +49,7 @@ export default class RawTestFileCompiler extends TestFileCompilerBase { } static _addTest (testFile, src) { - var test = new Test(testFile); + const test = new Test(testFile); test(src.name, RawTestFileCompiler._createTestFn(src.commands)); @@ -64,7 +65,7 @@ export default class RawTestFileCompiler extends TestFileCompilerBase { } static _addFixture (testFile, src) { - var fixture = new Fixture(testFile); + const fixture = new Fixture(testFile); fixture(src.name); @@ -88,8 +89,8 @@ export default class RawTestFileCompiler extends TestFileCompilerBase { } compile (code, filename) { - var data = null; - var testFile = new TestFile(filename); + let data = null; + const testFile = new TestFile(filename); try { data = JSON.parse(code); diff --git a/src/compiler/test-file/formats/typescript/compiler.js b/src/compiler/test-file/formats/typescript/compiler.js index f249d054..8de062c4 100644 --- a/src/compiler/test-file/formats/typescript/compiler.js +++ b/src/compiler/test-file/formats/typescript/compiler.js @@ -9,7 +9,7 @@ const RENAMED_DEPENDENCIES_MAP = new Map([['testcafe', APIBasedTestFileCompilerB export default class TypeScriptTestFileCompiler extends APIBasedTestFileCompilerBase { static _getTypescriptOptions () { // NOTE: lazy load the compiler - var ts = require('typescript'); + const ts = require('typescript'); return { experimentalDecorators: true, @@ -30,13 +30,13 @@ export default class TypeScriptTestFileCompiler extends APIBasedTestFileCompiler static _reportErrors (diagnostics) { // NOTE: lazy load the compiler - var ts = require('typescript'); - var errMsg = 'TypeScript compilation failed.\n'; + const ts = require('typescript'); + let errMsg = 'TypeScript compilation failed.\n'; diagnostics.forEach(d => { - var file = d.file; - var { line, character } = file.getLineAndCharacterOfPosition(d.start); - var message = ts.flattenDiagnosticMessageText(d.messageText, '\n'); + const file = d.file; + const { line, character } = file.getLineAndCharacterOfPosition(d.start); + const message = ts.flattenDiagnosticMessageText(d.messageText, '\n'); errMsg += `${file.fileName} (${line + 1}, ${character + 1}): ${message}\n`; }); @@ -55,21 +55,21 @@ export default class TypeScriptTestFileCompiler extends APIBasedTestFileCompiler _compileCode (code, filename) { // NOTE: lazy load the compiler - var ts = require('typescript'); + const ts = require('typescript'); - var normalizedFilename = TypeScriptTestFileCompiler._normalizeFilename(filename); + const normalizedFilename = TypeScriptTestFileCompiler._normalizeFilename(filename); if (this.cache[normalizedFilename]) return this.cache[normalizedFilename]; - var opts = TypeScriptTestFileCompiler._getTypescriptOptions(); - var program = ts.createProgram([filename], opts); + const opts = TypeScriptTestFileCompiler._getTypescriptOptions(); + const program = ts.createProgram([filename], opts); program.getSourceFiles().forEach(sourceFile => { sourceFile.renamedDependencies = RENAMED_DEPENDENCIES_MAP; }); - var diagnostics = ts.getPreEmitDiagnostics(program); + const diagnostics = ts.getPreEmitDiagnostics(program); if (diagnostics.length) TypeScriptTestFileCompiler._reportErrors(diagnostics); @@ -78,7 +78,7 @@ export default class TypeScriptTestFileCompiler extends APIBasedTestFileCompiler // will be compiled. contains a file specified in createProgram() plus all its dependencies. // This mode is much faster than compiling files one-by-one, and it is used in the tsc CLI compiler. program.emit(void 0, (outputName, result, writeBOM, onError, sources) => { - var sourcePath = TypeScriptTestFileCompiler._normalizeFilename(sources[0].fileName); + const sourcePath = TypeScriptTestFileCompiler._normalizeFilename(sources[0].fileName); this.cache[sourcePath] = result; }); diff --git a/src/compiler/test-file/formats/typescript/get-test-list.js b/src/compiler/test-file/formats/typescript/get-test-list.js index 693ee047..e5490d9d 100644 --- a/src/compiler/test-file/formats/typescript/get-test-list.js +++ b/src/compiler/test-file/formats/typescript/get-test-list.js @@ -1,5 +1,5 @@ import ts from 'typescript'; -import { repeat } from 'lodash'; +import { repeat, merge } from 'lodash'; import TypeScriptTestFileCompiler from './compiler'; import { TestFileParserBase } from '../../test-file-parser-base'; @@ -16,10 +16,33 @@ class TypeScriptTestFileParser extends TestFileParserBase { super(ts.SyntaxKind); } + getComputedNameString ({ pos, end }) { + const templatePos = this.getLocationByOffsets(pos, end); + + return TestFileParserBase.formatComputedName(templatePos.loc.start.line); + } + getTokenType (token) { return token.kind; } + getCalleeToken (token) { + return token.expression; + } + + getMemberFnName (token) { + return token.expression.name.text; + } + + getKeyValue (prop) { + const { name, initializer } = prop; + + return { + key: name.text, + value: this.getStringValue(initializer) + }; + } + getFixedStartOffset (start) { let fixedStartOffset = start; @@ -56,7 +79,16 @@ class TypeScriptTestFileParser extends TestFileParserBase { } getRValue (token) { - return token.initializer; + return token.declarationList.declarations[0].initializer; + } + + getStringValue (token) { + const stringTypes = [this.tokenType.StringLiteral, this.tokenType.TemplateExpression]; + + if (stringTypes.indexOf(token.kind) > -1 || token.text && token.kind !== this.tokenType.NumericLiteral) + return this.formatFnArg(token); + + return null; } isAsyncFn (token) { @@ -71,13 +103,7 @@ class TypeScriptTestFileParser extends TestFileParserBase { return token.body.statements; } - formatFnData (name, value, token) { - if (value && typeof value === 'object') { - const templatePos = this.getLocationByOffsets(value.pos, value.end); - - value = TypeScriptTestFileParser.formatComputedName(templatePos.loc.start.line); - } - + formatFnData (name, value, token, meta = [{}]) { const loc = this.getLocationByOffsets(token.pos, token.end); return { @@ -85,7 +111,8 @@ class TypeScriptTestFileParser extends TestFileParserBase { value: value, loc: loc.loc, start: loc.start, - end: loc.end + end: loc.end, + meta: merge({}, ...meta) }; } @@ -100,6 +127,8 @@ class TypeScriptTestFileParser extends TestFileParserBase { callStack.push(exp); } + const meta = this.getMetaInfo(callStack.slice()); + if (exp && this.isApiFn(exp.text)) { let parentExp = callStack.pop(); @@ -110,7 +139,7 @@ class TypeScriptTestFileParser extends TestFileParserBase { parentExp.expression.name.text; if (this.checkExpDefineTargetName(calleeType, calleeMemberFn)) - return this.formatFnData(exp.text, this.formatFnArg(parentExp.arguments[0]), token); + return this.formatFnData(exp.text, this.formatFnArg(parentExp.arguments[0]), token, meta); } if (parentExp.kind === tokenType.TaggedTemplateExpression && parentExp.tag) { @@ -118,7 +147,7 @@ class TypeScriptTestFileParser extends TestFileParserBase { const tagMemberFn = tagType === tokenType.PropertyAccessExpression && parentExp.tag.name.text; if (this.checkExpDefineTargetName(tagType, tagMemberFn)) - return this.formatFnData(exp.text, this.formatFnArg(parentExp), token); + return this.formatFnData(exp.text, this.formatFnArg(parentExp), token, meta); } parentExp = callStack.pop(); @@ -129,14 +158,17 @@ class TypeScriptTestFileParser extends TestFileParserBase { } formatFnArg (arg) { + if (arg.templateSpans) + return this.getComputedNameString({ pos: arg.pos, end: arg.end }); + if (arg.head) - return { pos: arg.template.pos, end: arg.template.end }; + return this.getComputedNameString({ pos: arg.template.pos, end: arg.template.end }); if (arg.template) - return arg.template.text || { pos: arg.template.pos, end: arg.template.end }; + return arg.template.text || this.getComputedNameString({ pos: arg.template.pos, end: arg.template.end }); if (arg.kind === this.tokenType.Identifier) - return { pos: arg.pos, end: arg.end }; + return this.getComputedNameString({ pos: arg.pos, end: arg.end }); if (arg.text && arg.kind !== this.tokenType.NumericLiteral) return arg.text; diff --git a/src/compiler/test-file/test-file-parser-base.js b/src/compiler/test-file/test-file-parser-base.js index 57f9ef89..12bb178d 100644 --- a/src/compiler/test-file/test-file-parser-base.js +++ b/src/compiler/test-file/test-file-parser-base.js @@ -11,21 +11,23 @@ const METHODS_SPECIFYING_NAME = ['only', 'skip']; const COMPUTED_NAME_TEXT_TMP = '(line: %s)'; export class Fixture { - constructor (name, start, end, loc) { + constructor (name, start, end, loc, meta) { this.name = name; this.loc = loc; this.start = start; this.end = end; + this.meta = meta; this.tests = []; } } export class Test { - constructor (name, start, end, loc) { + constructor (name, start, end, loc, meta) { this.name = name; this.loc = loc; this.start = start; this.end = end; + this.meta = meta; } } @@ -78,10 +80,83 @@ export class TestFileParserBase { throw new Error('Not implemented'); } + getTokenType (/* token */) { + throw new Error('Not implemented'); + } + + getCalleeToken (/* token */) { + throw new Error('Not implemented'); + } + + getMemberFnName () { + throw new Error('Not implemented'); + } + + getKeyValue () { + throw new Error('Not implemented'); + } + + getStringValue () { + throw new Error('Not implemented'); + } + isApiFn (fn) { return fn === 'fixture' || fn === 'test'; } + serializeObjExp (token) { + if (this.getTokenType(token) !== this.tokenType.ObjectLiteralExpression) + return {}; + + return token.properties.reduce((obj, prop) => { + const { key, value } = this.getKeyValue(prop); + + if (typeof value !== 'string') return {}; + + obj[key] = value; + + return obj; + }, {}); + } + + processMetaArgs (token) { + if (this.getTokenType(token) !== this.tokenType.CallExpression) + return null; + + const args = token.arguments; + + let meta = {}; + + if (args.length === 2) { + const value = this.getStringValue(args[1]); + + if (typeof value !== 'string') return {}; + + meta = { [this.formatFnArg(args[0])]: value }; + } + + else if (args.length === 1) + meta = this.serializeObjExp(args[0]); + + return meta; + } + + getMetaInfo (callStack) { + return callStack.reduce((metaCalls, exp) => { + if (this.getTokenType(exp) !== this.tokenType.CallExpression) + return metaCalls; + + const callee = this.getCalleeToken(exp); + const calleeType = this.getTokenType(callee); + const isCalleeMemberExp = calleeType === this.tokenType.PropertyAccessExpression; + + if (isCalleeMemberExp && this.getMemberFnName(exp) === 'meta') + return [this.processMetaArgs(exp)].concat(metaCalls); + + return metaCalls; + }, []); + } + checkExpDefineTargetName (type, apiFn) { //NOTE: fixture('fixtureName').chainFn or test('testName').chainFn const isDirectCall = type === this.tokenType.Identifier; @@ -113,12 +188,18 @@ export class TestFileParserBase { return this.getFunctionBody(token).map(this.analyzeToken, this); case tokenType.VariableDeclaration: - return this.analyzeToken(this.getRValue(token)); + case tokenType.VariableStatement: { + const variableValue = this.getRValue(token); // Skip variable declarations like `var foo;` + return variableValue ? this.analyzeToken(variableValue) : null; + } case tokenType.CallExpression: case tokenType.PropertyAccessExpression: case tokenType.TaggedTemplateExpression: return this.analyzeFnCall(token); + + case tokenType.ReturnStatement: + return token.argument ? this.analyzeToken(token.argument) : null; } return null; @@ -145,13 +226,13 @@ export class TestFileParserBase { if (!call || typeof call.value !== 'string') return; if (call.fnName === 'fixture') { - fixtures.push(new Fixture(call.value, call.start, call.end, call.loc)); + fixtures.push(new Fixture(call.value, call.start, call.end, call.loc, call.meta)); return; } if (!fixtures.length) return; - const test = new Test(call.value, call.start, call.end, call.loc); + const test = new Test(call.value, call.start, call.end, call.loc, call.meta); fixtures[fixtures.length - 1].tests.push(test); }); diff --git a/src/embedding-utils.js b/src/embedding-utils.js index 59c5fd01..6000c1f6 100644 --- a/src/embedding-utils.js +++ b/src/embedding-utils.js @@ -1,27 +1,75 @@ -import ReporterPluginHost from './reporter/plugin-host'; -import TestRunErrorFormattableAdapter from './errors/test-run/formattable-adapter'; -import * as testRunErrors from './errors/test-run'; -import TestRun from './test-run'; -import COMMAND_TYPE from './test-run/commands/type'; -import Assignable from './utils/assignable'; -import { getTestList, getTestListFromCode } from './compiler/test-file/formats/es-next/get-test-list'; -import { getTypeScriptTestList, getTypeScriptTestListFromCode } from './compiler/test-file/formats/typescript/get-test-list'; -import { initSelector } from './test-run/commands/validations/initializers'; +const lazyRequire = require('import-lazy')(require); +const hammerhead = lazyRequire('testcafe-hammerhead'); +const ReporterPluginHost = lazyRequire('./reporter/plugin-host'); +const TestRunErrorFormattableAdapter = lazyRequire('./errors/test-run/formattable-adapter'); +const testRunErrors = lazyRequire('./errors/test-run'); +const COMMAND_TYPE = lazyRequire('./test-run/commands/type'); +const getTestListModule = lazyRequire('./compiler/test-file/formats/es-next/get-test-list'); +const getTypeScriptTestListModule = lazyRequire('./compiler/test-file/formats/typescript/get-test-list'); +const getCoffeeScriptTestListModule = lazyRequire('./compiler/test-file/formats/coffeescript/get-test-list'); +const initializers = lazyRequire('./test-run/commands/validations/initializers'); + +// NOTE: we can't use lazy require for TestRun and Assignable, because it breaks prototype chain for inherited classes +let TestRun = null; +let Assignable = null; export default { - getTestList, - getTypeScriptTestList, - getTestListFromCode, - getTypeScriptTestListFromCode, TestRunErrorFormattableAdapter, - TestRun, testRunErrors, COMMAND_TYPE, - Assignable, - initSelector, + + get Assignable () { + if (!Assignable) + Assignable = require('./utils/assignable'); + + return Assignable; + }, + + get TestRun () { + if (!TestRun) + TestRun = require('./test-run'); + + return TestRun; + }, + + get getTestList () { + return getTestListModule.getTestList; + }, + + get getTypeScriptTestList () { + return getTypeScriptTestListModule.getTypeScriptTestList; + }, + + get getCoffeeScriptTestList () { + return getCoffeeScriptTestListModule.getCoffeeScriptTestList; + }, + + get getTestListFromCode () { + return getTestListModule.getTestListFromCode; + }, + + get getTypeScriptTestListFromCode () { + return getTypeScriptTestListModule.getTypeScriptTestListFromCode; + }, + + get getCoffeeScriptTestListFromCode () { + return getCoffeeScriptTestListModule.getCoffeeScriptTestListFromCode; + }, + + get initSelector () { + return initializers.initSelector; + }, + + ensureUploadDirectory (...args) { + return hammerhead.UploadStorage.ensureUploadsRoot(...args); + }, + + copyFilesToUploadFolder (...args) { + return hammerhead.UploadStorage.copy(...args); + }, buildReporterPlugin (pluginFactory, outStream) { - var plugin = pluginFactory(); + const plugin = pluginFactory(); return new ReporterPluginHost(plugin, outStream); } diff --git a/src/errors/create-stack-filter.js b/src/errors/create-stack-filter.js index 1f2de537..7825c567 100644 --- a/src/errors/create-stack-filter.js +++ b/src/errors/create-stack-filter.js @@ -18,16 +18,16 @@ const SOURCE_MAP_SUPPORT = `${sep}source-map-support${sep}`; const INTERNAL = 'internal/'; export default function createStackFilter (limit) { - var passedFramesCount = 0; + let passedFramesCount = 0; return function stackFilter (frame) { if (passedFramesCount >= limit) return false; - var filename = frame.getFileName(); + const filename = frame.getFileName(); // NOTE: filter out the internals of node, Babel and TestCafe - var pass = filename && + const pass = filename && filename.indexOf(sep) > -1 && filename.indexOf(INTERNAL) !== 0 && filename.indexOf(TESTCAFE_LIB) !== 0 && diff --git a/src/errors/error-list.js b/src/errors/error-list.js index 40c7ce3d..c27f522b 100644 --- a/src/errors/error-list.js +++ b/src/errors/error-list.js @@ -1,4 +1,5 @@ import processTestFnError from './process-test-fn-error'; +import { UncaughtErrorInTestCode } from './test-run'; export default class TestCafeErrorList { constructor () { @@ -9,6 +10,10 @@ export default class TestCafeErrorList { return !!this.items.length; } + get hasUncaughtErrorsInTestCode () { + return this.items.some(item => item instanceof UncaughtErrorInTestCode); + } + addError (err) { if (err instanceof TestCafeErrorList) this.items = this.items.concat(err.items); diff --git a/src/errors/get-callsite.js b/src/errors/get-callsite.js index fd3d9bfb..5cf9cb9d 100644 --- a/src/errors/get-callsite.js +++ b/src/errors/get-callsite.js @@ -5,13 +5,13 @@ import { wrapCallSite } from 'source-map-support'; const STACK_TRACE_LIMIT = 2000; function getCallsite (options) { - var originalStackCleaningEnabled = stackCleaningHook.enabled; - var originalStackTraceLimit = Error.stackTraceLimit; + const originalStackCleaningEnabled = stackCleaningHook.enabled; + const originalStackTraceLimit = Error.stackTraceLimit; stackCleaningHook.enabled = false; Error.stackTraceLimit = STACK_TRACE_LIMIT; - var callsiteRecord = createCallsiteRecord(options); + const callsiteRecord = createCallsiteRecord(options); Error.stackTraceLimit = originalStackTraceLimit; stackCleaningHook.enabled = originalStackCleaningEnabled; diff --git a/src/errors/process-test-fn-error.js b/src/errors/process-test-fn-error.js index e96d51d1..949463c5 100644 --- a/src/errors/process-test-fn-error.js +++ b/src/errors/process-test-fn-error.js @@ -13,7 +13,7 @@ import { const INTERNAL = 'internal/'; function isAssertionErrorCallsiteFrame (frame) { - var filename = frame.getFileName(); + const filename = frame.getFileName(); // NOTE: filter out the internals of node.js and assertion libraries return filename && @@ -31,11 +31,11 @@ export default function processTestFnError (err) { return new UncaughtErrorInTestCode(err.rawMessage, err.callsite); if (err instanceof Error) { - var isAssertionError = err.name === 'AssertionError' || err.constructor.name === 'AssertionError'; + const isAssertionError = err.name === 'AssertionError' || err.constructor.name === 'AssertionError'; // NOTE: assertion libraries can add their source files to the error stack frames. // We should skip them to create a correct callsite for the assertion error. - var callsite = isAssertionError ? getCallsiteForError(err, isAssertionErrorCallsiteFrame) : getCallsiteForError(err); + const callsite = isAssertionError ? getCallsiteForError(err, isAssertionErrorCallsiteFrame) : getCallsiteForError(err); return isAssertionError ? new ExternalAssertionLibraryError(err, callsite) : diff --git a/src/errors/render-forbidden-chars-list.js b/src/errors/render-forbidden-chars-list.js new file mode 100644 index 00000000..00cd701c --- /dev/null +++ b/src/errors/render-forbidden-chars-list.js @@ -0,0 +1,3 @@ +export default function (forbiddenCharsList) { + return forbiddenCharsList.map(charInfo => `\t"${charInfo.chars}" at index ${charInfo.index}\n`).join(''); +} diff --git a/src/errors/runtime/index.js b/src/errors/runtime/index.js index 19a87888..82e691f0 100644 --- a/src/errors/runtime/index.js +++ b/src/errors/runtime/index.js @@ -6,8 +6,8 @@ import renderTemplate from '../../utils/render-template'; // Errors export class GeneralError extends Error { - constructor () { - super(renderTemplate.apply(null, arguments)); + constructor (...args) { + super(renderTemplate(...args)); Error.captureStackTrace(this, GeneralError); // HACK: workaround for the `instanceof` problem @@ -28,7 +28,7 @@ export class TestCompilationError extends Error { export class APIError extends Error { constructor (methodName, template, ...args) { - var rawMessage = renderTemplate(template, ...args); + const rawMessage = renderTemplate(template, ...args); super(renderTemplate(MESSAGE.cannotPrepareTestsDueToError, rawMessage)); diff --git a/src/errors/runtime/message.js b/src/errors/runtime/message.js index db9199d6..48d8785b 100644 --- a/src/errors/runtime/message.js +++ b/src/errors/runtime/message.js @@ -1,3 +1,9 @@ +// ------------------------------------------------------------- +// WARNING: this file is used by both the client and the server. +// Do not use any browser or node-specific API! +// ------------------------------------------------------------- + + export default { browserDisconnected: 'The {userAgent} browser disconnected. This problem may appear when a browser hangs or is closed, or due to network issues.', cantRunAgainstDisconnectedBrowsers: 'The following browsers disconnected: {userAgents}. Tests will not be run.', @@ -10,6 +16,7 @@ export default { cantFindReporterForAlias: 'The provided "{name}" reporter does not exist. Check that you have specified the report format correctly.', multipleStdoutReporters: 'Multiple reporters attempting to write to stdout: "{reporters}". Only one reporter can write to stdout.', optionValueIsNotValidRegExp: 'The "{optionName}" option value is not a valid regular expression.', + optionValueIsNotValidKeyValue: 'The "{optionName}" option value is not a valid key-value pair.', testedAppFailedWithError: 'Tested app failed with an error:\n\n{errMessage}', invalidSpeedValue: 'Speed should be a number between 0.01 and 1.', invalidConcurrencyFactor: 'The concurrency factor should be an integer greater or equal to 1.', @@ -28,5 +35,8 @@ export default { invalidValueType: '{smthg} is expected to be a {type}, but it was {actual}.', unsupportedUrlProtocol: 'The specified "{url}" test page URL uses an unsupported {protocol}:// protocol. Only relative URLs or absolute URLs with http://, https:// and file:// protocols are supported.', unableToOpenBrowser: 'Was unable to open the browser "{alias}" due to error.\n\n{errMessage}', - testControllerProxyCantResolveTestRun: `Cannot implicitly resolve the test run in the context of which the test controller action should be executed. Use test function's 't' argument instead.` + testControllerProxyCantResolveTestRun: `Cannot implicitly resolve the test run in the context of which the test controller action should be executed. Use test function's 't' argument instead.`, + requestHookConfigureAPIError: 'There was an error while configuring the request hook:\n\n{requestHookName}: {errMsg}', + timeLimitedPromiseTimeoutExpired: 'Timeout expired for a time limited promise', + forbiddenCharatersInScreenshotPath: 'There are forbidden characters in the "{screenshotPath}" {screenshotPathType}:\n {forbiddenCharsDescription}' }; diff --git a/src/errors/runtime/type-assertions.js b/src/errors/runtime/type-assertions.js index cfbcb063..6a4a0d5c 100644 --- a/src/errors/runtime/type-assertions.js +++ b/src/errors/runtime/type-assertions.js @@ -26,7 +26,7 @@ function getNumberTypeActualValueMsg (value, type) { return value; } -export var is = { +export const is = { number: { name: 'number', predicate: isFiniteNumber, @@ -44,7 +44,7 @@ export var is = { predicate: value => isNonNegativeValue(parseInt(value, 10)), getActualValueMsg: value => { - var number = parseInt(value, 10); + const number = parseInt(value, 10); return isNaN(number) ? JSON.stringify(value) : number; } @@ -90,11 +90,11 @@ export var is = { export function assertType (types, callsiteName, what, value) { types = Array.isArray(types) ? types : [types]; - var pass = false; - var actualType = typeof value; - var actualMsg = actualType; - var expectedTypeMsg = ''; - var last = types.length - 1; + let pass = false; + const actualType = typeof value; + let actualMsg = actualType; + let expectedTypeMsg = ''; + const last = types.length - 1; types.forEach((type, i) => { pass = pass || type.predicate(value, actualType); diff --git a/src/errors/stack-cleaning-hook.js b/src/errors/stack-cleaning-hook.js index 0588f81b..6b11c562 100644 --- a/src/errors/stack-cleaning-hook.js +++ b/src/errors/stack-cleaning-hook.js @@ -5,6 +5,7 @@ import createStackFilter from './create-stack-filter'; const ORIGINAL_STACK_TRACE_LIMIT = Error.stackTraceLimit; const STACK_TRACE_LIMIT = 200; const TOP_ANONYMOUS_FRAME_RE = /\s+at\s$/; +const GENERATOR_NEXT_FRAME_RE = /\s+at\sgenerator.next\s\(\)$/im; export default { @@ -43,6 +44,7 @@ export default { cleanError (error) { error.stack = error.stack.replace(TOP_ANONYMOUS_FRAME_RE, ''); + error.stack = error.stack.replace(GENERATOR_NEXT_FRAME_RE, ''); let frames = this._getFrames(error); diff --git a/src/errors/test-run/formattable-adapter.js b/src/errors/test-run/formattable-adapter.js index 74c20f4e..af56074c 100644 --- a/src/errors/test-run/formattable-adapter.js +++ b/src/errors/test-run/formattable-adapter.js @@ -4,7 +4,7 @@ import { renderers } from 'callsite-record'; import TEMPLATES from './templates'; import createStackFilter from '../create-stack-filter'; -var parser = new Parser(); +const parser = new Parser(); export default class TestRunErrorFormattableAdapter { constructor (err, metaInfo) { @@ -20,14 +20,14 @@ export default class TestRunErrorFormattableAdapter { } static _getSelector (node) { - var classAttr = find(node.attrs, { name: 'class' }); - var cls = classAttr && classAttr.value; + const classAttr = find(node.attrs, { name: 'class' }); + const cls = classAttr && classAttr.value; return cls ? `${node.tagName} ${cls}` : node.tagName; } static _decorateHtml (node, decorator) { - var msg = ''; + let msg = ''; if (node.nodeName === '#text') msg = node.value; @@ -39,7 +39,7 @@ export default class TestRunErrorFormattableAdapter { } if (node.nodeName !== '#document-fragment') { - var selector = TestRunErrorFormattableAdapter._getSelector(node); + const selector = TestRunErrorFormattableAdapter._getSelector(node); msg = decorator[selector](msg, node.attrs); } @@ -72,8 +72,8 @@ export default class TestRunErrorFormattableAdapter { } formatMessage (decorator, viewportWidth) { - var msgHtml = this.getErrorMarkup(viewportWidth); - var fragment = parser.parseFragment(msgHtml); + const msgHtml = this.getErrorMarkup(viewportWidth); + const fragment = parser.parseFragment(msgHtml); return TestRunErrorFormattableAdapter._decorateHtml(fragment, decorator); } diff --git a/src/errors/test-run/index.js b/src/errors/test-run/index.js index fbb0ce68..922393ea 100644 --- a/src/errors/test-run/index.js +++ b/src/errors/test-run/index.js @@ -63,21 +63,29 @@ export class DomNodeClientFunctionResultError extends TestRunErrorBase { // Selector errors //-------------------------------------------------------------------- +class SelectorErrorBase extends TestRunErrorBase { + constructor (type, { apiFnChain, apiFnIndex }) { + super(type); + + this.apiFnChain = apiFnChain; + this.apiFnIndex = apiFnIndex; + } +} + export class InvalidSelectorResultError extends TestRunErrorBase { constructor () { super(TYPE.invalidSelectorResultError); } } -export class CantObtainInfoForElementSpecifiedBySelectorError extends TestRunErrorBase { - constructor (callsite) { - super(TYPE.cantObtainInfoForElementSpecifiedBySelectorError); +export class CantObtainInfoForElementSpecifiedBySelectorError extends SelectorErrorBase { + constructor (callsite, apiFnArgs) { + super(TYPE.cantObtainInfoForElementSpecifiedBySelectorError, apiFnArgs); this.callsite = callsite; } } - // Page errors //-------------------------------------------------------------------- export class PageLoadError extends TestRunErrorBase { @@ -92,10 +100,10 @@ export class PageLoadError extends TestRunErrorBase { // Uncaught errors //-------------------------------------------------------------------- export class UncaughtErrorOnPage extends TestRunErrorBase { - constructor (errMsg, pageDestUrl) { + constructor (errStack, pageDestUrl) { super(TYPE.uncaughtErrorOnPage); - this.errMsg = errMsg; + this.errStack = errStack; this.pageDestUrl = pageDestUrl; } } @@ -137,6 +145,22 @@ export class UncaughtErrorInCustomDOMPropertyCode extends TestRunErrorBase { } } +export class UnhandledPromiseRejectionError extends TestRunErrorBase { + constructor (err) { + super(TYPE.unhandledPromiseRejection); + + this.errMsg = String(err); + } +} + +export class UncaughtExceptionError extends TestRunErrorBase { + constructor (err) { + super(TYPE.uncaughtException); + + this.errMsg = String(err); + } +} + // Assertion errors //-------------------------------------------------------------------- @@ -157,6 +181,14 @@ export class AssertionExecutableArgumentError extends ActionArgumentErrorBase { } } +export class AssertionWithoutMethodCallError extends TestRunErrorBase { + constructor (callsite) { + super(TYPE.assertionWithoutMethodCallError); + + this.callsite = callsite; + } +} + export class AssertionUnawaitedPromiseError extends TestRunErrorBase { constructor (callsite) { super(TYPE.assertionUnawaitedPromiseError); @@ -186,6 +218,12 @@ export class ActionBooleanOptionError extends ActionOptionErrorBase { } } +export class ActionBooleanArgumentError extends ActionArgumentErrorBase { + constructor (argumentName, actualValue) { + super(TYPE.actionBooleanArgumentError, argumentName, actualValue); + } +} + export class ActionSpeedOptionError extends ActionOptionErrorBase { constructor (optionName, actualValue) { super(TYPE.actionSpeedOptionError, optionName, actualValue); @@ -270,9 +308,9 @@ export class ActionSelectorError extends TestRunErrorBase { // Action execution errors //-------------------------------------------------------------------- -export class ActionElementNotFoundError extends TestRunErrorBase { - constructor () { - super(TYPE.actionElementNotFoundError); +export class ActionElementNotFoundError extends SelectorErrorBase { + constructor (apiFnArgs) { + super(TYPE.actionElementNotFoundError, apiFnArgs); } } @@ -290,9 +328,9 @@ export class ActionSelectorMatchesWrongNodeTypeError extends TestRunErrorBase { } } -export class ActionAdditionalElementNotFoundError extends TestRunErrorBase { - constructor (argumentName) { - super(TYPE.actionAdditionalElementNotFoundError); +export class ActionAdditionalElementNotFoundError extends SelectorErrorBase { + constructor (argumentName, apiFnArgs) { + super(TYPE.actionAdditionalElementNotFoundError, apiFnArgs); this.argumentName = argumentName; } @@ -390,8 +428,8 @@ export class InvalidElementScreenshotDimensionsError extends TestRunErrorBase { constructor (width, height) { super(TYPE.invalidElementScreenshotDimensionsError); - var widthIsInvalid = width <= 0; - var heightIsInvalid = height <= 0; + const widthIsInvalid = width <= 0; + const heightIsInvalid = height <= 0; if (widthIsInvalid) { if (heightIsInvalid) { @@ -410,6 +448,16 @@ export class InvalidElementScreenshotDimensionsError extends TestRunErrorBase { } } +export class ForbiddenCharactersInScreenshotPathError extends TestRunErrorBase { + constructor (screenshotPath, forbiddenCharsList) { + super(TYPE.forbiddenCharactersInScreenshotPathError); + + this.screenshotPath = screenshotPath; + this.forbiddenCharsList = forbiddenCharsList; + } +} + + export class RoleSwitchInRoleInitializerError extends TestRunErrorBase { constructor (callsite) { super(TYPE.roleSwitchInRoleInitializerError); @@ -477,14 +525,3 @@ export class SetNativeDialogHandlerCodeWrongTypeError extends TestRunErrorBase { this.actualType = actualType; } } - -// Request Hooks -export class RequestHookConfigureAPIError extends TestRunErrorBase { - constructor (requestHookName, errMsg) { - super(TYPE.requestHookConfigureAPIError); - - this.requestHookName = requestHookName; - this.errMsg = errMsg; - } -} - diff --git a/src/errors/test-run/templates.js b/src/errors/test-run/templates.js index 04e00e90..656130de 100644 --- a/src/errors/test-run/templates.js +++ b/src/errors/test-run/templates.js @@ -1,6 +1,8 @@ import dedent from 'dedent'; import { escape as escapeHtml } from 'lodash'; import TYPE from './type'; +import renderForbiddenCharsList from '../render-forbidden-chars-list'; +import { replaceLeadingSpacesWithNbsp } from '../../utils/string'; import TEST_RUN_PHASE from '../../test-run/phase'; const SUBTITLES = { @@ -16,6 +18,29 @@ const SUBTITLES = { [TEST_RUN_PHASE.inBookmarkRestore]: 'Error while restoring configuration after Role switch\n' }; +function formatSelectorCallstack (apiFnChain, apiFnIndex, viewportWidth) { + if (typeof apiFnIndex === 'undefined') + return ''; + + const emptySpaces = 10; + const ellipsis = '...)'; + const availableWidth = viewportWidth - emptySpaces; + + return apiFnChain.map((apiFn, index) => { + let formattedApiFn = String.fromCharCode(160); + + formattedApiFn += index === apiFnIndex ? '>' : ' '; + formattedApiFn += ' | '; + formattedApiFn += index !== 0 ? ' ' : ''; + formattedApiFn += apiFn; + + if (formattedApiFn.length > availableWidth) + return formattedApiFn.substr(0, availableWidth - emptySpaces) + ellipsis; + + return formattedApiFn; + }).join('\n'); +} + function markup (err, msgMarkup, opts = {}) { msgMarkup = dedent(` ${SUBTITLES[err.testRunPhase]}
${dedent(msgMarkup)}
@@ -27,13 +52,14 @@ function markup (err, msgMarkup, opts = {}) { msgMarkup += `\n`; if (!opts.withoutCallsite) { - var callsiteMarkup = err.getCallsiteMarkup(); + const callsiteMarkup = err.getCallsiteMarkup(); if (callsiteMarkup) msgMarkup += `\n\n${callsiteMarkup}`; } - return msgMarkup; + return msgMarkup + .replace('\t', ' '.repeat(4)); } export default { @@ -60,7 +86,7 @@ export default { [TYPE.uncaughtErrorOnPage]: err => markup(err, ` Error on page ${err.pageDestUrl}: - ${escapeHtml(err.errMsg)} + ${replaceLeadingSpacesWithNbsp(escapeHtml(err.errStack))} `), [TYPE.uncaughtErrorInTestCode]: err => markup(err, ` @@ -105,6 +131,18 @@ export default { Uncaught ${err.objType} "${escapeHtml(err.objStr)}" was thrown. Throw Error instead. `, { withoutCallsite: true }), + [TYPE.unhandledPromiseRejection]: err => markup(err, ` + Unhandled promise rejection: + + ${escapeHtml(err.errMsg)} + `, { withoutCallsite: true }), + + [TYPE.uncaughtException]: err => markup(err, ` + Uncaught exception: + + ${escapeHtml(err.errMsg)} + `, { withoutCallsite: true }), + [TYPE.actionOptionsTypeError]: err => markup(err, ` Action options is expected to be an object, null or undefined but it was ${err.actualType}. `), @@ -113,6 +151,10 @@ export default { The "${err.argumentName}" argument is expected to be a non-empty string, but it was ${err.actualValue}. `), + [TYPE.actionBooleanArgumentError]: err => markup(err, ` + The "${err.argumentName}" argument is expected to be a boolean value, but it was ${err.actualValue}. + `), + [TYPE.actionNullableStringArgumentError]: err => markup(err, ` The "${err.argumentName}" argument is expected to be a null or a string, but it was ${err.actualValue}. `), @@ -137,8 +179,10 @@ export default { The "${err.argumentName}" argument is expected to be a positive integer, but it was ${err.actualValue}. `), - [TYPE.actionElementNotFoundError]: err => markup(err, ` + [TYPE.actionElementNotFoundError]: (err, viewportWidth) => markup(err, ` The specified selector does not match any element in the DOM tree. + + ${ formatSelectorCallstack(err.apiFnChain, err.apiFnIndex, viewportWidth) } `), [TYPE.actionElementIsInvisibleError]: err => markup(err, ` @@ -149,8 +193,10 @@ export default { The specified selector is expected to match a DOM element, but it matches a ${err.nodeDescription} node. `), - [TYPE.actionAdditionalElementNotFoundError]: err => markup(err, ` + [TYPE.actionAdditionalElementNotFoundError]: (err, viewportWidth) => markup(err, ` The specified "${err.argumentName}" does not match any element in the DOM tree. + + ${ formatSelectorCallstack(err.apiFnChain, err.apiFnIndex, viewportWidth) } `), [TYPE.actionAdditionalElementIsInvisibleError]: err => markup(err, ` @@ -240,14 +286,21 @@ export default { ${escapeHtml(err.errMsg)} `), - [TYPE.cantObtainInfoForElementSpecifiedBySelectorError]: err => markup(err, ` + [TYPE.cantObtainInfoForElementSpecifiedBySelectorError]: (err, viewportWidth) => markup(err, ` Cannot obtain information about the node because the specified selector does not match any node in the DOM tree. + + ${ formatSelectorCallstack(err.apiFnChain, err.apiFnIndex, viewportWidth) } `), [TYPE.windowDimensionsOverflowError]: err => markup(err, ` Unable to resize the window because the specified size exceeds the screen size. On macOS, a window cannot be larger than the screen. `), + [TYPE.forbiddenCharactersInScreenshotPathError]: err => markup(err, ` + There are forbidden characters in the "${err.screenshotPath}" screenshot path: + ${renderForbiddenCharsList(err.forbiddenCharsList)} + `), + [TYPE.invalidElementScreenshotDimensionsError]: err => markup(err, ` Unable to capture an element image because the resulting image ${err.dimensions} ${err.verb} zero or negative. `), @@ -262,10 +315,8 @@ export default { ${err.errMsg} `), - [TYPE.requestHookConfigureAPIError]: err => markup(err, ` - There was an error while configuring the request hook: - - ${err.requestHookName}: ${err.errMsg} + [TYPE.assertionWithoutMethodCallError]: err => markup(err, ` + An assertion method is not specified. `), [TYPE.assertionUnawaitedPromiseError]: err => markup(err, ` diff --git a/src/errors/test-run/type.js b/src/errors/test-run/type.js index f6ea778a..30b7375c 100644 --- a/src/errors/test-run/type.js +++ b/src/errors/test-run/type.js @@ -9,12 +9,15 @@ export default { uncaughtNonErrorObjectInTestCode: 'uncaughtNonErrorObjectInTestCode', uncaughtErrorInClientFunctionCode: 'uncaughtErrorInClientFunctionCode', uncaughtErrorInCustomDOMPropertyCode: 'uncaughtErrorInCustomDOMPropertyCode', + unhandledPromiseRejection: 'unhandledPromiseRejection', + uncaughtException: 'uncaughtException', missingAwaitError: 'missingAwaitError', actionIntegerOptionError: 'actionIntegerOptionError', actionPositiveIntegerOptionError: 'actionPositiveIntegerOptionError', actionBooleanOptionError: 'actionBooleanOptionError', actionSpeedOptionError: 'actionSpeedOptionError', actionOptionsTypeError: 'actionOptionsTypeError', + actionBooleanArgumentError: 'actionBooleanArgumentError', actionStringArgumentError: 'actionStringArgumentError', actionNullableStringArgumentError: 'actionNullableStringArgumentError', actionStringOrStringArrayArgumentError: 'actionStringOrStringArrayArgumentError', @@ -54,9 +57,10 @@ export default { externalAssertionLibraryError: 'externalAssertionLibraryError', pageLoadError: 'pageLoadError', windowDimensionsOverflowError: 'windowDimensionsOverflowError', + forbiddenCharactersInScreenshotPathError: 'forbiddenCharactersInScreenshotPathError', invalidElementScreenshotDimensionsError: 'invalidElementScreenshotDimensionsError', roleSwitchInRoleInitializerError: 'roleSwitchInRoleInitializerError', assertionExecutableArgumentError: 'assertionExecutableArgumentError', - assertionUnawaitedPromiseError: 'assertionUnawaitedPromiseError', - requestHookConfigureAPIError: 'requestHookConfigureAPIError' + assertionWithoutMethodCallError: 'assertionWithoutMethodCallError', + assertionUnawaitedPromiseError: 'assertionUnawaitedPromiseError' }; diff --git a/src/index.js b/src/index.js index fb9aff6d..bc75f6ca 100644 --- a/src/index.js +++ b/src/index.js @@ -1,17 +1,18 @@ import Promise from 'pinkie'; -import TestCafe from './testcafe'; -import * as endpointUtils from 'endpoint-utils'; -import setupExitHook from 'async-exit-hook'; import { GeneralError } from './errors/runtime'; import MESSAGE from './errors/runtime/message'; import embeddingUtils from './embedding-utils'; import exportableLib from './api/exportable-lib'; +const lazyRequire = require('import-lazy')(require); +const TestCafe = lazyRequire('./testcafe'); +const endpointUtils = lazyRequire('endpoint-utils'); +const setupExitHook = lazyRequire('async-exit-hook'); // Validations async function getValidHostname (hostname) { if (hostname) { - var valid = await endpointUtils.isMyHostname(hostname); + const valid = await endpointUtils.isMyHostname(hostname); if (!valid) throw new GeneralError(MESSAGE.invalidHostname, hostname); @@ -24,7 +25,7 @@ async function getValidHostname (hostname) { async function getValidPort (port) { if (port) { - var isFree = await endpointUtils.isFreePort(port); + const isFree = await endpointUtils.isFreePort(port); if (!isFree) throw new GeneralError(MESSAGE.portIsNotFree, port); @@ -36,14 +37,18 @@ async function getValidPort (port) { } // API -async function createTestCafe (hostname, port1, port2, sslOptions) { +async function createTestCafe (hostname, port1, port2, sslOptions, developmentMode, retryTestPages) { [hostname, port1, port2] = await Promise.all([ getValidHostname(hostname), getValidPort(port1), getValidPort(port2) ]); - var testcafe = new TestCafe(hostname, port1, port2, sslOptions); + const testcafe = new TestCafe(hostname, port1, port2, { + ssl: sslOptions, + developmentMode, + retryTestPages + }); setupExitHook(cb => testcafe.close().then(cb)); @@ -55,7 +60,7 @@ createTestCafe.embeddingUtils = embeddingUtils; // Common API Object.keys(exportableLib).forEach(key => { - createTestCafe[key] = exportableLib[key]; + Object.defineProperty(createTestCafe, key, { get: () => exportableLib[key] }); }); export default createTestCafe; diff --git a/src/load-assets.js b/src/load-assets.js new file mode 100644 index 00000000..65a80b9c --- /dev/null +++ b/src/load-assets.js @@ -0,0 +1,28 @@ +import { readSync as read } from 'read-file-relative'; + + +const ASSETS_CACHE = {}; + +function loadAsset (filename, asBuffer) { + if (!ASSETS_CACHE[filename]) + ASSETS_CACHE[filename] = read(filename, asBuffer); + + return ASSETS_CACHE[filename]; +} + +export default function (developmentMode) { + const scriptNameSuffix = developmentMode ? 'js' : 'min.js'; + + return { + favIcon: loadAsset('./client/ui/favicon.ico', true), + coreScript: loadAsset(`./client/core/index.${scriptNameSuffix}`), + driverScript: loadAsset(`./client/driver/index.${scriptNameSuffix}`), + uiScript: loadAsset(`./client/ui/index.${scriptNameSuffix}`), + uiStyle: loadAsset('./client/ui/styles.css'), + uiSprite: loadAsset('./client/ui/sprite.png', true), + automationScript: loadAsset(`./client/automation/index.${scriptNameSuffix}`), + + // NOTE: Load the legacy client script lazily to reduce startup time + legacyRunnerScript: require('testcafe-legacy-api').CLIENT_RUNNER_SCRIPT + }; +} diff --git a/src/notifications/debug-logger.js b/src/notifications/debug-logger.js index bcae1aa6..96c8c919 100644 --- a/src/notifications/debug-logger.js +++ b/src/notifications/debug-logger.js @@ -11,7 +11,7 @@ export default { streamsOverridden: false, _overrideStream (stream) { - var initialWrite = stream.write; + const initialWrite = stream.write; stream.write = (chunk, encoding, cb) => { if (this.debugLogging) @@ -39,9 +39,9 @@ export default { }, _getMessageAsString () { - var string = ''; + let string = ''; - for (var message of this.messages) + for (const message of this.messages) string += message.frame; return string; @@ -61,23 +61,23 @@ export default { this._overrideStreams(); // NOTE: Raw API does not have callsite. - var hasCallsite = callsite && callsite.renderSync; + const hasCallsite = callsite && callsite.renderSync; - var callsiteStr = hasCallsite ? callsite.renderSync({ + const callsiteStr = hasCallsite ? callsite.renderSync({ frameSize: 1, stackFilter: createStackFilter(Error.stackTraceLimit), stack: false }) : ''; - var frame = `\n` + + const frame = `\n` + `----\n` + `${userAgent}\n` + chalk.yellow(testError ? 'DEBUGGER PAUSE ON FAILED TEST:' : 'DEBUGGER PAUSE:') + `\n` + `${testError ? testError : callsiteStr}\n` + `----\n`; - var message = { testRunId, frame }; - var index = findIndex(this.messages, { testRunId }); + const message = { testRunId, frame }; + const index = findIndex(this.messages, { testRunId }); if (index === -1) this.messages.push(message); @@ -88,7 +88,7 @@ export default { }, hideBreakpoint (testRunId) { - var index = findIndex(this.messages, { testRunId }); + const index = findIndex(this.messages, { testRunId }); if (index !== -1) this.messages.splice(index, 1); diff --git a/src/notifications/deprecation-message.js b/src/notifications/deprecation-message.js index 274bcd25..5efbd3b6 100644 --- a/src/notifications/deprecation-message.js +++ b/src/notifications/deprecation-message.js @@ -3,7 +3,7 @@ import { renderers } from 'callsite-record'; import createStackFilter from '../errors/create-stack-filter'; export default function showDeprecationMessage (callsite, info) { - var callsiteStr = ''; + let callsiteStr = ''; if (callsite) { callsiteStr = callsite.renderSync({ diff --git a/src/notifications/warning-log.js b/src/notifications/warning-log.js index 5ec762c7..efba1730 100644 --- a/src/notifications/warning-log.js +++ b/src/notifications/warning-log.js @@ -6,7 +6,7 @@ export default class WarningLog { } addWarning () { - var msg = renderTemplate.apply(null, arguments); + const msg = renderTemplate.apply(null, arguments); // NOTE: avoid duplicates if (this.messages.indexOf(msg) < 0) diff --git a/src/notifications/warning-message.js b/src/notifications/warning-message.js index 2a521f82..ab3f210b 100644 --- a/src/notifications/warning-message.js +++ b/src/notifications/warning-message.js @@ -8,5 +8,7 @@ export default { resizeNotSupportedByBrowserProvider: 'The window resize functionality is not supported by the "{providerName}" browser provider.', maximizeNotSupportedByBrowserProvider: 'The window maximization functionality is not supported by the "{providerName}" browser provider.', resizeError: 'Was unable to resize the window due to an error.\n\n{errMessage}', - maximizeError: 'Was unable to maximize the window due to an error.\n\n{errMessage}' + maximizeError: 'Was unable to maximize the window due to an error.\n\n{errMessage}', + requestMockCORSValidationFailed: '{RequestHook}: CORS validation failed for a request specified as {requestFilterRule}', + debugInHeadlessError: 'You cannot debug in headless mode.' }; diff --git a/src/reporter/index.js b/src/reporter/index.js index aa3933a2..45a98550 100644 --- a/src/reporter/index.js +++ b/src/reporter/index.js @@ -1,21 +1,25 @@ +import Promise from 'pinkie'; import { find, sortBy } from 'lodash'; +import { writable as isWritableStream } from 'is-stream'; import ReporterPluginHost from './plugin-host'; export default class Reporter { constructor (plugin, task, outStream) { this.plugin = new ReporterPluginHost(plugin, outStream); - this.passed = 0; - this.skipped = task.tests.filter(test => test.skip).length; - this.testCount = task.tests.length - this.skipped; - this.reportQueue = Reporter._createReportQueue(task); + this.disposed = false; + this.passed = 0; + this.skipped = task.tests.filter(test => test.skip).length; + this.testCount = task.tests.length - this.skipped; + this.reportQueue = Reporter._createReportQueue(task); + this.stopOnFirstFail = task.opts.stopOnFirstFail; + this.outStream = outStream; this._assignTaskEventHandlers(task); } - // Static static _createReportQueue (task) { - var runsPerTest = task.browserConnectionGroups.length; + const runsPerTest = task.browserConnectionGroups.length; return task.tests.map(test => Reporter._createReportItem(test, runsPerTest)); } @@ -52,8 +56,8 @@ export default class Reporter { } _shiftReportQueue (reportItem) { - var currentFixture = null; - var nextReportItem = null; + let currentFixture = null; + let nextReportItem = null; while (this.reportQueue.length && this.reportQueue[0].testRunInfo) { reportItem = this.reportQueue.shift(); @@ -73,27 +77,28 @@ export default class Reporter { _assignTaskEventHandlers (task) { task.once('start', () => { - var startTime = new Date(); - var userAgents = task.browserConnectionGroups.map(group => group[0].userAgent); - var first = this.reportQueue[0]; + const startTime = new Date(); + const userAgents = task.browserConnectionGroups.map(group => group[0].userAgent); + const first = this.reportQueue[0]; this.plugin.reportTaskStart(startTime, userAgents, this.testCount); this.plugin.reportFixtureStart(first.fixture.name, first.fixture.path, first.fixture.meta); }); task.on('test-run-start', testRun => { - var reportItem = this._getReportItemForTestRun(testRun); + const reportItem = this._getReportItemForTestRun(testRun); if (!reportItem.startTime) reportItem.startTime = new Date(); }); task.on('test-run-done', testRun => { - var reportItem = this._getReportItemForTestRun(testRun); + const reportItem = this._getReportItemForTestRun(testRun); + const isTestRunStoppedTaskExecution = !!testRun.errs.length && this.stopOnFirstFail; - reportItem.pendingRuns--; - reportItem.unstable = reportItem.unstable || testRun.unstable; - reportItem.errs = reportItem.errs.concat(testRun.errs); + reportItem.pendingRuns = isTestRunStoppedTaskExecution ? 0 : reportItem.pendingRuns - 1; + reportItem.unstable = reportItem.unstable || testRun.unstable; + reportItem.errs = reportItem.errs.concat(testRun.errs); if (!reportItem.pendingRuns) { if (task.screenshots.hasCapturedFor(testRun.test)) { @@ -104,9 +109,9 @@ export default class Reporter { if (testRun.quarantine) { reportItem.quarantine = testRun.quarantine.attempts.reduce((result, errors, index) => { const passed = !errors.length; - const quarantineAttemptID = index + 1; + const quarantineAttempt = index + 1; - result[quarantineAttemptID] = { passed }; + result[quarantineAttempt] = { passed }; return result; }, {}); @@ -124,9 +129,26 @@ export default class Reporter { }); task.once('done', () => { - var endTime = new Date(); + const endTime = new Date(); this.plugin.reportTaskDone(endTime, this.passed, task.warningLog.messages); }); } + + async dispose () { + if (this.disposed) + return; + + this.disposed = true; + + if (!isWritableStream(this.outStream)) + return; + + this.outStream.end(); + + await new Promise(resolve => { + this.outStream.once('finish', resolve); + this.outStream.once('error', resolve); + }); + } } diff --git a/src/reporter/plugin-host.js b/src/reporter/plugin-host.js index 2cda4fba..bfab018b 100644 --- a/src/reporter/plugin-host.js +++ b/src/reporter/plugin-host.js @@ -11,10 +11,10 @@ import getViewportWidth from '../utils/get-viewport-width'; // Therefore we use symbols to store them. /*global Symbol*/ -var stream = Symbol(); -var wordWrapEnabled = Symbol(); -var indent = Symbol(); -var errorDecorator = Symbol(); +const stream = Symbol(); +const wordWrapEnabled = Symbol(); +const indent = Symbol(); +const errorDecorator = Symbol(); export default class ReporterPluginHost { constructor (plugin, outStream) { @@ -22,7 +22,7 @@ export default class ReporterPluginHost { this[wordWrapEnabled] = false; this[indent] = 0; - var useColors = this[stream] === process.stdout && chalk.enabled && !plugin.noColors; + const useColors = this[stream] === process.stdout && chalk.enabled && !plugin.noColors; this.chalk = new chalk.constructor({ enabled: useColors }); this.moment = moment; @@ -90,9 +90,9 @@ export default class ReporterPluginHost { } formatError (err, prefix = '') { - var prefixLengthWithoutColors = removeTTYColors(prefix).length; - var maxMsgLength = this.viewportWidth - this[indent] - prefixLengthWithoutColors; - var msg = err.formatMessage(this[errorDecorator], maxMsgLength); + const prefixLengthWithoutColors = removeTTYColors(prefix).length; + const maxMsgLength = this.viewportWidth - this[indent] - prefixLengthWithoutColors; + let msg = err.formatMessage(this[errorDecorator], maxMsgLength); if (this[wordWrapEnabled]) msg = this.wordWrap(msg, prefixLengthWithoutColors, maxMsgLength); diff --git a/src/role/index.js b/src/role/index.js index 7f27cefc..0e9bfaad 100644 --- a/src/role/index.js +++ b/src/role/index.js @@ -29,7 +29,7 @@ class Role extends EventEmitter { } async _navigateToLoginPage (testRun) { - var navigateCommand = new NavigateToCommand({ url: this.loginPage }); + const navigateCommand = new NavigateToCommand({ url: this.loginPage }); await testRun.executeCommand(navigateCommand); } diff --git a/src/runner/bootstrapper.js b/src/runner/bootstrapper.js index 12dfb87c..1ad17f2a 100644 --- a/src/runner/bootstrapper.js +++ b/src/runner/bootstrapper.js @@ -7,6 +7,7 @@ import browserProviderPool from '../browser/provider/pool'; import MESSAGE from '../errors/runtime/message'; import BrowserSet from './browser-set'; import TestedApp from './tested-app'; +import parseFileList from '../utils/parse-file-list'; const DEFAULT_APP_INIT_DELAY = 1000; @@ -14,18 +15,19 @@ export default class Bootstrapper { constructor (browserConnectionGateway) { this.browserConnectionGateway = browserConnectionGateway; - this.concurrency = 1; - this.sources = []; - this.browsers = []; - this.reporters = []; - this.filter = null; - this.appCommand = null; - this.appInitDelay = DEFAULT_APP_INIT_DELAY; + this.concurrency = 1; + this.sources = []; + this.browsers = []; + this.reporters = []; + this.filter = null; + this.appCommand = null; + this.appInitDelay = DEFAULT_APP_INIT_DELAY; + this.disableTestSyntaxValidation = false; } static _splitBrowserInfo (browserInfo) { - var remotes = []; - var automated = []; + const remotes = []; + const automated = []; browserInfo.forEach(browser => { if (browser instanceof BrowserConnection) @@ -41,7 +43,7 @@ export default class Bootstrapper { if (!this.browsers.length) throw new GeneralError(MESSAGE.browserNotSet); - var browserInfo = await Promise.all(this.browsers.map(browser => browserProviderPool.getBrowserInfo(browser))); + const browserInfo = await Promise.all(this.browsers.map(browser => browserProviderPool.getBrowserInfo(browser))); return flatten(browserInfo); } @@ -55,12 +57,12 @@ export default class Bootstrapper { } async _getBrowserConnections (browserInfo) { - var { automated, remotes } = Bootstrapper._splitBrowserInfo(browserInfo); + const { automated, remotes } = Bootstrapper._splitBrowserInfo(browserInfo); if (remotes && remotes.length % this.concurrency) throw new GeneralError(MESSAGE.cannotDivideRemotesCountByConcurrency); - var browserConnections = this._createAutomatedConnections(automated); + let browserConnections = this._createAutomatedConnections(automated); browserConnections = browserConnections.concat(chunk(remotes, this.concurrency)); @@ -71,16 +73,17 @@ export default class Bootstrapper { if (!this.sources.length) throw new GeneralError(MESSAGE.testSourcesNotSet); - var compiler = new Compiler(this.sources); - var tests = await compiler.getTests(); + const parsedFileList = await parseFileList(this.sources, process.cwd()); + const compiler = new Compiler(parsedFileList, this.disableTestSyntaxValidation); + let tests = await compiler.getTests(); - var testsWithOnlyFlag = tests.filter(test => test.only); + const testsWithOnlyFlag = tests.filter(test => test.only); if (testsWithOnlyFlag.length) tests = testsWithOnlyFlag; if (this.filter) - tests = tests.filter(test => this.filter(test.name, test.fixture.name, test.fixture.path)); + tests = tests.filter(test => this.filter(test.name, test.fixture.name, test.fixture.path, test.meta, test.fixture.meta)); if (!tests.length) throw new GeneralError(MESSAGE.noTestsToRun); @@ -89,7 +92,7 @@ export default class Bootstrapper { } _getReporterPlugins () { - var stdoutReporters = filter(this.reporters, r => isUndefined(r.outStream) || r.outStream === process.stdout); + const stdoutReporters = filter(this.reporters, r => isUndefined(r.outStream) || r.outStream === process.stdout); if (stdoutReporters.length > 1) throw new GeneralError(MESSAGE.multipleStdoutReporters, stdoutReporters.map(r => r.name).join(', ')); @@ -122,7 +125,7 @@ export default class Bootstrapper { async _startTestedApp () { if (this.appCommand) { - var testedApp = new TestedApp(); + const testedApp = new TestedApp(); await testedApp.start(this.appCommand, this.appInitDelay); @@ -132,20 +135,71 @@ export default class Bootstrapper { return null; } + _canUseParallelBootstrapping (browserInfo) { + return browserInfo.every(browser => browser.provider.isLocalBrowser(null, browserInfo.browserName)); + } + + async _bootstrapSequence (browserInfo) { + const tests = await this._getTests(); + const testedApp = await this._startTestedApp(); + const browserSet = await this._getBrowserConnections(browserInfo); + + return { tests, testedApp, browserSet }; + } + + _wrapBootstrappingPromise (promise) { + return promise + .then(result => ({ error: null, result })) + .catch(error => ({ result: null, error })); + } + + async _handleBootstrappingError ([browserSetStatus, testsStatus, testedAppStatus]) { + if (!browserSetStatus.error) + await browserSetStatus.result.dispose(); + + if (!testedAppStatus.error && testedAppStatus.result) + await testedAppStatus.result.kill(); + + if (testsStatus.error) + throw testsStatus.error; + else if (testedAppStatus.error) + throw testedAppStatus.error; + else + throw browserSetStatus.error; + } + + async _bootstrapParallel (browserInfo) { + let bootstrappingPromises = [ + this._getBrowserConnections(browserInfo), + this._getTests(), + this._startTestedApp() + ]; + + bootstrappingPromises = bootstrappingPromises.map(promise => this._wrapBootstrappingPromise(promise)); + + const bootstrappingStatuses = await Promise.all(bootstrappingPromises); + + if (bootstrappingStatuses.some(status => status.error)) + await this._handleBootstrappingError(bootstrappingStatuses); + + const [browserSet, tests, testedApp] = bootstrappingStatuses.map(status => status.result); + + return { browserSet, tests, testedApp }; + } // API async createRunnableConfiguration () { - var reporterPlugins = this._getReporterPlugins(); + const reporterPlugins = this._getReporterPlugins(); // NOTE: If a user forgot to specify a browser, but has specified a path to tests, the specified path will be // considered as the browser argument, and the tests path argument will have the predefined default value. // It's very ambiguous for the user, who might be confused by compilation errors from an unexpected test. // So, we need to retrieve the browser aliases and paths before tests compilation. - var browserInfo = await this._getBrowserInfo(); - var tests = await this._getTests(); - var testedApp = await this._startTestedApp(); - var browserSet = await this._getBrowserConnections(browserInfo); + const browserInfo = await this._getBrowserInfo(); + + if (this._canUseParallelBootstrapping(browserInfo)) + return { reporterPlugins, ...await this._bootstrapParallel(browserInfo) }; - return { reporterPlugins, browserSet, tests, testedApp }; + return { reporterPlugins, ...await this._bootstrapSequence(browserInfo) }; } } diff --git a/src/runner/browser-job.js b/src/runner/browser-job.js index 2ce3ad0f..ae177f8e 100644 --- a/src/runner/browser-job.js +++ b/src/runner/browser-job.js @@ -33,7 +33,7 @@ export default class BrowserJob extends EventEmitter { } _createTestRunController (test, index) { - var testRunController = new TestRunController(test, index + 1, this.proxy, this.screenshots, this.warningLog, + const testRunController = new TestRunController(test, index + 1, this.proxy, this.screenshots, this.warningLog, this.fixtureHookController, this.opts); testRunController.on('test-run-start', () => this.emit('test-run-start', testRunController.testRun)); @@ -100,7 +100,7 @@ export default class BrowserJob extends EventEmitter { if (this.testRunControllerQueue[0].blocked) break; - var testRunController = this.testRunControllerQueue.shift(); + const testRunController = this.testRunControllerQueue.shift(); this._addToCompletionQueue(testRunController); @@ -109,7 +109,7 @@ export default class BrowserJob extends EventEmitter { this.emit('start'); } - var testRunUrl = await testRunController.start(connection); + const testRunUrl = await testRunController.start(connection); if (testRunUrl) return testRunUrl; diff --git a/src/runner/browser-set.js b/src/runner/browser-set.js index 00c5b525..9568ccef 100644 --- a/src/runner/browser-set.js +++ b/src/runner/browser-set.js @@ -1,6 +1,6 @@ import { EventEmitter } from 'events'; import Promise from 'pinkie'; -import timeLimit from 'time-limit-promise'; +import getTimeLimitedPromise from 'time-limit-promise'; import promisifyEvent from 'promisify-event'; import { noop, pull as remove, flatten } from 'lodash'; import mapReverse from 'map-reverse'; @@ -49,8 +49,8 @@ export default class BrowserSet extends EventEmitter { } async _getReadyTimeout () { - var isLocalBrowser = connection => connection.provider.isLocalBrowser(connection.id, connection.browserInfo.browserName); - var remoteBrowsersExist = (await Promise.all(this.browserConnections.map(isLocalBrowser))).indexOf(false) > -1; + const isLocalBrowser = connection => connection.provider.isLocalBrowser(connection.id, connection.browserInfo.browserName); + const remoteBrowsersExist = (await Promise.all(this.browserConnections.map(isLocalBrowser))).indexOf(false) > -1; return remoteBrowsersExist ? REMOTE_BROWSERS_READY_TIMEOUT : LOCAL_BROWSERS_READY_TIMEOUT; } @@ -75,20 +75,20 @@ export default class BrowserSet extends EventEmitter { } async _waitConnectionsOpened () { - var connectionsReadyPromise = Promise.all( + const connectionsReadyPromise = Promise.all( this.browserConnections .filter(bc => !bc.opened) .map(bc => promisifyEvent(bc, 'opened')) ); - var timeoutError = new GeneralError(MESSAGE.cantEstablishBrowserConnection); - var readyTimeout = await this._getReadyTimeout(); + const timeoutError = new GeneralError(MESSAGE.cantEstablishBrowserConnection); + const readyTimeout = await this._getReadyTimeout(); await this._createPendingConnectionPromise(connectionsReadyPromise, readyTimeout, timeoutError); } _checkForDisconnections () { - var disconnectedUserAgents = this.browserConnections + const disconnectedUserAgents = this.browserConnections .filter(bc => bc.closed) .map(bc => bc.userAgent); @@ -99,9 +99,9 @@ export default class BrowserSet extends EventEmitter { //API static from (browserConnections) { - var browserSet = new BrowserSet(browserConnections); + const browserSet = new BrowserSet(browserConnections); - var prepareConnection = Promise.resolve() + const prepareConnection = Promise.resolve() .then(() => { browserSet._checkForDisconnections(); return browserSet._waitConnectionsOpened(); @@ -128,11 +128,11 @@ export default class BrowserSet extends EventEmitter { bc.removeListener('error', this.browserErrorHandler); - var appropriateStateSwitch = !bc.permanent ? + const appropriateStateSwitch = !bc.permanent ? BrowserSet._closeConnection(bc) : BrowserSet._waitIdle(bc); - var release = timeLimit(appropriateStateSwitch, this.RELEASE_TIMEOUT).then(() => remove(this.pendingReleases, release)); + const release = getTimeLimitedPromise(appropriateStateSwitch, this.RELEASE_TIMEOUT).then(() => remove(this.pendingReleases, release)); this.pendingReleases.push(release); diff --git a/src/runner/fixture-hook-controller.js b/src/runner/fixture-hook-controller.js index 0ff4f2f4..6e5ec1f3 100644 --- a/src/runner/fixture-hook-controller.js +++ b/src/runner/fixture-hook-controller.js @@ -8,7 +8,7 @@ export default class FixtureHookController { static _ensureFixtureMapItem (fixtureMap, fixture) { if (!fixtureMap.has(fixture)) { - var item = { + const item = { started: false, runningFixtureBeforeHook: false, fixtureBeforeHookErr: null, @@ -22,12 +22,12 @@ export default class FixtureHookController { static _createFixtureMap (tests, browserConnectionCount) { return tests.reduce((fixtureMap, test) => { - var fixture = test.fixture; + const fixture = test.fixture; if (!test.skip) { FixtureHookController._ensureFixtureMapItem(fixtureMap, fixture); - var item = fixtureMap.get(fixture); + const item = fixtureMap.get(fixture); item.pendingTestRunCount += browserConnectionCount; } @@ -41,17 +41,17 @@ export default class FixtureHookController { } isTestBlocked (test) { - var item = this._getFixtureMapItem(test); + const item = this._getFixtureMapItem(test); return item && item.runningFixtureBeforeHook; } async runFixtureBeforeHookIfNecessary (testRun) { - var fixture = testRun.test.fixture; - var item = this._getFixtureMapItem(testRun.test); + const fixture = testRun.test.fixture; + const item = this._getFixtureMapItem(testRun.test); if (item) { - var shouldRunBeforeHook = !item.started && fixture.beforeFn; + const shouldRunBeforeHook = !item.started && fixture.beforeFn; item.started = true; @@ -84,8 +84,8 @@ export default class FixtureHookController { } async runFixtureAfterHookIfNecessary (testRun) { - var fixture = testRun.test.fixture; - var item = this._getFixtureMapItem(testRun.test); + const fixture = testRun.test.fixture; + const item = this._getFixtureMapItem(testRun.test); if (item) { item.pendingTestRunCount--; diff --git a/src/runner/index.js b/src/runner/index.js index 91208dcd..f5d5c0a3 100644 --- a/src/runner/index.js +++ b/src/runner/index.js @@ -1,7 +1,8 @@ +import { resolve as resolvePath } from 'path'; +import debug from 'debug'; import Promise from 'pinkie'; import promisifyEvent from 'promisify-event'; import mapReverse from 'map-reverse'; -import { resolve as resolvePath } from 'path'; import { EventEmitter } from 'events'; import { flattenDeep as flatten, pull as remove } from 'lodash'; import Bootstrapper from './bootstrapper'; @@ -10,15 +11,19 @@ import Task from './task'; import { GeneralError } from '../errors/runtime'; import MESSAGE from '../errors/runtime/message'; import { assertType, is } from '../errors/runtime/type-assertions'; +import renderForbiddenCharsList from '../errors/render-forbidden-chars-list'; +import checkFilePath from '../utils/check-file-path'; +import { addRunningTest, removeRunningTest, startHandlingTestErrors, stopHandlingTestErrors } from '../utils/handle-errors'; const DEFAULT_SELECTOR_TIMEOUT = 10000; const DEFAULT_ASSERTION_TIMEOUT = 3000; const DEFAULT_PAGE_LOAD_TIMEOUT = 3000; +const DEBUG_LOGGER = debug('testcafe:runner'); export default class Runner extends EventEmitter { - constructor (proxy, browserConnectionGateway) { + constructor (proxy, browserConnectionGateway, options = {}) { super(); this.proxy = proxy; @@ -31,31 +36,47 @@ export default class Runner extends EventEmitter { screenshotPath: null, takeScreenshotsOnFails: false, recordScreenCapture: false, + screenshotPathPattern: null, skipJsErrors: false, quarantineMode: false, debugMode: false, + retryTestPages: options.retryTestPages, selectorTimeout: DEFAULT_SELECTOR_TIMEOUT, pageLoadTimeout: DEFAULT_PAGE_LOAD_TIMEOUT }; } - static async _disposeTaskAndRelatedAssets (task, browserSet, testedApp) { + + static _disposeBrowserSet (browserSet) { + return browserSet.dispose().catch(e => DEBUG_LOGGER(e)); + } + + static _disposeReporters (reporters) { + return Promise.all(reporters.map(reporter => reporter.dispose().catch(e => DEBUG_LOGGER(e)))); + } + + static _disposeTestedApp (testedApp) { + return testedApp ? testedApp.kill().catch(e => DEBUG_LOGGER(e)) : Promise.resolve(); + } + + static async _disposeTaskAndRelatedAssets (task, browserSet, reporters, testedApp) { task.abort(); task.removeAllListeners(); - await Runner._disposeBrowserSetAndTestedApp(browserSet, testedApp); + await Runner._disposeAssets(browserSet, reporters, testedApp); } - static async _disposeBrowserSetAndTestedApp (browserSet, testedApp) { - await browserSet.dispose(); - - if (testedApp) - await testedApp.kill(); + static _disposeAssets (browserSet, reporters, testedApp) { + return Promise.all([ + Runner._disposeBrowserSet(browserSet), + Runner._disposeReporters(reporters), + Runner._disposeTestedApp(testedApp) + ]); } _createCancelablePromise (taskPromise) { - var promise = taskPromise.then(({ completionPromise }) => completionPromise); - var removeFromPending = () => remove(this.pendingTaskPromises, promise); + const promise = taskPromise.then(({ completionPromise }) => completionPromise); + const removeFromPending = () => remove(this.pendingTaskPromises, promise); promise .then(removeFromPending) @@ -70,10 +91,19 @@ export default class Runner extends EventEmitter { } // Run task - async _getTaskResult (task, browserSet, reporter, testedApp) { + _getFailedTestCount (task, reporter) { + let failedTestCount = reporter.testCount - reporter.passed; + + if (task.opts.stopOnFirstFail && !!failedTestCount) + failedTestCount = 1; + + return failedTestCount; + } + + async _getTaskResult (task, browserSet, reporters, testedApp) { task.on('browser-job-done', job => browserSet.releaseConnection(job.browserConnection)); - var promises = [ + const promises = [ promisifyEvent(task, 'done'), promisifyEvent(browserSet, 'error') ]; @@ -85,23 +115,32 @@ export default class Runner extends EventEmitter { await Promise.race(promises); } catch (err) { - await Runner._disposeTaskAndRelatedAssets(task, browserSet, testedApp); + await Runner._disposeTaskAndRelatedAssets(task, browserSet, reporters, testedApp); throw err; } - await Runner._disposeBrowserSetAndTestedApp(browserSet, testedApp); + await Runner._disposeAssets(browserSet, reporters, testedApp); - return reporter.testCount - reporter.passed; + return this._getFailedTestCount(task, reporters[0]); } _runTask (reporterPlugins, browserSet, tests, testedApp) { - var completed = false; - var task = new Task(tests, browserSet.browserConnectionGroups, this.proxy, this.opts); - var reporters = reporterPlugins.map(reporter => new Reporter(reporter.plugin, task, reporter.outStream)); - var completionPromise = this._getTaskResult(task, browserSet, reporters[0], testedApp); + let completed = false; + const task = new Task(tests, browserSet.browserConnectionGroups, this.proxy, this.opts); + const reporters = reporterPlugins.map(reporter => new Reporter(reporter.plugin, task, reporter.outStream)); + const completionPromise = this._getTaskResult(task, browserSet, reporters, testedApp); + + task.once('start', startHandlingTestErrors); + + if (!this.opts.skipUncaughtErrors) { + task.on('test-run-start', addRunningTest); + task.on('test-run-done', removeRunningTest); + } - var setCompleted = () => { + task.once('done', stopHandlingTestErrors); + + const setCompleted = () => { completed = true; }; @@ -109,9 +148,9 @@ export default class Runner extends EventEmitter { .then(setCompleted) .catch(setCompleted); - var cancelTask = async () => { + const cancelTask = async () => { if (!completed) - await Runner._disposeTaskAndRelatedAssets(task, browserSet, testedApp); + await Runner._disposeTaskAndRelatedAssets(task, browserSet, reporters, testedApp); }; return { completionPromise, cancelTask }; @@ -122,9 +161,20 @@ export default class Runner extends EventEmitter { } _validateRunOptions () { - const concurrency = this.bootstrapper.concurrency; - const speed = this.opts.speed; - let proxyBypass = this.opts.proxyBypass; + const concurrency = this.bootstrapper.concurrency; + const speed = this.opts.speed; + const screenshotPath = this.opts.screenshotPath; + const screenshotPathPattern = this.opts.screenshotPathPattern; + let proxyBypass = this.opts.proxyBypass; + + if (screenshotPath) { + this._validateScreenshotPath(screenshotPath, 'screenshots base directory path'); + + this.opts.screenshotPath = resolvePath(screenshotPath); + } + + if (screenshotPathPattern) + this._validateScreenshotPath(screenshotPathPattern, 'screenshots path pattern'); if (typeof speed !== 'number' || isNaN(speed) || speed < 0.01 || speed > 1) throw new GeneralError(MESSAGE.invalidSpeedValue); @@ -148,6 +198,12 @@ export default class Runner extends EventEmitter { } } + _validateScreenshotPath (screenshotPath, pathType) { + const forbiddenCharsList = checkFilePath(screenshotPath); + + if (forbiddenCharsList.length) + throw new GeneralError(MESSAGE.forbiddenCharatersInScreenshotPath, screenshotPath, pathType, renderForbiddenCharsList(forbiddenCharsList)); + } // API embeddingOptions (opts) { @@ -158,9 +214,7 @@ export default class Runner extends EventEmitter { } src (...sources) { - sources = flatten(sources).map(path => resolvePath(path)); - - this.bootstrapper.sources = this.bootstrapper.sources.concat(sources); + this.bootstrapper.sources = this.bootstrapper.sources.concat(flatten(sources)); return this; } @@ -199,9 +253,10 @@ export default class Runner extends EventEmitter { return this; } - screenshots (path, takeOnFails = false, recordScreenCapture = false) { + screenshots (path, takeOnFails = false, pattern = null, recordScreenCapture = false) { this.opts.takeScreenshotsOnFails = takeOnFails; this.opts.screenshotPath = path; + this.opts.screenshotPathPattern = pattern; this.opts.recordScreenCapture = recordScreenCapture; return this; @@ -214,7 +269,7 @@ export default class Runner extends EventEmitter { return this; } - run ({ skipJsErrors, disablePageReloads, quarantineMode, debugMode, selectorTimeout, assertionTimeout, pageLoadTimeout, speed = 1, debugOnFail } = {}) { + run ({ skipJsErrors, disablePageReloads, quarantineMode, debugMode, selectorTimeout, assertionTimeout, pageLoadTimeout, speed = 1, debugOnFail, skipUncaughtErrors, stopOnFirstFail, disableTestSyntaxValidation } = {}) { this.opts.skipJsErrors = !!skipJsErrors; this.opts.disablePageReloads = !!disablePageReloads; this.opts.quarantineMode = !!quarantineMode; @@ -223,12 +278,16 @@ export default class Runner extends EventEmitter { this.opts.selectorTimeout = selectorTimeout === void 0 ? DEFAULT_SELECTOR_TIMEOUT : selectorTimeout; this.opts.assertionTimeout = assertionTimeout === void 0 ? DEFAULT_ASSERTION_TIMEOUT : assertionTimeout; this.opts.pageLoadTimeout = pageLoadTimeout === void 0 ? DEFAULT_PAGE_LOAD_TIMEOUT : pageLoadTimeout; + this.opts.speed = speed; + this.opts.skipUncaughtErrors = !!skipUncaughtErrors; + this.opts.stopOnFirstFail = !!stopOnFirstFail; - this.opts.speed = speed; + this.bootstrapper.disableTestSyntaxValidation = disableTestSyntaxValidation; - var runTaskPromise = Promise.resolve() + const runTaskPromise = Promise.resolve() .then(() => { this._validateRunOptions(); + return this.bootstrapper.createRunnableConfiguration(); }) .then(({ reporterPlugins, browserSet, tests, testedApp }) => { @@ -245,7 +304,7 @@ export default class Runner extends EventEmitter { // the pendingTaskPromises array, which leads to shifting indexes // towards the beginning. So, we must copy the array in order to iterate it, // or we can perform iteration from the end to the beginning. - var cancellationPromises = mapReverse(this.pendingTaskPromises, taskPromise => taskPromise.cancel()); + const cancellationPromises = mapReverse(this.pendingTaskPromises, taskPromise => taskPromise.cancel()); await Promise.all(cancellationPromises); } diff --git a/src/runner/task.js b/src/runner/task.js index 7793506f..e269d948 100644 --- a/src/runner/task.js +++ b/src/runner/task.js @@ -12,16 +12,24 @@ export default class Task extends EventEmitter { this.running = false; this.browserConnectionGroups = browserConnectionGroups; this.tests = tests; - this.screenshots = new Screenshots(opts.screenshotPath); + this.opts = opts; + this.screenshots = new Screenshots(this.opts.screenshotPath, this.opts.screenshotPathPattern); this.warningLog = new WarningLog(); this.fixtureHookController = new FixtureHookController(tests, browserConnectionGroups.length); - this.pendingBrowserJobs = this._createBrowserJobs(proxy, opts); + this.pendingBrowserJobs = this._createBrowserJobs(proxy, this.opts); } _assignBrowserJobEventHandlers (job) { job.on('test-run-start', testRun => this.emit('test-run-start', testRun)); - job.on('test-run-done', testRun => this.emit('test-run-done', testRun)); + job.on('test-run-done', testRun => { + this.emit('test-run-done', testRun); + + if (this.opts.stopOnFirstFail && testRun.errs.length) { + this.abort(); + this.emit('done'); + } + }); job.once('start', () => { if (!this.running) { @@ -41,7 +49,7 @@ export default class Task extends EventEmitter { _createBrowserJobs (proxy, opts) { return this.browserConnectionGroups.map(browserConnectionGroup => { - var job = new BrowserJob(this.tests, browserConnectionGroup, proxy, this.screenshots, this.warningLog, this.fixtureHookController, opts); + const job = new BrowserJob(this.tests, browserConnectionGroup, proxy, this.screenshots, this.warningLog, this.fixtureHookController, opts); this._assignBrowserJobEventHandlers(job); browserConnectionGroup.map(bc => bc.addJob(job)); diff --git a/src/runner/test-run-controller.js b/src/runner/test-run-controller.js index 184cb820..fd87e1ef 100644 --- a/src/runner/test-run-controller.js +++ b/src/runner/test-run-controller.js @@ -3,9 +3,8 @@ import { TestRun as LegacyTestRun } from 'testcafe-legacy-api'; import TestRun from '../test-run'; import SessionController from '../test-run/session-controller'; - -// Const const QUARANTINE_THRESHOLD = 3; +const DISCONNECT_THRESHOLD = 3; class Quarantine { constructor () { @@ -40,9 +39,10 @@ export default class TestRunController extends EventEmitter { this.TestRunCtor = TestRunController._getTestRunCtor(test, opts); - this.testRun = null; - this.done = false; - this.quarantine = null; + this.testRun = null; + this.done = false; + this.quarantine = null; + this.disconnectionCount = 0; if (this.opts.quarantineMode) this.quarantine = new Quarantine(); @@ -56,8 +56,8 @@ export default class TestRunController extends EventEmitter { } _createTestRun (connection) { - var screenshotCapturer = this.screenshots.createCapturerFor(this.test, this.index, this.quarantine, connection, this.warningLog); - var TestRunCtor = this.TestRunCtor; + const screenshotCapturer = this.screenshots.createCapturerFor(this.test, this.index, this.quarantine, connection, this.warningLog); + const TestRunCtor = this.TestRunCtor; this.testRun = new TestRunCtor(this.test, connection, screenshotCapturer, this.warningLog, this.opts); @@ -91,6 +91,10 @@ export default class TestRunController extends EventEmitter { } _keepInQuarantine () { + this._restartTest(); + } + + _restartTest () { this.emit('test-run-restart'); } @@ -118,14 +122,26 @@ export default class TestRunController extends EventEmitter { this.emit('test-run-done'); } + async _testRunDisconnected (connection) { + this.disconnectionCount++; + + if (this.disconnectionCount < DISCONNECT_THRESHOLD) { + connection.suppressError(); + + await connection.restartBrowser(); + + this._restartTest(); + } + } + get blocked () { return this.fixtureHookController.isTestBlocked(this.test); } async start (connection) { - var testRun = this._createTestRun(connection); + const testRun = this._createTestRun(connection); - var hookOk = await this.fixtureHookController.runFixtureBeforeHookIfNecessary(testRun); + const hookOk = await this.fixtureHookController.runFixtureBeforeHookIfNecessary(testRun); if (this.test.skip || !hookOk) { this.emit('test-run-start'); @@ -135,6 +151,7 @@ export default class TestRunController extends EventEmitter { testRun.once('start', () => this.emit('test-run-start')); testRun.once('done', () => this._testRunDone()); + testRun.once('disconnected', () => this._testRunDisconnected(connection)); testRun.start(); diff --git a/src/runner/tested-app.js b/src/runner/tested-app.js index 39eddd13..86c850bf 100644 --- a/src/runner/tested-app.js +++ b/src/runner/tested-app.js @@ -12,7 +12,7 @@ const MODULES_BIN_DIR = pathJoin(process.cwd(), './node_modules/.bin'); const ENV_PATH_KEY = (function () { if (OS.win) { - var pathKey = 'Path'; + let pathKey = 'Path'; Object.keys(process.env).forEach(key => { if (key.toLowerCase() === 'path') @@ -35,9 +35,9 @@ export default class TestedApp { async start (command, initDelay) { this.errorPromise = new Promise((resolve, reject) => { - var env = Object.assign({}, process.env); - var path = env[ENV_PATH_KEY] || ''; - var pathParts = path.split(pathDelimiter); + const env = Object.assign({}, process.env); + const path = env[ENV_PATH_KEY] || ''; + const pathParts = path.split(pathDelimiter); pathParts.unshift(MODULES_BIN_DIR); @@ -45,7 +45,7 @@ export default class TestedApp { this.process = exec(command, { env }, err => { if (!this.killed && err) { - var message = err.stack || String(err); + const message = err.stack || String(err); reject(new GeneralError(MESSAGE.testedAppFailedWithError, message)); } @@ -61,7 +61,7 @@ export default class TestedApp { async kill () { this.killed = true; - var killPromise = new Promise(resolve => kill(this.process.pid, 'SIGTERM', resolve)); + const killPromise = new Promise(resolve => kill(this.process.pid, 'SIGTERM', resolve)); await killPromise; } diff --git a/src/screenshots/capturer.js b/src/screenshots/capturer.js index 55060ca9..5f69489f 100644 --- a/src/screenshots/capturer.js +++ b/src/screenshots/capturer.js @@ -1,46 +1,21 @@ import { join as joinPath, dirname, basename } from 'path'; -import sanitizeFilename from 'sanitize-filename'; import { generateThumbnail } from 'testcafe-browser-tools'; import cropScreenshot from './crop'; -import { ensureDir } from '../utils/promisified-functions'; +import makeDir from 'make-dir'; import { isInQueue, addToQueue } from '../utils/async-queue'; import WARNING_MESSAGE from '../notifications/warning-message'; - - -const PNG_EXTENSION_RE = /(\.png)$/; - +import escapeUserAgent from '../utils/escape-user-agent'; +import correctFilePath from '../utils/correct-file-path'; export default class Capturer { - constructor (baseScreenshotsPath, testEntry, connection, namingOptions, warningLog) { - this.enabled = !!baseScreenshotsPath; - this.baseScreenshotsPath = baseScreenshotsPath; - this.testEntry = testEntry; - this.provider = connection.provider; - this.browserId = connection.id; - this.baseDirName = namingOptions.baseDirName; - this.userAgentName = namingOptions.userAgentName; - this.quarantine = namingOptions.quarantine; - this.attemptNumber = this.quarantine ? this.quarantine.getNextAttemptNumber() : null; - this.testIndex = namingOptions.testIndex; - this.screenshotIndex = 1; - this.errorScreenshotIndex = 1; - this.warningLog = warningLog; - - var testDirName = `test-${this.testIndex}`; - var screenshotsPath = this.enabled ? joinPath(this.baseScreenshotsPath, this.baseDirName, testDirName) : ''; - - this.screenshotsPath = screenshotsPath; - this.screenshotPathForReport = screenshotsPath; - } - - static _correctFilePath (path) { - var correctedPath = path - .replace(/\\/g, '/') - .split('/') - .map(str => sanitizeFilename(str)) - .join('/'); - - return PNG_EXTENSION_RE.test(correctedPath) ? correctedPath : `${correctedPath}.png`; + constructor (baseScreenshotsPath, testEntry, connection, pathPattern, warningLog) { + this.enabled = !!baseScreenshotsPath; + this.baseScreenshotsPath = baseScreenshotsPath; + this.testEntry = testEntry; + this.provider = connection.provider; + this.browserId = connection.id; + this.warningLog = warningLog; + this.pathPattern = pathPattern; } static _getDimensionWithoutScrollbar (fullDimension, documentDimension, bodyDimension) { @@ -80,36 +55,41 @@ export default class Capturer { }; } - _getFileName (forError) { - var fileName = `${forError ? this.errorScreenshotIndex : this.screenshotIndex}.png`; + _joinWithBaseScreenshotPath (path) { + return joinPath(this.baseScreenshotsPath, path); + } + _incrementFileIndexes (forError) { if (forError) - this.errorScreenshotIndex++; + this.pathPattern.data.errorFileIndex++; + else - this.screenshotIndex++; + this.pathPattern.data.fileIndex++; + } + + _getCustomScreenshotPath (customPath) { + const correctedCustomPath = correctFilePath(customPath); - return fileName; + return this._joinWithBaseScreenshotPath(correctedCustomPath); } - _getScreenshotPath (fileName, customPath) { - if (customPath) - return joinPath(this.baseScreenshotsPath, Capturer._correctFilePath(customPath)); + _getScreenshotPath (forError) { + const path = this.pathPattern.getPath(forError); - var screenshotPath = this.attemptNumber !== null ? - joinPath(this.screenshotsPath, `run-${this.attemptNumber}`) : this.screenshotsPath; + this._incrementFileIndexes(forError); - return joinPath(screenshotPath, this.userAgentName, fileName); + return this._joinWithBaseScreenshotPath(path); } _getThumbnailPath (screenshotPath) { - var imageName = basename(screenshotPath); - var imageDir = dirname(screenshotPath); + const imageName = basename(screenshotPath); + const imageDir = dirname(screenshotPath); return joinPath(imageDir, 'thumbnails', imageName); } async _takeScreenshot (filePath, pageWidth, pageHeight) { - await ensureDir(dirname(filePath)); + await makeDir(dirname(filePath)); await this.provider.takeScreenshot(this.browserId, filePath, pageWidth, pageHeight); } @@ -117,12 +97,8 @@ export default class Capturer { if (!this.enabled) return null; - var fileName = this._getFileName(forError); - - fileName = forError ? joinPath('errors', fileName) : fileName; - - var screenshotPath = this._getScreenshotPath(fileName, customPath); - var thumbnailPath = this._getThumbnailPath(screenshotPath); + const screenshotPath = customPath ? this._getCustomScreenshotPath(customPath) : this._getScreenshotPath(forError); + const thumbnailPath = this._getThumbnailPath(screenshotPath); if (isInQueue(screenshotPath)) this.warningLog.addWarning(WARNING_MESSAGE.screenshotRewritingError, screenshotPath); @@ -135,19 +111,12 @@ export default class Capturer { await generateThumbnail(screenshotPath, thumbnailPath); }); - // NOTE: if test contains takeScreenshot action with custom path - // we should specify the most common screenshot folder in report - if (customPath) - this.screenshotPathForReport = this.baseScreenshotsPath; - - this.testEntry.path = this.screenshotPathForReport; - const screenshot = { screenshotPath, thumbnailPath, - userAgent: this.userAgentName, - quarantineAttemptID: this.attemptNumber, - takenOnFail: forError, + userAgent: escapeUserAgent(this.pathPattern.data.parsedUserAgent), + quarantineAttempt: this.pathPattern.data.quarantineAttempt, + takenOnFail: forError, }; this.testEntry.screenshots.push(screenshot); @@ -155,7 +124,6 @@ export default class Capturer { return screenshotPath; } - async captureAction (options) { return await this._capture(false, options); } diff --git a/src/screenshots/crop.js b/src/screenshots/crop.js index be614674..498b2f59 100644 --- a/src/screenshots/crop.js +++ b/src/screenshots/crop.js @@ -11,8 +11,8 @@ import WARNING_MESSAGES from '../notifications/warning-message'; function readPng (filePath) { - var png = new PNG(); - var parsedPromise = Promise.race([ + const png = new PNG(); + const parsedPromise = Promise.race([ promisifyEvent(png, 'parsed'), promisifyEvent(png, 'error') ]); @@ -24,8 +24,8 @@ function readPng (filePath) { } function writePng (filePath, png) { - var outStream = fs.createWriteStream(filePath); - var finishPromise = Promise.race([ + const outStream = fs.createWriteStream(filePath); + const finishPromise = Promise.race([ promisifyEvent(outStream, 'finish'), promisifyEvent(outStream, 'error') ]); @@ -53,14 +53,14 @@ function detectClippingArea (srcImage, { markSeed, clientAreaDimensions, cropDim let clipHeight = srcImage.height; if (markSeed && clientAreaDimensions) { - var mark = Buffer.from(markSeed); + const mark = Buffer.from(markSeed); - var markIndex = srcImage.data.indexOf(mark); + const markIndex = srcImage.data.indexOf(mark); if (markIndex < 0) throw new Error(renderTemplate(WARNING_MESSAGES.screenshotMarkNotFound, screenshotPath, markSeedToId(markSeed))); - var endPosition = markIndex / MARK_BYTES_PER_PIXEL + MARK_LENGTH + MARK_RIGHT_MARGIN; + const endPosition = markIndex / MARK_BYTES_PER_PIXEL + MARK_LENGTH + MARK_RIGHT_MARGIN; clipRight = endPosition % srcImage.width || srcImage.width; clipBottom = (endPosition - clipRight) / srcImage.width + 1; @@ -94,11 +94,11 @@ function detectClippingArea (srcImage, { markSeed, clientAreaDimensions, cropDim } function copyImagePart (srcImage, { left, top, width, height }) { - var dstImage = new PNG({ width, height }); - var stride = dstImage.width * MARK_BYTES_PER_PIXEL; + const dstImage = new PNG({ width, height }); + const stride = dstImage.width * MARK_BYTES_PER_PIXEL; for (let i = 0; i < height; i++) { - var srcStartIndex = (srcImage.width * (i + top) + left) * MARK_BYTES_PER_PIXEL; + const srcStartIndex = (srcImage.width * (i + top) + left) * MARK_BYTES_PER_PIXEL; srcImage.data.copy(dstImage.data, stride * i, srcStartIndex, srcStartIndex + stride); } @@ -107,7 +107,7 @@ function copyImagePart (srcImage, { left, top, width, height }) { } export default async function (screenshotPath, markSeed, clientAreaDimensions, cropDimensions) { - var srcImage = await readPng(screenshotPath); + const srcImage = await readPng(screenshotPath); const clippingArea = detectClippingArea(srcImage, { markSeed, clientAreaDimensions, cropDimensions, screenshotPath }); diff --git a/src/screenshots/generate-mark.js b/src/screenshots/generate-mark.js index cd2478b8..c95d52a9 100644 --- a/src/screenshots/generate-mark.js +++ b/src/screenshots/generate-mark.js @@ -8,21 +8,21 @@ const ALPHABET = '01'; export default function () { // NOTE: 32-bit id - var id = generateId(ALPHABET, MARK_LENGTH); + const id = generateId(ALPHABET, MARK_LENGTH); // NOTE: array of RGB values - var markSeed = flatten(map(id, bit => bit === '0' ? [0, 0, 0, 255] : [255, 255, 255, 255])); + const markSeed = flatten(map(id, bit => bit === '0' ? [0, 0, 0, 255] : [255, 255, 255, 255])); // NOTE: macOS browsers can't display an element, if it's CSS height is lesser than 1. // It happens on Retina displays, because they have more than 1 physical pixel in a CSS pixel. // So increase mark size by prepending transparent pixels before the actual mark. - var imageData = times(MARK_BYTES_PER_PIXEL * MARK_LENGTH * (MARK_HEIGHT - 1), constant(0)).concat(markSeed); - var imageDataBuffer = Buffer.from(imageData); - var pngImage = new PNG({ width: MARK_LENGTH, height: MARK_HEIGHT }); + const imageData = times(MARK_BYTES_PER_PIXEL * MARK_LENGTH * (MARK_HEIGHT - 1), constant(0)).concat(markSeed); + const imageDataBuffer = Buffer.from(imageData); + const pngImage = new PNG({ width: MARK_LENGTH, height: MARK_HEIGHT }); imageDataBuffer.copy(pngImage.data); - var markData = 'data:image/png;base64,' + PNG.sync.write(pngImage).toString('base64'); + const markData = 'data:image/png;base64,' + PNG.sync.write(pngImage).toString('base64'); return { markSeed, markData }; } diff --git a/src/screenshots/index.js b/src/screenshots/index.js index 4e7ed786..c59bf18c 100644 --- a/src/screenshots/index.js +++ b/src/screenshots/index.js @@ -1,58 +1,21 @@ import { find } from 'lodash'; -import sanitizeFilename from 'sanitize-filename'; import moment from 'moment'; import Capturer from './capturer'; +import PathPattern from './path-pattern'; +import getCommonPath from '../utils/get-common-path'; export default class Screenshots { - constructor (path) { - this.enabled = !!path; - this.screenshotsPath = path; - this.testEntries = []; - this.screenshotBaseDirName = Screenshots._getScreenshotBaseDirName(); - this.userAgentNames = []; - } - - static _getScreenshotBaseDirName () { - var now = Date.now(); - - return moment(now).format('YYYY-MM-DD_hh-mm-ss'); - } - - static _escapeUserAgent (userAgent) { - return sanitizeFilename(userAgent.toString()).replace(/\s+/g, '_'); - } - - _getUsedUserAgent (name, testIndex, quarantineAttemptNum) { - var userAgent = null; - - for (var i = 0; i < this.userAgentNames.length; i++) { - userAgent = this.userAgentNames[i]; - - if (userAgent.name === name && userAgent.testIndex === testIndex && - userAgent.quarantineAttemptNum === quarantineAttemptNum) - return userAgent; - } - - return null; - } - - _getUserAgentName (userAgent, testIndex, quarantineAttemptNum) { - var userAgentName = Screenshots._escapeUserAgent(userAgent); - var usedUserAgent = this._getUsedUserAgent(userAgentName, testIndex, quarantineAttemptNum); - - if (usedUserAgent) { - usedUserAgent.index++; - return `${userAgentName}_${usedUserAgent.index}`; - } - - this.userAgentNames.push({ name: userAgentName, index: 0, testIndex, quarantineAttemptNum }); - return userAgentName; + constructor (path, pattern) { + this.enabled = !!path; + this.screenshotsPath = path; + this.screenshotsPattern = pattern; + this.testEntries = []; + this.now = moment(); } _addTestEntry (test) { - var testEntry = { + const testEntry = { test: test, - path: this.screenshotsPath || '', screenshots: [] }; @@ -65,6 +28,15 @@ export default class Screenshots { return find(this.testEntries, entry => entry.test === test); } + _ensureTestEntry (test) { + let testEntry = this._getTestEntry(test); + + if (!testEntry) + testEntry = this._addTestEntry(test); + + return testEntry; + } + getScreenshotsInfo (test) { return this._getTestEntry(test).screenshots; } @@ -74,24 +46,23 @@ export default class Screenshots { } getPathFor (test) { - return this._getTestEntry(test).path; + const testEntry = this._getTestEntry(test); + const screenshotPaths = testEntry.screenshots.map(screenshot => screenshot.screenshotPath); + + return getCommonPath(screenshotPaths); } createCapturerFor (test, testIndex, quarantine, connection, warningLog) { - var testEntry = this._getTestEntry(test); - - if (!testEntry) - testEntry = this._addTestEntry(test); - - const quarantineAttemptNum = quarantine ? quarantine.getNextAttemptNumber() : null; - - var namingOptions = { + const testEntry = this._ensureTestEntry(test); + const pathPattern = new PathPattern(this.screenshotsPattern, { testIndex, - quarantine, - baseDirName: this.screenshotBaseDirName, - userAgentName: this._getUserAgentName(connection.userAgent, testIndex, quarantineAttemptNum) - }; - - return new Capturer(this.screenshotsPath, testEntry, connection, namingOptions, warningLog); + quarantineAttempt: quarantine ? quarantine.getNextAttemptNumber() : null, + now: this.now, + fixture: test.fixture.name, + test: test.name, + parsedUserAgent: connection.browserInfo.parsedUserAgent, + }); + + return new Capturer(this.screenshotsPath, testEntry, connection, pathPattern, warningLog); } } diff --git a/src/screenshots/path-pattern.js b/src/screenshots/path-pattern.js new file mode 100644 index 00000000..51c1f6e1 --- /dev/null +++ b/src/screenshots/path-pattern.js @@ -0,0 +1,113 @@ +import { escapeRegExp as escapeRe } from 'lodash'; +import correctFilePath from '../utils/correct-file-path'; +import escapeUserAgent from '../utils/escape-user-agent'; + +const DATE_FORMAT = 'YYYY-MM-DD'; +const TIME_FORMAT = 'HH-mm-ss'; + +const SCRENSHOT_EXTENTION = 'png'; + +const ERRORS_FOLDER = 'errors'; + +const PLACEHOLDERS = { + DATE: '${DATE}', + TIME: '${TIME}', + TEST_INDEX: '${TEST_INDEX}', + FILE_INDEX: '${FILE_INDEX}', + QUARANTINE_ATTEMPT: '${QUARANTINE_ATTEMPT}', + FIXTURE: '${FIXTURE}', + TEST: '${TEST}', + USERAGENT: '${USERAGENT}', + BROWSER: '${BROWSER}', + BROWSER_VERSION: '${BROWSER_VERSION}', + OS: '${OS}', + OS_VERSION: '${OS_VERSION}' +}; + +const DEFAULT_PATH_PATTERN_FOR_REPORT = `${PLACEHOLDERS.DATE}_${PLACEHOLDERS.TIME}\\test-${PLACEHOLDERS.TEST_INDEX}`; +const DEFAULT_PATH_PATTERN = `${DEFAULT_PATH_PATTERN_FOR_REPORT}\\${PLACEHOLDERS.USERAGENT}\\${PLACEHOLDERS.FILE_INDEX}.${SCRENSHOT_EXTENTION}`; +const QUARANTINE_MODE_DEFAULT_PATH_PATTERN = `${DEFAULT_PATH_PATTERN_FOR_REPORT}\\run-${PLACEHOLDERS.QUARANTINE_ATTEMPT}\\${PLACEHOLDERS.USERAGENT}\\${PLACEHOLDERS.FILE_INDEX}.${SCRENSHOT_EXTENTION}`; + +export default class PathPattern { + constructor (pattern, data) { + this.pattern = this._ensurePattern(pattern, data.quarantineAttempt); + this.data = this._addDefaultFields(data); + this.placeholderToDataMap = this._createPlaceholderToDataMap(); + } + + _ensurePattern (pattern, quarantineAttempt) { + if (pattern) + return pattern; + + return quarantineAttempt ? QUARANTINE_MODE_DEFAULT_PATH_PATTERN : DEFAULT_PATH_PATTERN; + } + + _addDefaultFields (data) { + const defaultFields = { + formattedDate: data.now.format(DATE_FORMAT), + formattedTime: data.now.format(TIME_FORMAT), + fileIndex: 1, + errorFileIndex: 1 + }; + + return Object.assign({}, defaultFields, data); + } + + _createPlaceholderToDataMap () { + return { + [PLACEHOLDERS.DATE]: this.data.formattedDate, + [PLACEHOLDERS.TIME]: this.data.formattedTime, + [PLACEHOLDERS.TEST_INDEX]: this.data.testIndex, + [PLACEHOLDERS.QUARANTINE_ATTEMPT]: this.data.quarantineAttempt || 1, + [PLACEHOLDERS.FIXTURE]: this.data.fixture, + [PLACEHOLDERS.TEST]: this.data.test, + [PLACEHOLDERS.FILE_INDEX]: forError => forError ? this.data.errorFileIndex : this.data.fileIndex, + [PLACEHOLDERS.USERAGENT]: this.data.parsedUserAgent.toString(), + [PLACEHOLDERS.BROWSER]: this.data.parsedUserAgent.family, + [PLACEHOLDERS.BROWSER_VERSION]: this.data.parsedUserAgent.toVersion(), + [PLACEHOLDERS.OS]: this.data.parsedUserAgent.os.family, + [PLACEHOLDERS.OS_VERSION]: this.data.parsedUserAgent.os.toVersion() + }; + } + + static _buildPath (pattern, placeholderToDataMap, forError) { + let resultFilePath = pattern; + + for (const placeholder in placeholderToDataMap) { + const findPlaceholderRegExp = new RegExp(escapeRe(placeholder), 'g'); + + resultFilePath = resultFilePath.replace(findPlaceholderRegExp, () => { + if (placeholder === PLACEHOLDERS.FILE_INDEX) { + const getFileIndexFn = placeholderToDataMap[placeholder]; + let result = getFileIndexFn(forError); + + if (forError) + result = `${ERRORS_FOLDER}\\${result}`; + + return result; + } + + else if (placeholder === PLACEHOLDERS.USERAGENT) { + const userAgent = placeholderToDataMap[placeholder]; + + return escapeUserAgent(userAgent); + } + + return placeholderToDataMap[placeholder]; + }); + } + + return resultFilePath; + } + + getPath (forError) { + const path = PathPattern._buildPath(this.pattern, this.placeholderToDataMap, forError); + + return correctFilePath(path, SCRENSHOT_EXTENTION); + } + + // For testing purposes + static get PLACEHOLDERS () { + return PLACEHOLDERS; + } +} diff --git a/src/test-run/bookmark.js b/src/test-run/bookmark.js index 9cfde891..1515029a 100644 --- a/src/test-run/bookmark.js +++ b/src/test-run/bookmark.js @@ -41,7 +41,7 @@ export default class TestRunBookmark { async _restoreDialogHandler () { if (this.testRun.activeDialogHandler !== this.dialogHandler) { - var restoreDialogCommand = new SetNativeDialogHandlerCommand({ dialogHandler: { fn: this.dialogHandler } }); + const restoreDialogCommand = new SetNativeDialogHandlerCommand({ dialogHandler: { fn: this.dialogHandler } }); await this.testRun.executeCommand(restoreDialogCommand); } @@ -49,7 +49,7 @@ export default class TestRunBookmark { async _restoreSpeed () { if (this.testRun.speed !== this.speed) { - var restoreSpeedCommand = new SetTestSpeedCommand({ speed: this.speed }); + const restoreSpeedCommand = new SetTestSpeedCommand({ speed: this.speed }); await this.testRun.executeCommand(restoreSpeedCommand); } @@ -57,7 +57,7 @@ export default class TestRunBookmark { async _restorePageLoadTimeout () { if (this.testRun.pageLoadTimeout !== this.pageLoadTimeout) { - var restorePageLoadTimeoutCommand = new SetPageLoadTimeoutCommand({ duration: this.pageLoadTimeout }); + const restorePageLoadTimeoutCommand = new SetPageLoadTimeoutCommand({ duration: this.pageLoadTimeout }); await this.testRun.executeCommand(restorePageLoadTimeoutCommand); } @@ -65,7 +65,7 @@ export default class TestRunBookmark { async _restoreWorkingFrame () { if (this.testRun.activeIframeSelector !== this.iframeSelector) { - var switchWorkingFrameCommand = this.iframeSelector ? + const switchWorkingFrameCommand = this.iframeSelector ? new SwitchToIframeCommand({ selector: this.iframeSelector }) : new SwitchToMainWindowCommand(); @@ -85,13 +85,13 @@ export default class TestRunBookmark { } async _restorePage (url, stateSnapshot) { - var navigateCommand = new NavigateToCommand({ url, stateSnapshot }); + const navigateCommand = new NavigateToCommand({ url, stateSnapshot }); await this.testRun.executeCommand(navigateCommand); } async restore (callsite, stateSnapshot) { - var prevPhase = this.testRun.phase; + const prevPhase = this.testRun.phase; this.testRun.phase = TEST_RUN_PHASE.inBookmarkRestore; @@ -104,8 +104,8 @@ export default class TestRunBookmark { await this._restorePageLoadTimeout(); await this._restoreDialogHandler(); - var preserveUrl = this.role.opts.preserveUrl; - var url = preserveUrl ? this.role.url : this.url; + const preserveUrl = this.role.opts.preserveUrl; + const url = preserveUrl ? this.role.url : this.url; await this._restorePage(url, JSON.stringify(stateSnapshot)); diff --git a/src/test-run/browser-console-messages.js b/src/test-run/browser-console-messages.js index ead9c38a..bcec4866 100644 --- a/src/test-run/browser-console-messages.js +++ b/src/test-run/browser-console-messages.js @@ -38,8 +38,8 @@ export default class BrowserConsoleMessages extends Assignable { } getCopy () { - var copy = {}; - var properties = this._getAssignableProperties(); + const copy = {}; + const properties = this._getAssignableProperties(); for (const property of properties) copy[property.name] = this[property.name].slice(); diff --git a/src/test-run/browser-manipulation-queue.js b/src/test-run/browser-manipulation-queue.js index 3637eade..8008d814 100644 --- a/src/test-run/browser-manipulation-queue.js +++ b/src/test-run/browser-manipulation-queue.js @@ -16,7 +16,7 @@ export default class BrowserManipulationQueue { } async _resizeWindow (width, height, currentWidth, currentHeight) { - var canResizeWindow = await this.browserProvider.canResizeWindowToDimensions(this.browserId, width, height); + const canResizeWindow = await this.browserProvider.canResizeWindowToDimensions(this.browserId, width, height); if (!canResizeWindow) throw new WindowDimensionsOverflowError(); @@ -31,10 +31,10 @@ export default class BrowserManipulationQueue { } async _resizeWindowToFitDevice (device, portrait, currentWidth, currentHeight) { - var { landscapeWidth, portraitWidth } = getViewportSize(device); + const { landscapeWidth, portraitWidth } = getViewportSize(device); - var width = portrait ? portraitWidth : landscapeWidth; - var height = portrait ? landscapeWidth : portraitWidth; + const width = portrait ? portraitWidth : landscapeWidth; + const height = portrait ? landscapeWidth : portraitWidth; return await this._resizeWindow(width, height, currentWidth, currentHeight); } @@ -68,7 +68,7 @@ export default class BrowserManipulationQueue { } async executePendingManipulation (driverMsg) { - var command = this.commands.shift(); + const command = this.commands.shift(); switch (command.type) { case COMMAND_TYPE.takeElementScreenshot: diff --git a/src/test-run/commands/actions.js b/src/test-run/commands/actions.js index ec567035..6bf170ce 100644 --- a/src/test-run/commands/actions.js +++ b/src/test-run/commands/actions.js @@ -2,9 +2,11 @@ import TYPE from './type'; import SelectorBuilder from '../../client-functions/selectors/selector-builder'; import ClientFunctionBuilder from '../../client-functions/client-function-builder'; import functionBuilderSymbol from '../../client-functions/builder-symbol'; -import Assignable from '../../utils/assignable'; +import CommandBase from './base'; import { ActionOptions, ClickOptions, MouseOptions, TypeOptions, DragToElementOptions } from './options'; -import { initSelector } from './validations/initializers'; +import { initSelector, initUploadSelector } from './validations/initializers'; +import executeJsExpression from '../execute-js-expression'; +import { isJSExpression } from './utils'; import { actionOptions, @@ -15,7 +17,8 @@ import { urlArgument, stringOrStringArrayArgument, setSpeedArgument, - actionRoleArgument + actionRoleArgument, + booleanArgument } from './validations/argument'; import { SetNativeDialogHandlerCodeWrongTypeError } from '../../errors/test-run'; @@ -43,17 +46,22 @@ function initDragToElementOptions (name, val) { return new DragToElementOptions(val, true); } -function initDialogHandler (name, val) { - var fn = val.fn; +function initDialogHandler (name, val, { skipVisibilityCheck, testRun }) { + let fn; + + if (isJSExpression(val)) + fn = executeJsExpression(val.value, testRun, { skipVisibilityCheck }); + else + fn = val.fn; if (fn === null || fn instanceof ExecuteClientFunctionCommand) return fn; - var options = val.options; - var methodName = 'setNativeDialogHandler'; - var builder = fn && fn[functionBuilderSymbol]; - var isSelector = builder instanceof SelectorBuilder; - var functionType = typeof fn; + const options = val.options; + const methodName = 'setNativeDialogHandler'; + let builder = fn && fn[functionBuilderSymbol]; + const isSelector = builder instanceof SelectorBuilder; + const functionType = typeof fn; if (functionType !== 'function' || isSelector) throw new SetNativeDialogHandlerCodeWrongTypeError(isSelector ? 'Selector' : functionType); @@ -67,15 +75,9 @@ function initDialogHandler (name, val) { } // Commands -export class ClickCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.click; - this.selector = null; - this.options = null; - - this._assignFrom(obj, true); +export class ClickCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.click); } _getAssignableProperties () { @@ -86,15 +88,9 @@ export class ClickCommand extends Assignable { } } -export class RightClickCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.rightClick; - this.selector = null; - this.options = null; - - this._assignFrom(obj, true); +export class RightClickCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.rightClick); } _getAssignableProperties () { @@ -105,15 +101,23 @@ export class RightClickCommand extends Assignable { } } -export class DoubleClickCommand extends Assignable { - constructor (obj) { - super(obj); +export class ExecuteExpressionCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.executeExpression); + } - this.type = TYPE.doubleClick; - this.selector = null; - this.options = null; + _getAssignableProperties () { + return [ + { name: 'expression', type: nonEmptyStringArgument, required: true }, + { name: 'resultVariableName', type: nonEmptyStringArgument, defaultValue: null }, + { name: 'isAsyncExpression', type: booleanArgument, defaultValue: false } + ]; + } +} - this._assignFrom(obj, true); +export class DoubleClickCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.doubleClick); } _getAssignableProperties () { @@ -124,15 +128,9 @@ export class DoubleClickCommand extends Assignable { } } -export class HoverCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.hover; - this.selector = null; - this.options = null; - - this._assignFrom(obj, true); +export class HoverCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.hover); } _getAssignableProperties () { @@ -143,16 +141,9 @@ export class HoverCommand extends Assignable { } } -export class TypeTextCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.typeText; - this.selector = null; - this.text = null; - this.options = null; - - this._assignFrom(obj, true); +export class TypeTextCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.typeText); } _getAssignableProperties () { @@ -164,17 +155,9 @@ export class TypeTextCommand extends Assignable { } } -export class DragCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.drag; - this.selector = null; - this.dragOffsetX = null; - this.dragOffsetY = null; - this.options = null; - - this._assignFrom(obj, true); +export class DragCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.drag); } _getAssignableProperties () { @@ -187,17 +170,9 @@ export class DragCommand extends Assignable { } } -export class DragToElementCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.dragToElement; - - this.selector = null; - this.destinationSelector = null; - this.options = null; - - this._assignFrom(obj, true); +export class DragToElementCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.dragToElement); } _getAssignableProperties () { @@ -209,86 +184,55 @@ export class DragToElementCommand extends Assignable { } } -export class SelectTextCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.selectText; - this.selector = null; - this.startPos = null; - this.endPos = null; - this.options = null; - - this._assignFrom(obj, true); +export class SelectTextCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.selectText); } _getAssignableProperties () { return [ { name: 'selector', init: initSelector, required: true }, - { name: 'startPos', type: positiveIntegerArgument }, - { name: 'endPos', type: positiveIntegerArgument }, + { name: 'startPos', type: positiveIntegerArgument, defaultValue: null }, + { name: 'endPos', type: positiveIntegerArgument, defaultValue: null }, { name: 'options', type: actionOptions, init: initActionOptions, required: true } ]; } } -export class SelectEditableContentCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.selectEditableContent; - this.startSelector = null; - this.endSelector = null; - this.options = null; - - this._assignFrom(obj, true); +export class SelectEditableContentCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.selectEditableContent); } _getAssignableProperties () { return [ { name: 'startSelector', init: initSelector, required: true }, - { name: 'endSelector', init: initSelector }, + { name: 'endSelector', init: initSelector, defaultValue: null }, { name: 'options', type: actionOptions, init: initActionOptions, required: true } ]; } } -export class SelectTextAreaContentCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.selectTextAreaContent; - this.selector = null; - this.startLine = null; - this.startPos = null; - this.endLine = null; - this.endPos = null; - this.options = null; - - this._assignFrom(obj, true); +export class SelectTextAreaContentCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.selectTextAreaContent); } _getAssignableProperties () { return [ { name: 'selector', init: initSelector, required: true }, - { name: 'startLine', type: positiveIntegerArgument }, - { name: 'startPos', type: positiveIntegerArgument }, - { name: 'endLine', type: positiveIntegerArgument }, - { name: 'endPos', type: positiveIntegerArgument }, + { name: 'startLine', type: positiveIntegerArgument, defaultValue: null }, + { name: 'startPos', type: positiveIntegerArgument, defaultValue: null }, + { name: 'endLine', type: positiveIntegerArgument, defaultValue: null }, + { name: 'endPos', type: positiveIntegerArgument, defaultValue: null }, { name: 'options', type: actionOptions, init: initActionOptions, required: true } ]; } } -export class PressKeyCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.pressKey; - this.keys = ''; - this.options = null; - - this._assignFrom(obj, true); +export class PressKeyCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.pressKey); } _getAssignableProperties () { @@ -299,70 +243,47 @@ export class PressKeyCommand extends Assignable { } } -export class NavigateToCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.navigateTo; - this.url = null; - this.stateSnapshot = null; - - this._assignFrom(obj, true); +export class NavigateToCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.navigateTo); } _getAssignableProperties () { return [ { name: 'url', type: urlArgument, required: true }, - { name: 'stateSnapshot', type: nullableStringArgument } + { name: 'stateSnapshot', type: nullableStringArgument, defaultValue: null } ]; } } -export class SetFilesToUploadCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.setFilesToUpload; - - this.selector = null; - this.filePath = ''; - - this._assignFrom(obj, true); +export class SetFilesToUploadCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.setFilesToUpload); } _getAssignableProperties () { return [ - { name: 'selector', init: (name, val) => initSelector(name, val, true), required: true }, + { name: 'selector', init: initUploadSelector, required: true }, { name: 'filePath', type: stringOrStringArrayArgument, required: true } ]; } } -export class ClearUploadCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.clearUpload; - - this.selector = null; - - this._assignFrom(obj, true); +export class ClearUploadCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.clearUpload); } _getAssignableProperties () { return [ - { name: 'selector', init: (name, val) => initSelector(name, val, true), required: true } + { name: 'selector', init: initUploadSelector, required: true } ]; } } -export class SwitchToIframeCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.switchToIframe; - this.selector = null; - this._assignFrom(obj, true); +export class SwitchToIframeCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.switchToIframe); } _getAssignableProperties () { @@ -378,14 +299,9 @@ export class SwitchToMainWindowCommand { } } -export class SetNativeDialogHandlerCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.setNativeDialogHandler; - this.dialogHandler = {}; - - this._assignFrom(obj, true); +export class SetNativeDialogHandlerCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.setNativeDialogHandler); } _getAssignableProperties () { @@ -407,14 +323,9 @@ export class GetBrowserConsoleMessagesCommand { } } -export class SetTestSpeedCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.setTestSpeed; - this.speed = null; - - this._assignFrom(obj, true); +export class SetTestSpeedCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.setTestSpeed); } _getAssignableProperties () { @@ -424,14 +335,9 @@ export class SetTestSpeedCommand extends Assignable { } } -export class SetPageLoadTimeoutCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.setPageLoadTimeout; - this.duration = null; - - this._assignFrom(obj, true); +export class SetPageLoadTimeoutCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.setPageLoadTimeout); } _getAssignableProperties () { @@ -441,14 +347,9 @@ export class SetPageLoadTimeoutCommand extends Assignable { } } -export class UseRoleCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.useRole; - this.role = null; - - this._assignFrom(obj, true); +export class UseRoleCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.useRole); } _getAssignableProperties () { diff --git a/src/test-run/commands/assertion.js b/src/test-run/commands/assertion.js index 2c3c1cbb..7e8ae8b9 100644 --- a/src/test-run/commands/assertion.js +++ b/src/test-run/commands/assertion.js @@ -1,9 +1,9 @@ import TYPE from './type'; -import Assignable from '../../utils/assignable'; +import CommandBase from './base'; import { AssertionOptions } from './options'; import { APIError } from '../../errors/runtime'; import { AssertionExecutableArgumentError } from '../../errors/test-run'; -import { executeJsExpression } from '../execute-js-expression'; +import executeJsExpression from '../execute-js-expression'; import { isJSExpression } from './utils'; import { stringArgument, actionOptions, nonEmptyStringArgument } from './validations/argument'; @@ -14,44 +14,33 @@ function initAssertionOptions (name, val) { } //Initializers -function initAssertionParameter (name, val, skipVisibilityCheck) { +function initAssertionParameter (name, val, { skipVisibilityCheck, testRun }) { try { if (isJSExpression(val)) - val = executeJsExpression(val.value, skipVisibilityCheck); + val = executeJsExpression(val.value, testRun, { skipVisibilityCheck }); return val; } catch (err) { - var msg = err.constructor === APIError ? err.rawMessage : err.message; + const msg = err.constructor === APIError ? err.rawMessage : err.message; throw new AssertionExecutableArgumentError(name, val.value, msg); } } // Commands -export default class AssertionCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.assertion; - - this.assertionType = null; - this.actual = void 0; - this.expected = void 0; - this.expected2 = void 0; - this.message = null; - this.options = null; - - this._assignFrom(obj, true); +export default class AssertionCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.assertion); } _getAssignableProperties () { return [ { name: 'assertionType', type: nonEmptyStringArgument, required: true }, - { name: 'actual', init: initAssertionParameter }, - { name: 'expected', init: initAssertionParameter }, - { name: 'expected2', init: initAssertionParameter }, - { name: 'message', type: stringArgument }, + { name: 'actual', init: initAssertionParameter, defaultValue: void 0 }, + { name: 'expected', init: initAssertionParameter, defaultValue: void 0 }, + { name: 'expected2', init: initAssertionParameter, defaultValue: void 0 }, + { name: 'message', type: stringArgument, defaultValue: null }, { name: 'options', type: actionOptions, init: initAssertionOptions, required: true } ]; } diff --git a/src/test-run/commands/base.js b/src/test-run/commands/base.js new file mode 100644 index 00000000..7affea3c --- /dev/null +++ b/src/test-run/commands/base.js @@ -0,0 +1,11 @@ +import Assignable from '../../utils/assignable'; + +export default class CommandBase extends Assignable { + constructor (obj, testRun, type, validateProperties = true) { + super(); + + this.type = type; + + this._assignFrom(obj, validateProperties, { testRun }); + } +} diff --git a/src/test-run/commands/browser-manipulation.js b/src/test-run/commands/browser-manipulation.js index 5491920a..13cd7519 100644 --- a/src/test-run/commands/browser-manipulation.js +++ b/src/test-run/commands/browser-manipulation.js @@ -1,11 +1,11 @@ import TYPE from './type'; -import Assignable from '../../utils/assignable'; +import CommandBase from './base'; import { ElementScreenshotOptions, ResizeToFitDeviceOptions } from './options'; import { initSelector } from './validations/initializers'; import { positiveIntegerArgument, - nonEmptyStringArgument, + screenshotPathArgument, resizeWindowDeviceArgument, actionOptions } from './validations/argument'; @@ -22,18 +22,12 @@ function initElementScreenshotOptions (name, val) { } // Commands -class TakeScreenshotBaseCommand extends Assignable { - constructor (obj) { - super(obj); +class TakeScreenshotBaseCommand extends CommandBase { + constructor (obj, testRun, type) { + super(obj, testRun, type); this.markSeed = null; this.markData = ''; - - this._assignFrom(obj, true); - } - - _getAssignableProperties () { - return []; } generateScreenshotMark () { @@ -42,58 +36,44 @@ class TakeScreenshotBaseCommand extends Assignable { } export class TakeScreenshotCommand extends TakeScreenshotBaseCommand { - constructor (obj) { - super(obj); - - this.type = TYPE.takeScreenshot; - this.path = ''; - - this._assignFrom(obj, true); + constructor (obj, testRun) { + super(obj, testRun, TYPE.takeScreenshot); } _getAssignableProperties () { - return super._getAssignableProperties().concat([ - { name: 'path', type: nonEmptyStringArgument } - ]); + return [ + { name: 'path', type: screenshotPathArgument, defaultValue: '' } + ]; } } -export class TakeElementScreenshotCommand extends TakeScreenshotCommand { - constructor (obj) { - super(obj); - - this.type = TYPE.takeElementScreenshot; - this.selector = null; - this.options = null; - - this._assignFrom(obj, true); +export class TakeElementScreenshotCommand extends TakeScreenshotBaseCommand { + constructor (obj, testRun) { + super(obj, testRun, TYPE.takeElementScreenshot); } _getAssignableProperties () { - return super._getAssignableProperties().concat([ + return [ { name: 'selector', init: initSelector, required: true }, - { name: 'options', init: initElementScreenshotOptions, required: true } - ]); + { name: 'options', init: initElementScreenshotOptions, required: true }, + { name: 'path', type: screenshotPathArgument, defaultValue: '' } + ]; } } export class TakeScreenshotOnFailCommand extends TakeScreenshotBaseCommand { - constructor () { - super(); + constructor (obj, testRun) { + super(obj, testRun, TYPE.takeScreenshotOnFail); + } - this.type = TYPE.takeScreenshotOnFail; + _getAssignableProperties () { + return []; } } -export class ResizeWindowCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.resizeWindow; - this.width = 0; - this.height = 0; - - this._assignFrom(obj, true); +export class ResizeWindowCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.resizeWindow); } _getAssignableProperties () { @@ -104,15 +84,9 @@ export class ResizeWindowCommand extends Assignable { } } -export class ResizeWindowToFitDeviceCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.resizeWindowToFitDevice; - this.device = null; - this.options = null; - - this._assignFrom(obj, true); +export class ResizeWindowToFitDeviceCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.resizeWindowToFitDevice); } _getAssignableProperties () { diff --git a/src/test-run/commands/from-object.js b/src/test-run/commands/from-object.js index 25d6bc30..7c005e8c 100644 --- a/src/test-run/commands/from-object.js +++ b/src/test-run/commands/from-object.js @@ -19,7 +19,8 @@ import { SwitchToMainWindowCommand, SetNativeDialogHandlerCommand, SetTestSpeedCommand, - SetPageLoadTimeoutCommand + SetPageLoadTimeoutCommand, + ExecuteExpressionCommand } from './actions'; import AssertionCommand from './assertion'; @@ -34,91 +35,100 @@ import { import { WaitCommand, DebugCommand } from './observation'; - -// Create command from object -export default function createCommandFromObject (obj) { - switch (obj.type) { +function getCmdCtor (type) { + switch (type) { case TYPE.click: - return new ClickCommand(obj); + return ClickCommand; case TYPE.rightClick: - return new RightClickCommand(obj); + return RightClickCommand; case TYPE.doubleClick: - return new DoubleClickCommand(obj); + return DoubleClickCommand; case TYPE.hover: - return new HoverCommand(obj); + return HoverCommand; case TYPE.drag: - return new DragCommand(obj); + return DragCommand; case TYPE.dragToElement: - return new DragToElementCommand(obj); + return DragToElementCommand; case TYPE.typeText: - return new TypeTextCommand(obj); + return TypeTextCommand; case TYPE.selectText: - return new SelectTextCommand(obj); + return SelectTextCommand; case TYPE.selectTextAreaContent: - return new SelectTextAreaContentCommand(obj); + return SelectTextAreaContentCommand; case TYPE.selectEditableContent: - return new SelectEditableContentCommand(obj); + return SelectEditableContentCommand; case TYPE.pressKey: - return new PressKeyCommand(obj); + return PressKeyCommand; case TYPE.wait: - return new WaitCommand(obj); + return WaitCommand; case TYPE.navigateTo: - return new NavigateToCommand(obj); + return NavigateToCommand; case TYPE.setFilesToUpload: - return new SetFilesToUploadCommand(obj); + return SetFilesToUploadCommand; case TYPE.clearUpload: - return new ClearUploadCommand(obj); + return ClearUploadCommand; case TYPE.takeScreenshot: - return new TakeScreenshotCommand(obj); + return TakeScreenshotCommand; case TYPE.takeElementScreenshot: - return new TakeElementScreenshotCommand(obj); + return TakeElementScreenshotCommand; case TYPE.resizeWindow: - return new ResizeWindowCommand(obj); + return ResizeWindowCommand; case TYPE.resizeWindowToFitDevice: - return new ResizeWindowToFitDeviceCommand(obj); + return ResizeWindowToFitDeviceCommand; case TYPE.maximizeWindow: - return new MaximizeWindowCommand(obj); + return MaximizeWindowCommand; case TYPE.switchToIframe: - return new SwitchToIframeCommand(obj); + return SwitchToIframeCommand; case TYPE.switchToMainWindow: - return new SwitchToMainWindowCommand(); + return SwitchToMainWindowCommand; case TYPE.setNativeDialogHandler: - return new SetNativeDialogHandlerCommand(obj); + return SetNativeDialogHandlerCommand; case TYPE.setTestSpeed: - return new SetTestSpeedCommand(obj); + return SetTestSpeedCommand; case TYPE.setPageLoadTimeout: - return new SetPageLoadTimeoutCommand(obj); + return SetPageLoadTimeoutCommand; case TYPE.assertion: - return new AssertionCommand(obj); + return AssertionCommand; case TYPE.debug: - return new DebugCommand(obj); + return DebugCommand; + + case TYPE.executeExpression: + return ExecuteExpressionCommand; + + default: + return null; } +} + +// Create command from object +export default function createCommandFromObject (obj, testRun) { + const CmdCtor = getCmdCtor(obj.type); - return null; + return CmdCtor && new CmdCtor(obj, testRun); } diff --git a/src/test-run/commands/observation.js b/src/test-run/commands/observation.js index 484f234a..816e6460 100644 --- a/src/test-run/commands/observation.js +++ b/src/test-run/commands/observation.js @@ -1,15 +1,11 @@ import TYPE from './type'; -import Assignable from '../../utils/assignable'; +import CommandBase from './base'; import { positiveIntegerArgument } from './validations/argument'; // Commands -export class WaitCommand extends Assignable { - constructor (obj) { - super(obj); - - this.type = TYPE.wait; - this.timeout = null; - this._assignFrom(obj, true); +export class WaitCommand extends CommandBase { + constructor (obj, testRun) { + super(obj, testRun, TYPE.wait); } _getAssignableProperties () { @@ -19,52 +15,39 @@ export class WaitCommand extends Assignable { } } -class ExecuteClientFunctionCommandBase extends Assignable { - constructor (type, obj) { - super(); - - this.type = type; - - this.instantiationCallsiteName = ''; - this.fnCode = ''; - this.args = []; - this.dependencies = []; - - this._assignFrom(obj, false); +class ExecuteClientFunctionCommandBase extends CommandBase { + constructor (obj, testRun, type) { + super(obj, testRun, type, false); } _getAssignableProperties () { return [ - { name: 'instantiationCallsiteName' }, - { name: 'fnCode' }, - { name: 'args' }, - { name: 'dependencies' } + { name: 'instantiationCallsiteName', defaultValue: '' }, + { name: 'fnCode', defaultValue: '' }, + { name: 'args', defaultValue: [] }, + { name: 'dependencies', defaultValue: [] } ]; } } export class ExecuteClientFunctionCommand extends ExecuteClientFunctionCommandBase { - constructor (obj) { - super(TYPE.executeClientFunction, obj); + constructor (obj, testRun) { + super(obj, testRun, TYPE.executeClientFunction); } } export class ExecuteSelectorCommand extends ExecuteClientFunctionCommandBase { - constructor (obj) { - super(TYPE.executeSelector); - - this.visibilityCheck = false; - this.timeout = null; - this.index = 0; - - this._assignFrom(obj, false); + constructor (obj, testRun) { + super(obj, testRun, TYPE.executeSelector); } _getAssignableProperties () { return super._getAssignableProperties().concat([ - { name: 'visibilityCheck' }, - { name: 'timeout' }, - { name: 'index' } + { name: 'visibilityCheck', defaultValue: false }, + { name: 'timeout', defaultValue: null }, + { name: 'apiFnChain' }, + { name: 'needError' }, + { name: 'index', defaultValue: 0 } ]); } } diff --git a/src/test-run/commands/options.js b/src/test-run/commands/options.js index cfbcc5d7..71247ce6 100644 --- a/src/test-run/commands/options.js +++ b/src/test-run/commands/options.js @@ -17,10 +17,10 @@ import { ActionSpeedOptionError } from '../../errors/test-run'; -export var integerOption = createIntegerValidator(ActionIntegerOptionError); -export var positiveIntegerOption = createPositiveIntegerValidator(ActionPositiveIntegerOptionError); -export var booleanOption = createBooleanValidator(ActionBooleanOptionError); -export var speedOption = createSpeedValidator(ActionSpeedOptionError); +export const integerOption = createIntegerValidator(ActionIntegerOptionError); +export const positiveIntegerOption = createPositiveIntegerValidator(ActionPositiveIntegerOptionError); +export const booleanOption = createBooleanValidator(ActionBooleanOptionError); +export const speedOption = createSpeedValidator(ActionSpeedOptionError); // Acitons @@ -161,10 +161,11 @@ export class MoveOptions extends MouseOptions { constructor (obj, validate) { super(); - this.speed = null; - this.minMovingTime = null; - this.holdLeftButton = false; - this.skipScrolling = false; + this.speed = null; + this.minMovingTime = null; + this.holdLeftButton = false; + this.skipScrolling = false; + this.skipDefaultDragBehavior = false; this._assignFrom(obj, validate); } @@ -174,7 +175,8 @@ export class MoveOptions extends MouseOptions { { name: 'speed' }, { name: 'minMovingTime' }, { name: 'holdLeftButton' }, - { name: 'skipScrolling', type: booleanOption } + { name: 'skipScrolling', type: booleanOption }, + { name: 'skipDefaultDragBehavior', type: booleanOption } ]); } } diff --git a/src/test-run/commands/type.js b/src/test-run/commands/type.js index edfa6119..1b371c72 100644 --- a/src/test-run/commands/type.js +++ b/src/test-run/commands/type.js @@ -43,4 +43,5 @@ export default { useRole: 'useRole', testDone: 'test-done', backupStorages: 'backup-storages', + executeExpression: 'execute-expression' }; diff --git a/src/test-run/commands/utils.js b/src/test-run/commands/utils.js index f1bd8372..7793983d 100644 --- a/src/test-run/commands/utils.js +++ b/src/test-run/commands/utils.js @@ -20,7 +20,8 @@ function isClientFunctionCommand (command) { function isObservationCommand (command) { return isClientFunctionCommand(command) || command.type === TYPE.wait || - command.type === TYPE.assertion; + command.type === TYPE.assertion || + command.type === TYPE.executeExpression; } function isWindowSwitchingCommand (command) { @@ -77,3 +78,12 @@ export function isJSExpression (val) { return val !== null && typeof val === 'object' && val.type === RAW_API_JS_EXPRESSION_TYPE && typeof val.value === 'string'; } + +export function isExecutableOnClientCommand (command) { + return command.type !== TYPE.wait && + command.type !== TYPE.setPageLoadTimeout && + command.type !== TYPE.debug && + command.type !== TYPE.useRole && + command.type !== TYPE.assertion && + command.type !== TYPE.executeExpression; +} diff --git a/src/test-run/commands/validations/argument.js b/src/test-run/commands/validations/argument.js index f72c1b98..cc894d4d 100644 --- a/src/test-run/commands/validations/argument.js +++ b/src/test-run/commands/validations/argument.js @@ -19,17 +19,19 @@ import { ActionStringOrStringArrayArgumentError, ActionStringArrayElementError, ActionUnsupportedDeviceTypeError, - SetTestSpeedArgumentError + SetTestSpeedArgumentError, + ForbiddenCharactersInScreenshotPathError } from '../../../errors/test-run'; import { assertUrl } from '../../../api/test-page-url'; +import checkFilePath from '../../../utils/check-file-path'; // Validators -export var integerArgument = createIntegerValidator(ActionIntegerArgumentError); -export var positiveIntegerArgument = createPositiveIntegerValidator(ActionPositiveIntegerArgumentError); -export var booleanArgument = createBooleanValidator(ActionBooleanArgumentError); -export var setSpeedArgument = createSpeedValidator(SetTestSpeedArgumentError); +export const integerArgument = createIntegerValidator(ActionIntegerArgumentError); +export const positiveIntegerArgument = createPositiveIntegerValidator(ActionPositiveIntegerArgumentError); +export const booleanArgument = createBooleanValidator(ActionBooleanArgumentError); +export const setSpeedArgument = createSpeedValidator(SetTestSpeedArgumentError); export function actionRoleArgument (name, val) { @@ -38,7 +40,7 @@ export function actionRoleArgument (name, val) { } export function actionOptions (name, val) { - var type = typeof val; + const type = typeof val; if (type !== 'object' && val !== null && val !== void 0) throw new ActionOptionsTypeError(type); @@ -49,7 +51,7 @@ export function nonEmptyStringArgument (argument, val, createError) { if (!createError) createError = actualValue => new ActionStringArgumentError(argument, actualValue); - var type = typeof val; + const type = typeof val; if (type !== 'string') throw createError(type); @@ -59,7 +61,7 @@ export function nonEmptyStringArgument (argument, val, createError) { } export function nullableStringArgument (argument, val) { - var type = typeof val; + const type = typeof val; if (type !== 'string' && val !== null) throw new ActionNullableStringArgumentError(argument, type); @@ -72,7 +74,7 @@ export function urlArgument (name, val) { } export function stringOrStringArrayArgument (argument, val) { - var type = typeof val; + const type = typeof val; if (type === 'string') { if (!val.length) @@ -83,13 +85,13 @@ export function stringOrStringArrayArgument (argument, val) { if (!val.length) throw new ActionStringOrStringArrayArgumentError(argument, '[]'); - var validateElement = elementIndex => nonEmptyStringArgument( + const validateElement = elementIndex => nonEmptyStringArgument( argument, val[elementIndex], actualValue => new ActionStringArrayElementError(argument, actualValue, elementIndex) ); - for (var i = 0; i < val.length; i++) + for (let i = 0; i < val.length; i++) validateElement(i); } @@ -103,3 +105,12 @@ export function resizeWindowDeviceArgument (name, val) { if (!isValidDeviceName(val)) throw new ActionUnsupportedDeviceTypeError(name, val); } + +export function screenshotPathArgument (name, val) { + nonEmptyStringArgument(name, val); + + const forbiddenCharsList = checkFilePath(val); + + if (forbiddenCharsList.length) + throw new ForbiddenCharactersInScreenshotPathError(val, forbiddenCharsList); +} diff --git a/src/test-run/commands/validations/factories.js b/src/test-run/commands/validations/factories.js index ccdb8b54..5e2866cf 100644 --- a/src/test-run/commands/validations/factories.js +++ b/src/test-run/commands/validations/factories.js @@ -5,12 +5,12 @@ export function createIntegerValidator (ErrorCtor) { return (name, val) => { - var valType = typeof val; + const valType = typeof val; if (valType !== 'number') throw new ErrorCtor(name, valType); - var isInteger = !isNaN(val) && + const isInteger = !isNaN(val) && isFinite(val) && val === Math.floor(val); @@ -20,7 +20,7 @@ export function createIntegerValidator (ErrorCtor) { } export function createPositiveIntegerValidator (ErrorCtor) { - var integerValidator = createIntegerValidator(ErrorCtor); + const integerValidator = createIntegerValidator(ErrorCtor); return (name, val) => { integerValidator(name, val); @@ -32,7 +32,7 @@ export function createPositiveIntegerValidator (ErrorCtor) { export function createBooleanValidator (ErrorCtor) { return (name, val) => { - var valType = typeof val; + const valType = typeof val; if (valType !== 'boolean') throw new ErrorCtor(name, valType); @@ -41,7 +41,7 @@ export function createBooleanValidator (ErrorCtor) { export function createSpeedValidator (ErrorCtor) { return (name, val) => { - var valType = typeof val; + const valType = typeof val; if (valType !== 'number') throw new ErrorCtor(name, valType); diff --git a/src/test-run/commands/validations/initializers.js b/src/test-run/commands/validations/initializers.js index 453ad772..73e7cd3b 100644 --- a/src/test-run/commands/validations/initializers.js +++ b/src/test-run/commands/validations/initializers.js @@ -2,24 +2,34 @@ import SelectorBuilder from '../../../client-functions/selectors/selector-builde import { ActionSelectorError } from '../../../errors/test-run'; import { APIError } from '../../../errors/runtime'; import { ExecuteSelectorCommand } from '../observation'; -import { executeJsExpression } from '../../execute-js-expression'; +import executeJsExpression from '../../execute-js-expression'; import { isJSExpression } from '../utils'; +export function initUploadSelector (name, val, initOptions) { + initOptions.skipVisibilityCheck = true; -export function initSelector (name, val, skipVisibilityCheck) { + return initSelector(name, val, initOptions); +} + +export function initSelector (name, val, { testRun, ...options }) { if (val instanceof ExecuteSelectorCommand) return val; try { if (isJSExpression(val)) - val = executeJsExpression(val.value, skipVisibilityCheck); + val = executeJsExpression(val.value, testRun, options); + + const { skipVisibilityCheck, ...builderOptions } = options; - var builder = new SelectorBuilder(val, { visibilityCheck: !skipVisibilityCheck }, { instantiation: 'Selector' }); + const builder = new SelectorBuilder(val, { + visibilityCheck: !skipVisibilityCheck, + ...builderOptions + }, { instantiation: 'Selector' }); return builder.getCommand([]); } catch (err) { - var msg = err.constructor === APIError ? err.rawMessage : err.message; + const msg = err.constructor === APIError ? err.rawMessage : err.message; throw new ActionSelectorError(name, msg); } diff --git a/src/test-run/debug-log.js b/src/test-run/debug-log.js index 8e748cb4..03fa74a6 100644 --- a/src/test-run/debug-log.js +++ b/src/test-run/debug-log.js @@ -8,7 +8,7 @@ export default class TestRunDebugLog { } static _addEntry (logger, data) { - var entry = data ? + const entry = data ? indentString(`\n${JSON.stringify(data, null, 2)}\n`, ' ', 4) : ''; diff --git a/src/test-run/execute-js-expression.js b/src/test-run/execute-js-expression.js index 45c944db..16e9cbd5 100644 --- a/src/test-run/execute-js-expression.js +++ b/src/test-run/execute-js-expression.js @@ -2,31 +2,62 @@ import { createContext, runInContext } from 'vm'; import SelectorBuilder from '../client-functions/selectors/selector-builder'; import ClientFunctionBuilder from '../client-functions/client-function-builder'; -export function executeJsExpression (expression, skipVisibilityCheck, testRun) { - var sandbox = { +const contextsInfo = []; + +function getContextInfo (testRun) { + let contextInfo = contextsInfo.find(info => info.testRun === testRun); + + if (!contextInfo) { + contextInfo = { testRun, context: createExecutionContext(testRun), options: {} }; + + contextsInfo.push(contextInfo); + } + + return contextInfo; +} + +function getContext (testRun, options = {}) { + const contextInfo = getContextInfo(testRun); + + contextInfo.options = options; + + return contextInfo.context; +} + +function createExecutionContext (testRun) { + const sandbox = { Selector: (fn, options = {}) => { + const { skipVisibilityCheck, collectionMode } = getContextInfo(testRun).options; + if (skipVisibilityCheck) options.visibilityCheck = false; - if (testRun) + if (testRun && testRun.id) options.boundTestRun = testRun; - var builder = new SelectorBuilder(fn, options, { instantiation: 'Selector' }); + if (collectionMode) + options.collectionMode = collectionMode; + + const builder = new SelectorBuilder(fn, options, { instantiation: 'Selector' }); return builder.getFunction(); }, ClientFunction: (fn, options = {}) => { - if (testRun) + if (testRun && testRun.id) options.boundTestRun = testRun; - var builder = new ClientFunctionBuilder(fn, options, { instantiation: 'ClientFunction' }); + const builder = new ClientFunctionBuilder(fn, options, { instantiation: 'ClientFunction' }); return builder.getFunction(); } }; - var context = createContext(sandbox); + return createContext(sandbox); +} + +export default function (expression, testRun, options) { + const context = getContext(testRun, options); return runInContext(expression, context, { displayErrors: false }); } diff --git a/src/test-run/index.js b/src/test-run/index.js index c3a7cd10..8dfbdef6 100644 --- a/src/test-run/index.js +++ b/src/test-run/index.js @@ -5,47 +5,43 @@ import promisifyEvent from 'promisify-event'; import Promise from 'pinkie'; import Mustache from 'mustache'; import debugLogger from '../notifications/debug-logger'; -import SessionController from './session-controller'; import TestRunDebugLog from './debug-log'; import TestRunErrorFormattableAdapter from '../errors/test-run/formattable-adapter'; import TestCafeErrorList from '../errors/error-list'; -import { executeJsExpression } from './execute-js-expression'; import { PageLoadError, RoleSwitchInRoleInitializerError } from '../errors/test-run/'; -import BrowserManipulationQueue from './browser-manipulation-queue'; import PHASE from './phase'; import CLIENT_MESSAGES from './client-messages'; import COMMAND_TYPE from './commands/type'; -import AssertionExecutor from '../assertions/executor'; import delay from '../utils/delay'; import testRunMarker from './marker-symbol'; import testRunTracker from '../api/test-run-tracker'; import ROLE_PHASE from '../role/phase'; -import TestRunBookmark from './bookmark'; -import ClientFunctionBuilder from '../client-functions/client-function-builder'; import ReporterPluginHost from '../reporter/plugin-host'; import BrowserConsoleMessages from './browser-console-messages'; - -import { TakeScreenshotOnFailCommand } from './commands/browser-manipulation'; -import { SetNativeDialogHandlerCommand, SetTestSpeedCommand, SetPageLoadTimeoutCommand } from './commands/actions'; - - -import { - TestDoneCommand, - ShowAssertionRetriesStatusCommand, - HideAssertionRetriesStatusCommand, - SetBreakpointCommand, - BackupStoragesCommand -} from './commands/service'; +import { UNSTABLE_NETWORK_MODE_HEADER } from '../browser/connection/unstable-network-mode'; +import WARNING_MESSAGE from '../notifications/warning-message'; import { isCommandRejectableByPageError, isBrowserManipulationCommand, isScreenshotCommand, isServiceCommand, - canSetDebuggerBreakpointBeforeCommand + canSetDebuggerBreakpointBeforeCommand, + isExecutableOnClientCommand } from './commands/utils'; -//Const +const lazyRequire = require('import-lazy')(require); +const SessionController = lazyRequire('./session-controller'); +const ClientFunctionBuilder = lazyRequire('../client-functions/client-function-builder'); +const executeJsExpression = lazyRequire('./execute-js-expression'); +const BrowserManipulationQueue = lazyRequire('./browser-manipulation-queue'); +const TestRunBookmark = lazyRequire('./bookmark'); +const AssertionExecutor = lazyRequire('../assertions/executor'); +const actionCommands = lazyRequire('./commands/actions'); +const browserManipulationCommands = lazyRequire('./commands/browser-manipulation'); +const serviceCommands = lazyRequire('./commands/service'); + + const TEST_RUN_TEMPLATE = read('../client/test-run/index.js.mustache'); const IFRAME_TEST_RUN_TEMPLATE = read('../client/test-run/iframe.js.mustache'); const TEST_DONE_CONFIRMATION_RESPONSE = 'test-done-confirmation'; @@ -112,6 +108,8 @@ export default class TestRun extends EventEmitter { this.quarantine = null; + this.warningLog = warningLog; + this.injectable.scripts.push('/testcafe-core.js'); this.injectable.scripts.push('/testcafe-ui.js'); this.injectable.scripts.push('/testcafe-automation.js'); @@ -152,6 +150,8 @@ export default class TestRun extends EventEmitter { } _initRequestHook (hook) { + hook.warningLog = this.warningLog; + hook._instantiateRequestFilterRules(); hook._instantiatedRequestFilterRules.forEach(rule => { this.session.addRequestEventListeners(rule, { @@ -163,6 +163,8 @@ export default class TestRun extends EventEmitter { } _disposeRequestHook (hook) { + hook.warningLog = null; + hook._instantiatedRequestFilterRules.forEach(rule => { this.session.removeRequestEventListeners(rule); }); @@ -172,7 +174,6 @@ export default class TestRun extends EventEmitter { this.requestHooks.forEach(hook => this._initRequestHook(hook)); } - // Hammerhead payload _getPayloadScript () { this.fileDownloadingHandled = false; @@ -190,6 +191,7 @@ export default class TestRun extends EventEmitter { selectorTimeout: this.opts.selectorTimeout, pageLoadTimeout: this.pageLoadTimeout, skipJsErrors: this.opts.skipJsErrors, + retryTestPages: !!this.opts.retryTestPages, speed: this.speed, dialogHandler: JSON.stringify(this.activeDialogHandler) }); @@ -200,12 +202,12 @@ export default class TestRun extends EventEmitter { testRunId: JSON.stringify(this.session.id), selectorTimeout: this.opts.selectorTimeout, pageLoadTimeout: this.pageLoadTimeout, + retryTestPages: !!this.opts.retryTestPages, speed: this.speed, dialogHandler: JSON.stringify(this.activeDialogHandler) }); } - // Hammerhead handlers getAuthCredentials () { return this.test.authCredentials; @@ -221,6 +223,11 @@ export default class TestRun extends EventEmitter { } handlePageError (ctx, err) { + if (ctx.req.headers[UNSTABLE_NETWORK_MODE_HEADER]) { + ctx.closeWithError(500, err.toString()); + return; + } + this.pendingPageError = new PageLoadError(err); ctx.redirect(ctx.toProxyUrl('about:error')); @@ -234,10 +241,11 @@ export default class TestRun extends EventEmitter { await fn(this); } catch (err) { - var screenshotPath = null; + let screenshotPath = null; if (this.opts.takeScreenshotsOnFails || this.opts.recordScreenCapture) - screenshotPath = await this.executeCommand(new TakeScreenshotOnFailCommand()); + // screenshotPath = await this.executeCommand(new TakeScreenshotOnFailCommand()); + screenshotPath = await this.executeCommand(new browserManipulationCommands.TakeScreenshotOnFailCommand()); this.addError(err, screenshotPath); return false; @@ -271,15 +279,25 @@ export default class TestRun extends EventEmitter { this.emit('start'); + const onDisconnected = err => this._disconnect(err); + + this.browserConnection.once('disconnected', onDisconnected); + if (await this._runBeforeHook()) { await this._executeTestFn(PHASE.inTest, this.test.fn); await this._runAfterHook(); } + if (this.disconnected) + return; + + this.browserConnection.removeListener('disconnected', onDisconnected); + if (this.errs.length && this.debugOnFail) await this._enqueueSetBreakpointCommand(null, this.debugReporterPluginHost.formatError(this.errs[0])); - await this.executeCommand(new TestDoneCommand()); + await this.executeCommand(new serviceCommands.TestDoneCommand()); + this._addPendingPageErrorIfAny(); delete testRunTracker.activeTestRuns[this.session.id]; @@ -289,7 +307,7 @@ export default class TestRun extends EventEmitter { _evaluate (code) { try { - return executeJsExpression(code, false, this); + return executeJsExpression(code, this, { skipVisibilityCheck: false }); } catch (err) { return { err }; @@ -308,10 +326,10 @@ export default class TestRun extends EventEmitter { } addError (err, screenshotPath) { - var errList = err instanceof TestCafeErrorList ? err.items : [err]; + const errList = err instanceof TestCafeErrorList ? err.items : [err]; errList.forEach(item => { - var adapter = new TestRunErrorFormattableAdapter(item, { + const adapter = new TestRunErrorFormattableAdapter(item, { userAgent: this.browserConnection.userAgent, screenshotPath: screenshotPath || '', testRunPhase: this.phase @@ -346,9 +364,14 @@ export default class TestRun extends EventEmitter { } async _enqueueSetBreakpointCommand (callsite, error) { + if (this.browserConnection.isHeadlessBrowser()) { + this.warningLog.addWarning(WARNING_MESSAGE.debugInHeadlessError); + return; + } + debugLogger.showBreakpoint(this.session.id, this.browserConnection.userAgent, callsite, error); - this.debugging = await this.executeCommand(new SetBreakpointCommand(!!error), callsite); + this.debugging = await this.executeCommand(new serviceCommands.SetBreakpointCommand(!!error), callsite); } _removeAllNonServiceTasks () { @@ -357,7 +380,6 @@ export default class TestRun extends EventEmitter { this.browserManipulationQueue.removeAllNonServiceManipulations(); } - // Current driver task get currentDriverTask () { return this.driverTaskQueue[0]; @@ -372,13 +394,13 @@ export default class TestRun extends EventEmitter { } _rejectCurrentDriverTask (err) { - err.callsite = err.callsite || this.driverTaskQueue[0].callsite; + err.callsite = err.callsite || this.currentDriverTask.callsite; + err.isRejectedDriverTask = true; this.currentDriverTask.reject(err); this._removeAllNonServiceTasks(); } - // Pending request _clearPendingRequest () { if (this.pendingRequest) { @@ -393,7 +415,6 @@ export default class TestRun extends EventEmitter { this._clearPendingRequest(); } - // Handle driver request _fulfillCurrentDriverTask (driverStatus) { if (driverStatus.executionError) @@ -416,14 +437,17 @@ export default class TestRun extends EventEmitter { } _handleDriverRequest (driverStatus) { - var pageError = this.pendingPageError || driverStatus.pageError; + const isTestDone = this.currentDriverTask && this.currentDriverTask.command.type === COMMAND_TYPE.testDone; + const pageError = this.pendingPageError || driverStatus.pageError; + const currentTaskRejectedByError = pageError && this._handlePageErrorStatus(pageError); - var currentTaskRejectedByError = pageError && this._handlePageErrorStatus(pageError); + if (this.disconnected) + return new Promise((_, reject) => reject()); this.consoleMessages.concat(driverStatus.consoleMessages); if (!currentTaskRejectedByError && driverStatus.isCommandResult) { - if (this.currentDriverTask.command.type === COMMAND_TYPE.testDone) { + if (isTestDone) { this._resolveCurrentDriverTask(); return TEST_DONE_CONFIRMATION_RESPONSE; @@ -448,17 +472,31 @@ export default class TestRun extends EventEmitter { } // Execute command - static _shouldAddCommandToQueue (command) { - return command.type !== COMMAND_TYPE.wait && command.type !== COMMAND_TYPE.setPageLoadTimeout && - command.type !== COMMAND_TYPE.debug && command.type !== COMMAND_TYPE.useRole && command.type !== COMMAND_TYPE.assertion; + async _executeExpression (command) { + const { resultVariableName, isAsyncExpression } = command; + + let expression = command.expression; + + if (isAsyncExpression) + expression = `await ${expression}`; + + if (resultVariableName) + expression = `${resultVariableName} = ${expression}, ${resultVariableName}`; + + if (isAsyncExpression) + expression = `(async () => { return ${expression}; }).apply(this);`; + + const result = this._evaluate(expression); + + return isAsyncExpression ? await result : result; } async _executeAssertion (command, callsite) { - var assertionTimeout = command.options.timeout === void 0 ? this.opts.assertionTimeout : command.options.timeout; - var executor = new AssertionExecutor(command, assertionTimeout, callsite); + const assertionTimeout = command.options.timeout === void 0 ? this.opts.assertionTimeout : command.options.timeout; + const executor = new AssertionExecutor(command, assertionTimeout, callsite); - executor.once('start-assertion-retries', timeout => this.executeCommand(new ShowAssertionRetriesStatusCommand(timeout))); - executor.once('end-assertion-retries', success => this.executeCommand(new HideAssertionRetriesStatusCommand(success))); + executor.once('start-assertion-retries', timeout => this.executeCommand(new serviceCommands.ShowAssertionRetriesStatusCommand(timeout))); + executor.once('end-assertion-retries', success => this.executeCommand(new serviceCommands.HideAssertionRetriesStatusCommand(success))); return executor.run(); } @@ -507,7 +545,7 @@ export default class TestRun extends EventEmitter { if (this.pendingPageError && isCommandRejectableByPageError(command)) return this._rejectCommandWithPageError(callsite); - if (TestRun._shouldAddCommandToQueue(command)) + if (isExecutableOnClientCommand(command)) this.addingDriverTasksCount++; this._adjustConfigurationWithCommand(command); @@ -535,6 +573,9 @@ export default class TestRun extends EventEmitter { if (command.type === COMMAND_TYPE.assertion) return this._executeAssertion(command, callsite); + if (command.type === COMMAND_TYPE.executeExpression) + return await this._executeExpression(command, callsite); + if (command.type === COMMAND_TYPE.getBrowserConsoleMessages) return await this._enqueueBrowserConsoleMessagesCommand(command, callsite); @@ -542,7 +583,7 @@ export default class TestRun extends EventEmitter { } _rejectCommandWithPageError (callsite) { - var err = this.pendingPageError; + const err = this.pendingPageError; err.callsite = callsite; this.pendingPageError = null; @@ -552,9 +593,9 @@ export default class TestRun extends EventEmitter { // Role management async getStateSnapshot () { - var state = this.session.getStateSnapshot(); + const state = this.session.getStateSnapshot(); - state.storages = await this.executeCommand(new BackupStoragesCommand()); + state.storages = await this.executeCommand(new serviceCommands.BackupStoragesCommand()); return state; } @@ -567,26 +608,26 @@ export default class TestRun extends EventEmitter { this.session.useStateSnapshot(null); if (this.activeDialogHandler) { - var removeDialogHandlerCommand = new SetNativeDialogHandlerCommand({ dialogHandler: { fn: null } }); + const removeDialogHandlerCommand = new actionCommands.SetNativeDialogHandlerCommand({ dialogHandler: { fn: null } }); await this.executeCommand(removeDialogHandlerCommand); } if (this.speed !== this.opts.speed) { - var setSpeedCommand = new SetTestSpeedCommand({ speed: this.opts.speed }); + const setSpeedCommand = new actionCommands.SetTestSpeedCommand({ speed: this.opts.speed }); await this.executeCommand(setSpeedCommand); } if (this.pageLoadTimeout !== this.opts.pageLoadTimeout) { - var setPageLoadTimeoutCommand = new SetPageLoadTimeoutCommand({ duration: this.opts.pageLoadTimeout }); + const setPageLoadTimeoutCommand = new actionCommands.SetPageLoadTimeoutCommand({ duration: this.opts.pageLoadTimeout }); await this.executeCommand(setPageLoadTimeoutCommand); } } async _getStateSnapshotFromRole (role) { - var prevPhase = this.phase; + const prevPhase = this.phase; this.phase = PHASE.inRoleInitializer; @@ -610,14 +651,14 @@ export default class TestRun extends EventEmitter { this.disableDebugBreakpoints = true; - var bookmark = new TestRunBookmark(this, role); + const bookmark = new TestRunBookmark(this, role); await bookmark.init(); if (this.currentRoleId) this.usedRoleStates[this.currentRoleId] = await this.getStateSnapshot(); - var stateSnapshot = this.usedRoleStates[role.id] || await this._getStateSnapshotFromRole(role); + const stateSnapshot = this.usedRoleStates[role.id] || await this._getStateSnapshotFromRole(role); this.session.useStateSnapshot(stateSnapshot); @@ -628,24 +669,32 @@ export default class TestRun extends EventEmitter { this.disableDebugBreakpoints = false; } - // Get current URL async getCurrentUrl () { - var builder = new ClientFunctionBuilder(() => { + const builder = new ClientFunctionBuilder(() => { /* eslint-disable no-undef */ return window.location.href; /* eslint-enable no-undef */ }, { boundTestRun: this }); - var getLocation = builder.getFunction(); + const getLocation = builder.getFunction(); return await getLocation(); } -} + _disconnect (err) { + this.disconnected = true; + + this._rejectCurrentDriverTask(err); + + this.emit('disconnected', err); + + delete testRunTracker.activeTestRuns[this.session.id]; + } +} // Service message handlers -var ServiceMessages = TestRun.prototype; +const ServiceMessages = TestRun.prototype; ServiceMessages[CLIENT_MESSAGES.ready] = function (msg) { this.debugLog.driverMessage(msg); @@ -665,7 +714,7 @@ ServiceMessages[CLIENT_MESSAGES.ready] = function (msg) { // NOTE: browsers abort an opened xhr request after a certain timeout (the actual duration depends on the browser). // To avoid this, we send an empty response after 2 minutes if we didn't get any command. - var responseTimeout = setTimeout(() => this._resolvePendingRequest(null), MAX_RESPONSE_DELAY); + const responseTimeout = setTimeout(() => this._resolvePendingRequest(null), MAX_RESPONSE_DELAY); return new Promise((resolve, reject) => { this.pendingRequest = { resolve, reject, responseTimeout }; @@ -675,8 +724,8 @@ ServiceMessages[CLIENT_MESSAGES.ready] = function (msg) { ServiceMessages[CLIENT_MESSAGES.readyForBrowserManipulation] = async function (msg) { this.debugLog.driverMessage(msg); - var result = null; - var error = null; + let result = null; + let error = null; try { result = await this.browserManipulationQueue.executePendingManipulation(msg); diff --git a/src/test-run/session-controller.js b/src/test-run/session-controller.js index 4eeaa674..f0b1e78d 100644 --- a/src/test-run/session-controller.js +++ b/src/test-run/session-controller.js @@ -1,5 +1,6 @@ import path from 'path'; import { Session } from 'testcafe-hammerhead'; +import { UNSTABLE_NETWORK_MODE_HEADER } from '../browser/connection/unstable-network-mode'; const ACTIVE_SESSIONS_MAP = {}; @@ -41,6 +42,18 @@ export default class SessionController extends Session { return this.currentTestRun.handlePageError(ctx, err); } + onPageRequest (ctx) { + const requireStateSwitch = this.requireStateSwitch; + const pendingStateSnapshot = this.pendingStateSnapshot; + + super.onPageRequest(ctx); + + if (requireStateSwitch && ctx.req.headers[UNSTABLE_NETWORK_MODE_HEADER]) { + this.requireStateSwitch = true; + + this.pendingStateSnapshot = pendingStateSnapshot; + } + } // API static getSession (testRun) { let sessionInfo = ACTIVE_SESSIONS_MAP[testRun.browserConnection.id]; diff --git a/src/testcafe.js b/src/testcafe.js index 2c130a06..23a6cc02 100644 --- a/src/testcafe.js +++ b/src/testcafe.js @@ -1,61 +1,77 @@ import Promise from 'pinkie'; -import { readSync as read } from 'read-file-relative'; -import { Proxy } from 'testcafe-hammerhead'; -import { CLIENT_RUNNER_SCRIPT as LEGACY_RUNNER_SCRIPT } from 'testcafe-legacy-api'; -import BrowserConnectionGateway from './browser/connection/gateway'; -import BrowserConnection from './browser/connection'; -import browserProviderPool from './browser/provider/pool'; -import Runner from './runner'; - -// Const -const CORE_SCRIPT = read('./client/core/index.js'); -const DRIVER_SCRIPT = read('./client/driver/index.js'); -const UI_SCRIPT = read('./client/ui/index.js'); -const AUTOMATION_SCRIPT = read('./client/automation/index.js'); -const UI_STYLE = read('./client/ui/styles.css'); -const UI_SPRITE = read('./client/ui/sprite.png', true); -const FAVICON = read('./client/ui/favicon.ico', true); +const lazyRequire = require('import-lazy')(require); +const sourceMapSupport = lazyRequire('source-map-support'); +const hammerhead = lazyRequire('testcafe-hammerhead'); +const loadAssets = lazyRequire('./load-assets'); +const errorHandlers = lazyRequire('./utils/handle-errors'); +const BrowserConnectionGateway = lazyRequire('./browser/connection/gateway'); +const BrowserConnection = lazyRequire('./browser/connection'); +const browserProviderPool = lazyRequire('./browser/provider/pool'); +const Runner = lazyRequire('./runner'); + +// NOTE: CoffeeScript can't be loaded lazily, because it will break stack traces +require('coffeescript'); export default class TestCafe { - constructor (hostname, port1, port2, sslOptions) { + constructor (hostname, port1, port2, options = {}) { + this._setupSourceMapsSupport(); + + errorHandlers.registerErrorHandlers(); + + if (options.retryTestPages) + options.staticContentCaching = { maxAge: 3600, mustRevalidate: false }; + this.closed = false; - this.proxy = new Proxy(hostname, port1, port2, sslOptions); - this.browserConnectionGateway = new BrowserConnectionGateway(this.proxy); + this.proxy = new hammerhead.Proxy(hostname, port1, port2, options); + this.browserConnectionGateway = new BrowserConnectionGateway(this.proxy, { retryTestPages: options.retryTestPages }); this.runners = []; + this.retryTestPages = options.retryTestPages; - this._registerAssets(); + this._registerAssets(options.developmentMode); } - _registerAssets () { - this.proxy.GET('/testcafe-core.js', { content: CORE_SCRIPT, contentType: 'application/x-javascript' }); - this.proxy.GET('/testcafe-driver.js', { content: DRIVER_SCRIPT, contentType: 'application/x-javascript' }); + _registerAssets (developmentMode) { + const { favIcon, coreScript, driverScript, uiScript, + uiStyle, uiSprite, automationScript, legacyRunnerScript } = loadAssets(developmentMode); + + this.proxy.GET('/testcafe-core.js', { content: coreScript, contentType: 'application/x-javascript' }); + this.proxy.GET('/testcafe-driver.js', { content: driverScript, contentType: 'application/x-javascript' }); + this.proxy.GET('/testcafe-legacy-runner.js', { - content: LEGACY_RUNNER_SCRIPT, + content: legacyRunnerScript, contentType: 'application/x-javascript' }); - this.proxy.GET('/testcafe-automation.js', { content: AUTOMATION_SCRIPT, contentType: 'application/x-javascript' }); - this.proxy.GET('/testcafe-ui.js', { content: UI_SCRIPT, contentType: 'application/x-javascript' }); - this.proxy.GET('/testcafe-ui-sprite.png', { content: UI_SPRITE, contentType: 'image/png' }); - this.proxy.GET('/favicon.ico', { content: FAVICON, contentType: 'image/x-icon' }); + + this.proxy.GET('/testcafe-automation.js', { content: automationScript, contentType: 'application/x-javascript' }); + this.proxy.GET('/testcafe-ui.js', { content: uiScript, contentType: 'application/x-javascript' }); + this.proxy.GET('/testcafe-ui-sprite.png', { content: uiSprite, contentType: 'image/png' }); + this.proxy.GET('/favicon.ico', { content: favIcon, contentType: 'image/x-icon' }); this.proxy.GET('/testcafe-ui-styles.css', { - content: UI_STYLE, + content: uiStyle, contentType: 'text/css', isShadowUIStylesheet: true }); } + _setupSourceMapsSupport () { + sourceMapSupport.install({ + hookRequire: true, + handleUncaughtExceptions: false, + environment: 'node' + }); + } // API async createBrowserConnection () { - var browserInfo = await browserProviderPool.getBrowserInfo('remote'); + const browserInfo = await browserProviderPool.getBrowserInfo('remote'); return new BrowserConnection(this.browserConnectionGateway, browserInfo, true); } createRunner () { - var newRunner = new Runner(this.proxy, this.browserConnectionGateway); + const newRunner = new Runner(this.proxy, this.browserConnectionGateway, { retryTestPages: this.retryTestPages }); this.runners.push(newRunner); diff --git a/src/utils/assignable.js b/src/utils/assignable.js index c3fc9883..cb9f77bd 100644 --- a/src/utils/assignable.js +++ b/src/utils/assignable.js @@ -8,34 +8,37 @@ export default class Assignable { throw new Error('Not implemented'); } - _assignFrom (obj, validate) { + _assignFrom (obj, validate, initOptions = {}) { if (!obj) return; - var props = this._getAssignableProperties(); + const props = this._getAssignableProperties(); - for (var i = 0; i < props.length; i++) { - var { name, type, required, init } = props[i]; + for (let i = 0; i < props.length; i++) { + const { name, type, required, init, defaultValue } = props[i]; - var path = name.split('.'); - var lastIdx = path.length - 1; - var last = path[lastIdx]; - var srcObj = obj; - var destObj = this; + const path = name.split('.'); + const lastIdx = path.length - 1; + const last = path[lastIdx]; + let srcObj = obj; + let destObj = this; - for (var j = 0; j < lastIdx && srcObj && destObj; j++) { + for (let j = 0; j < lastIdx && srcObj && destObj; j++) { srcObj = srcObj[path[j]]; destObj = destObj[path[j]]; } + if (destObj && 'defaultValue' in props[i]) + destObj[name] = defaultValue; + if (srcObj && destObj) { - var srcVal = srcObj[last]; + const srcVal = srcObj[last]; if (srcVal !== void 0 || required) { if (validate && type) type(name, srcVal); - destObj[last] = init ? init(name, srcVal) : srcVal; + destObj[last] = init ? init(name, srcVal, initOptions) : srcVal; } } } diff --git a/src/utils/check-file-path.js b/src/utils/check-file-path.js new file mode 100644 index 00000000..d58495b4 --- /dev/null +++ b/src/utils/check-file-path.js @@ -0,0 +1,37 @@ +import path from 'path'; +import { win as isWin } from 'os-family'; +import sanitizeFilename from 'sanitize-filename'; + + +const SAFE_CHAR = '_'; +const ALLOWED_CHARS_LIST = [path.win32.sep, path.posix.sep, '.', '..']; + + +function correctForbiddenCharsList (forbiddenCharsList, filePath) { + const isWinAbsolutePath = isWin && path.isAbsolute(filePath); + const hasDriveSeparatorInList = forbiddenCharsList.length && forbiddenCharsList[0].chars === ':' && forbiddenCharsList[0].index === 1; + + if (isWinAbsolutePath && hasDriveSeparatorInList) + forbiddenCharsList.shift(); +} + +function addForbiddenCharsToList (forbiddenCharsList, forbiddenCharsInfo) { + const { chars } = forbiddenCharsInfo; + + if (!ALLOWED_CHARS_LIST.includes(chars)) + forbiddenCharsList.push(forbiddenCharsInfo); + + return SAFE_CHAR.repeat(chars.length); +} + +export default function (filePath) { + const forbiddenCharsList = []; + + sanitizeFilename(filePath, { + replacement: (chars, index) => addForbiddenCharsToList(forbiddenCharsList, { chars, index }) + }); + + correctForbiddenCharsList(forbiddenCharsList, filePath); + + return forbiddenCharsList; +} diff --git a/src/utils/correct-file-path.js b/src/utils/correct-file-path.js new file mode 100644 index 00000000..a6a746c8 --- /dev/null +++ b/src/utils/correct-file-path.js @@ -0,0 +1,19 @@ +import path from 'path'; +import sanitizeFilename from 'sanitize-filename'; +import { escapeRegExp as escapeRe } from 'lodash'; + +export default function (filePath, expectedExtention) { + filePath = filePath.replace(new RegExp(escapeRe(path.win32.sep), 'g'), path.posix.sep); + + const correctedPath = filePath + .split(path.posix.sep) + .map(str => sanitizeFilename(str)) + .join(path.sep); + + if (!expectedExtention) + return correctedPath; + + const extentionRe = new RegExp(escapeRe(expectedExtention)); + + return extentionRe.test(correctedPath) ? correctedPath : `${correctedPath}.${expectedExtention}`; +} diff --git a/src/utils/delegated-api.js b/src/utils/delegated-api.js index 0688475d..05799f64 100644 --- a/src/utils/delegated-api.js +++ b/src/utils/delegated-api.js @@ -2,9 +2,9 @@ const API_IMPLEMENTATION_METHOD_RE = /^_(\S+)\$(getter|setter)?$/; export function getDelegatedAPIList (src) { return Object - .keys(src) + .getOwnPropertyNames(src) .map(prop => { - var match = prop.match(API_IMPLEMENTATION_METHOD_RE); + const match = prop.match(API_IMPLEMENTATION_METHOD_RE); if (match) { return { @@ -21,11 +21,11 @@ export function getDelegatedAPIList (src) { export function delegateAPI (dest, apiList, opts) { apiList.forEach(({ srcProp, apiProp, accessor }) => { - var fn = function (...args) { + const fn = function (...args) { if (opts.proxyMethod) opts.proxyMethod(); - var handler = null; + let handler = null; if (opts.useCurrentCtxAsHandler) handler = this; diff --git a/src/utils/escape-user-agent.js b/src/utils/escape-user-agent.js new file mode 100644 index 00000000..6c9a7a05 --- /dev/null +++ b/src/utils/escape-user-agent.js @@ -0,0 +1,5 @@ +import sanitizeFilename from 'sanitize-filename'; + +export default function escapeUserAgent (userAgent) { + return sanitizeFilename(userAgent.toString()).replace(/\s+/g, '_'); +} diff --git a/src/utils/get-common-path.js b/src/utils/get-common-path.js new file mode 100644 index 00000000..b072e37e --- /dev/null +++ b/src/utils/get-common-path.js @@ -0,0 +1,43 @@ +import path from 'path'; + + +function getCommonPathFragmentsCount (fragmentedPath1, fragmentedPath2) { + const maxCommonPathFragmentsCount = Math.min(fragmentedPath1.length, fragmentedPath2.length); + + let commonPathFragmentsIndex = 0; + + while (commonPathFragmentsIndex < maxCommonPathFragmentsCount) { + if (fragmentedPath1[commonPathFragmentsIndex] !== fragmentedPath2[commonPathFragmentsIndex]) + break; + + commonPathFragmentsIndex++; + } + + return commonPathFragmentsIndex; +} + +function getCommonPathFragments (fragmentedPaths) { + const lastFragmentedPath = fragmentedPaths.pop(); + + const commonPathFragmentsCounts = fragmentedPaths + .map(fragmentedPath => getCommonPathFragmentsCount(fragmentedPath, lastFragmentedPath)); + + return lastFragmentedPath.splice(0, Math.min(...commonPathFragmentsCounts)); +} + +export default function (paths) { + if (!paths) + return null; + + if (paths.length === 1) + return paths[0]; + + const fragmentedPaths = paths.map(item => item.split(path.sep)); + const commonPathFragments = getCommonPathFragments(fragmentedPaths); + + + if (!commonPathFragments.length) + return null; + + return commonPathFragments.join(path.sep); +} diff --git a/src/utils/get-viewport-width.js b/src/utils/get-viewport-width.js index 64aded31..45084094 100644 --- a/src/utils/get-viewport-width.js +++ b/src/utils/get-viewport-width.js @@ -4,7 +4,7 @@ const DEFAULT_VIEWPORT_WIDTH = 78; export default function (outStream) { if (outStream === process.stdout && tty.isatty(1)) { - var detectedViewportWidth = process.stdout.getWindowSize ? + const detectedViewportWidth = process.stdout.getWindowSize ? process.stdout.getWindowSize(1)[0] : tty.getWindowSize()[1]; diff --git a/src/utils/handle-errors.js b/src/utils/handle-errors.js new file mode 100644 index 00000000..62a3d7ee --- /dev/null +++ b/src/utils/handle-errors.js @@ -0,0 +1,71 @@ +import { UnhandledPromiseRejectionError, UncaughtExceptionError } from '../errors/test-run'; +import util from 'util'; + +const runningTests = {}; +let handlingTestErrors = false; + +function handleError (ErrorCtor, message) { + if (handlingTestErrors) { + Object.values(runningTests).forEach(testRun => { + testRun.addError(new ErrorCtor(message)); + + removeRunningTest(testRun); + }); + } + else { + /* eslint-disable no-process-exit */ + /* eslint-disable no-console */ + console.log(message); + + setTimeout(() => process.exit(1), 0); + /* eslint-enable no-process-exit */ + /* eslint-enable no-console */ + } +} + +function formatUnhandledRejectionReason (reason) { + const reasonType = typeof reason; + const isPrimitiveType = reasonType !== 'object' && reasonType !== 'function'; + + if (isPrimitiveType) + return String(reason); + + if (reason instanceof Error) + return reason.stack; + + return util.inspect(reason, { depth: 2, breakLength: Infinity }); +} + +function onUnhandledRejection (reason) { + if (reason && reason.isRejectedDriverTask) + return; + + const message = formatUnhandledRejectionReason(reason); + + handleError(UnhandledPromiseRejectionError, message); +} + +function onUncaughtException (err) { + handleError(UncaughtExceptionError, err.stack); +} + +export function registerErrorHandlers () { + process.on('unhandledRejection', onUnhandledRejection); + process.on('uncaughtException', onUncaughtException); +} + +export function addRunningTest (testRun) { + runningTests[testRun.id] = testRun; +} + +export function removeRunningTest (testRun) { + delete runningTests[testRun.id]; +} + +export function startHandlingTestErrors () { + handlingTestErrors = true; +} + +export function stopHandlingTestErrors () { + handlingTestErrors = false; +} diff --git a/src/utils/moment-loader.js b/src/utils/moment-loader.js index 1649fc98..6493cdc6 100644 --- a/src/utils/moment-loader.js +++ b/src/utils/moment-loader.js @@ -1,79 +1,24 @@ -import resolveFrom from 'resolve-from'; +import momentDurationFormatSetup from 'moment-duration-format-commonjs'; -const MOMENT_MODULE_NAME = 'moment'; -const DURATION_FORMAT_MODULE_NAME = 'moment-duration-format'; +const MOMENT_MODULE_NAME = 'moment'; -function restoreInitialCacheState (module, path) { - if (module) - require.cache[path] = module; - else - delete require.cache[path]; -} - -function getSideMomentModulePath (sidePath) { - try { - return resolveFrom(sidePath, MOMENT_MODULE_NAME); - } - catch (err) { - return ''; - } -} - -function getModulesPaths () { - const durationFormatModulePath = require.resolve(DURATION_FORMAT_MODULE_NAME); - - return { - durationFormatModulePath, - - mainMomentModulePath: require.resolve(MOMENT_MODULE_NAME), - sideMomentModulePath: getSideMomentModulePath(durationFormatModulePath) - }; -} - -function getCachedAndCleanModules (modulePath) { - const cachedModule = require.cache[modulePath]; - - delete require.cache[modulePath]; +function loadMomentModule () { + const momentModulePath = require.resolve(MOMENT_MODULE_NAME); + const savedMomentModule = require.cache[momentModulePath]; - require(modulePath); + delete require.cache[savedMomentModule]; - return { cachedModule, cleanModule: require.cache[modulePath] }; -} - -function getMomentModules ({ mainMomentModulePath, sideMomentModulePath }) { - const { cachedModule, cleanModule } = getCachedAndCleanModules(mainMomentModulePath); - - return { - sideModule: require.cache[sideMomentModulePath], - mainModule: cleanModule, - cachedModule - }; -} - -function getMomentModuleWithDurationFormatPatch () { - const modulesPaths = getModulesPaths(); - const momentModules = getMomentModules(modulesPaths); + const moment = require(momentModulePath); - const { sideMomentModulePath, mainMomentModulePath, durationFormatModulePath } = modulesPaths; + momentDurationFormatSetup(moment); - if (sideMomentModulePath && sideMomentModulePath !== mainMomentModulePath) { - require.cache[sideMomentModulePath] = momentModules.mainModule; - - require(durationFormatModulePath); - - restoreInitialCacheState(momentModules.sideModule, sideMomentModulePath); - } - else { - const durationFormatSetup = require(durationFormatModulePath); - - if (!sideMomentModulePath) - durationFormatSetup(momentModules.mainModule.exports); - } - - restoreInitialCacheState(momentModules.cachedModule, mainMomentModulePath); + if (savedMomentModule) + require.cache[momentModulePath] = savedMomentModule; + else + delete require.cache[momentModulePath]; - return momentModules.mainModule.exports; + return moment; } -export default getMomentModuleWithDurationFormatPatch(); +export default loadMomentModule(); diff --git a/src/utils/parse-file-list.js b/src/utils/parse-file-list.js new file mode 100644 index 00000000..925d82ca --- /dev/null +++ b/src/utils/parse-file-list.js @@ -0,0 +1,52 @@ +import path from 'path'; +import Promise from 'pinkie'; +import globby from 'globby'; +import isGlob from 'is-glob'; +import Compiler from '../compiler'; +import { isEmpty } from 'lodash'; +import { stat } from '../utils/promisified-functions'; + +const DEFAULT_TEST_LOOKUP_DIRS = ['test/', 'tests/']; +const TEST_FILE_GLOB_PATTERN = `./**/*@(${Compiler.getSupportedTestFileExtensions().join('|')})`; + +async function getDefaultDirs (baseDir) { + return await globby(DEFAULT_TEST_LOOKUP_DIRS, { + cwd: baseDir, + nocase: true, + onlyDirectories: true, + onlyFiles: false + }); +} + +async function convertDirsToGlobs (fileList, baseDir) { + fileList = await Promise.all(fileList.map(async file => { + if (!isGlob(file)) { + const absPath = path.resolve(baseDir, file); + let fileStat = null; + + try { + fileStat = await stat(absPath); + } + catch (err) { + return null; + } + + if (fileStat.isDirectory()) + return path.join(file, TEST_FILE_GLOB_PATTERN); + } + + return file; + })); + + return fileList.filter(file => !!file); +} + +export default async function parseFileList (fileList, baseDir) { + if (isEmpty(fileList)) + fileList = await getDefaultDirs(baseDir); + + fileList = await convertDirsToGlobs(fileList, baseDir); + fileList = await globby(fileList, { cwd: baseDir }); + + return fileList.map(file => path.resolve(baseDir, file)); +} diff --git a/src/utils/process.js b/src/utils/process.js new file mode 100644 index 00000000..345ebcd6 --- /dev/null +++ b/src/utils/process.js @@ -0,0 +1,147 @@ +import { spawn } from 'child_process'; +import Promise from 'pinkie'; +import OS from 'os-family'; +import promisifyEvent from 'promisify-event'; +import delay from '../utils/delay'; + +const CHECK_PROCESS_IS_KILLED_TIMEOUT = 5000; +const CHECK_KILLED_DELAY = 1000; +const NEW_LINE_SEPERATOR_RE = /(\r\n)|(\n\r)|\n|\r/g; +const cantGetListOfProcessError = 'Can not get list of processes'; +const killProcessTimeoutError = 'Kill process timeout'; + +function getProcessOutputUnix () { + const error = new Error(cantGetListOfProcessError); + + return new Promise((resolve, reject) => { + const child = spawn('ps', ['-eo', 'pid,command']); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', data => { + stdout += data.toString(); + }); + + child.stderr.on('data', data => { + stderr += data.toString(); + }); + + child.on('exit', () => { + if (stderr) + reject(error); + else + resolve(stdout); + }); + + child.on('error', () => { + reject(error); + }); + }); +} + +function findProcessIdUnix (browserId, psOutput) { + const processIdRegex = new RegExp('^\\s*(\\d+)\\s+.*' + browserId); + const lines = psOutput.split(NEW_LINE_SEPERATOR_RE); + + for (let i = 0; i < lines.length; i++) { + const match = processIdRegex.exec(lines[i]); + + if (match) + return parseInt(match[1], 10); + } + + return null; +} + +function isProcessExistUnix (processId, psOutput) { + const processIdRegex = new RegExp('^\\s*' + processId + '\\s+.*'); + const lines = psOutput.split(NEW_LINE_SEPERATOR_RE); + + return lines.some(line => processIdRegex.test(line)); +} + +async function findProcessUnix (browserId) { + const output = await getProcessOutputUnix(); + + return findProcessIdUnix(browserId, output); +} + +async function checkUnixProcessIsKilled (processId) { + const output = await getProcessOutputUnix(); + + if (isProcessExistUnix(processId, output)) { + await delay(CHECK_KILLED_DELAY); + + await checkUnixProcessIsKilled(); + } +} + +async function killProcessUnix (processId) { + let timeoutError = false; + + process.kill(processId); + + const killTimeoutTimer = delay(CHECK_PROCESS_IS_KILLED_TIMEOUT) + .then(() => { + timeoutError = true; + }); + + return Promise.race([killTimeoutTimer, checkUnixProcessIsKilled(processId)]).then(() => { + if (timeoutError) + throw new Error(killProcessTimeoutError); + }); +} + +async function runWMIC (args) { + const wmicProcess = spawn('wmic.exe', args, { detached: true }); + + let wmicOutput = ''; + + wmicProcess.stdout.on('data', data => { + wmicOutput += data.toString(); + }); + + try { + await Promise.race([ + promisifyEvent(wmicProcess.stdout, 'end'), + promisifyEvent(wmicProcess, 'error') + ]); + + return wmicOutput; + } + catch (e) { + return ''; + } +} + +async function findProcessWin (browserId) { + const wmicArgs = ['process', 'where', `commandline like '%${browserId}%' and name <> 'cmd.exe' and name <> 'wmic.exe'`, 'get', 'processid']; + const wmicOutput = await runWMIC(wmicArgs); + let processList = wmicOutput.split(/\s*\n/); + + processList = processList + // NOTE: remove list's header and empty last element, caused by trailing newline + .slice(1, -1) + .map(pid => ({ pid: Number(pid) })); + + return processList[0] ? processList[0].pid : null; +} + +export async function killBrowserProcess (browserId) { + const processId = OS.win ? await findProcessWin(browserId) : await findProcessUnix(browserId); + + if (!processId) + return true; + + try { + if (OS.win) + process.kill(processId); + else + await killProcessUnix(processId); + + return true; + } + catch (e) { + return false; + } +} diff --git a/src/utils/promisified-functions.js b/src/utils/promisified-functions.js index a6aa010a..eb2dc08c 100644 --- a/src/utils/promisified-functions.js +++ b/src/utils/promisified-functions.js @@ -1,17 +1,14 @@ import childProcess from 'child_process'; import fs from 'graceful-fs'; -import mkdirp from 'mkdirp'; -import psNode from 'ps-node'; import promisify from './promisify'; -export const ensureDir = promisify(mkdirp); +export const readDir = promisify(fs.readdir); export const stat = promisify(fs.stat); export const writeFile = promisify(fs.writeFile); export const readFile = promisify(fs.readFile); export const deleteFile = promisify(fs.unlink); -export const findProcess = promisify(psNode.lookup); -export const killProcess = promisify(psNode.kill); - export const exec = promisify(childProcess.exec); + +export const sendMessageToChildProcess = promisify((process, ...args) => process.send(...args)); diff --git a/src/utils/re-executable-promise.js b/src/utils/re-executable-promise.js index 887aeed5..fb536403 100644 --- a/src/utils/re-executable-promise.js +++ b/src/utils/re-executable-promise.js @@ -34,7 +34,7 @@ export default class ReExecutablePromise extends Promise { } static fromFn (asyncExecutorFn) { - var testRunId = testRunTracker.getContextTestRunId(); + const testRunId = testRunTracker.getContextTestRunId(); if (testRunId) asyncExecutorFn = testRunTracker.addTrackingMarkerToFunction(testRunId, asyncExecutorFn); diff --git a/src/utils/render-template.js b/src/utils/render-template.js index bcf6b98c..6ed2877c 100644 --- a/src/utils/render-template.js +++ b/src/utils/render-template.js @@ -2,7 +2,7 @@ export default function renderTemplate (template, ...args) { if (!args.length) return template; - var counter = 0; + let counter = 0; return template.replace(/{.+?}/g, match => counter < args.length ? args[counter++] : match); } diff --git a/src/utils/string.js b/src/utils/string.js index 7c3af7c9..a77ebfa4 100644 --- a/src/utils/string.js +++ b/src/utils/string.js @@ -1,4 +1,5 @@ import indentString from 'indent-string'; +import { repeat } from 'lodash'; function rtrim (str) { return str.replace(/\s+$/, ''); @@ -9,8 +10,8 @@ export function removeTTYColors (str) { } export function wordWrap (str, indent, width) { - var curStr = ''; - var wrappedMsg = ''; + let curStr = ''; + let wrappedMsg = ''; if (removeTTYColors(str).length <= width - indent) return indentString(str, ' ', indent); @@ -21,7 +22,7 @@ export function wordWrap (str, indent, width) { .filter(elm => !!elm); str.forEach(word => { - var newStr = curStr + word; + const newStr = curStr + word; if (removeTTYColors(newStr).length > width - indent) { wrappedMsg += `${rtrim(curStr)}\n`; @@ -41,12 +42,12 @@ export function wordWrap (str, indent, width) { } export function splitQuotedText (str, splitChar, quotes = '"\'') { - var currentPart = ''; - var parts = []; - var quoteChar = null; + let currentPart = ''; + const parts = []; + let quoteChar = null; - for (var i = 0; i < str.length; i++) { - var currentChar = str[i]; + for (let i = 0; i < str.length; i++) { + const currentChar = str[i]; if (currentChar === splitChar) { if (quoteChar) @@ -73,3 +74,9 @@ export function splitQuotedText (str, splitChar, quotes = '"\'') { return parts; } + +export function replaceLeadingSpacesWithNbsp (str) { + return str.replace(/^ +/mg, match => { + return repeat(' ', match.length); + }); +} diff --git a/src/utils/temp-directory/cleanup-process/commands.js b/src/utils/temp-directory/cleanup-process/commands.js new file mode 100644 index 00000000..619f22f5 --- /dev/null +++ b/src/utils/temp-directory/cleanup-process/commands.js @@ -0,0 +1,5 @@ +export default { + init: 'init', + add: 'add', + remove: 'remove' +}; diff --git a/src/utils/temp-directory/cleanup-process/index.js b/src/utils/temp-directory/cleanup-process/index.js new file mode 100644 index 00000000..043e4a49 --- /dev/null +++ b/src/utils/temp-directory/cleanup-process/index.js @@ -0,0 +1,183 @@ +import { spawn } from 'child_process'; +import debug from 'debug'; +import promisifyEvent from 'promisify-event'; +import Promise from 'pinkie'; +import { sendMessageToChildProcess } from '../../promisified-functions'; +import COMMANDS from './commands'; + + +const WORKER_PATH = require.resolve('./worker'); +const WORKER_STDIO_CONFIG = ['ignore', 'pipe', 'pipe', 'ipc']; + +const DEBUG_LOGGER = debug('testcafe:utils:temp-directory:cleanup-process'); + +class CleanupProcess { + constructor () { + this.worker = null; + this.initialized = false; + this.initPromise = Promise.resolve(void 0); + this.errorPromise = null; + + this.messageCounter = 0; + + this.pendingResponses = {}; + } + + _sendMessage (id, msg) { + return Promise.race([ + sendMessageToChildProcess(this.worker, { id, ...msg }), + this._waitProcessError() + ]); + } + + _onResponse (response) { + const pendingResponse = this.pendingResponses[response.id]; + + if (response.error) { + if (pendingResponse) + pendingResponse.control.reject(response.error); + else + this.pendingResponses[response.id] = Promise.reject(response.error); + } + else if (pendingResponse) + pendingResponse.control.resolve(); + else + this.pendingResponses[response.id] = Promise.resolve(); + } + + async _waitResponse (id) { + if (!this.pendingResponses[id]) { + const promiseControl = {}; + + this.pendingResponses[id] = new Promise((resolve, reject) => { + Object.assign(promiseControl, { resolve, reject }); + }); + + this.pendingResponses[id].control = promiseControl; + } + + try { + await this.pendingResponses[id]; + } + finally { + delete this.pendingResponses[id]; + } + } + + async _waitResponseForMessage (msg) { + const currentId = this.messageCounter; + + this.messageCounter++; + + await this._sendMessage(currentId, msg); + await this._waitResponse(currentId); + } + + _waitProcessExit () { + return promisifyEvent(this.worker, 'exit') + .then(exitCode => Promise.reject(new Error(`Worker process terminated with code ${exitCode}`))); + } + + _waitProcessError () { + if (this.errorPromise) + return this.errorPromise; + + this.errorPromise = promisifyEvent(this.worker, 'error'); + + this.errorPromise.then(() => { + this.errorPromise = null; + }); + + return this.errorPromise; + } + + _setupWorkerEventHandlers () { + this.worker.on('message', message => this._onResponse(message)); + + this.worker.stdout.on('data', data => DEBUG_LOGGER('Worker process stdout:\n', String(data))); + this.worker.stderr.on('data', data => DEBUG_LOGGER('Worker process stderr:\n', String(data))); + } + + _unrefWorkerProcess () { + this.worker.unref(); + this.worker.stdout.unref(); + this.worker.stderr.unref(); + + const channel = this.worker.channel || this.worker._channel; + + channel.unref(); + } + + _handleProcessError (error) { + this.initialized = false; + + DEBUG_LOGGER(error); + } + + init () { + this.initPromise = this.initPromise + .then(async initialized => { + if (initialized !== void 0) + return initialized; + + this.worker = spawn(process.argv[0], [WORKER_PATH], { detached: true, stdio: WORKER_STDIO_CONFIG }); + + this._setupWorkerEventHandlers(); + this._unrefWorkerProcess(); + + const exitPromise = this._waitProcessExit(); + + try { + await Promise.race([ + this._waitResponseForMessage({ command: COMMANDS.init }), + this._waitProcessError(), + exitPromise + ]); + + this.initialized = true; + + exitPromise.catch(error => this._handleProcessError(error)); + + this.worker.on('error', error => this._handleProcessError(error)); + } + catch (e) { + DEBUG_LOGGER('Failed to start cleanup process'); + DEBUG_LOGGER(e); + + this.initialized = false; + } + + return this.initialized; + }); + + return this.initPromise; + } + + async addDirectory (path) { + if (!this.initialized) + return; + + try { + await this._waitResponseForMessage({ command: COMMANDS.add, path }); + } + catch (e) { + DEBUG_LOGGER(`Failed to add the ${path} directory to cleanup process`); + DEBUG_LOGGER(e); + } + } + + async removeDirectory (path) { + if (!this.initialized) + return; + + try { + await this._waitResponseForMessage({ command: COMMANDS.remove, path }); + } + catch (e) { + DEBUG_LOGGER(`Failed to remove the ${path} directory in cleanup process`); + DEBUG_LOGGER(e); + } + } +} + +export default new CleanupProcess(); diff --git a/src/utils/temp-directory/cleanup-process/worker.js b/src/utils/temp-directory/cleanup-process/worker.js new file mode 100644 index 00000000..6fa3e1db --- /dev/null +++ b/src/utils/temp-directory/cleanup-process/worker.js @@ -0,0 +1,70 @@ +import path from 'path'; +import { inspect } from 'util'; +import del from 'del'; +import Promise from 'pinkie'; +import { noop } from 'lodash'; +import { killBrowserProcess } from '../../process'; +import COMMANDS from './commands'; + + +const DIRECTORIES_TO_CLEANUP = {}; + +function addDirectory (dirPath) { + if (!DIRECTORIES_TO_CLEANUP[dirPath]) + DIRECTORIES_TO_CLEANUP[dirPath] = {}; +} + +async function removeDirectory (dirPath) { + if (!DIRECTORIES_TO_CLEANUP[dirPath]) + return; + + let delPromise = DIRECTORIES_TO_CLEANUP[dirPath].delPromise; + + if (!delPromise) { + delPromise = killBrowserProcess(path.basename(dirPath)) + .then(() => del(dirPath, { force: true })); + + DIRECTORIES_TO_CLEANUP[dirPath].delPromise = delPromise; + } + + await DIRECTORIES_TO_CLEANUP[dirPath].delPromise; + + delete DIRECTORIES_TO_CLEANUP[dirPath].delPromise; +} + +async function dispatchCommand (message) { + switch (message.command) { + case COMMANDS.init: + return; + case COMMANDS.add: + addDirectory(message.path); + return; + case COMMANDS.remove: + addDirectory(message.path); + await removeDirectory(message.path); + return; + } +} + +process.on('message', async message => { + let error = ''; + + try { + await dispatchCommand(message); + } + catch (e) { + error = inspect(e); + } + + process.send({ id: message.id, error }); +}); + +process.on('disconnect', async () => { + const removePromises = Object + .keys(DIRECTORIES_TO_CLEANUP) + .map(dirPath => removeDirectory(dirPath).catch(noop)); + + await Promise.all(removePromises); + + process.exit(0); //eslint-disable-line no-process-exit +}); diff --git a/src/utils/temp-directory/index.js b/src/utils/temp-directory/index.js new file mode 100644 index 00000000..efa6def5 --- /dev/null +++ b/src/utils/temp-directory/index.js @@ -0,0 +1,117 @@ +import debug from 'debug'; +import os from 'os'; +import path from 'path'; +import setupExitHook from 'async-exit-hook'; +import tmp from 'tmp'; +import makeDir from 'make-dir'; +import LockFile from './lockfile'; +import cleanupProcess from './cleanup-process'; +import { readDir } from '../../utils/promisified-functions'; + + +// NOTE: mutable for testing purposes +const TESTCAFE_TMP_DIRS_ROOT = path.join(os.tmpdir(), 'testcafe'); +const DEFAULT_NAME_PREFIX = 'tmp'; +const USED_TEMP_DIRS = {}; +const DEBUG_LOGGER = debug('testcafe:utils:temp-directory'); + +export default class TempDirectory { + constructor (namePrefix) { + this.namePrefix = namePrefix || DEFAULT_NAME_PREFIX; + + this.path = ''; + this.lockFile = null; + } + + async _getTmpDirsList () { + const tmpDirNames = await readDir(TempDirectory.TEMP_DIRECTORIES_ROOT); + + return tmpDirNames + .filter(tmpDir => !USED_TEMP_DIRS[tmpDir]) + .filter(tmpDir => path.basename(tmpDir).startsWith(this.namePrefix)); + } + + async _findFreeTmpDir (tmpDirNames) { + for (const tmpDirName of tmpDirNames) { + const tmpDirPath = path.join(TempDirectory.TEMP_DIRECTORIES_ROOT, tmpDirName); + + const lockFile = new LockFile(tmpDirPath); + + if (lockFile.init()) { + this.path = tmpDirPath; + this.lockFile = lockFile; + + return true; + } + } + + return false; + } + + async _createNewTmpDir () { + this.path = tmp.tmpNameSync({ dir: TempDirectory.TEMP_DIRECTORIES_ROOT, prefix: this.namePrefix + '-' }); + + await makeDir(this.path); + + this.lockFile = new LockFile(this.path); + + this.lockFile.init(); + } + + _disposeSync () { + if (!USED_TEMP_DIRS[this.path]) + return; + + this.lockFile.dispose(); + + delete USED_TEMP_DIRS[this.path]; + } + + static async createDirectory (prefix) { + const tmpDir = new TempDirectory(prefix); + + await tmpDir.init(); + + return tmpDir; + } + + static disposeDirectoriesSync () { + Object.values(USED_TEMP_DIRS).forEach(tmpDir => tmpDir._disposeSync()); + } + + async init () { + await makeDir(TempDirectory.TEMP_DIRECTORIES_ROOT); + + const tmpDirNames = await this._getTmpDirsList(this.namePrefix); + + DEBUG_LOGGER('Found temp directories:', tmpDirNames); + + const existingTmpDirFound = await this._findFreeTmpDir(tmpDirNames); + + if (!existingTmpDirFound) + await this._createNewTmpDir(); + + DEBUG_LOGGER('Temp directory path: ', this.path); + + await cleanupProcess.init(); + await cleanupProcess.addDirectory(this.path); + + USED_TEMP_DIRS[this.path] = this; + } + + async dispose () { + if (!USED_TEMP_DIRS[this.path]) + return; + + this.lockFile.dispose(); + + await cleanupProcess.removeDirectory(this.path); + + delete USED_TEMP_DIRS[this.path]; + } +} + +// NOTE: exposed for testing purposes +TempDirectory.TEMP_DIRECTORIES_ROOT = TESTCAFE_TMP_DIRS_ROOT; + +setupExitHook(TempDirectory.disposeDirectoriesSync); diff --git a/src/utils/temp-directory/lockfile.js b/src/utils/temp-directory/lockfile.js new file mode 100644 index 00000000..fb9857a2 --- /dev/null +++ b/src/utils/temp-directory/lockfile.js @@ -0,0 +1,64 @@ +import path from 'path'; +import debug from 'debug'; +import fs from 'fs'; + + +const LOCKFILE_NAME = '.testcafe-lockfile'; +const STALE_LOCKFILE_AGE = 2 * 24 * 60 * 60 * 1000; +const DEBUG_LOGGER = debug('testcafe:utils:temp-directory:lockfile'); + +export default class LockFile { + constructor (dirPath) { + this.path = path.join(dirPath, LOCKFILE_NAME); + } + + _open ({ force = false } = {}) { + try { + fs.writeFileSync(this.path, '', { flag: force ? 'w' : 'wx' }); + + return true; + } + catch (e) { + DEBUG_LOGGER('Failed to init lockfile ' + this.path); + DEBUG_LOGGER(e); + + return false; + } + } + + _isStale () { + const currentMs = Date.now(); + + try { + const { mtimeMs } = fs.statSync(this.path); + + return currentMs - mtimeMs > STALE_LOCKFILE_AGE; + } + catch (e) { + DEBUG_LOGGER('Failed to check status of lockfile ' + this.path); + DEBUG_LOGGER(e); + + return false; + } + } + + init () { + if (this._open()) + return true; + + if (this._isStale()) + return this._open({ force: true }); + + return false; + } + + dispose () { + try { + fs.unlinkSync(this.path); + } + catch (e) { + DEBUG_LOGGER('Failed to dispose lockfile ' + this.path); + DEBUG_LOGGER(e); + } + } +} diff --git a/test/client/before-test.js b/test/client/before-test.js index 0f9ab015..39c8ffa6 100644 --- a/test/client/before-test.js +++ b/test/client/before-test.js @@ -13,14 +13,14 @@ } //Hammerhead setup - var hammerhead = getTestCafeModule('hammerhead'); - var INSTRUCTION = hammerhead.get('../processing/script/instruction'); - var location = 'http://localhost/sessionId/https://example.com'; - var browserUtils = hammerhead.utils.browser; + const hammerhead = getTestCafeModule('hammerhead'); + const INSTRUCTION = hammerhead.get('../processing/script/instruction'); + const location = 'http://localhost/sessionId/https://example.com'; + const browserUtils = hammerhead.utils.browser; hammerhead.get('./utils/destination-location').forceLocation(location); - var iframeTaskScriptTempate = [ + const iframeTaskScriptTempate = [ 'window["%hammerhead%"].get("./utils/destination-location").forceLocation("{{{location}}}");', 'window["%hammerhead%"].start({', ' referer : "{{{referer}}}",', @@ -40,9 +40,9 @@ }; window.initIFrameTestHandler = function (e) { - var referer = location; - var serviceMsg = '/service-msg/100'; - var iframeTaskScript = window.getIframeTaskScript(referer, serviceMsg, location).replace(/"/g, '\\"'); + const referer = location; + const serviceMsg = '/service-msg/100'; + const iframeTaskScript = window.getIframeTaskScript(referer, serviceMsg, location).replace(/"/g, '\\"'); if (e.iframe.id.indexOf('test') !== -1) { e.iframe.contentWindow.eval.call(e.iframe.contentWindow, [ @@ -61,9 +61,9 @@ //TestCafe setup - var testCafeLegacyRunner = getTestCafeModule('testCafeLegacyRunner'); - var tcSettings = testCafeLegacyRunner.get('./settings'); - var sandboxedJQuery = testCafeLegacyRunner.get('./sandboxed-jquery'); + const testCafeLegacyRunner = getTestCafeModule('testCafeLegacyRunner'); + const tcSettings = testCafeLegacyRunner.get('./settings'); + const sandboxedJQuery = testCafeLegacyRunner.get('./sandboxed-jquery'); tcSettings.get().REFERER = 'https://example.com'; tcSettings.get().SELECTOR_TIMEOUT = 10000; @@ -75,8 +75,8 @@ if (browserUtils.isMSEdge && browserUtils.version >= 17) { $(function () { - var nativeMethods = hammerhead.nativeMethods; - var input = nativeMethods.createElement.call(document, 'input'); + const nativeMethods = hammerhead.nativeMethods; + const input = nativeMethods.createElement.call(document, 'input'); nativeMethods.appendChild.call(document.body, input); nativeMethods.inputValueSetter.call(input, 'text'); @@ -102,9 +102,10 @@ // With this hack, we only allow setting the scroll by a script and prevent native browser scrolling. if (hammerhead.utils.browser.isIOS) { document.addEventListener('DOMContentLoaded', function () { - var originWindowScrollTo = window.scrollTo; - var lastScrollTop = window.scrollY; - var lastScrollLeft = window.scrollX; + const originWindowScrollTo = window.scrollTo; + + let lastScrollTop = window.scrollY; + let lastScrollLeft = window.scrollX; window.scrollTo = function () { lastScrollLeft = arguments[0]; diff --git a/test/client/config-qunit-server-app.js b/test/client/config-qunit-server-app.js index 4290b03f..c3ca7b62 100644 --- a/test/client/config-qunit-server-app.js +++ b/test/client/config-qunit-server-app.js @@ -1,23 +1,23 @@ -var url = require('url'); -var fs = require('fs'); -var path = require('path'); +const url = require('url'); +const fs = require('fs'); +const path = require('path'); -var createShadowStylesheet = require('testcafe-hammerhead/lib/shadow-ui/create-shadow-stylesheet'); +const createShadowStylesheet = require('testcafe-hammerhead/lib/shadow-ui/create-shadow-stylesheet'); //The following code is copied from testcafe-hammerhead //NOTE: Url rewrite proxied requests (e.g. for iframes), so they will hit our server function urlRewriteProxyRequest (req, res, next) { - var proxiedUrlPartRegExp = /^\/\S+?\/(https?:)/; + const proxiedUrlPartRegExp = /^\/\S+?\/(https?:)/; if (proxiedUrlPartRegExp.test(req.url)) { // NOTE: store original URL so we can sent it back for testing purposes (see GET xhr-test route). req.originalUrl = req.url; - var reqUrl = req.url.replace(proxiedUrlPartRegExp, '$1'); + const reqUrl = req.url.replace(proxiedUrlPartRegExp, '$1'); //NOTE: create host-relative URL - var parsedUrl = url.parse(reqUrl); + const parsedUrl = url.parse(reqUrl); parsedUrl.host = null; parsedUrl.hostname = null; @@ -39,8 +39,8 @@ module.exports = function (app) { app.use(urlRewriteProxyRequest); app.get('/wrap-responseText-test/:isJSON', function (req, res) { - var isJSON = req.params.isJSON === 'json'; - var responseText = isJSON ? + const isJSON = req.params.isJSON === 'json'; + const responseText = isJSON ? '{tag: "a", location: "location", attribute: {src: "example.com"}}' : ''; @@ -48,7 +48,7 @@ module.exports = function (app) { }); app.all('/xhr-test/:delay', function (req, res) { - var delay = req.params.delay || 0; + const delay = req.params.delay || 0; preventCaching(res); diff --git a/test/client/data/dom-utils/iframe.html b/test/client/data/dom-utils/iframe.html index fe3cd43d..016c8d98 100644 --- a/test/client/data/dom-utils/iframe.html +++ b/test/client/data/dom-utils/iframe.html @@ -14,12 +14,12 @@ diff --git a/test/client/legacy-fixtures/sandboxed-jquery-test/index-test.js b/test/client/legacy-fixtures/sandboxed-jquery-test/index-test.js index 8029995e..6ff3f3f1 100644 --- a/test/client/legacy-fixtures/sandboxed-jquery-test/index-test.js +++ b/test/client/legacy-fixtures/sandboxed-jquery-test/index-test.js @@ -23,7 +23,7 @@ }*/ test('T173577: TD_14_2 Uncaught TypeError: Cannot read property "call" of undefined - ikea.ru', function () { - var documentReadyCalled = false; + let documentReadyCalled = false; document.ready = function () { documentReadyCalled = true; @@ -77,7 +77,7 @@ HOW TO FIX - go to sandboxed-jquery and replace the following code: }*/ asyncTest('T230756: TD15.1 - _ is not defined on tula.metro-cc.ru', function () { - var iframe = $(' + + diff --git a/test/functional/fixtures/api/raw/native-dialogs-handling/test.js b/test/functional/fixtures/api/raw/native-dialogs-handling/test.js new file mode 100644 index 00000000..8f0e6ce9 --- /dev/null +++ b/test/functional/fixtures/api/raw/native-dialogs-handling/test.js @@ -0,0 +1,34 @@ +const pageUrl = 'http://localhost:3000/fixtures/api/es-next/native-dialogs-handling/pages/index.html'; +const errorInEachBrowserContains = require('../../../../assertion-helper.js').errorInEachBrowserContains; +const getNativeDialogNotHandledErrorText = require('../../es-next/native-dialogs-handling/errors.js').getNativeDialogNotHandledErrorText; + +describe('Native dialogs handling', function () { + it('Should pass if the expected confirm dialog appears after an action', function () { + return runTests('./testcafe-fixtures/native-dialogs-test.testcafe', 'Expected confirm after an action'); + }); + + it('Should pass if the expected confirm dialog appears after an action (client function)', function () { + return runTests('./testcafe-fixtures/native-dialogs-test.testcafe', 'Expected confirm after an action (client function)'); + }); + + it('Should fail if Selector send as dialog handler', function () { + return runTests('./testcafe-fixtures/native-dialogs-test.testcafe', 'Selector as dialogHandler', { shouldFail: true }) + .catch(function (errs) { + errorInEachBrowserContains(errs, 'The native dialog handler is expected to be a function, ClientFunction or null, but it was Selector.', 0); + }); + }); + + it('Should fail if dialog handler has wrong type', function () { + return runTests('./testcafe-fixtures/native-dialogs-test.testcafe', 'Dialog handler has wrong type', { shouldFail: true }) + .catch(function (errs) { + errorInEachBrowserContains(errs, 'The native dialog handler is expected to be a function, ClientFunction or null, but it was number.', 0); + }); + }); + + it('Should remove dialog handler if `null` specified', function () { + return runTests('./testcafe-fixtures/native-dialogs-test.testcafe', 'Null handler', { shouldFail: true }) + .catch(function (errs) { + errorInEachBrowserContains(errs, getNativeDialogNotHandledErrorText('alert', pageUrl), 0); + }); + }); +}); diff --git a/test/functional/fixtures/api/raw/native-dialogs-handling/testcafe-fixtures/native-dialogs-test.testcafe b/test/functional/fixtures/api/raw/native-dialogs-handling/testcafe-fixtures/native-dialogs-test.testcafe new file mode 100644 index 00000000..e4f69a40 --- /dev/null +++ b/test/functional/fixtures/api/raw/native-dialogs-handling/testcafe-fixtures/native-dialogs-test.testcafe @@ -0,0 +1,129 @@ +{ + "fixtures": [ + { + "name": "Native dialogs", + "pageUrl": "http://localhost:3000/fixtures/api/es-next/native-dialogs-handling/pages/index.html", + "tests": [ + { + "name": "Expected confirm after an action", + "commands": [ + { + "type": "set-native-dialog-handler", + "dialogHandler": { + "type": "js-expr", + "value": "(type, text) => {\r\n if (type === 'confirm' && text === 'Confirm?')\r\n return true;\r\n\r\n return null;\r\n}" + } + }, + { + "selector": { + "type": "js-expr", + "value": "'#buttonConfirm'" + }, + "type": "click" + }, + { + "type": "assertion", + "assertionType": "eql", + "actual": { + "type": "js-expr", + "value": "Selector('#result').textContent" + }, + "expected": { + "type": "js-expr", + "value": "'true'" + } + } + ] + }, + { + "name": "Expected confirm after an action (client function)", + "commands": [ + { + "type": "set-native-dialog-handler", + "dialogHandler": { + "type": "js-expr", + "value": "ClientFunction((type, text) => {\r\n if (type === 'confirm' && text === 'Confirm?')\r\n return true;\r\n\r\n return null;\r\n})" + } + }, + { + "selector": { + "type": "js-expr", + "value": "'#buttonConfirm'" + }, + "type": "click" + }, + { + "type": "assertion", + "assertionType": "eql", + "actual": { + "type": "js-expr", + "value": "Selector('#result').textContent" + }, + "expected": { + "type": "js-expr", + "value": "'true'" + } + } + ] + }, + { + "name": "Selector as dialogHandler", + "commands": [ + { + "type": "set-native-dialog-handler", + "dialogHandler": { + "type": "js-expr", + "value": "Selector(() => document.body)" + } + } + ] + }, + { + "name": "Dialog handler has wrong type", + "commands": [ + { + "type": "set-native-dialog-handler", + "dialogHandler": { + "type": "js-expr", + "value": "42" + } + } + ] + }, + { + "name": "Null handler", + "commands": [ + { + "type": "set-native-dialog-handler", + "dialogHandler": { + "type": "js-expr", + "value": "() => true" + } + }, + { + "selector": { + "type": "js-expr", + "value": "'#buttonAlert'" + }, + "type": "click" + }, + { + "type": "set-native-dialog-handler", + "dialogHandler": { + "type": "js-expr", + "value": "null" + } + }, + { + "selector": { + "type": "js-expr", + "value": "'#buttonAlert'" + }, + "type": "click" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/test/functional/fixtures/api/raw/navigate-to/test.js b/test/functional/fixtures/api/raw/navigate-to/test.js index 901d92c9..3b64daec 100644 --- a/test/functional/fixtures/api/raw/navigate-to/test.js +++ b/test/functional/fixtures/api/raw/navigate-to/test.js @@ -1,4 +1,4 @@ -var errorInEachBrowserContains = require('../../../../assertion-helper.js').errorInEachBrowserContains; +const errorInEachBrowserContains = require('../../../../assertion-helper.js').errorInEachBrowserContains; describe('[Raw API] Navigate to action', function () { diff --git a/test/functional/fixtures/api/raw/press-key/pages/index.html b/test/functional/fixtures/api/raw/press-key/pages/index.html index eb66f34b..4d92305e 100644 --- a/test/functional/fixtures/api/raw/press-key/pages/index.html +++ b/test/functional/fixtures/api/raw/press-key/pages/index.html @@ -7,13 +7,15 @@ diff --git a/test/functional/fixtures/api/raw/press-key/test.js b/test/functional/fixtures/api/raw/press-key/test.js index 05201208..8c29bbb8 100644 --- a/test/functional/fixtures/api/raw/press-key/test.js +++ b/test/functional/fixtures/api/raw/press-key/test.js @@ -1,5 +1,5 @@ -var expect = require('chai').expect; -var errorInEachBrowserContains = require('../../../../assertion-helper.js').errorInEachBrowserContains; +const expect = require('chai').expect; +const errorInEachBrowserContains = require('../../../../assertion-helper.js').errorInEachBrowserContains; describe('[Raw API] Press action', function () { diff --git a/test/functional/fixtures/api/raw/right-click/test.js b/test/functional/fixtures/api/raw/right-click/test.js index 25997821..0696365e 100644 --- a/test/functional/fixtures/api/raw/right-click/test.js +++ b/test/functional/fixtures/api/raw/right-click/test.js @@ -1,4 +1,4 @@ -var errorInEachBrowserContains = require('../../../../assertion-helper.js').errorInEachBrowserContains; +const errorInEachBrowserContains = require('../../../../assertion-helper.js').errorInEachBrowserContains; describe('[Raw API] Right click action', function () { diff --git a/test/functional/fixtures/api/raw/select-editable-content/pages/index.html b/test/functional/fixtures/api/raw/select-editable-content/pages/index.html index 322b82e4..2a677bdf 100644 --- a/test/functional/fixtures/api/raw/select-editable-content/pages/index.html +++ b/test/functional/fixtures/api/raw/select-editable-content/pages/index.html @@ -9,12 +9,12 @@

5

div

6

7

diff --git a/test/functional/fixtures/driver/script-execution-barrier/pages/index.html b/test/functional/fixtures/driver/script-execution-barrier/pages/index.html index c16a5220..dbdad2c6 100644 --- a/test/functional/fixtures/driver/script-execution-barrier/pages/index.html +++ b/test/functional/fixtures/driver/script-execution-barrier/pages/index.html @@ -13,7 +13,7 @@ window.loadedScripts = 0; function appendScript (delay) { - var script = document.createElement('script'); + const script = document.createElement('script'); script.src = './script.js?delay=' + delay; @@ -23,7 +23,7 @@ } document.getElementById('add-scripts').addEventListener('click', function () { - var addedScript = appendScript(500); + const addedScript = appendScript(500); addedScript.onload = function () { appendScript(250); @@ -35,12 +35,13 @@ }); document.getElementById('add-repetitive-adding-scripts').addEventListener('click', function () { - var scriptsCount = 0; - var maxScriptsCount = 10; + const maxScriptsCount = 10; + + let scriptsCount = 0; function iterate () { // HACK: we should request different URLs to avoid caching of response in IE 10 - var addedScript = appendScript(1000 + scriptsCount); + const addedScript = appendScript(1000 + scriptsCount); addedScript.onload = function () { if (++scriptsCount < maxScriptsCount) @@ -52,4 +53,4 @@ }); - \ No newline at end of file + diff --git a/test/functional/fixtures/driver/test.js b/test/functional/fixtures/driver/test.js index f5923357..331f5ae9 100644 --- a/test/functional/fixtures/driver/test.js +++ b/test/functional/fixtures/driver/test.js @@ -1,4 +1,4 @@ -var errorInEachBrowserContains = require('../../assertion-helper').errorInEachBrowserContains; +const errorInEachBrowserContains = require('../../assertion-helper').errorInEachBrowserContains; describe('TestRun - Driver protocol', function () { it('TestRun should not process the same driver status twice', function () { diff --git a/test/functional/fixtures/page-error/test.js b/test/functional/fixtures/page-error/test.js index 1ed40b96..916ff1ff 100644 --- a/test/functional/fixtures/page-error/test.js +++ b/test/functional/fixtures/page-error/test.js @@ -1,4 +1,4 @@ -var expect = require('chai').expect; +const expect = require('chai').expect; describe('Handle page error', function () { it('Should fail if the error is not caught in the test', function () { diff --git a/test/functional/fixtures/page-js-errors/test.js b/test/functional/fixtures/page-js-errors/test.js index 9e7886ac..46a53508 100644 --- a/test/functional/fixtures/page-js-errors/test.js +++ b/test/functional/fixtures/page-js-errors/test.js @@ -1,60 +1,62 @@ -var errorInEachBrowserContains = require('../../assertion-helper.js').errorInEachBrowserContains; +const { errorInEachBrowserContains } = require('../../assertion-helper.js'); +const { expect } = require('chai'); - -describe('Test should fail after js-error on the page', function () { - it('if an error is raised before test done', function () { +describe('Test should fail after js-error on the page', () => { + it('if an error is raised before test done', () => { return runTests('./testcafe-fixtures/error-on-load-test.js', 'Empty test', { shouldFail: true }) - .catch(function (errs) { + .catch(errs => { errorInEachBrowserContains(errs, 'The first error on page load', 0); + errorInEachBrowserContains(errs, 'http://localhost:3000/fixtures/page-js-errors/pages/error-on-load.html', 0); }); }); - it('if an error is raised before a command', function () { + it('if an error is raised before a command', () => { return runTests('./testcafe-fixtures/error-on-load-test.js', 'Click body', { shouldFail: true }) - .catch(function (errs) { + .catch(errs => { errorInEachBrowserContains(errs, 'The first error on page load', 0); }); }); - it('if an error is raised after a command', function () { + it('if an error is raised after a command', () => { return runTests('./testcafe-fixtures/error-after-click-test.js', 'Click button', { shouldFail: true }) - .catch(function (errs) { + .catch(errs => { errorInEachBrowserContains(errs, 'Error on click', 0); }); }); - it('if an error is raised after a command after the page reloaded', function () { + it('if an error is raised after a command after the page reloaded', () => { return runTests('./testcafe-fixtures/error-after-reload-test.js', 'Click button', { shouldFail: true }) - .catch(function (errs) { + .catch(errs => { errorInEachBrowserContains(errs, 'The first error on page load', 0); }); }); - it('if an error is raised after a command before the page reloaded', function () { + it('if an error is raised after a command before the page reloaded', () => { return runTests('./testcafe-fixtures/error-before-reload-test.js', 'Click button', { shouldFail: true }) - .catch(function (errs) { + .catch(errs => { errorInEachBrowserContains(errs, 'Error before reload', 0); }); }); - it('if unhandled promise rejection is raised', function () { + it('if unhandled promise rejection is raised', () => { return runTests('./testcafe-fixtures/unhandled-promise-rejection-test.js', 'Click button', { shouldFail: true, only: 'chrome' }) - .catch(function (errs) { - errorInEachBrowserContains(errs, 'Rejection reason', 0); + .catch(errs => { + expect(errs[0]).contains('Rejection reason'); + expect(errs[0]).contains('No stack trace available'); }); }); }); -describe('Should ignore an js-error on the page if the skipJsErrors option is set to true', function () { - it('uncaught JavaScript error', function () { +describe('Should ignore an js-error on the page if the skipJsErrors option is set to true', () => { + it('uncaught JavaScript error', () => { return runTests('./testcafe-fixtures/error-after-click-test.js', 'Click button', { skipJsErrors: true }); }); - it ('unhandled Promise rejection', function () { + it ('unhandled Promise rejection', () => { return runTests('./testcafe-fixtures/unhandled-promise-rejection-test.js', 'Click button', { skipJsErrors: true, diff --git a/test/functional/fixtures/proxy/test.js b/test/functional/fixtures/proxy/test.js index 3153349e..98dc13f6 100644 --- a/test/functional/fixtures/proxy/test.js +++ b/test/functional/fixtures/proxy/test.js @@ -1,5 +1,5 @@ -var os = require('os'); -var expect = require('chai').expect; +const os = require('os'); +const expect = require('chai').expect; const TRUSTED_PROXY_URL = os.hostname() + ':3004'; const TRANSPARENT_PROXY_URL = os.hostname() + ':3005'; diff --git a/test/functional/fixtures/regression/gh-1054/pages/index.html b/test/functional/fixtures/regression/gh-1054/pages/index.html index 5b33b203..378f0340 100644 --- a/test/functional/fixtures/regression/gh-1054/pages/index.html +++ b/test/functional/fixtures/regression/gh-1054/pages/index.html @@ -9,11 +9,11 @@ - \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-1054/testcafe-fixtures/index.test.js b/test/functional/fixtures/regression/gh-1054/testcafe-fixtures/index.test.js index 8e78ce69..bc7cf82a 100644 --- a/test/functional/fixtures/regression/gh-1054/testcafe-fixtures/index.test.js +++ b/test/functional/fixtures/regression/gh-1054/testcafe-fixtures/index.test.js @@ -21,8 +21,8 @@ test('Type text in the content editable element', async t => { .typeText('div', 'text', { replace: true }) .expect(Selector('div').textContent).eql('text'); - var userAgentStr = await getUserAgent(); - var isIE = userAgent.is(userAgentStr).ie; + const userAgentStr = await getUserAgent(); + const isIE = userAgent.is(userAgentStr).ie; if (!isIE) await t.expect(await getFirstValue()).eql('t'); diff --git a/test/functional/fixtures/regression/gh-1057/pages/hiddenByFixedParent.html b/test/functional/fixtures/regression/gh-1057/pages/hiddenByFixedParent.html index 571eece2..cad9ce83 100644 --- a/test/functional/fixtures/regression/gh-1057/pages/hiddenByFixedParent.html +++ b/test/functional/fixtures/regression/gh-1057/pages/hiddenByFixedParent.html @@ -13,8 +13,8 @@ position: absolute; left: 500px; top: 500px; - width: 20px; - height: 20px; + width: 200px; + height: 200px; background-color: red; } @@ -22,8 +22,8 @@ position: absolute; left: 2500px; top: 2500px; - width: 20px; - height: 20px; + width: 200px; + height: 200px; background-color: blue; } @@ -43,14 +43,15 @@
- \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-1267/test.js b/test/functional/fixtures/regression/gh-1267/test.js index 671aa12f..76190bd4 100644 --- a/test/functional/fixtures/regression/gh-1267/test.js +++ b/test/functional/fixtures/regression/gh-1267/test.js @@ -1,4 +1,4 @@ -var expect = require('chai').expect; +const expect = require('chai').expect; describe('[Regression](GH-1267)', function () { it('Incorrect callsite stack for failed assertion in a method of some class (GH-1267)', function () { diff --git a/test/functional/fixtures/regression/gh-1275/pages/index.html b/test/functional/fixtures/regression/gh-1275/pages/index.html index 2e4bdc33..fe69ce0e 100644 --- a/test/functional/fixtures/regression/gh-1275/pages/index.html +++ b/test/functional/fixtures/regression/gh-1275/pages/index.html @@ -10,8 +10,8 @@ - \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-1353/pages/index.html b/test/functional/fixtures/regression/gh-1353/pages/index.html index d45c9c7c..71487b15 100644 --- a/test/functional/fixtures/regression/gh-1353/pages/index.html +++ b/test/functional/fixtures/regression/gh-1353/pages/index.html @@ -29,14 +29,15 @@ // NOTE: scrolling has issues in iOS Simulator https://github.com/DevExpress/testcafe/issues/1237 diff --git a/test/functional/fixtures/regression/gh-1486/pages/index.html b/test/functional/fixtures/regression/gh-1486/pages/index.html index 4aed3b35..009e8cc4 100644 --- a/test/functional/fixtures/regression/gh-1486/pages/index.html +++ b/test/functional/fixtures/regression/gh-1486/pages/index.html @@ -7,13 +7,13 @@ - \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-1521/pages/moving-element.html b/test/functional/fixtures/regression/gh-1521/pages/moving-element.html index 4a443869..7b02ac10 100644 --- a/test/functional/fixtures/regression/gh-1521/pages/moving-element.html +++ b/test/functional/fixtures/regression/gh-1521/pages/moving-element.html @@ -28,24 +28,25 @@
- \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-1521/pages/overlap-element.html b/test/functional/fixtures/regression/gh-1521/pages/overlap-element.html index d7699e13..39e709d9 100644 --- a/test/functional/fixtures/regression/gh-1521/pages/overlap-element.html +++ b/test/functional/fixtures/regression/gh-1521/pages/overlap-element.html @@ -24,8 +24,8 @@
diff --git a/test/functional/fixtures/regression/gh-1874/testcafe-fixtures/index-test.js b/test/functional/fixtures/regression/gh-1874/testcafe-fixtures/index-test.js index d8742134..dc61c2fd 100644 --- a/test/functional/fixtures/regression/gh-1874/testcafe-fixtures/index-test.js +++ b/test/functional/fixtures/regression/gh-1874/testcafe-fixtures/index-test.js @@ -4,7 +4,7 @@ fixture `GH-1874` test('gh-1874', async t => { await t.click('h1'); - var foo = t.eval(() => window.foo); + const foo = t.eval(() => window.foo); await t.expect(foo).eql(42); }); diff --git a/test/functional/fixtures/regression/gh-1907/test.js b/test/functional/fixtures/regression/gh-1907/test.js index 9c4cb9c0..074af6a9 100644 --- a/test/functional/fixtures/regression/gh-1907/test.js +++ b/test/functional/fixtures/regression/gh-1907/test.js @@ -1,4 +1,4 @@ -var expect = require('chai').expect; +const expect = require('chai').expect; describe('[Regression](GH-1907)', function () { it('Base selector should pass the boundTestRun option to derivative selectors', function () { @@ -11,7 +11,11 @@ describe('[Regression](GH-1907)', function () { shouldFail: true }) .catch(function (errs) { - expect(errs[0]).contains('Cannot obtain information about the node because the specified selector does not match any node in the DOM tree.'); + expect(errs[0]).contains( + 'Cannot obtain information about the node because the specified selector does not match any node in the DOM tree. ' + + ' | Selector(\'#hidden\')' + + ' | .withText(\'Hidden\')' + ); expect(errs[0]).contains('> 40 | await t.expect(div.textContent).eql(\'Hidden\');'); }); }); diff --git a/test/functional/fixtures/regression/gh-1940/test.js b/test/functional/fixtures/regression/gh-1940/test.js index 173fe646..a5438b82 100644 --- a/test/functional/fixtures/regression/gh-1940/test.js +++ b/test/functional/fixtures/regression/gh-1940/test.js @@ -1,4 +1,4 @@ -var config = require('../../../config.js'); +const config = require('../../../config.js'); describe('[Regression](GH-1940)', function () { if (config.useLocalBrowsers) { diff --git a/test/functional/fixtures/regression/gh-1956/pages/index.html b/test/functional/fixtures/regression/gh-1956/pages/index.html index f903cbbd..f01766fa 100644 --- a/test/functional/fixtures/regression/gh-1956/pages/index.html +++ b/test/functional/fixtures/regression/gh-1956/pages/index.html @@ -44,11 +44,11 @@

Type to ContentEditable div when selected node was replaced on TextInput eve

B

- \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-1956/test.js b/test/functional/fixtures/regression/gh-1956/test.js index 97616caa..b3646dea 100644 --- a/test/functional/fixtures/regression/gh-1956/test.js +++ b/test/functional/fixtures/regression/gh-1956/test.js @@ -1,6 +1,6 @@ -var expect = require('chai').expect; +const expect = require('chai').expect; -var browsersWithLimitations = [ 'ie', 'firefox', 'firefox-osx' ]; +const browsersWithLimitations = [ 'ie', 'firefox', 'firefox-osx' ]; describe('Should support TextInput event[Regression](GH-1956)', function () { it('Prevent Input event on TextInput when type to input element', function () { @@ -14,7 +14,7 @@ describe('Should support TextInput event[Regression](GH-1956)', function () { 'Prevent Input event on TextInput when type to input element IE11/Firefox', { only: [ 'ie', 'firefox', 'firefox-osx' ], shouldFail: true }) .catch(function (errs) { - var errors = [ errs['ie'], errs['firefox'] ].filter(err => err); + const errors = [ errs['ie'], errs['firefox'] ].filter(err => err); errors.forEach(err => { expect(err[0]).contains('Input event has raised'); diff --git a/test/functional/fixtures/regression/gh-1994/test.js b/test/functional/fixtures/regression/gh-1994/test.js index 045c1886..da6962b6 100644 --- a/test/functional/fixtures/regression/gh-1994/test.js +++ b/test/functional/fixtures/regression/gh-1994/test.js @@ -1,4 +1,4 @@ -var expect = require('chai').expect; +const expect = require('chai').expect; describe('[Regression](GH-1994)', function () { it('Click on hidden element recreated on timeout', function () { @@ -8,7 +8,9 @@ describe('[Regression](GH-1994)', function () { it('Click on hidden element removed on timeout', function () { return runTests('./testcafe-fixtures/index.js', 'Remove invisible element and click', { shouldFail: true, selectorTimeout: 1000 }) .catch(function (errs) { - expect(errs[0]).contains('The specified selector does not match any element in the DOM tree.'); + expect(errs[0]).contains( + 'The specified selector does not match any element in the DOM tree.' + + ' > | Selector(\'#targetRemove\')'); }); }); diff --git a/test/functional/fixtures/regression/gh-2015/pages/logon.html b/test/functional/fixtures/regression/gh-2015/pages/logon.html index a045f52c..0878311a 100644 --- a/test/functional/fixtures/regression/gh-2015/pages/logon.html +++ b/test/functional/fixtures/regression/gh-2015/pages/logon.html @@ -14,13 +14,13 @@ document.location = "./logged.html"; } - var btn = document.getElementById('setAuthToken'); + let btn = document.getElementById('setAuthToken'); btn.addEventListener('click', function () { window.localStorage.loginKey = 'login-key'; }); - var btn = document.getElementById('redirectAfterLogin'); + btn = document.getElementById('redirectAfterLogin'); btn.addEventListener('click', function () { if(window.localStorage.loginKey) { @@ -29,4 +29,4 @@ }); - \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-2067/pages/index.html b/test/functional/fixtures/regression/gh-2067/pages/index.html new file mode 100644 index 00000000..35207ae4 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2067/pages/index.html @@ -0,0 +1,29 @@ + + + + Title + + + + + Windows + MacOS + Linux + Android + + 1 + 2 + 3 + + + + + + ford + bmw + mazda + honda + + + + \ No newline at end of file diff --git a/test/functional/fixtures/regression/gh-2067/test.js b/test/functional/fixtures/regression/gh-2067/test.js new file mode 100644 index 00000000..a23ebca8 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2067/test.js @@ -0,0 +1,17 @@ +describe('[Regression](GH-2067) - Radio button navigation by keyboard', function () { + it('named', function () { + return runTests('testcafe-fixtures/index.js', 'named'); + }); + + it('nonamed - chrome', function () { + return runTests('testcafe-fixtures/index.js', 'nonamed - chrome', { only: ['chrome', 'chrome-osx'] }); + }); + + it('nonamed - ie, firefox', function () { + return runTests('testcafe-fixtures/index.js', 'nonamed - ie, firefox', { skip: ['chrome', 'chrome-osx', 'android'] }); + }); + + it('Should select the checked radio button by pressing the tab key', function () { + return runTests('testcafe-fixtures/index.js', 'Should select the checked radio button by pressing the tab key'); + }); +}); diff --git a/test/functional/fixtures/regression/gh-2067/testcafe-fixtures/index.js b/test/functional/fixtures/regression/gh-2067/testcafe-fixtures/index.js new file mode 100644 index 00000000..f5a4e3b7 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2067/testcafe-fixtures/index.js @@ -0,0 +1,79 @@ +import { Selector } from 'testcafe'; + +fixture `GH-2067` + .page `http://localhost:3000/fixtures/regression/gh-2067/pages/index.html`; + +const radioWindows = Selector('#windows'); +const radioMacos = Selector('#macos'); +const radioLinux = Selector('#linux'); +const radioAndroid = Selector('#android'); + +const radioFord = Selector('#ford'); +const radioBmw = Selector('#bmw'); +const radioMazda = Selector('#mazda'); +const radioHonda = Selector('#honda'); + +async function checkRadio (t, radio, condition) { + await t.expect(radio.focused).eql(condition); + await t.expect(radio.checked).eql(condition); +} + +const radioButtonsOS = [radioMacos, radioLinux, radioAndroid, radioWindows, radioMacos]; +const radioButtonsCars = [radioBmw, radioMazda, radioHonda, radioFord, radioBmw]; + +async function testRadioButtons (t, key, radios) { + await t.click(radios[0]); + + for (let i = 0; i < radios.length; i++) { + await checkRadio(t, radios[i], true); + await t.pressKey(key); + } +} + +async function testRadioButtonsNonamed (t, key, radios) { + await t.click(radios[0]); + + for (let i = 1; i < radios.length - 1; i++) { + await checkRadio(t, radios[0], true); + await checkRadio(t, radios[i], false); + await t.pressKey(key); + } +} + +test('named', async t => { + await testRadioButtons(t, 'down', radioButtonsOS); + await testRadioButtons(t, 'right', radioButtonsOS); + await testRadioButtons(t, 'up', [...radioButtonsOS].reverse()); + await testRadioButtons(t, 'up', [...radioButtonsOS].reverse()); +}); + +test('nonamed - chrome', async t => { + await testRadioButtons(t, 'down', radioButtonsCars); + await testRadioButtons(t, 'right', radioButtonsCars); + await testRadioButtons(t, 'up', [...radioButtonsCars].reverse()); + await testRadioButtons(t, 'up', [...radioButtonsCars].reverse()); +}); + +test('nonamed - ie, firefox', async t => { + await testRadioButtonsNonamed(t, 'down', radioButtonsCars); + await testRadioButtonsNonamed(t, 'right', radioButtonsCars); + await testRadioButtonsNonamed(t, 'up', [...radioButtonsCars].reverse()); + await testRadioButtonsNonamed(t, 'up', [...radioButtonsCars].reverse()); +}); + +test('Should select the checked radio button by pressing the tab key', async t => { + await t + .click(Selector('#check1')) + .pressKey('tab') + .expect(radioWindows.focused).ok() + .expect(radioWindows.checked).notOk() + + .pressKey('right') + .expect(radioMacos.focused).ok() + .expect(radioMacos.checked).ok() + + .click(Selector('#check1')) + .pressKey('tab') + .expect(radioMacos.focused).ok() + .expect(radioMacos.checked).ok(); +}); diff --git a/test/functional/fixtures/regression/gh-2074/pages/index.html b/test/functional/fixtures/regression/gh-2074/pages/index.html new file mode 100644 index 00000000..c353009b --- /dev/null +++ b/test/functional/fixtures/regression/gh-2074/pages/index.html @@ -0,0 +1,9 @@ + + + + + gh-2074 + + + + \ No newline at end of file diff --git a/test/functional/fixtures/regression/gh-2074/test.js b/test/functional/fixtures/regression/gh-2074/test.js new file mode 100644 index 00000000..62df508e --- /dev/null +++ b/test/functional/fixtures/regression/gh-2074/test.js @@ -0,0 +1,17 @@ +const expect = require('chai').expect; + +describe('[Regression](GH-2074)', function () { + it('Should execute test located in external module', function () { + return runTests('testcafe-fixtures/index.js', null, { shouldFail: true, disableTestSyntaxValidation: true }) + .catch(errors => { + if (Array.isArray(errors)) + expect(errors[0]).contains('test is executed'); + else { + Object.values(errors).forEach(err => { + expect(err[0]).contains('test is executed'); + }); + } + }); + }); +}); + diff --git a/test/functional/fixtures/regression/gh-2074/testcafe-fixtures/index.js b/test/functional/fixtures/regression/gh-2074/testcafe-fixtures/index.js new file mode 100644 index 00000000..0be3f5b0 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2074/testcafe-fixtures/index.js @@ -0,0 +1,3 @@ +import libraryTest from './lib'; + +libraryTest(); diff --git a/test/functional/fixtures/regression/gh-2074/testcafe-fixtures/lib.js b/test/functional/fixtures/regression/gh-2074/testcafe-fixtures/lib.js new file mode 100644 index 00000000..b02b5cfb --- /dev/null +++ b/test/functional/fixtures/regression/gh-2074/testcafe-fixtures/lib.js @@ -0,0 +1,9 @@ +export default function libraryTests () { + fixture('gh-2074').page('../pages/index.html'); + + test('Do nothing', async () => { + throw new Error('test is executed'); + }); +} + + diff --git a/test/functional/fixtures/regression/gh-2080/pages/index.html b/test/functional/fixtures/regression/gh-2080/pages/index.html new file mode 100644 index 00000000..3ab223a9 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2080/pages/index.html @@ -0,0 +1,82 @@ + + + + + Title + + + +
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+ + + diff --git a/test/functional/fixtures/regression/gh-2080/test.js b/test/functional/fixtures/regression/gh-2080/test.js new file mode 100644 index 00000000..999dafe3 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2080/test.js @@ -0,0 +1,5 @@ +describe('[Regression](GH-2080)', function () { + it('Should find element with not-integer offset', function () { + return runTests('testcafe-fixtures/index.js'); + }); +}); diff --git a/test/functional/fixtures/regression/gh-2080/testcafe-fixtures/index.js b/test/functional/fixtures/regression/gh-2080/testcafe-fixtures/index.js new file mode 100644 index 00000000..66200a15 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2080/testcafe-fixtures/index.js @@ -0,0 +1,15 @@ +import { Selector } from 'testcafe'; + +fixture `GH-2080 - Should find element with not-integer offset` + .page `http://localhost:3000/fixtures/regression/gh-2080/pages/index.html`; + +const result = Selector('#result'); + +test('click', async t => { + await t + .click('#child1', { offsetX: 0, offsetY: 0 }) + .click('#child2', { offsetX: 0, offsetY: 0 }) + .click('#child3', { offsetX: 0, offsetY: 0 }) + .click('#child4', { offsetX: 0, offsetY: 0 }) + .expect(result.innerText).contains('leaf1 child1 parent leaf2 child2 parent leaf3 child3 parent leaf4 child4 parent'); +}); diff --git a/test/functional/fixtures/regression/gh-2153/pages/index.html b/test/functional/fixtures/regression/gh-2153/pages/index.html index 0af9f8f8..304407e3 100644 --- a/test/functional/fixtures/regression/gh-2153/pages/index.html +++ b/test/functional/fixtures/regression/gh-2153/pages/index.html @@ -5,10 +5,10 @@ Title - \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-2205/pages/index.html b/test/functional/fixtures/regression/gh-2205/pages/index.html index 5e6abd48..3128e055 100644 --- a/test/functional/fixtures/regression/gh-2205/pages/index.html +++ b/test/functional/fixtures/regression/gh-2205/pages/index.html @@ -86,8 +86,8 @@

Two hidden divs inside

- \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-2271/pages/index.html b/test/functional/fixtures/regression/gh-2271/pages/index.html index eedcbb2e..d0ed8171 100644 --- a/test/functional/fixtures/regression/gh-2271/pages/index.html +++ b/test/functional/fixtures/regression/gh-2271/pages/index.html @@ -11,8 +11,8 @@ log('dragleave', event.target, event.relatedTarget); } function log (eventName, target, relatedTarget) { - var logger = document.getElementById('logger'); - + const logger = document.getElementById('logger'); + logger.innerHTML += eventName + ' ' + target.id + ' ' + (relatedTarget ? relatedTarget.id : 'none') + '
'; } @@ -37,4 +37,4 @@
- \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-2282/pages/authorized.html b/test/functional/fixtures/regression/gh-2282/pages/authorized.html index 746d421c..24fb7943 100644 --- a/test/functional/fixtures/regression/gh-2282/pages/authorized.html +++ b/test/functional/fixtures/regression/gh-2282/pages/authorized.html @@ -8,7 +8,7 @@ if (!document.cookie || document.cookie === 'auth=false') location.href = './login.html'; else { - var result = document.getElementById('result'); + const result = document.getElementById('result'); result.innerHTML = 'logged' } @@ -24,4 +24,4 @@
no access
Reset cookie
- \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-2450/index.js b/test/functional/fixtures/regression/gh-2450/index.js new file mode 100644 index 00000000..d7de2c0e --- /dev/null +++ b/test/functional/fixtures/regression/gh-2450/index.js @@ -0,0 +1,5 @@ +describe('[Regression](GH-2450)', function () { + it('Should scroll to element which is hidden by fixed', function () { + return runTests('testcafe-fixtures/index.js'); + }); +}); diff --git a/test/functional/fixtures/regression/gh-2450/pages/index.html b/test/functional/fixtures/regression/gh-2450/pages/index.html new file mode 100644 index 00000000..a7eb71e4 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2450/pages/index.html @@ -0,0 +1,61 @@ + + + + + Title + + + +
+ +
+
+
+
+ + + \ No newline at end of file diff --git a/test/functional/fixtures/regression/gh-2450/testcafe-fixtures/index.js b/test/functional/fixtures/regression/gh-2450/testcafe-fixtures/index.js new file mode 100644 index 00000000..e4d103b8 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2450/testcafe-fixtures/index.js @@ -0,0 +1,33 @@ +import { Selector, ClientFunction } from 'testcafe'; + +fixture `GH-2450 - Scroll to element which is hidden by fixed` + .page `http://localhost:3000/fixtures/regression/gh-2450/pages/index.html`; + +const button1 = Selector('#button1'); +const result = Selector('#result'); + +const doScroll = ClientFunction(() => { + window.scrollTo(5000, 5000); +}); + +const changeFixed = ClientFunction(() => { + const inversed = 'inversed'; + + document.getElementById('fixedTop').className = inversed; + document.getElementById('fixedLeft').className = inversed; +}); + +test('Scroll to right bottom corner', async t => { + await doScroll(); + await t + .click(button1) + .expect(result.innerText).eql('button1'); +}); + +test('Scroll to left upper corner', async t => { + await changeFixed(); + await t + .click(button1) + .expect(result.innerText).eql('button1'); +}); + diff --git a/test/functional/fixtures/regression/gh-2546/pages/index.html b/test/functional/fixtures/regression/gh-2546/pages/index.html new file mode 100644 index 00000000..0217bc67 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2546/pages/index.html @@ -0,0 +1,9 @@ + + + + + gh-2546 + + + + \ No newline at end of file diff --git a/test/functional/fixtures/regression/gh-2546/test.js b/test/functional/fixtures/regression/gh-2546/test.js new file mode 100644 index 00000000..7becdbb8 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2546/test.js @@ -0,0 +1,85 @@ +const path = require('path'); +const expect = require('chai').expect; +const { exec } = require('child_process'); +const config = require('../../../config'); + +if (config.useLocalBrowsers) { + describe('[Regression](GH-2546)', function () { + this.timeout(60000); + + it('Should fail on uncaught promise rejection when skipUncaughtErrors is false', function () { + return runTests('./testcafe-fixtures/index.js', 'Unhandled promise rejection', { shouldFail: true }) + .catch(function (errs) { + const allErrors = []; + + if (!Array.isArray(errs)) { + const browsers = Object.keys(errs); + + browsers.forEach(browser => { + allErrors.push(errs[browser][0]); + }); + } + else + allErrors.push(errs[0]); + + expect(allErrors.length).gte(1); + + allErrors.forEach(function (err) { + expect(err).contains('Unhandled promise rejection'); + }); + }); + }); + + it('Should not fail on uncaught exception when skipUncaughtErrors is true', function () { + let unhandledRejectionRaiseCount = 0; + + const listener = err => { + unhandledRejectionRaiseCount++; + + expect(err.message).eql('reject'); + }; + + process.on('unhandledRejection', listener); + + return runTests('./testcafe-fixtures/index.js', 'Unhandled promise rejection', { skipUncaughtErrors: true }) + .then(() => { + process.removeListener('unhandledRejection', listener); + + expect(unhandledRejectionRaiseCount).gte(1); + }); + }); + + it('Should fail on uncaught exception when skipUncaughtErrors is false', function () { + const testcafePath = path.resolve('bin/testcafe'); + const testFilePath = path.resolve('test/functional/fixtures/regression/gh-2546/testcafe-fixtures/uncaughtException.js'); + const browsers = '"chrome:headless --no-sandbox"'; + const command = `node ${testcafePath} ${browsers} ${testFilePath}`; + + return new Promise(resolve => { + exec(command, (error, stdout) => { + resolve({ error, stdout }); + }); + }).then(value => { + expect(value.stdout).contains('Uncaught exception'); + expect(value.stdout).contains('unhandled'); + expect(value.error).is.not.null; + }); + }); + + it('Should not fail on uncaught promise rejection when skipUncaughtErrors is true', function () { + const testcafePath = path.resolve('bin/testcafe'); + const testFilePath = path.resolve('test/functional/fixtures/regression/gh-2546/testcafe-fixtures/uncaughtException.js'); + const browsers = '"chrome:headless --no-sandbox"'; + const args = '--skip-uncaught-errors'; + const command = `node ${testcafePath} ${browsers} ${testFilePath} ${args}`; + + return new Promise(resolve => { + exec(command, (error, stdout) => { + resolve({ error, stdout }); + }); + }).then(value => { + expect(value.error).is.null; + }); + }); + }); +} diff --git a/test/functional/fixtures/regression/gh-2546/testcafe-fixtures/index.js b/test/functional/fixtures/regression/gh-2546/testcafe-fixtures/index.js new file mode 100644 index 00000000..390b8c6b --- /dev/null +++ b/test/functional/fixtures/regression/gh-2546/testcafe-fixtures/index.js @@ -0,0 +1,12 @@ +fixture `Should fail on unhandled promise rejection` + .page `http://localhost:3000/fixtures/regression/gh-2546/pages/index.html`; + +test('Unhandled promise rejection', async t => { + await t.wait(0); + + /* eslint-disable no-new */ + new Promise((resolve, reject) => { + reject(new Error('reject')); + }); + /* eslint-enable no-new */ +}); diff --git a/test/functional/fixtures/regression/gh-2546/testcafe-fixtures/uncaughtException.js b/test/functional/fixtures/regression/gh-2546/testcafe-fixtures/uncaughtException.js new file mode 100644 index 00000000..451eeb71 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2546/testcafe-fixtures/uncaughtException.js @@ -0,0 +1,8 @@ +fixture('Fixture3') + .page `https://example.com`; + +test('test', async () => { + setTimeout(function () { + throw new Error('unhandled'); + }, 0); +}); diff --git a/test/functional/fixtures/regression/gh-2568/pages/index.html b/test/functional/fixtures/regression/gh-2568/pages/index.html new file mode 100644 index 00000000..33dd3dce --- /dev/null +++ b/test/functional/fixtures/regression/gh-2568/pages/index.html @@ -0,0 +1,22 @@ + + + + Title + + +
+ + +
loren ipsum
+
loren ipsum
+
loren bipsum
+
+
+
+
1
+
+ 2 +
3
+
+ + \ No newline at end of file diff --git a/test/functional/fixtures/regression/gh-2568/test.js b/test/functional/fixtures/regression/gh-2568/test.js new file mode 100644 index 00000000..4da7c1d8 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2568/test.js @@ -0,0 +1,191 @@ +const expect = require('chai').expect; + +function removeWhitespaces (str) { + return str.replace(/\s+|\n/g, ' ').trim(); +} + +function assertSelectorCallstack (actual, expected) { + expect(removeWhitespaces(actual)).contains(removeWhitespaces(expected)); +} + +describe('[Regression](GH-2568)', function () { + it('nested selector', function () { + return runTests('testcafe-fixtures/index.js', 'nested selector', { selectorTimeout: 100, shouldFail: true }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + The specified selector does not match any element in the DOM tree. + | Selector('div') + > | .filter('.non-existing-class') + | .filterVisible() + `); + }); + }); + + it('client function selector', function () { + return runTests('testcafe-fixtures/index.js', 'client function selector', { selectorTimeout: 100, shouldFail: true, }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + The specified selector does not match any element in the DOM tree. + > | Selector([function]) + | .filterVisible() + `); + }); + }); + + it('nested client function selector', function () { + return runTests('testcafe-fixtures/index.js', 'nested client function selector', { selectorTimeout: 100, shouldFail: true }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + The specified selector does not match any element in the DOM tree. + | Selector([function]) + | .withText('loren') + | .filter([function]) + > | .filter([function]) + | .filterVisible() + `); + }); + }); + + it('nth', function () { + return runTests('testcafe-fixtures/index.js', 'nth', { selectorTimeout: 100, shouldFail: true }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + The specified selector does not match any element in the DOM tree. + | Selector('div') + | .filter('.filtered') + | .withText('loren') + | .withExactText('loren ipsum') + | .withAttribute('attr', '3') + | .filterVisible() + > | .nth(500) + `); + }); + }); + + it('nth in collectionMode', function () { + return runTests('testcafe-fixtures/index.js', 'nth in collectionMode', { selectorTimeout: 100, shouldFail: true }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + The specified selector does not match any element in the DOM tree. + | Selector('div') + > | .nth(500) + | .filter('.filtered') + | .withText('loren') + | .withExactText('loren ipsum') + | .withAttribute('attr', '3') + | .filterVisible() + `); + }); + }); + + it('filterVisible', function () { + return runTests('testcafe-fixtures/index.js', 'filterVisible', { selectorTimeout: 100, shouldFail: true }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + The specified selector does not match any element in the DOM tree. + | Selector('div') + | .filter('.filtered') + | .withText('loren') + | .withExactText('loren ipsum') + | .withAttribute('attr', '1') + > | .filterVisible() + | .nth(0) + `); + }); + }); + + it('filterHidden', function () { + return runTests('testcafe-fixtures/index.js', 'filterHidden', { selectorTimeout: 100, shouldFail: true }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + The specified selector does not match any element in the DOM tree. + | Selector('div') + | .filter('.filtered') + | .withText('loren') + | .withExactText('loren ipsum') + | .withAttribute('attr', '3') + > | .filterHidden() + | .nth(0) + `); + }); + }); + + it('withAttribute', function () { + return runTests('testcafe-fixtures/index.js', 'withAttribute', { selectorTimeout: 100, shouldFail: true }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + The specified selector does not match any element in the DOM tree. + | Selector('div') + | .filter('.filtered') + | .withText('loren') + | .withExactText('loren ipsum') + > | .withAttribute('attr', '4') + | .filterVisible() + | .nth(0) + `); + }); + }); + + it('root', function () { + return runTests('testcafe-fixtures/index.js', 'root', { selectorTimeout: 100, shouldFail: true }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + The specified selector does not match any element in the DOM tree. + > | Selector('divf') + | .filter('.filtered') + | .withText('loren') + | .withExactText('loren ipsum') + | .withAttribute('attr', '3') + | .filterVisible() + | .nth(500) + `); + }); + }); + + it('parent', function () { + return runTests('testcafe-fixtures/index.js', 'parent', { selectorTimeout: 100, shouldFail: true }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + The specified selector does not match any element in the DOM tree. + | Selector('body') + | .find('div.parent > div') + | .nextSibling() + > | .parent('span') + | .child('p') + `); + }); + }); + + it('snapshot', function () { + return runTests('testcafe-fixtures/index.js', 'snapshot', { selectorTimeout: 100, shouldFail: true }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + Cannot obtain information about the node because the specified selector does not match any node in the DOM tree. + > | Selector('ul li') + | .filter('test') + `); + }); + }); + + it('custom DOM properties', function () { + return runTests('testcafe-fixtures/index.js', 'custom DOM properties', { selectorTimeout: 100, shouldFail: true }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + Cannot obtain information about the node because the specified selector does not match any node in the DOM tree. + > | Selector('ul li') + `); + }); + }); + + it('custom methods', function () { + return runTests('testcafe-fixtures/index.js', 'custom methods', { selectorTimeout: 100, shouldFail: true }) + .catch(function (errs) { + assertSelectorCallstack(errs[0], ` + The specified selector does not match any element in the DOM tree. + | Selector('div') + > | .customFilter('1', 2, [object Object], /regexp/, [function]) + | .withText('loren') + `); + }); + }); +}); diff --git a/test/functional/fixtures/regression/gh-2568/testcafe-fixtures/index.js b/test/functional/fixtures/regression/gh-2568/testcafe-fixtures/index.js new file mode 100644 index 00000000..36f2635e --- /dev/null +++ b/test/functional/fixtures/regression/gh-2568/testcafe-fixtures/index.js @@ -0,0 +1,140 @@ +import { Selector } from 'testcafe'; + +fixture `GH-2568` + .page `http://localhost:3000/fixtures/regression/gh-2568/pages/index.html`; + +test('nested selector', async t => { + await t.click(Selector(Selector(Selector(Selector(Selector('div'))).filter('.non-existing-class'))).filterVisible()); +}); + +test('client function selector', async t => { + await t.click(Selector(function () { + return document.querySelectorAll('b'); + }).filterVisible()); +}); + +test('nested client function selector', async t => { + await t.click(Selector(function () { + return document.querySelectorAll('div'); + }) + .withText('loren') + .filter(function () { + return true; + }) + .filter(function () { + return false; + }) + .filterVisible()); +}); + +test('nth', async t => { + const selector = Selector('div') + .filter('.filtered') + .withText('loren') + .withExactText('loren ipsum') + .withAttribute('attr', '3') + .filterVisible() + .nth(500); + + await t.click(selector); +}); + +test('nth in collectionMode', async t => { + const selector = Selector('div') + .nth(500) + .filter('.filtered') + .withText('loren') + .withExactText('loren ipsum') + .withAttribute('attr', '3') + .filterVisible(); + + await t.click(selector); +}); + +test('filterVisible', async t => { + const selector = Selector('div') + .filter('.filtered') + .withText('loren') + .withExactText('loren ipsum') + .withAttribute('attr', '1') + .filterVisible() + .nth(0); + + await t.click(selector); +}); + +test('filterHidden', async t => { + const selector = Selector('div') + .filter('.filtered') + .withText('loren') + .withExactText('loren ipsum') + .withAttribute('attr', '3') + .filterHidden() + .nth(0); + + await t.click(selector); +}); + +test('withAttribute', async t => { + const selector = Selector('div') + .filter('.filtered') + .withText('loren') + .withExactText('loren ipsum') + .withAttribute('attr', '4') + .filterVisible() + .nth(0); + + await t.click(selector); +}); + +test('root', async t => { + const selector = Selector('divf') + .filter('.filtered') + .withText('loren') + .withExactText('loren ipsum') + .withAttribute('attr', '3') + .filterVisible() + .nth(500); + + await t.click(selector); +}); + +test('parent', async t => { + const selector = Selector('body') + .find('div.parent > div') + .nextSibling() + .parent('span') + .child('p'); + + await t.click(selector); +}); + +test('drag', async t => { + const selector = Selector('div.parent').child('ul'); + + await t.dragToElement('div.parent', selector); +}); + +test('snapshot', async () => { + await Selector('ul li').filter('test').hasClass('yo'); +}); + +test('custom DOM properties', async t => { + const selector = Selector('ul li').addCustomDOMProperties({ + innerHTML: el => el.innerHTML + }); + + await t.expect(selector.innerHTML).eql('test'); +}); + +test('custom methods', async t => { + let selector = Selector('div').addCustomMethods({ + customFilter: nodes => nodes.filter(node => !!node.id) + }, { returnDOMNodes: true }); + + selector = selector + .customFilter('1', 2, { key: 'value' }, new RegExp('regexp'), () => {}) + .withText('loren'); + + await t.click(selector); +}); diff --git a/test/functional/fixtures/regression/gh-2846/pages/index.html b/test/functional/fixtures/regression/gh-2846/pages/index.html new file mode 100644 index 00000000..6e2c6c76 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2846/pages/index.html @@ -0,0 +1,9 @@ + + + + + gh-2846 + + + + \ No newline at end of file diff --git a/test/functional/fixtures/regression/gh-2846/test.js b/test/functional/fixtures/regression/gh-2846/test.js new file mode 100644 index 00000000..e94769f6 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2846/test.js @@ -0,0 +1,14 @@ +const config = require('../../../config'); +const expect = require('chai').expect; + +if (config.useHeadlessBrowsers) { + describe('[Regression](GH-2846)', function () { + it('Should add warning on t.debug', function () { + return runTests('./testcafe-fixtures/index.js') + .then(() => { + expect(testReport.warnings).eql(['You cannot debug in headless mode.']); + }); + }); + }); +} + diff --git a/test/functional/fixtures/regression/gh-2846/testcafe-fixtures/index.js b/test/functional/fixtures/regression/gh-2846/testcafe-fixtures/index.js new file mode 100644 index 00000000..77e0f374 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2846/testcafe-fixtures/index.js @@ -0,0 +1,6 @@ +fixture `GH-2846` + .page `http://localhost:3000/fixtures/regression/gh-2846/pages/index.html`; + +test(`Debug`, async t => { + await t.debug(); +}); diff --git a/test/functional/fixtures/regression/gh-2861/pages/index.html b/test/functional/fixtures/regression/gh-2861/pages/index.html new file mode 100644 index 00000000..2a6475cd --- /dev/null +++ b/test/functional/fixtures/regression/gh-2861/pages/index.html @@ -0,0 +1,34 @@ + + + + + + TestCafé + + +Just text +Wrapped in span +
+ + \ No newline at end of file diff --git a/test/functional/fixtures/regression/gh-2861/test.js b/test/functional/fixtures/regression/gh-2861/test.js new file mode 100644 index 00000000..8930f1f7 --- /dev/null +++ b/test/functional/fixtures/regression/gh-2861/test.js @@ -0,0 +1,5 @@ +describe('[Regression](GH-2861) - Should not hang on custom element click', function () { + it('Click on custom element', function () { + return runTests('testcafe-fixtures/index.js', null, { only: ['chrome'] }); + }); +}); diff --git a/test/functional/fixtures/regression/gh-2861/testcafe-fixtures/index.js b/test/functional/fixtures/regression/gh-2861/testcafe-fixtures/index.js new file mode 100644 index 00000000..1218a08f --- /dev/null +++ b/test/functional/fixtures/regression/gh-2861/testcafe-fixtures/index.js @@ -0,0 +1,18 @@ +import { Selector } from 'testcafe'; + +fixture `GH-2861` + .page `http://localhost:3000/fixtures/regression/gh-2861/pages/index.html`; + +const btn1 = Selector('#btn1'); +const btn2 = Selector('#btn2'); +const result = Selector('#result'); + +test('Should not hang on custom element click', async (t) => { + await t + .click(btn1) + .expect(result.innerText).eql('') + .click(btn1, { offsetX: 1, offsetY: 1 }) + .expect(result.innerText).eql('clicked') + .click(btn2) + .expect(result.innerText).eql('clickedclicked'); +}); diff --git a/test/functional/fixtures/regression/gh-423/pages/index.html b/test/functional/fixtures/regression/gh-423/pages/index.html index a1e078f7..14961fc0 100644 --- a/test/functional/fixtures/regression/gh-423/pages/index.html +++ b/test/functional/fixtures/regression/gh-423/pages/index.html @@ -21,85 +21,87 @@
- \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-637/test.js b/test/functional/fixtures/regression/gh-637/test.js index 782b922b..d30155ab 100644 --- a/test/functional/fixtures/regression/gh-637/test.js +++ b/test/functional/fixtures/regression/gh-637/test.js @@ -1,16 +1,16 @@ -var tmp = require('tmp'); -var path = require('path'); -var copy = require('recursive-copy'); +const tmp = require('tmp'); +const path = require('path'); +const copy = require('recursive-copy'); describe('[Regression](GH-637)', function () { it("Should let test file locate babel-runtime if it's not installed on global or test file node_modules lookup scope", function () { tmp.setGracefulCleanup(); - var tmpDir = tmp.dirSync().name; - var srcDir = path.join(__dirname, './data'); + const tmpDir = tmp.dirSync().name; + const srcDir = path.join(__dirname, './data'); return copy(srcDir, tmpDir).then(function () { - var testFile = path.join(tmpDir, './testfile.js'); + const testFile = path.join(tmpDir, './testfile.js'); return runTests(testFile, 'Some test', { only: 'chrome' }); }); diff --git a/test/functional/fixtures/regression/gh-711/testcafe-fixtures/typing-in-content-editable-test.js b/test/functional/fixtures/regression/gh-711/testcafe-fixtures/typing-in-content-editable-test.js index 41b818e1..c0faab9f 100644 --- a/test/functional/fixtures/regression/gh-711/testcafe-fixtures/typing-in-content-editable-test.js +++ b/test/functional/fixtures/regression/gh-711/testcafe-fixtures/typing-in-content-editable-test.js @@ -6,8 +6,8 @@ fixture `GH-711` .page `http://localhost:3000/fixtures/regression/gh-711/pages/index.html`; -var getBodyPlainText = ClientFunction(() => { - var plainTextRE = /\s+|\n|\r/g; +const getBodyPlainText = ClientFunction(() => { + const plainTextRE = /\s+|\n|\r/g; return document.body.textContent.replace(plainTextRE, ''); }); @@ -22,7 +22,7 @@ test('Typing in contentEditable body', async t => { .click('body') .typeText('body', 'test'); - var actualText = await getBodyPlainText(); + const actualText = await getBodyPlainText(); expect(actualText.indexOf('test') !== -1).to.be.ok; }); @@ -32,8 +32,8 @@ test('Typing in contentEditable body with not-contentEditable children', async t .selectText('body') .typeText('body', 'test'); - var actualText = await getBodyPlainText(); - var expectedText = 'div1testdiv3'; + const actualText = await getBodyPlainText(); + const expectedText = 'div1testdiv3'; expect(actualText.indexOf(expectedText) !== -1).to.be.ok; }); diff --git a/test/functional/fixtures/regression/gh-743/testcafe-fixtures/two-tests-in-a-fixture.js b/test/functional/fixtures/regression/gh-743/testcafe-fixtures/two-tests-in-a-fixture.js index 19735595..8ec91b6d 100644 --- a/test/functional/fixtures/regression/gh-743/testcafe-fixtures/two-tests-in-a-fixture.js +++ b/test/functional/fixtures/regression/gh-743/testcafe-fixtures/two-tests-in-a-fixture.js @@ -2,7 +2,7 @@ import { expect } from 'chai'; fixture `GH-743`; -var secondStarted = false; +let secondStarted = false; test('First test', async t => { await t.wait(100); diff --git a/test/functional/fixtures/regression/gh-751/pages/index.html b/test/functional/fixtures/regression/gh-751/pages/index.html index 51d5a3ab..e030469b 100644 --- a/test/functional/fixtures/regression/gh-751/pages/index.html +++ b/test/functional/fixtures/regression/gh-751/pages/index.html @@ -15,7 +15,7 @@ window.dblclickEvents.push(Date.now()); } - var dblclickBtn = document.getElementById('dblclick'); + const dblclickBtn = document.getElementById('dblclick'); dblclickBtn.addEventListener('mouseup', logDblclickPerformance); dblclickBtn.addEventListener('click', logDblclickPerformance); @@ -31,13 +31,13 @@ } function doHardWork () { - var startTime = Date.now(); + const startTime = Date.now(); while (Date.now() < startTime + window.HARD_WORK_TIME) { } } - var hardWorkMousedownBtn = document.getElementById('hardWorkMousedown'); + const hardWorkMousedownBtn = document.getElementById('hardWorkMousedown'); hardWorkMousedownBtn.addEventListener('mousedown', logClickPerformance); hardWorkMousedownBtn.addEventListener('mouseup', logClickPerformance); diff --git a/test/functional/fixtures/regression/gh-751/testcafe-fixtures/index-test.js b/test/functional/fixtures/regression/gh-751/testcafe-fixtures/index-test.js index 22a82ebc..4c995595 100644 --- a/test/functional/fixtures/regression/gh-751/testcafe-fixtures/index-test.js +++ b/test/functional/fixtures/regression/gh-751/testcafe-fixtures/index-test.js @@ -8,12 +8,13 @@ fixture `GH-751` test('Test dblclick performance', async t => { await t.doubleClick('#dblclick'); - var dblclickPerformanceLog = await ClientFunction(() => window.dblclickEvents)(); - var firstMouseupTime = null; - var firstClickTime = null; - var secondMouseupTime = null; - var secondClickTime = null; - var dblclickTime = null; + const dblclickPerformanceLog = await ClientFunction(() => window.dblclickEvents)(); + + let firstMouseupTime = null; + let firstClickTime = null; + let secondMouseupTime = null; + let secondClickTime = null; + let dblclickTime = null; [firstMouseupTime, firstClickTime, secondMouseupTime, secondClickTime, dblclickTime] = dblclickPerformanceLog; @@ -27,7 +28,7 @@ test('Test click performance with hard work', async t => { await t.click('#hardWorkMousedown'); - var [mousedownTime, mouseupTime] = await ClientFunction(() => window.clickEvents)(); + const [mousedownTime, mouseupTime] = await ClientFunction(() => window.clickEvents)(); expect(mouseupTime - mousedownTime).is.most(HARD_WORK_TIME + 30); }); diff --git a/test/functional/fixtures/regression/gh-770/pages/index.html b/test/functional/fixtures/regression/gh-770/pages/index.html index e7a38e1c..d96db06d 100644 --- a/test/functional/fixtures/regression/gh-770/pages/index.html +++ b/test/functional/fixtures/regression/gh-770/pages/index.html @@ -11,12 +11,14 @@
- \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-845/testcafe-fixtures/index-test.js b/test/functional/fixtures/regression/gh-845/testcafe-fixtures/index-test.js index 8756b6c8..7a8b13d3 100644 --- a/test/functional/fixtures/regression/gh-845/testcafe-fixtures/index-test.js +++ b/test/functional/fixtures/regression/gh-845/testcafe-fixtures/index-test.js @@ -16,7 +16,7 @@ test('Click on a download link', async t => { await t.click('#link'); - var delay = await getDelay(); + const delay = await getDelay(); expect(delay).to.be.below(MAX_UNLOADING_TIMEOUT); }); @@ -28,7 +28,7 @@ test('Click on a download link in iframe', async t => { await t.click('#link'); - var delay = await getDelay(); + const delay = await getDelay(); expect(delay).to.be.below(MAX_UNLOADING_TIMEOUT); }); diff --git a/test/functional/fixtures/regression/gh-847/pages/index.html b/test/functional/fixtures/regression/gh-847/pages/index.html index 92f91931..6ff80f4a 100644 --- a/test/functional/fixtures/regression/gh-847/pages/index.html +++ b/test/functional/fixtures/regression/gh-847/pages/index.html @@ -23,7 +23,7 @@ diff --git a/test/functional/fixtures/regression/gh-850/pages/index.html b/test/functional/fixtures/regression/gh-850/pages/index.html index 64ea4242..fe5b97c5 100644 --- a/test/functional/fixtures/regression/gh-850/pages/index.html +++ b/test/functional/fixtures/regression/gh-850/pages/index.html @@ -12,11 +12,11 @@ - \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-851/pages/index.html b/test/functional/fixtures/regression/gh-851/pages/index.html index fef4c5ea..5d8b57c4 100644 --- a/test/functional/fixtures/regression/gh-851/pages/index.html +++ b/test/functional/fixtures/regression/gh-851/pages/index.html @@ -9,69 +9,71 @@
- \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-851/testcafe-fixtures/index.test.js b/test/functional/fixtures/regression/gh-851/testcafe-fixtures/index.test.js index 3c6b874e..9ccae79d 100644 --- a/test/functional/fixtures/regression/gh-851/testcafe-fixtures/index.test.js +++ b/test/functional/fixtures/regression/gh-851/testcafe-fixtures/index.test.js @@ -7,81 +7,81 @@ fixture `GH-851` const getEventStorage = ClientFunction(() => window.eventStorage); test('Raise click for common parent', async t => { - var expectedEvents = ['body click']; + const expectedEvents = ['body click']; await t.click('#div1'); - var actualEvents = await getEventStorage(); + const actualEvents = await getEventStorage(); expect(actualEvents).deep.eql(expectedEvents); }); test('Raise click for top element', async t => { - var expectedEvents = ['body click']; + const expectedEvents = ['body click']; await t.click('#div2'); - var actualEvents = await getEventStorage(); + const actualEvents = await getEventStorage(); expect(actualEvents).deep.eql(expectedEvents); }); test('Raise dblclick for common element', async t => { - var expectedEvents = ['div click', 'body click', 'body dblclick']; + const expectedEvents = ['div click', 'body click', 'body dblclick']; await t.doubleClick('#div3'); - var actualEvents = await getEventStorage(); + const actualEvents = await getEventStorage(); expect(actualEvents).deep.eql(expectedEvents); }); test('Raise dblclick for a top element', async t => { - var expectedEvents = ['div click', 'body click', 'body dblclick']; + const expectedEvents = ['div click', 'body click', 'body dblclick']; await t.doubleClick('#div4'); - var actualEvents = await getEventStorage(); + const actualEvents = await getEventStorage(); expect(actualEvents).deep.eql(expectedEvents); }); test("Don't raise click for common parent", async t => { - var expectedEvents = []; + const expectedEvents = []; await t.click('#div1'); - var actualEvents = await getEventStorage(); + const actualEvents = await getEventStorage(); expect(actualEvents).deep.eql(expectedEvents); }); test("Don't raise click for top element", async t => { - var expectedEvents = []; + const expectedEvents = []; await t.click('#div2'); - var actualEvents = await getEventStorage(); + const actualEvents = await getEventStorage(); expect(actualEvents).deep.eql(expectedEvents); }); test("Don't raise dblclick for common element", async t => { - var expectedEvents = ['div click']; + const expectedEvents = ['div click']; await t.doubleClick('#div3'); - var actualEvents = await getEventStorage(); + const actualEvents = await getEventStorage(); expect(actualEvents).deep.eql(expectedEvents); }); test("Don't raise dblclick for a top element", async t => { - var expectedEvents = ['div click']; + const expectedEvents = ['div click']; await t.doubleClick('#div4'); - var actualEvents = await getEventStorage(); + const actualEvents = await getEventStorage(); expect(actualEvents).deep.eql(expectedEvents); }); diff --git a/test/functional/fixtures/regression/gh-883/pages/index.html b/test/functional/fixtures/regression/gh-883/pages/index.html index 405ae2cc..e00c0366 100644 --- a/test/functional/fixtures/regression/gh-883/pages/index.html +++ b/test/functional/fixtures/regression/gh-883/pages/index.html @@ -8,14 +8,15 @@
FILLER
TARGET
- \ No newline at end of file + diff --git a/test/functional/fixtures/regression/gh-889/testcafe-fixtures/index.test.js b/test/functional/fixtures/regression/gh-889/testcafe-fixtures/index.test.js index d4e68e44..03cc8192 100644 --- a/test/functional/fixtures/regression/gh-889/testcafe-fixtures/index.test.js +++ b/test/functional/fixtures/regression/gh-889/testcafe-fixtures/index.test.js @@ -5,8 +5,8 @@ import { expect } from 'chai'; fixture `GH-889` .page `http://localhost:3000/fixtures/regression/gh-889/pages/index.html`; -var getTableFocusEventCount = ClientFunction(() => window.tableFocusEventCount); -var getTableBlurEventCount = ClientFunction(() => window.tableBlurEventCount); +const getTableFocusEventCount = ClientFunction(() => window.tableFocusEventCount); +const getTableBlurEventCount = ClientFunction(() => window.tableBlurEventCount); test('Click on children of table', async t => { await t @@ -18,8 +18,8 @@ test('Click on children of table', async t => { }); test('Click on children of table (for IE)', async t => { - var getFirstTableDataFocusEventCount = ClientFunction(() => window.firstTableDataFocusEventCount); - var getSecondTableDataFocusEventCount = ClientFunction(() => window.secondTableDataFocusEventCount); + const getFirstTableDataFocusEventCount = ClientFunction(() => window.firstTableDataFocusEventCount); + const getSecondTableDataFocusEventCount = ClientFunction(() => window.secondTableDataFocusEventCount); await t .click('#td1') diff --git a/test/functional/fixtures/regression/gh-965/test.js b/test/functional/fixtures/regression/gh-965/test.js index 0bf425ef..4f3f91ca 100644 --- a/test/functional/fixtures/regression/gh-965/test.js +++ b/test/functional/fixtures/regression/gh-965/test.js @@ -1,4 +1,4 @@ -var expect = require('chai').expect; +const expect = require('chai').expect; describe('[Regression](GH-965)', function () { diff --git a/test/functional/fixtures/regression/gh-973/pages/index.html b/test/functional/fixtures/regression/gh-973/pages/index.html index f193d586..c72de539 100644 --- a/test/functional/fixtures/regression/gh-973/pages/index.html +++ b/test/functional/fixtures/regression/gh-973/pages/index.html @@ -24,14 +24,15 @@
Lower right target
- \ No newline at end of file + diff --git a/test/functional/fixtures/reporter/test.js b/test/functional/fixtures/reporter/test.js index 888010cf..69fce053 100644 --- a/test/functional/fixtures/reporter/test.js +++ b/test/functional/fixtures/reporter/test.js @@ -1,27 +1,10 @@ -var expect = require('chai').expect; +const expect = require('chai').expect; +const { createTestStream, createAsyncTestStream } = require('../../utils/stream'); -describe('Reporter', function () { +describe('Reporter', () => { it('Should support several different reporters for a test run', function () { - var data1 = ''; - var data2 = ''; - var stream1 = { - write: function (data) { - data1 += data; - }, - - end: function (data) { - data1 += data; - } - }; - var stream2 = { - write: function (data) { - data2 += data; - }, - - end: function (data) { - data2 += data; - } - }; + const stream1 = createTestStream(); + const stream2 = createTestStream(); return runTests('testcafe-fixtures/index-test.js', 'Simple test', { only: ['chrome'], @@ -36,13 +19,51 @@ describe('Reporter', function () { } ] }) - .then(function () { - expect(data1).to.contains('Chrome'); - expect(data1).to.contains('Reporter'); - expect(data1).to.contains('Simple test'); - expect(data2).to.contains('Chrome'); - expect(data2).to.contains('Reporter'); - expect(data2).to.contains('Simple test'); + .then(() => { + expect(stream1.data).to.contains('Chrome'); + expect(stream1.data).to.contains('Reporter'); + expect(stream1.data).to.contains('Simple test'); + expect(stream2.data).to.contains('Chrome'); + expect(stream2.data).to.contains('Reporter'); + expect(stream2.data).to.contains('Simple test'); + }); + }); + + it('Should wait until reporter stream is finished (GH-2502)', function () { + const stream = createAsyncTestStream(); + + const runOpts = { + only: ['chrome'], + reporters: [ + { + reporter: 'json', + outStream: stream + } + ] + }; + + return runTests('testcafe-fixtures/index-test.js', 'Simple test', runOpts) + .then(() => { + expect(stream.finalCalled).to.be.ok; + }); + }); + + it('Should wait until reporter stream failed to finish (GH-2502)', function () { + const stream = createAsyncTestStream({ shouldFail: true }); + + const runOpts = { + only: ['chrome'], + reporters: [ + { + reporter: 'json', + outStream: stream + } + ] + }; + + return runTests('testcafe-fixtures/index-test.js', 'Simple test', runOpts) + .then(() => { + expect(stream.finalCalled).to.be.ok; }); }); }); diff --git a/test/functional/fixtures/run-options/selector-timeout/test.js b/test/functional/fixtures/run-options/selector-timeout/test.js index 0ef4b74b..11d832e8 100644 --- a/test/functional/fixtures/run-options/selector-timeout/test.js +++ b/test/functional/fixtures/run-options/selector-timeout/test.js @@ -1,4 +1,4 @@ -var expect = require('chai').expect; +const expect = require('chai').expect; describe('Selector timeout', function () { it('Should pass if selector timeout exceeds time required for the element to appear', function () { diff --git a/test/functional/fixtures/run-options/speed/testcafe-fixtures/speed-test.js b/test/functional/fixtures/run-options/speed/testcafe-fixtures/speed-test.js index 6a6f8850..d0f85527 100644 --- a/test/functional/fixtures/run-options/speed/testcafe-fixtures/speed-test.js +++ b/test/functional/fixtures/run-options/speed/testcafe-fixtures/speed-test.js @@ -7,7 +7,7 @@ test('Decrease speed', async t => { // NOTE: do the first click to wait while the page is loaded await t.click('#button1'); - var startTime = Date.now(); + const startTime = Date.now(); await t.click('#button1'); @@ -20,7 +20,7 @@ test('Decrease speed in iframe', async t => { .switchToIframe('#iframe') .click('#button1'); - var startTime = Date.now(); + const startTime = Date.now(); await t.click('#button1'); @@ -31,7 +31,7 @@ test('Default speed', async t => { // NOTE: do the first click to wait while the page is loaded await t.click('#button1'); - var startTime = Date.now(); + const startTime = Date.now(); await t.click('#button1'); diff --git a/test/functional/fixtures/run-options/stop-on-first-fail/test.js b/test/functional/fixtures/run-options/stop-on-first-fail/test.js new file mode 100644 index 00000000..df1fe2ea --- /dev/null +++ b/test/functional/fixtures/run-options/stop-on-first-fail/test.js @@ -0,0 +1,50 @@ +const expect = require('chai').expect; +const fs = require('fs'); +const { createTestStream } = require('../../../utils/stream'); +const ReporterPluginHost = require('../../../../../lib/reporter/plugin-host'); + +const TEST_RUN_COUNT_FILENAME = 'testRunCount.txt'; + +const getTestRunCount = () => { + const content = fs.readFileSync(TEST_RUN_COUNT_FILENAME).toString(); + + return parseInt(content, 10); +}; + +describe('Stop test task on first failed test', () => { + afterEach(() => { + fs.unlinkSync(TEST_RUN_COUNT_FILENAME); + }); + + it('Basic', () => { + return runTests('./testcafe-fixtures/stop-on-first-fail-test.js', void 0, { + shouldFail: true, + stopOnFirstFail: true, + only: 'chrome' + }).catch(() => { + expect(getTestRunCount()).eql(2); + expect(testReport.failedCount).eql(1); + }); + }); + + it('Reporting', () => { + const stream = createTestStream(); + + return runTests('./testcafe-fixtures/stop-on-first-fail-test.js', void 0, { + shouldFail: true, + stopOnFirstFail: true, + reporters: [{ + reporter: 'spec', + outStream: stream + }] + }).catch(() => { + const pluginHost = new ReporterPluginHost({ noColors: true }); + const { ok, err } = pluginHost.symbols; + + expect(stream.data).contains(`${ok} test1`); + expect(stream.data).contains(`${err} test2`); + expect(stream.data).to.not.contains(`${ok} test3`); + expect(stream.data).contains('2/3 failed'); + }); + }); +}); diff --git a/test/functional/fixtures/run-options/stop-on-first-fail/testcafe-fixtures/stop-on-first-fail-test.js b/test/functional/fixtures/run-options/stop-on-first-fail/testcafe-fixtures/stop-on-first-fail-test.js new file mode 100644 index 00000000..a603dff0 --- /dev/null +++ b/test/functional/fixtures/run-options/stop-on-first-fail/testcafe-fixtures/stop-on-first-fail-test.js @@ -0,0 +1,25 @@ +import fs from 'fs'; + +fixture `Stop on first fail test`; + +let testRunCount = 0; + +const updateTestRunCount = () => { + testRunCount++; + + fs.writeFileSync('testRunCount.txt', testRunCount); +}; + +test('test1', async () => { + updateTestRunCount(); +}); + +test('test2', async t => { + updateTestRunCount(); + + await t.expect(false).ok(); +}); + +test('test3', async () => { + updateTestRunCount(); +}); diff --git a/test/functional/fixtures/screenshots-on-fails/pages/index.html b/test/functional/fixtures/screenshots-on-fails/pages/index.html index 30bac9fb..3985f155 100644 --- a/test/functional/fixtures/screenshots-on-fails/pages/index.html +++ b/test/functional/fixtures/screenshots-on-fails/pages/index.html @@ -6,7 +6,7 @@