diff --git a/.eslintignore b/.eslintignore index c50ce005281..45d0e83020f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,7 +7,12 @@ **/dist **/dist-test **/node_modules -**/support/fixtures +**/support/fixtures/* +!**/support/fixtures/projects +**/support/fixtures/projects/**/_fixtures/* +**/support/fixtures/projects/**/*.jsx +**/support/fixtures/projects/**/jquery.js +**/support/fixtures/projects/**/fail.js **/test/fixtures **/vendor diff --git a/.gitignore b/.gitignore index 80e31ba9813..7f8a5d66f38 100644 --- a/.gitignore +++ b/.gitignore @@ -22,19 +22,14 @@ Cached Theme Material Design.pak packages/https-proxy/ca/ # from desktop-gui +packages/desktop-gui/cypress/videos packages/desktop-gui/src/jsconfig.json -# from driver -packages/driver/test/cypress/videos - # from example packages/example/app packages/example/build packages/example/cypress -# from driver -packages/driver/test/cypress/videos - # from server packages/server/.cy packages/server/.projects diff --git a/.node-version b/.node-version index 22333f1ec56..4044f90867d 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -8.9.3 +12.0.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index ae8c20a1d0d..40b1429a422 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,4 +28,22 @@ }, ], "eslint.enable": true, + // this project does not use Prettier + // thus set all settings to disable accidentally running Prettier + "prettier.requireConfig": true, + "prettier.disableLanguages": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "json" +], +"editor.codeActionsOnSave": { + "source.fixAll.eslint": true +}, +"[coffeescript]": { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": false + } +} } diff --git a/.vscode/terminals.json b/.vscode/terminals.json index c566965a74a..6f5b1d776bf 100644 --- a/.vscode/terminals.json +++ b/.vscode/terminals.json @@ -29,7 +29,7 @@ "onlySingle": true, "execute": false, "cwd": "[cwd]/packages/server", - "command": "npm run test-e2e -- --spec name" + "command": "npm run test-e2e -- --spec [fileBasename]" }, { "name": "packages/runner watch", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3aa2a5558d0..3af4423e22d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,8 +4,8 @@ Thanks for taking the time to contribute! :smile: **Once you learn how to use Cypress, you can contribute in many ways:** -- Join the [Cypress Gitter chat](https://gitter.im/cypress-io/cypress) and answer questions. Teaching others how to use Cypress is a great way to learn more about how it works. -- Blog about Cypress. We display blogs featuring Cypress on our [Examples](https://on.cypress.io/examples) page. If you'd like your blog featured, [contact us](mailto:support@cypress.io). +- Join the [Cypress Gitter chat](https://on.cypress.io/chat) and answer questions. Teaching others how to use Cypress is a great way to learn more about how it works. +- Blog about Cypress. We display blogs featuring Cypress on our [Examples](https://on.cypress.io/examples) page. If you'd like your blog featured, [open a PR to add it to our docs](https://github.com/cypress-io/cypress-documentation/blob/develop/CONTRIBUTING.md#adding-examples). - Write some documentation or improve our existing docs. Know another language? You can help us translate them. See our [guide to contributing to our docs](https://github.com/cypress-io/cypress-documentation/blob/master/CONTRIBUTING.md). - Give a talk about Cypress. [Contact us](mailto:support@cypress.io) ahead of time and we'll send you some swag. :shirt: @@ -13,7 +13,8 @@ Thanks for taking the time to contribute! :smile: - [Report bugs](https://github.com/cypress-io/cypress/issues/new) by opening an issue. - [Request features](https://github.com/cypress-io/cypress/issues/new) by opening an issue. -- Write code for one of our core packages. [Please thoroughly read our writing code guide](#writing-code). +- [Help triage existing issue](#triaging-issues). +- Write code to address an issue. We have some issues labeled as [`first-timers-only`](https://github.com/cypress-io/cypress/labels/first-timers-only) that are good place to start. [Please thoroughly read our writing code guide](#writing-code). ## Table of Contents @@ -66,7 +67,7 @@ All contributors are expecting to abide by our [Code of Conduct](CODE_OF_CONDUCT - [Describe your problem, not your solution](#describe-problems) - [Explain how to reproduce the issue](#reproducibility). -Finally, if you are up to date, supported, have collected information about the problem, and have the best reproduction instructions you can come up with, you are ready to [open an issue](https://github.com/cypress-io/cypress/issues/new). +Finally, if you are up to date, supported, have collected information about the problem, and have the best reproduction instructions you can give, you are ready to [open an issue](https://github.com/cypress-io/cypress/issues/new). ### Update Cypress @@ -116,6 +117,8 @@ Some opened issue are questions, not bug reports or feature requests. Issues are ### Does this issue belong in this repository? +#### Other open source repos + Issues may be opened about wanting changes to our [documentation](), our [example-kitchensink app](https://github.com/cypress-io/cypress-example-kitchensink), or [another repository](https://github.com/cypress-io). In this case you should: - Thank them for their contribution. @@ -123,6 +126,15 @@ Issues may be opened about wanting changes to our [documentation](), our [exampl - If you have permission to 'Transfer the issue', do so. If not, explain that they can open an issue in our other repository and link to the repository. - Close the issue (if not already transferred). +#### Our Dashboard Service + +Issues may be opened about wanting features in our Dashboard Service. In this case you should: + +- Thank them for expressing interest in a new feature. +- Refer them to the Dashboard ProductBoard: "You can express interest and see progress for this feature on our Roadmap from our Dashboard's product board here: https://portal.productboard.com/cypress-io/1-cypress-dashboard All related work for the Dashboard features is handled in that ProductBoard and will be handled by the Dashboard team directly when you comment there." +- Close the issue +- Close the issue to comments + ### Is this already an open issue? Search [all issues](https://github.com/cypress-io/cypress/issues) for keywords from the issue to ensure there isn't already an issue open for this. GitHub has some [search tips](https://help.github.com/articles/searching-issues-and-pull-requests/) that may help you better find the relevant issue. @@ -138,7 +150,7 @@ If an issue already exists you should: When opening an issue, there is a provided [issue template](./ISSUE_TEMPLATE.md). If the opened issue does not provide enough information asked from the issue template you should: -- Explain that we require new issues follow our provided [issue template](./ISSUE_TEMPLATE.md) and that issues that are opened without this information are automatically closed per our [contributing guidelines](#fill-out-our-issue-template). +- Explain that we require new issues follow our provided [issue template](./ISSUE_TEMPLATE.md) and that issues that are opened without this information are automatically closed per our [contributing guidelines](#fill-out-our-issue-template). - Close the issue. ### Are they running the current version of Cypress? @@ -170,7 +182,7 @@ The best way to determine the validity of a bug is to recreate it yourself. Foll - Thank them for their contribution. - Explain that there is not enough information to reproduce the bug. Provide information on how you went about recreating the scenario, if you’re able. Note your OS, Browser, Cypress version and any other information. -- Link them to our contributing guideline for [opening issues](#opening-issues). +- Link them to our contributing guideline for [opening issues](#opening-issues). - Note that if no reproducible example is provided, we will unfortunately have to close the issue. - Add the `stage: needs information` label to the issue. @@ -362,9 +374,11 @@ When you edit files, you can quickly fix all changed files before you commit usi npm run lint-changed-fix ``` -When committing files, we run a Git pre-commit hook to lint the staged JS files. See the [`lint-staged` project](https://github.com/okonet/lint-staged). +When committing files, we run a Git pre-commit hook to lint the staged JS files. See the [`lint-staged` project](https://github.com/okonet/lint-staged). If this command fails, you may need to run `npm run lint-changed-fix` and commit those changes. +We **DO NOT** use Prettier to format code. You can find [.prettierignore](.prettierignore) file that ignores all files in this repository. To ensure this file is loaded, please always open _the root repository folder_ in your text editor, otherwise your code formatter might execute, reformatting lots of source files. + ### Tests For most packages there are typically unit and some integration tests. @@ -377,11 +391,22 @@ If you're curious how we manage all of these tests in CI check out our [`circle. #### Docker -Sometimes tests pass locally, but fail on CI. Our CI environment should be dockerized. In order to run the same image locally, there is script [scripts/run-docker-local.sh](scripts/run-docker-local.sh) that assumes that you have pulled the image `cypress/internal:chrome61` (see [circle.yml](circle.yml) for the current image name). +Sometimes tests pass locally, but fail in CI. Our CI environment is dockerized. In order to run the image used in CI locally: + +1. [Install Docker](https://docs.docker.com/install/) and get it running on your machine. +2. Run the following command from the root of the project: + + ```shell + npm run docker + ``` + +There is a script [scripts/run-docker-local.sh](scripts/run-docker-local.sh) that runs the cypress image (see [circle.yml](circle.yml) for the current image name). The image will start and will map the root of the repository to `/cypress` inside the image. Now you can modify the files using your favorite environment and rerun tests inside the docker environment. -**hint** sometimes building inside the image has problems with `node-sass` library. +##### Troubleshooting + +Sometimes building inside the image has problems with `node-sass` library. ```text Error: Missing binding /cypress/packages/desktop-gui/node_modules/node-sass/vendor/linux-x64-48/binding.node @@ -404,13 +429,13 @@ npm rebuild node-sass #### Docker for built binary -You can also use Docker to simulate and debug built binary. In a temp folder (for example from the folder `/tmp/test-folder/`) start a Docker image +You can also use Docker to simulate and debug the built binary. In a temporary folder (for example from the folder `/tmp/test-folder/`) start a Docker image: ```shell $ docker run -it -w /app -v $PWD:/app cypress/base:8 /bin/bash ``` -Point installation at a specific binary and NPM (if needed) and _set local cache folder_ to unzip downloaded binary into a subfolder. +Point the installation at a specific binary and npm (if needed) and _set local cache folder_ to unzip the downloaded binary into a subfolder. ```shell $ export CYPRESS_INSTALL_BINARY=https://cdn.cypress.io/beta/.../cypress.zip @@ -418,13 +443,13 @@ $ export CYPRESS_CACHE_FOLDER=./cypress-cache $ npm i https://cdn.cypress.io/beta/npm/.../cypress.tgz ``` -Note that unzipping Linux binary inside Docker container onto a mapped volume drive is slow. But once this is done you can modify application resource folder in local folder `/tmp/test-folder/node_modules/cypress/cypress-cache/3.3.0/Cypress/resources/app` to debug issues. +Note that unzipping the Linux binary inside a Docker container onto a mapped volume drive is *slow*. But once this is done you can modify the application resource folder in the local folder `/tmp/test-folder/node_modules/cypress/cypress-cache/3.3.0/Cypress/resources/app` to debug issues. ### Packages Generally when making contributions, you are typically making them to a small number of packages. Most of your local development work will be inside a single package at a time. -Each package documents how to best work with it, so simply consult the `README.md` of each package. +Each package documents how to best work with it, so consult the `README.md` of each package. They will outline development and test procedures. When in doubt just look at the `scripts` of each `package.json` file. Everything we do at Cypress is contained there. @@ -435,16 +460,33 @@ They will outline development and test procedures. When in doubt just look at th The repository is setup with two main (protected) branches. - `master` is the code already published in the last Cypress version. -- `develop` is the current latest "edge" code. This branch is set as the default branch, and all pull requests should be made against this branch. +- `develop` is the current latest "pre-release" code. This branch is set as the default branch, and all pull requests should be made against this branch. ### Pull Requests -- When opening a PR for a specific issue already open, please name the branch you are working on using the convention `issue-[issue number]`. For example, if your PR fixes Issue #803, name your branch `issue-803`. If there is not an associated open issue, **create an issue using our [Issue Template](./ISSUE_TEMPLATE.md)**. -- Please use the `address #[issue number]` or `close #[issue number]` syntax in the pull request description. This will automatically close the issue once the issue is merged. -- Add [tests](#tests)! We are a testing product afterall. 😉 +- When opening a PR for a specific issue already open, please name the branch you are working on using the convention `issue-[issue number]`. For example, if your PR fixes Issue #803, name your branch `issue-803`. If the PR is a larger issue, you can add more context like `issue-803-new-scrollable-area` If there is not an associated open issue, **create an issue using our [Issue Template](./ISSUE_TEMPLATE.md)**. +- PR's can be opened before all the work is finished. In fact we encourage this! Please write `[WIP]` in the title of your Pull Request if your PR is not ready for review - someone will review your PR as soon as the `[WIP]` is removed. +- Fill out the [Pull Request Tempalte](./PULL_REQUEST_TEMPLATE.md) completely within the body of the PR. If you feel some areas are not relevant add `N/A` as opposed to deleteing those sections. PR's will not be reviewed if this template is not filled in. - Please check the "Allow edits from maintainers" checkbox when submitting your PR. This will make it easier for the maintainers to make minor adjustments, to help with tests or any other changes we may need. ![Allow edits from maintainers checkbox](https://user-images.githubusercontent.com/1271181/31393427-b3105d44-ada9-11e7-80f2-0dac51e3919e.png) +### Pull Request Reviews + +After a PR has been opened, our `cypress-bot` will comment on the PR detailing the guidelines to be used to review Pull Requests. Please read these guidelines carefully and make any updates where you see the PR may not be meeting the quality of these guidelines. + +**Some rules about Pull Requests Reviews:** + +1. The contributor opening the pull request may not approve their own PR. +2. The PR will not be merged if some reviewers have voted "Needs changes". + +If any of the Pull Request Review guidelines can't be met, a comment will be left by the reviewer with 'Request changes'. Please make any updates as appropriate and we will rereview once those changes are addressed. + +**During a Pull Request Review, the following should be done:** + +- Run the code and use it as the end user would. Double check issue and PR description to ensure it is meeting requirements. +- Read through every line of changed code (Yes, we know this could be a LOT). +- If you don’t understand why some piece of code is required, ask for clarification! Likely the contributor had a reason and can provide the answer quicker than investigating yourself. + ### Testing This repository is exhaustively tested by [CircleCI](https://circleci.com/gh/cypress-io/cypress). Additionally we test the code by running it against various other example projects. See CI badges and links at the top of this document. @@ -455,13 +497,14 @@ To run local tests, consult the `README.md` of each package. We use [RenovateBot](https://renovatebot.com/) to automatically upgrade our dependencies. The bot uses the settings in [renovate.json](renovate.json) to maintain our [Update Dependencies](https://github.com/cypress-io/cypress/issues/3777) issue and open PRs. You can manually select a package to open a PR from our [Update Dependencies](https://github.com/cypress-io/cypress/issues/3777) issue. -Every PR for a package upgrade requires a review of the packages changes either from their changelog or their commits as well as all of the existing Cypress tests to pass. - -#### If there are test failures or breaking changes: - -- Note the breaking changes in a PR comment and note where the breaking change occured. -- Edit the PR to fix any breaking changes, if you are able. If you are not able, mark the PR review as 'changes requested' and note that there are breaking changes. +After a PR has been opened for a dependency update, our `cypress-bot` will comment on the PR detailing the guidelines to be used to review the dependency update. Please read these guidelines carefully and make any updates where you see the PR may not be meeting the quality of these guidelines. ## Deployment We will try to review and merge pull requests quickly. After merging we will try releasing a new version. If you want to know our build process or build your own Cypress binary, read [DEPLOY.md](DEPLOY.md) + +## Known problems + +### ENFILE or EMFILE + +If you get `ENFILE: file table overflow`, `ENFILE: too many open files` or any other `ENFILE` or `EMFILE` errors on Mac, that means you are doing synchronous file system operations. Cypress should **NEVER** do them. Instead we should use async file system operations and let `graceful-fs` retry them. Find the place where the synchronous `fs` operation is done from the stacktrace and make it async. diff --git a/DEPLOY.md b/DEPLOY.md index 95b68b510af..e9ee0b2511a 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -104,6 +104,7 @@ Once all test projects are reliably working with new changes, publishing can pro ### Steps to Publish a New Version +0. Make sure that if there is a new [`cypress-example-kitchensink`][https://github.com/cypress-io/cypress-example-kitchensink/releases] version, the corresponding dependency in `packages/example` has been updated to that new version. 1. Make sure that you have the correct environment variables set up before proceeding. - You'll need Cypress AWS access keys in `aws_credentials_json`, which looks like this: ```text @@ -144,15 +145,17 @@ Once all test projects are reliably working with new changes, publishing can pro ``` npm run binary-release -- --version 3.4.0 --commit` ``` -9. Tag the current commit with `v3.4.0` and push that tag up. -10. If needed, push out the updated changes to the docs manifest to `on.cypress.io`. -11. If needed, push out an updated kitchen sink. -12. Close the release in [ZenHub](https://app.zenhub.com/workspaces/test-runner-5c3ea3baeb1e75374f7b0708/reports/release). -13. Bump `version` in `package.json` from `develop` branch and then merge into `master`. -14. Using [cypress-io/release-automations][release-automations]: +9. If needed, push out any updated changes to the links manifest to [`on.cypress.io`](https://github.com/cypress-io/cypress-services/tree/develop/packages/on). +10. If needed, deploy the updated [`cypress-example-kitchensink`][cypress-example-kitchensink] to `example.cypress.io` by following [these instructions under "Deployment"](./packages/example/README.md). +11. Close the release in [ZenHub](https://app.zenhub.com/workspaces/test-runner-5c3ea3baeb1e75374f7b0708/reports/release). +12. Bump `version` in `package.json` and commit it to `develop` using a commit message like `release 3.4.0 [skip ci]` +13. Tag this commit with `v3.4.0` and push that tag up. +14. Merge `develop` into `master` and push that branch up. +15. Using [cypress-io/release-automations][release-automations]: - Publish GitHub release to [cypress-io/cypress/releases](https://github.com/cypress-io/cypress/releases) using package `set-releases` (see its README for details). - Add a comment to each GH issue that has been resolved with the new published version using package `issues-in-release` (see its README for details) Take a break, you deserve it! :sunglasses: [release-automations]: https://github.com/cypress-io/release-automations +[cypress-example-kitchensink]: https://github.com/cypress-io/cypress-example-kitchensink diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md index 00497ede687..ccb08cd62bf 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/PULL_REQUEST_TEMPLATE.md @@ -1,16 +1,43 @@ - +https://github.com/cypress-io/cypress/blob/develop/CONTRIBUTING.md +--> -- Closes + -### Pre-merge Tasks +- Closes - +### User facing changelog -- [ ] Have tests been added/updated for the changes in this PR? -- [ ] Has a PR to [`cypress-documentation`](https://github.com/cypress-io/cypress-documentation) been submitted to document any user-facing changes? -- [ ] Have the [type definitions](cli/types/index.d.ts) been updated with any user-facing API changes? -- [ ] Has the [cypress.schema.json](cli/schema/cypress.schema.json) been updated with any new configuration options? -- [ ] Has the original issue been tagged with a release in ZenHub? + + +### Additional details + + + +### How has the user experience changed? + + + +### PR Tasks + + + +- [ ] Have tests been added/updated? +- [ ] Has a PR for user-facing changes been opened in [`cypress-documentation`](https://github.com/cypress-io/cypress-documentation)? +- [ ] Have API changes been updated in the [`type definitions`](cli/types/index.d.ts)? +- [ ] Have new configuration options been added to the [`cypress.schema.json`](cli/schema/cypress.schema.json)? diff --git a/README.md b/README.md index f02051cb342..ec83b1d16db 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,10 @@ Gitter chat -
- - + + StackShare +

## What is Cypress? @@ -57,9 +57,9 @@ Please see our [Contributing Guideline](/CONTRIBUTING.md) which explains repo or ## License -[![license](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/cypress-io/cypress/blob/master/LICENSE.md) +[![license](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/cypress-io/cypress/blob/master/LICENSE) -This project is licensed under the terms of the [MIT license](/LICENSE.md). +This project is licensed under the terms of the [MIT license](/LICENSE). ## Badges diff --git a/appveyor.yml b/appveyor.yml index a0ab300d40e..10dcf01cb3c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,30 +7,29 @@ branches: # https://www.appveyor.com/docs/lang/nodejs-iojs/ environment: # use matching version of Node.js - nodejs_version: "8.9.3" + nodejs_version: "12.0.0" # encode secure variables which will NOT be used # in pull requests # https://www.appveyor.com/docs/build-configuration/#secure-variables # the variables can be encrypted at # https://ci.appveyor.com/tools/encrypt ci_json: - secure: tf3fK5S5Gh5HGUcDo3eGw7nqdcFU/4A+2s3JJovmn/eA0p9dEspjPFp7G1I9BxdUc4OoCeJ3dSSrCQ1BPIzb/bzK6aQqAZQWNcJ1sanoIiF0QUPbiW40Js3xpWylIh8qutVoaWtZz5a1ygg9sJmAYR7qB5+aQqQNA55TBKkUCydXpnDBfWuagb6d/7cblULsXasvvji3RIoxWTKd8HmaD/xxqONjPAJ3IJsiDTaWc5S9bAgV8/IYa7YZaQm5vpTTsWU5IGwkA1l9yMu7j+7BSNK9esvAYyKsx7kUV9jiVFo= + secure: uOM7Bj+6MfQA/wiUzA4MolZDlcdhIqrOWLN0LdR+Lg4olc1onF3IpWfRf+3B6Q5uT98OTnIU71OOqSRY7inGQg== # for uploading built binary to S3 bucket aws_credentials_json: - secure: ttGzd2/rW+i8H+pozcFxzZKU07B5INL8+LjD4vCOKes+tI6EaKhrLvAQ9xT7r+e1oTWbC8olZQ96ZZ8P5Ve8pIpG8oe1ITMs5f50iXaKULfwIcJOm+G8a3pkMRZOWa0wGs7/sKtRSyIpMFRfCOIl8TePBKEgeRtVzixBqSuyYLn/u2dz0z8uHeJDq/H1kJlI + secure: ttGzd2/rW+i8H+pozcFxzZKU07B5INL8+LjD4vCOKes+tI6EaKhrLvAQ9xT7r+e1p8My8f4LrqvT+i37kbRCUPY4DHmUgagj3aj0OghsT0eX/Vr/6T4v1yndB7SX7FnG07eVcGb63r9f5kT7xu7ElJ9WXh1Ok5K69W0zDPsa1RGCCYqsTi4tH2h5EAZwjY1b # CDN control - CF_DOMAIN: "cypress.io" - CF_EMAIL: - secure: +kZOcImCZVZJv/e/hQc3gvJ6xXSH88qg46cMwKn8mRo= CF_TOKEN: - secure: d8SQfJ2r6qrKDjYWoFg3AzgY7aL6hTuE5OIlRr0TXkcXkZzdmYCujfzIYcCQfpZg + secure: nVGxcWxXGvrT621HmgMf9Mwm84dqo+cKHZj94OvyyWEGIZOJJkbWHsD/l2/r4a6Y + CF_ZONEID: + secure: SrZnd4BYW9PILEEi7y9VyXuC16C+qMEDT3QuV1PZZJa47TRkjcaYpAXhAHz0j0jT # authenticate as Cypress bot when posting commit status checks GH_APP_ID: secure: oR0RVDbv6GKej4wwjkz7Zw== GH_INSTALLATION_ID: secure: tAoqu4zIgZUxOfW0u9YQgw== GH_PRIVATE_KEY: - secure: msLmlIBnkNovqrqTeCqa7ZPjETyS8Xn4JLuiRMWYK7gZBTO66pNnFaoeqwPFwH+o9fhC3NYlr9IFIeChXEtMJljqRfZbvfKbgbQGlVsFrg4GdFOKE24w7Hq/M0pjeFsffJ4gr+tFEya4Hri010Z8tD8kFeAv6S6y4rnOz20wambNNMb3C10Jrw5kHk4ED0h+KxggFFgmC8E+23bLuYqyXhgWHiDMDuFYuZTg6di/YExVNTo54bv8vakbdpAiX6VA4sy8jcu9134vygwPyEgDbJVIHYTbQQdiEcKc5V7+7ccF5dOZCDu3YQ0gG5CTcCKlMSts/nYeTnyRL1nGMK63HgzejGy7w7uUigo50tEIt0NQNpx03S8A65LjTJsPKjmIrh3KTPsxmrnFBhlDE4kdLNsQYptRe3oGtIJ/8DpyinAmbLVkq1xV66lQ9EqQjiJlKdK+X56EZYYBu9F49i9MfcZTtlK9uDHdzZAZJagZzIEJ4T+6sl2Q/gDMXwB7OO1+CAe3oUpaLNda9T4APzplzpFdSePTjtDI/5bzXD6DUs5xhuWqCDo+tZCHW7uBYdGTD3mNf1H1Su+HTHB0G6goHyljwBrKvRB5dm4jEYGiTt8ndxmVhZd/bzI9Uftd4mx0hJeselkXCFrmzTVGpFa9wbpbD6A1TVKwwBSbXlj11ON7ktAaPClvRAjCcwyWt0YglXlUC8tMXCml7mmFNZx8moqAAC8uf8ZG8+wI9ip1vjCsPs/yE/RY/yayWkjfYFWnh/LDmLV20gYnlk0YCkezM43lm1+dcNfI1DrAyu3sMh3xxnGXUZt2CdKfYCDRn8u32MnXkgdHre//6oIdlg5yVCHLwfBkO0Tc+qr2O+jlWYNK2k1UvghSS+w10iVbMi2XHIKKvnQ2+dkpTiDX7AIzCZvDG4lWreQMMZzuAkUvSM8qESBP4txHUVAYQy9bvToLPQVz1pTQaFasoczMOgwDidfO6+z45pm/hv3IHgEVVV/LD2B3pQlUNgMYYfn2F5Qj81wsGCudV0foznIwcLWA7dCW6KkGzK5+Sn0qS2FOcPbOPorJRLLNLbOO5oIS3EcnOOxgSwouXFzag0twpI7bfeqjeKqkd7ung3G36Mq6qrlTq4mAOJXfiETFhE7WCQz8ecz2LqV1dz0vcwsQeeoVCoK4RCNI2BxwvEwHrk8BLLgSFr8zwoOCAiViWVqphZ0SfIM/Jov7lNKls5ZJmRlyuJuI825wxEZTogNe9ZCUAA8R8CnfmaHfaJAMbEKbs4SXV57H5M33XKf9QWSvHoHusn4yVXcpIxftI7SQ14NaiOHBrAcuRMA6ZQe1/s0fN6aAnx96ToT4shKn0TOq84DJm2P48FjwI+avZ5FDrdZVu9nZ99MiZIw6sVE9BW/LTyZOchEAJvZGA0yTiJyCHhAspQqn8vS1RUZFy1vSfymyHuh76z0rzZMfwHZTmdDUrBFouc1bSi16/QbOq2ZrmO4B7GEPInJRSLB7MPqkv+W8F3v7h/YW9tS75ZIROwj/EYfhfsyjpgSSeDImQN4YYpG1fv5Ty7GPG5i4TXaxz0wtyElRnY22fxMUEWXboyvbxseB2QWo5RaUXcOe5X1bL3UynTZmZJGcOgp9tLv350D43t3eJ/c6i6lXDzBWiJOcHeXkwjTY1WT14qZfpZ0owYF+9Wk8tMOPWBUTwCioXLs8uckIHJzdZHPUGaIzFITlcrDMuDaknS5cTHRLWbiD/eJuvey827JtYOz0/yx3FCTZsbVTNdBAaxZcNrJE13UzTq58XKwAu+O7eQYn0/wuHuWkZj7dHdoafg6jJG3ArxrCO31a2aQvSVn5jfKzLz3Y42hBl1USAC8bQ2Dx5vl0rFyhc9YfwqO5tIRcW1Z8a+e8n91WFms1Y77fAEW4s8Q6BccvmLrsocOFhDrgTeJPlf0LGfj5lvR3EFfFOO4m97pQSmt0tjsP0aaro6Afsx67np37LztcdleedqcyvLA/LVWgWf0xCTbX81p94ri5g2pH1KQwT/ETRSv4pomBgU3EG1C19Jnld0L3N9aYVxEDfJyfITakO1LxZFNuDUdNu4eu4H67Fv0Lvm0vBMg2IQ19jB0yvqrMMcDwC5OEc+YmduVSuR7Tj6DH8+KBANFm5xQi4q1H1QKNJlrwjNlDjJST1Qd1Qam14NJ4buJpf0t6XS+RAvppPSp2LiDjON9LE3qkEQNTP+Qpdf0jTOVPTzp9L+O5W0lm/1dX0khcvsZoRghq/tHZS9PeIIxKLmwBS3sPL+G9ao2xcQp9D1bvAcQPECWswp5ndfi+fUmRgnHxKqE0UAUB+sLb4rE8QkWwijjVZDOf9BpnJ5wQO36w3ZqC1z3bKFMraDLnS/gBjaKx3HAwesSLEzWIgcyyfhpTZJMDDnKcbSk3whzs2RSYL7uu41jCMebo6x+JCTiJX7ZYvlJybbHTQCcHK+iuV7Z7T2ss4ibHTs2w94nYutiKOSIN9+LMffrG20PhS9qlJbk6c9UkeT0VKGb278QAVcgm1w1GQSy/OKXxPcvb6s/A/uhrAbLo2H5qIX0pqjlLSuynm++3u0ocDDUp9aBl0qJZQT5ONPBJsy3UCbawNFcXu8DcLOMcu/v4LCwCJQPt0fdi+luZG1VPKlHTmzE2m0VP0uf3zEGquUBYzM0YTT9MxyyBRCZIfBWdIIVMsjg8KyEGdyK2RKNRD3oANoTKa4RbxiXk6CJwzhB8d/9cmt2ra7M1aH9T4N+GhXP3tzaNxem1tWAy3l0n5StpCYQYQ5fsgw32RSHYcmwCmGobbONwauU4V1qHqtYLKsrlVpB8A9owgqE1Hq9s6LEAeH5Bu7X6+jhx9nTWGF03TZw+X8PJgJcC3Fx8gvOfhOZ6T+MeoFoSKFoJfpJmgGsw6QQPigdSegbf3a1/1/TypWWKxKPu4149g5R8lf0hTRg6deaYQ19W3S09oYIHVSF9e0XBUNDDr4DQ8QKI9wxAm6V+DhyvmlAonXGq + secure: msLmlIBnkNovqrqTeCqa7ZPjETyS8Xn4JLuiRMWYK7gZBTO66pNnFaoeqwPFwH+ooO0cDFhAOPTToLisgTLXCo4hnw38zuBuKq+ywCh5mtk5uZn4x4F8G2XyRLD/ViZm+VuD2yZzaTWF11upDqC4xbXDe32yD6OSLKhA5ms5F5ke83zEuWSLTqVVCIpVH12rVTJHl3QHaWPwZbBBE3SFN8D6uiclvI06y3pEg2bVShU8YqlwearYTRuErsYXNCUmT0SrDd2kHznlYf08edQDHpydnQvvTViZMgomvYp5wDCXFD+/FxtTMuTptJFpspirXL8w/xjYy1/JaTd/K01oUUD2Xwl/v0cS28OpdcraETyrQxQhEgTCXfg9ONbZ5mRvQlkaRROaTqDSGMmEPs4N91zarpA7RLxu7PPvxXQcbDW4GiJvH5BhVWu8lY/QBZsr8It1dhLYSzTPNIh9ey8xNaUbZ3oQhPBoreRi36B+FSPBsrZpB8Q8aa97gd+lCa8br2RfaEpzx8gA0pSK44odqcGuJe7T8MHOqYo0cUEUb2UypPPG7mWyjGip+x3Z9P/vSrZzDV+YFFvEzQAMoyRMp/456V+YL8iduryMRIadkJcB4ZVZz2hsxY5Gv6Eeh9NhwzyM64Rz5NP5fJ9Kw8E5Vm+ddEmft8Ec6dajcURoVN0i+s8t7h/e3Hzrr62UjWr0FpUx5fPBC/Tldn3+h4Rr9/HFI2RCZAI5wHOrx/aQ/HknA9UCEdqdod8ix5yAdSpTxp3aCGEoS97STXU43CjLEiQFyLaReoHOOwFp5EqaAiAqiORJaKuShWoir+OqSk7rucU7kFvIlU9GDfLuKUpxcQoDq/8fKT3lcG3Pr4MVV79BJ6EcjcsEf4ukQ3IfwMY+2RbwYWEowsQP18k4HztZpMEOuYPlSCiAPL7Cz4dcE5oybSURr9QQbSqVMoiCKZBn344KxpvH59KW90wt8CYyoeLSlPpM9s73g9My4fwbB3W9lcbw/AteRGer01VYEHY+1MyQwhqgHoXQ//op4gztFbpSLcli88v1IOopcr0Dw5NrylcjCTKuVWmQs0uIAfOr7zxqCZ8DCXG6spdipjF1jx+bxp318ZgH56pmmTOTMbj5Cmdpr3KlCFbYB4JI7lexnZmti1NcHtOglDSq+XT4092myAiarSzQLA6smB+gk68M50W492+QNuc+6LAOfev+Da4geLiErqMpuIqfA3jw4h5+9Ns6mf3JnOLZd1c/X/xvnV3JjBzSJ6f9xGMLBcMTQm/wVfkHM9tO1oZrHswDiBlE1AkQrj6kqT9Kznu/rbAUGRnWL65FoCwdMbYVEhQQvLbLvVCRGBJfB01oD2xs80jyZ2YYZFRZCl/d0lGrVVVZsq6XM7CsxR5WlpJy5JLxCQ4kliG8cjexh0GkVYJoRYneJifw8yThMlyAnMQ88iNS2p2MnYk0WZgTJOIHliIhPRFY4z6BtrxmL8SR1no1vhaQCdbE5RI/rYbk8NpOmQunkjcDwp7nTKn1d8bMTfKGUH+DzhvmqwxA5PW37P84FFSK+3ePY9+oKXcInkAaxiXUpzcZJ4KzUGEZaZCB6irU+sxs6QLDzsq05PprwVz2DGtEn1TcY8qQ6ezeMGxJMRgDvEGq2J0nEgOEZ98CJ7XiPJRlnvUjGUzBlcjnbfFH8zzl/0p189YtENhE6Fyr5bD9MAI6NpVHjLLlg3yjmQ6X95fUtiNCmSpCUveEqIQCRtHCY2E/RrulGqTWE+vCvbM6IJV3WnatPOtWZfXEntWHmS08j6aUkUDM9TodBuzG8TRhW2Kgv8b4pfoejuMa4WkvwRAUU7V+clTWG26dT9UHdk+QuOIQDUiCewWk3PmpIJI4WdcxpBWwDvIgojob7uaGzhkabFKi77RJRc5/Ulxm6yM2MX79jgJxrQprWxxkjlsQnJk186nQZQqpuwziH/ZxV82n1bmI9zCqMXgE1Yr86gvyZpk2UbWhlFdtXEPapge9Cfo/fWUBCIbVcd77Bk98E88Y5Y372YWW+D8oHZed8l+0tCeyZmoHQNCYykcf6w77C+8C+bVdJplPns96vyLgbWIr0cpqZBK4qmkAxHuKZoG0AKRw4U379lnXOsI+02TaTzGOMlFTg4ME5miCbxo/2pUnjrydyTE5evdImLzKAK50Fhy1XASaPxgLrkjhGZebwf1UD2kYg6A1NCHchQId25vSEwGRkMPWvY3a5KOmgsMmRoOUJ17uo/r57p7nLgZV9c1+YEdZxu+GmgwQDLNGpgW1cpEN6GSVpx8xhaGKeYSuqd4lh6H9U5/P8masNckrsz+EHv+w5plzx8nJ/Fx/H50OdOm1KUjo66m26aITX7EjJB/U1qtqNfiK6dt8EttJ5iRXlCbfOkj2biRYeKbXQ2Ezr+61/Mu/W/nhLqmLFDtM6K3xf2bSJnEXQFZOOXTRkKXnRDP7Y47ZgG3563fJQjSfoU4Hsw5xnegTOKlJsoEm95Rnq0esdMTA450Ki2wBOeIsOycljoApACBYLAlSe+ewxEaOjrLtnIR0LfzcKXlCRYbM31YWOCtMhMRehJbX9qWGNPTQHmjabYz7/IhLKtJuaMIpj3pfYgS/oQQ36g6ItCo7vLQAq+rgU99IUyQROOGXMUgK/8umL71oijA9dht0LmH9E7EGwih0WuLO2SndovTJODDfK9YrRTEocbo3B9S05O4fpGoQ32TK99mXjoQdlyxd/dn9Q9uDD27u/fGgUoYdt9VzAIigbRIQuRx430n33V0ZyXv90QuD4ESOLxVI1vnLj6JKAS4PGRz66rouYG6U+1syDWpf5Y6DzC/2KOfdLPwmuwjMQxuhf+6+tGeJbeotNX/eJF0LkRfyieRwEGKxIo0PaxdmVwsF7vKR6ZnOpr5BuLm/+44Rg3bQdJ4bcRW6i6dIhOyHWniLvsAPLu1NZDVN6jA13KTChhcrNnSGddjRFLekawl80E3KhG1p+KvItIZX3kzG4QjJ platform: - x64 @@ -54,6 +53,7 @@ install: - node --version - node --print process.arch - npm --version + - npm run check-next-dev-version # prints all public variables relevant to the build - print-env Platform - npm run check-node-version diff --git a/circle.yml b/circle.yml index d1824b323ac..d6c70c70b6b 100644 --- a/circle.yml +++ b/circle.yml @@ -23,7 +23,15 @@ executors: # the Docker image with Cypress dependencies and Chrome browser cy-doc: docker: - - image: cypress/browsers:node8.9.3-npm6.10.1-chrome75 + - image: cypress/browsers:node12.0.0-chrome73 + environment: + PLATFORM: linux + + # Docker image with non-root "node" user + non-root-docker-user: + docker: + - image: cypress/base:12.0.0 + user: node environment: PLATFORM: linux @@ -37,6 +45,30 @@ executors: PLATFORM: mac commands: + run-e2e-tests: + parameters: + browser: + description: browser shortname to target + type: string + chunk: + description: e2e test chunk number + type: integer + steps: + - attach_workspace: + at: ~/ + - run: + command: npm run test-e2e -- --chunk << parameters.chunk >> --browser << parameters.browser >> + working_directory: packages/server + - store_test_results: + path: /tmp/cypress + - store-npm-logs + + store-npm-logs: + description: Saves any NPM debug logs as artifacts in case there is a problem + steps: + - store_artifacts: + path: ~/.npm/_logs + # for caching node modules use project environment variable # like CACHE_VERSION=15 save-cache: @@ -217,7 +249,8 @@ jobs: # Install the root packages # Link sup packages in ./node_modules/@packages/* # Install sub packages dependencies and build all sub packages via postinstall script - - run: npm install + # try several times, because flaky NPM installs ... + - run: npm install || npm install - run: name: Top level packages command: npm ls --depth=0 || true @@ -226,6 +259,7 @@ jobs: - run: npm run all prune - save-caches + - store-npm-logs ## save entire folder as artifact for other jobs to run without reinstalling - persist_to_workspace: @@ -240,6 +274,7 @@ jobs: at: ~/ ## this will catch .only's in js/coffee as well - run: npm run lint-all + - store-npm-logs unit-tests: <<: *defaults @@ -269,6 +304,7 @@ jobs: - run: npm run all test -- --package static - store_test_results: path: /tmp/cypress + - store-npm-logs lint-types: <<: *defaults @@ -285,6 +321,7 @@ jobs: - run: command: npm run dtslint working_directory: cli + - store-npm-logs "server-unit-tests": <<: *defaults @@ -295,6 +332,7 @@ jobs: - run: npm run all test-unit -- --package server - store_test_results: path: /tmp/cypress + - store-npm-logs "server-integration-tests": <<: *defaults @@ -305,6 +343,7 @@ jobs: - run: npm run all test-integration -- --package server - store_test_results: path: /tmp/cypress + - store-npm-logs "server-performance-tests": <<: *defaults @@ -317,98 +356,123 @@ jobs: path: /tmp/cypress - store_artifacts: path: /tmp/artifacts + - store-npm-logs - "server-e2e-tests-1": + "server-e2e-tests-chrome-1": <<: *defaults steps: - - attach_workspace: - at: ~/ - - run: - command: npm run test-e2e -- --chunk 1 - working_directory: packages/server - - store_test_results: - path: /tmp/cypress + - run-e2e-tests: + browser: chrome + chunk: 1 - "server-e2e-tests-2": + "server-e2e-tests-chrome-2": <<: *defaults steps: - - attach_workspace: - at: ~/ - - run: - command: npm run test-e2e -- --chunk 2 - working_directory: packages/server - - store_test_results: - path: /tmp/cypress + - run-e2e-tests: + browser: chrome + chunk: 2 - "server-e2e-tests-3": + "server-e2e-tests-chrome-3": <<: *defaults steps: - - attach_workspace: - at: ~/ - - run: - command: npm run test-e2e -- --chunk 3 - working_directory: packages/server - - store_test_results: - path: /tmp/cypress + - run-e2e-tests: + browser: chrome + chunk: 3 - "server-e2e-tests-4": + "server-e2e-tests-chrome-4": <<: *defaults steps: - - attach_workspace: - at: ~/ - - run: - command: npm run test-e2e -- --chunk 4 - working_directory: packages/server - - store_test_results: - path: /tmp/cypress + - run-e2e-tests: + browser: chrome + chunk: 4 - "server-e2e-tests-5": + "server-e2e-tests-chrome-5": <<: *defaults steps: - - attach_workspace: - at: ~/ - - run: - command: npm run test-e2e -- --chunk 5 - working_directory: packages/server - - store_test_results: - path: /tmp/cypress + - run-e2e-tests: + browser: chrome + chunk: 5 - "server-e2e-tests-6": + "server-e2e-tests-chrome-6": <<: *defaults steps: - - attach_workspace: - at: ~/ - - run: - command: npm run test-e2e -- --chunk 6 - working_directory: packages/server - - store_test_results: - path: /tmp/cypress + - run-e2e-tests: + browser: chrome + chunk: 6 - "server-e2e-tests-7": + "server-e2e-tests-chrome-7": <<: *defaults steps: - - attach_workspace: - at: ~/ - - run: - command: npm run test-e2e -- --chunk 7 - working_directory: packages/server - - store_test_results: - path: /tmp/cypress + - run-e2e-tests: + browser: chrome + chunk: 7 - "server-e2e-tests-8": - <<: *defaults - steps: - - attach_workspace: - at: ~/ - - run: - command: npm run test-e2e -- --chunk 8 - working_directory: packages/server - - store_test_results: - path: /tmp/cypress + "server-e2e-tests-chrome-8": + <<: *defaults + steps: + - run-e2e-tests: + browser: chrome + chunk: 8 + + "server-e2e-tests-electron-1": + <<: *defaults + steps: + - run-e2e-tests: + browser: electron + chunk: 1 + + "server-e2e-tests-electron-2": + <<: *defaults + steps: + - run-e2e-tests: + browser: electron + chunk: 2 + + "server-e2e-tests-electron-3": + <<: *defaults + steps: + - run-e2e-tests: + browser: electron + chunk: 3 + + "server-e2e-tests-electron-4": + <<: *defaults + steps: + - run-e2e-tests: + browser: electron + chunk: 4 + + "server-e2e-tests-electron-5": + <<: *defaults + steps: + - run-e2e-tests: + browser: electron + chunk: 5 + + "server-e2e-tests-electron-6": + <<: *defaults + steps: + - run-e2e-tests: + browser: electron + chunk: 6 + + "server-e2e-tests-electron-7": + <<: *defaults + steps: + - run-e2e-tests: + browser: electron + chunk: 7 - "driver-integration-tests-3x": + "server-e2e-tests-electron-8": <<: *defaults - parallelism: 3 + steps: + - run-e2e-tests: + browser: electron + chunk: 8 + + "driver-integration-tests-chrome": + <<: *defaults + parallelism: 5 steps: - attach_workspace: at: ~/ @@ -423,12 +487,38 @@ jobs: command: | CYPRESS_KONFIG_ENV=production \ CYPRESS_RECORD_KEY=$PACKAGES_RECORD_KEY \ - npm run cypress:run -- --record --parallel --group 3x-driver-chrome --browser chrome + npm run cypress:run -- --record --parallel --group 5x-driver-chrome --browser chrome working_directory: packages/driver - store_test_results: path: /tmp/cypress - store_artifacts: path: /tmp/artifacts + - store-npm-logs + + # "driver-integration-tests-electron": + # <<: *defaults + # parallelism: 5 + # steps: + # - attach_workspace: + # at: ~/ + # - run: + # command: npm start + # background: true + # working_directory: packages/driver + # - run: + # command: $(npm bin)/wait-on http://localhost:3500 + # working_directory: packages/driver + # - run: + # command: | + # CYPRESS_KONFIG_ENV=production \ + # CYPRESS_RECORD_KEY=$PACKAGES_RECORD_KEY \ + # npm run cypress:run -- --record --parallel --group 5x-driver-electron --browser electron + # working_directory: packages/driver + # - store_test_results: + # path: /tmp/cypress + # - store_artifacts: + # path: /tmp/artifacts + # - store-npm-logs "desktop-gui-integration-tests-2x": <<: *defaults @@ -449,6 +539,7 @@ jobs: path: /tmp/cypress - store_artifacts: path: /tmp/artifacts + - store-npm-logs "reporter-integration-tests": <<: *defaults @@ -468,6 +559,7 @@ jobs: path: /tmp/cypress - store_artifacts: path: /tmp/artifacts + - store-npm-logs "run-launcher": <<: *defaults @@ -515,9 +607,25 @@ jobs: - run: environment: DEBUG: electron-builder,electron-osx-sign* - command: npm run binary-build -- --platform $PLATFORM --version $NEXT_DEV_VERSION + # if this is a forked pull request, the NEXT_DEV_VERSION environment variable + # won't be set and we will use default version, since we are not going to + # upload the dev binary build anywhere + command: npm run binary-build -- --platform $PLATFORM --version ${NEXT_DEV_VERSION:-0.0.0-development} - run: npm run binary-zip -- --platform $PLATFORM + # Cypress binary file should be zipped to cypress.zip - run: ls -l *.zip + - store-npm-logs + - persist_to_workspace: + root: ~/ + paths: + - cypress/cypress.zip + + upload-binary: + <<: *defaults + steps: + - attach_workspace: + at: ~/ + - run: ls -l - run: name: upload unique binary command: | @@ -525,15 +633,37 @@ jobs: --file cypress.zip \ --version $NEXT_DEV_VERSION - run: cat binary-url.json - - run: mkdir /tmp/urls - - run: cp binary-url.json /tmp/urls - - run: cp cypress.zip /tmp/urls - - run: ls /tmp/urls + - store-npm-logs - persist_to_workspace: - root: /tmp/urls + root: ~/ paths: - - binary-url.json - - cypress.zip + - cypress/binary-url.json + + test-kitchensink: + <<: *defaults + steps: + - attach_workspace: + at: ~/ + - run: + name: Cloning test project + command: git clone https://github.com/cypress-io/cypress-example-kitchensink.git /tmp/repo + - run: + name: Install prod dependencies + command: npm install --production + working_directory: /tmp/repo + - run: + name: Example server + command: npm start + working_directory: /tmp/repo + background: true + - run: + name: Run Kitchensink example project + command: npm run cypress:run -- --project /tmp/repo + - store_artifacts: + path: /tmp/repo/cypress/screenshots + - store_artifacts: + path: /tmp/repo/cypress/videos + - store-npm-logs "test-kitchensink-against-staging": <<: *defaults @@ -560,6 +690,7 @@ jobs: CYPRESS_ENV=staging \ CYPRESS_video=false \ npm run cypress:run -- --project /tmp/repo --record + - store-npm-logs "test-against-staging": <<: *defaults @@ -576,15 +707,17 @@ jobs: CYPRESS_RECORD_KEY=$TEST_TINY_RECORD_KEY \ CYPRESS_ENV=staging \ npm run cypress:run -- --project /tmp/repo --record + - store-npm-logs - "build-npm-package": + build-npm-package: <<: *defaults steps: - attach_workspace: at: ~/ + - run: npm run check-next-dev-version - run: name: bump NPM version - command: npm --no-git-tag-version version $NEXT_DEV_VERSION + command: npm --no-git-tag-version --allow-same-version version ${NEXT_DEV_VERSION:-0.0.0-development} - run: name: build NPM package working_directory: cli @@ -605,32 +738,50 @@ jobs: working_directory: cli/build command: ls -l # created file should have filename cypress-.tgz + - run: mkdir /tmp/urls + - run: cp cli/build/cypress-${NEXT_DEV_VERSION:-0.0.0-development}.tgz cypress.tgz + - run: cp cli/build/cypress-${NEXT_DEV_VERSION:-0.0.0-development}.tgz /tmp/urls/cypress.tgz + - run: ls -l /tmp/urls + - store-npm-logs + - run: pwd + - run: ls -l + - persist_to_workspace: + root: ~/ + paths: + - cypress/cypress.tgz + + upload-npm-package: + <<: *defaults + steps: + - attach_workspace: + at: ~/ + - run: ls -l + # NPM package file should have filename cypress-.tgz - run: name: upload NPM package command: | node scripts/binary.js upload-npm-package \ - --file cli/build/cypress-$NEXT_DEV_VERSION.tgz \ + --file cypress.tgz \ --version $NEXT_DEV_VERSION + - store-npm-logs + - run: ls -l - run: cat npm-package-url.json - - run: mkdir /tmp/urls - - run: cp cli/build/cypress-$NEXT_DEV_VERSION.tgz /tmp/urls/cypress.tgz - - run: cp npm-package-url.json /tmp/urls - - run: ls /tmp/urls - persist_to_workspace: - root: /tmp/urls + root: ~/ paths: - - npm-package-url.json - - cypress.tgz + - cypress/npm-package-url.json "test-binary-and-npm-against-other-projects": <<: *defaults steps: + # needs uploaded NPM and test binary - attach_workspace: at: ~/ - - attach_workspace: - at: /tmp/urls - - run: ls -la /tmp/urls - - run: cat /tmp/urls/*.json + - run: ls -la + # make sure JSON files with uploaded urls are present + - run: ls -la binary-url.json npm-package-url.json + - run: cat binary-url.json + - run: cat npm-package-url.json - run: mkdir /tmp/testing - run: name: create dummy package @@ -641,8 +792,8 @@ jobs: name: Install Cypress command: | node scripts/test-unique-npm-and-binary.js \ - --npm /tmp/urls/npm-package-url.json \ - --binary /tmp/urls/binary-url.json \ + --npm npm-package-url.json \ + --binary binary-url.json \ --cwd /tmp/testing - run: name: Verify Cypress binary @@ -652,29 +803,25 @@ jobs: name: Post pre-release install comment command: | node scripts/add-install-comment.js \ - --npm /tmp/urls/npm-package-url.json \ - --binary /tmp/urls/binary-url.json + --npm npm-package-url.json \ + --binary binary-url.json - run: name: Running other test projects with new NPM package and binary command: | node scripts/test-other-projects.js \ - --npm /tmp/urls/npm-package-url.json \ - --binary /tmp/urls/binary-url.json \ + --npm npm-package-url.json \ + --binary binary-url.json \ --provider circle + - store-npm-logs "test-npm-module-and-verify-binary": <<: *defaults steps: - attach_workspace: at: ~/ - - attach_workspace: - at: /tmp/urls # make sure we have cypress.zip received - - run: ls -l /tmp/urls/cypress.zip - # build NPM package - - run: - command: npm run build - working_directory: cli + - run: ls -l + - run: ls -l cypress.zip cypress.tgz - run: mkdir test-binary - run: name: Create new NPM package @@ -685,11 +832,12 @@ jobs: name: Install Cypress working_directory: test-binary # force installing the freshly built binary - command: CYPRESS_INSTALL_BINARY=/tmp/urls/cypress.zip npm i /tmp/urls/cypress.tgz + command: CYPRESS_INSTALL_BINARY=/root/cypress/cypress.zip npm i /root/cypress/cypress.tgz - run: name: Verify Cypress binary working_directory: test-binary command: $(npm bin)/cypress verify + - store-npm-logs # install NPM + binary zip and run against staging API "test-binary-against-staging": @@ -697,12 +845,9 @@ jobs: steps: - attach_workspace: at: ~/ - - attach_workspace: - at: /tmp/urls - # make sure we have the binary - - run: ls -l /tmp/urls/cypress.zip - # make sure we have the NPM package - - run: ls -l /tmp/urls/cypress.tgz + - run: ls -l + # make sure we have the binary and NPM package + - run: ls -l cypress.zip cypress.tgz - run: name: Cloning test project command: git clone https://github.com/cypress-io/cypress-test-tiny.git /tmp/cypress-test-tiny @@ -710,7 +855,7 @@ jobs: name: Install Cypress working_directory: /tmp/cypress-test-tiny # force installing the freshly built binary - command: CYPRESS_INSTALL_BINARY=/tmp/urls/cypress.zip npm i /tmp/urls/cypress.tgz + command: CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm i ~/cypress/cypress.tgz - run: name: Run test project working_directory: /tmp/cypress-test-tiny @@ -719,14 +864,16 @@ jobs: CYPRESS_RECORD_KEY=$TEST_TINY_RECORD_KEY \ CYPRESS_ENV=staging \ $(npm bin)/cypress run --record + - store-npm-logs "test-binary-against-kitchensink": <<: *defaults steps: - attach_workspace: at: ~/ - - attach_workspace: - at: /tmp/urls + # make sure the binary and NPM package files are present + - run: ls -l + - run: ls -l cypress.zip cypress.tgz - run: name: Cloning kitchensink project command: git clone --depth 1 https://github.com/cypress-io/cypress-example-kitchensink.git /tmp/kitchensink @@ -737,7 +884,7 @@ jobs: name: Install Cypress working_directory: /tmp/kitchensink # force installing the freshly built binary - command: CYPRESS_INSTALL_BINARY=/tmp/urls/cypress.zip npm i /tmp/urls/cypress.tgz + command: CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm i ~/cypress/cypress.tgz - run: working_directory: /tmp/kitchensink command: npm run build @@ -748,6 +895,47 @@ jobs: - run: working_directory: /tmp/kitchensink command: npm run e2e + - store-npm-logs + + test-binary-as-specific-user: + <<: *defaults + steps: + - attach_workspace: + at: ~/ + # the user should be "node" + - run: whoami + - run: pwd + # prints the current user's effective user id + # for root it is 0 + # for other users it is a positive integer + - run: node -e 'console.log(process.geteuid())' + # make sure the binary and NPM package files are present + - run: ls -l + - run: ls -l cypress.zip cypress.tgz + - run: mkdir test-binary + - run: + name: Create new NPM package + working_directory: test-binary + command: npm init -y + - run: + # install NPM from built NPM package folder + name: Install Cypress + working_directory: test-binary + # force installing the freshly built binary + command: CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm i ~/cypress/cypress.tgz + - run: + name: Add Cypress demo + working_directory: test-binary + command: npx @bahmutov/cly init + - run: + name: Verify Cypress binary + working_directory: test-binary + command: DEBUG=cypress:cli $(npm bin)/cypress verify + - run: + name: Run Cypress binary + working_directory: test-binary + command: DEBUG=cypress:cli $(npm bin)/cypress run + - store-npm-logs linux-workflow: &linux-workflow jobs: @@ -772,33 +960,61 @@ linux-workflow: &linux-workflow - server-performance-tests: requires: - build - - server-e2e-tests-1: + - server-e2e-tests-chrome-1: + requires: + - build + - server-e2e-tests-chrome-2: + requires: + - build + - server-e2e-tests-chrome-3: + requires: + - build + - server-e2e-tests-chrome-4: + requires: + - build + - server-e2e-tests-chrome-5: + requires: + - build + - server-e2e-tests-chrome-6: + requires: + - build + - server-e2e-tests-chrome-7: + requires: + - build + - server-e2e-tests-chrome-8: + requires: + - build + - server-e2e-tests-electron-1: requires: - build - - server-e2e-tests-2: + - server-e2e-tests-electron-2: requires: - build - - server-e2e-tests-3: + - server-e2e-tests-electron-3: requires: - build - - server-e2e-tests-4: + - server-e2e-tests-electron-4: requires: - build - - server-e2e-tests-5: + - server-e2e-tests-electron-5: requires: - build - - server-e2e-tests-6: + - server-e2e-tests-electron-6: requires: - build - - server-e2e-tests-7: + - server-e2e-tests-electron-7: requires: - build - - server-e2e-tests-8: + - server-e2e-tests-electron-8: requires: - build - - driver-integration-tests-3x: + - driver-integration-tests-chrome: requires: - build + ## TODO: add these back in when flaky tests are fixed + # - driver-integration-tests-electron: + # requires: + # - build - desktop-gui-integration-tests-2x: requires: - build @@ -811,13 +1027,18 @@ linux-workflow: &linux-workflow # various testing scenarios, like building full binary # and testing it on a real project - test-against-staging: + context: test-runner:record-tests filters: branches: only: - develop requires: - build + - test-kitchensink: + requires: + - build - test-kitchensink-against-staging: + context: test-runner:record-tests filters: branches: only: @@ -825,27 +1046,36 @@ linux-workflow: &linux-workflow requires: - build - build-npm-package: + requires: + - build + - upload-npm-package: + context: test-runner:upload filters: branches: only: - develop requires: - - build + - build-npm-package - build-binary: + requires: + - build + - upload-binary: + context: test-runner:upload filters: branches: only: - develop requires: - - build + - build-binary - test-binary-and-npm-against-other-projects: + context: test-runner:trigger-test-jobs filters: branches: only: - develop requires: - - build-npm-package - - build-binary + - upload-npm-package + - upload-binary - test-npm-module-and-verify-binary: filters: branches: @@ -855,6 +1085,7 @@ linux-workflow: &linux-workflow - build-npm-package - build-binary - test-binary-against-staging: + context: test-runner:record-tests filters: branches: only: @@ -870,41 +1101,90 @@ linux-workflow: &linux-workflow requires: - build-npm-package - build-binary + - test-binary-as-specific-user: + name: "test binary as a non-root user" + executor: non-root-docker-user + requires: + - build-npm-package + - build-binary + - test-binary-as-specific-user: + name: "test binary as a root user" + requires: + - build-npm-package + - build-binary mac-workflow: &mac-workflow jobs: - build: name: Mac build executor: mac + filters: + branches: + only: + - develop + - lint: name: Mac lint executor: mac requires: - Mac build + filters: + branches: + only: + - develop # maybe run unit tests? - build-npm-package: name: Mac NPM package executor: mac + requires: + - Mac build + filters: + branches: + only: + - develop + + - upload-npm-package: + name: Mac NPM package upload + context: test-runner:upload + executor: mac filters: branches: only: - develop - - test-example-repos-on-mac-4526 requires: - - Mac build + - Mac NPM package - build-binary: name: Mac binary - executor: mac context: org-global + executor: mac + filters: + branches: + only: + - develop + requires: + - Mac build + + - upload-binary: + name: Mac binary upload + executor: mac + context: test-runner:upload + filters: + branches: + only: + - develop + requires: + - Mac binary + + - test-kitchensink: + name: Test Mac Kitchensink + executor: mac filters: branches: only: - develop - - binary-metadata - - test-example-repos-on-mac-4526 requires: - Mac build @@ -920,6 +1200,7 @@ mac-workflow: &mac-workflow - Mac binary - test-binary-against-staging: + context: test-runner:record-tests name: Test Mac binary against staging executor: mac filters: @@ -931,16 +1212,16 @@ mac-workflow: &mac-workflow - Mac binary - test-binary-and-npm-against-other-projects: + context: test-runner:trigger-test-jobs name: Test Mac binary against other projects executor: mac filters: branches: only: - develop - - test-example-repos-on-mac-4526 requires: - - Mac NPM package - - Mac binary + - Mac NPM package upload + - Mac binary upload workflows: linux: diff --git a/cli/README.md b/cli/README.md index d1c8809bf53..36b756ec806 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,6 +1,6 @@ # CLI -The CLI is used to build Cypress npm module to be run within a terminal. +The CLI is used to build the [cypress npm module](https://www.npmjs.com/package/cypress) to be run within a terminal. **The CLI has the following responsibilities:** @@ -8,9 +8,10 @@ The CLI is used to build Cypress npm module to be run within a terminal. - Allow users to install the Cypress executable - Allow users to print their current Cypress version - Allow users to run Cypress tests from the terminal -- Allow users to open Cypress in the interactive GUI. -- Allow users to verifies that Cypress is installed correctly and executable +- Allow users to open Cypress in the interactive Test Runner. +- Allow users to verify that Cypress is installed correctly and executable - Allow users to manages the Cypress binary cache +- Allow users to pass in options that change way tests are ran or recorded (browsers used, specfiles ran, grouping, parallelization) ## Installing @@ -23,7 +24,7 @@ npm install ## Building -See `scripts/build.js`. Note that the built NPM package will include [NPM_README.md](NPM_README.md) as its public README file. +See `scripts/build.js`. Note that the built npm package will include [NPM_README.md](NPM_README.md) as its public README file. ## Testing diff --git a/cli/__snapshots__/cli_spec.js b/cli/__snapshots__/cli_spec.js index d471f77b5da..00513e25bbf 100644 --- a/cli/__snapshots__/cli_spec.js +++ b/cli/__snapshots__/cli_spec.js @@ -293,15 +293,16 @@ exports['shows help for open --foo 1'] = ` Options: - -p, --port runs Cypress on a specific port. overrides any value in cypress.json. - -e, --env sets environment variables. separate multiple values with a comma. overrides any value in cypress.json or cypress.env.json - -c, --config sets configuration values. separate multiple values with a comma. overrides any value in cypress.json. - -d, --detached [bool] runs Cypress application in detached mode - -b, --browser path to a custom browser to be added to the list of available browsers in Cypress - -P, --project path to the project - --global force Cypress into global mode as if its globally installed - --dev runs cypress in development and bypasses binary check - -h, --help output usage information + -p, --port runs Cypress on a specific port. overrides any value in the configuration file. + -e, --env sets environment variables. separate multiple values with a comma. overrides any value in the configuration file or cypress.env.json + -c, --config sets configuration values. separate multiple values with a comma. overrides any value in the configuration file. + -C, --config-file path to JSON file where configuration values are set. defaults to "cypress.json". pass "false" to disable. + -d, --detached [bool] runs Cypress application in detached mode + -b, --browser path to a custom browser to be added to the list of available browsers in Cypress + -P, --project path to the project + --global force Cypress into global mode as if its globally installed + --dev runs cypress in development and bypasses binary check + -h, --help output usage information ------- stderr: ------- @@ -336,9 +337,10 @@ exports['shows help for run --foo 1'] = ` -s, --spec runs a specific spec file. defaults to "all" -r, --reporter runs a specific mocha reporter. pass a path to use a custom reporter. defaults to "spec" -o, --reporter-options options for the mocha reporter. defaults to "null" - -p, --port runs Cypress on a specific port. overrides any value in cypress.json. - -e, --env sets environment variables. separate multiple values with a comma. overrides any value in cypress.json or cypress.env.json - -c, --config sets configuration values. separate multiple values with a comma. overrides any value in cypress.json. + -p, --port runs Cypress on a specific port. overrides any value in the configuration file. + -e, --env sets environment variables. separate multiple values with a comma. overrides any value in the configuration file or cypress.env.json + -c, --config sets configuration values. separate multiple values with a comma. overrides any value in the configuration file. + -C, --config-file path to JSON file where configuration values are set. defaults to "cypress.json". pass "false" to disable. -b, --browser runs Cypress in the browser with the given name. if a filesystem path is supplied, Cypress will attempt to use the browser at that path. -P, --project path to the project --parallel enables concurrent runs and automatic load balancing of specs across multiple machines or processes @@ -354,3 +356,32 @@ exports['shows help for run --foo 1'] = ` ------- ` + +exports['cli CYPRESS_ENV allows staging environment 1'] = ` + code: 0 + stderr: + ------- + + ------- + +` + +exports['cli CYPRESS_ENV catches environment "foo" 1'] = ` + code: 11 + stderr: + ------- + The environment variable with the reserved name "CYPRESS_ENV" is set. + + Unset the "CYPRESS_ENV" environment variable and run Cypress again. + + ---------- + + CYPRESS_ENV=foo + + ---------- + + Platform: xxx + Cypress Version: 1.2.3 + ------- + +` diff --git a/cli/__snapshots__/errors_spec.js b/cli/__snapshots__/errors_spec.js index f8333d37acb..44b1a631d78 100644 --- a/cli/__snapshots__/errors_spec.js +++ b/cli/__snapshots__/errors_spec.js @@ -32,6 +32,7 @@ exports['errors individual has the following errors 1'] = [ "failedDownload", "failedUnzip", "invalidCacheDirectory", + "invalidCypressEnv", "invalidSmokeTestDisplayError", "missingApp", "missingDependency", diff --git a/cli/__snapshots__/run_spec.js b/cli/__snapshots__/run_spec.js index b83f3addbe4..01ee3023592 100644 --- a/cli/__snapshots__/run_spec.js +++ b/cli/__snapshots__/run_spec.js @@ -20,3 +20,10 @@ exports['exec run .processRunOptions passes --record option 1'] = [ "--record", "my record id" ] + +exports['exec run .processRunOptions passes --config-file false option 1'] = [ + "--run-project", + null, + "--config-file", + false +] diff --git a/cli/__snapshots__/verify_spec.js b/cli/__snapshots__/verify_spec.js index 9b7e43f9257..32089782d5b 100644 --- a/cli/__snapshots__/verify_spec.js +++ b/cli/__snapshots__/verify_spec.js @@ -159,7 +159,7 @@ Error: Cypress verification timed out. This command failed with the following output: -/cache/Cypress/1.2.3/Cypress.app/Contents/MacOS/Cypress --smoke-test --ping=222 +/cache/Cypress/1.2.3/Cypress.app/Contents/MacOS/Cypress --no-sandbox --smoke-test --ping=222 ---------- @@ -181,7 +181,7 @@ Error: Cypress verification failed. This command failed with the following output: -/cache/Cypress/1.2.3/Cypress.app/Contents/MacOS/Cypress --smoke-test --ping=222 +/cache/Cypress/1.2.3/Cypress.app/Contents/MacOS/Cypress --no-sandbox --smoke-test --ping=222 ---------- @@ -203,7 +203,7 @@ Error: Cypress verification failed. This command failed with the following output: -/cache/Cypress/1.2.3/Cypress.app/Contents/MacOS/Cypress --smoke-test --ping=222 +/cache/Cypress/1.2.3/Cypress.app/Contents/MacOS/Cypress --no-sandbox --smoke-test --ping=222 ---------- diff --git a/cli/index.js b/cli/index.js index 7da84f9ec87..250d3d4b52b 100644 --- a/cli/index.js +++ b/cli/index.js @@ -23,6 +23,6 @@ switch (args.exec) { break default: - // export our node module interface + debug('exporting Cypress module interface') module.exports = require('./lib/cypress') } diff --git a/cli/lib/cli.js b/cli/lib/cli.js index dc6a6e82e7c..6280439531b 100644 --- a/cli/lib/cli.js +++ b/cli/lib/cli.js @@ -5,6 +5,7 @@ const logSymbols = require('log-symbols') const debug = require('debug')('cypress:cli') const util = require('./util') const logger = require('./logger') +const errors = require('./errors') const cache = require('./tasks/cache') // patch "commander" method called when a user passed an unknown option @@ -27,7 +28,9 @@ const coerceFalse = (arg) => { const spaceDelimitedSpecsMsg = (files) => { logger.log() logger.warn(stripIndent` - ${logSymbols.warning} Warning: It looks like you're passing --spec a space-separated list of files: + ${ + logSymbols.warning +} Warning: It looks like you're passing --spec a space-separated list of files: "${files.join(' ')}" @@ -54,7 +57,8 @@ const parseVariableOpts = (fnArgs, args) => { const nextOptOffset = _.findIndex(_.slice(args, argIndex), (arg) => { return _.startsWith(arg, '--') }) - const endIndex = nextOptOffset !== -1 ? argIndex + nextOptOffset : args.length + const endIndex = + nextOptOffset !== -1 ? argIndex + nextOptOffset : args.length const maybeSpecs = _.slice(args, argIndex, endIndex) const extraSpecs = _.intersection(maybeSpecs, fnArgs) @@ -70,11 +74,35 @@ const parseVariableOpts = (fnArgs, args) => { } const parseOpts = (opts) => { - opts = _.pick(opts, - 'project', 'spec', 'reporter', 'reporterOptions', 'path', 'destination', - 'port', 'env', 'cypressVersion', 'config', 'record', 'key', - 'browser', 'detached', 'headed', 'global', 'dev', 'force', 'exit', - 'cachePath', 'cacheList', 'cacheClear', 'parallel', 'group', 'ciBuildId') + opts = _.pick( + opts, + 'project', + 'spec', + 'reporter', + 'reporterOptions', + 'path', + 'destination', + 'port', + 'env', + 'cypressVersion', + 'config', + 'record', + 'key', + 'configFile', + 'browser', + 'detached', + 'headed', + 'global', + 'dev', + 'force', + 'exit', + 'cachePath', + 'cacheList', + 'cacheClear', + 'parallel', + 'group', + 'ciBuildId' + ) if (opts.exit) { opts = _.omit(opts, 'exit') @@ -86,19 +114,23 @@ const parseOpts = (opts) => { } const descriptions = { - record: 'records the run. sends test results, screenshots and videos to your Cypress Dashboard.', - key: 'your secret Record Key. you can omit this if you set a CYPRESS_RECORD_KEY environment variable.', + record: + 'records the run. sends test results, screenshots and videos to your Cypress Dashboard.', + key: + 'your secret Record Key. you can omit this if you set a CYPRESS_RECORD_KEY environment variable.', spec: 'runs a specific spec file. defaults to "all"', - reporter: 'runs a specific mocha reporter. pass a path to use a custom reporter. defaults to "spec"', + reporter: + 'runs a specific mocha reporter. pass a path to use a custom reporter. defaults to "spec"', reporterOptions: 'options for the mocha reporter. defaults to "null"', - port: 'runs Cypress on a specific port. overrides any value in cypress.json.', - env: 'sets environment variables. separate multiple values with a comma. overrides any value in cypress.json or cypress.env.json', - config: 'sets configuration values. separate multiple values with a comma. overrides any value in cypress.json.', + port: 'runs Cypress on a specific port. overrides any value in the configuration file.', + env: 'sets environment variables. separate multiple values with a comma. overrides any value in the configuration file or cypress.env.json', + config: 'sets configuration values. separate multiple values with a comma. overrides any value in the configuration file.', browserRunMode: 'runs Cypress in the browser with the given name. if a filesystem path is supplied, Cypress will attempt to use the browser at that path.', browserOpenMode: 'path to a custom browser to be added to the list of available browsers in Cypress', detached: 'runs Cypress application in detached mode', project: 'path to the project', global: 'force Cypress into global mode as if its globally installed', + configFile: 'path to JSON file where configuration values are set. defaults to "cypress.json". pass "false" to disable.', version: 'prints Cypress version', headed: 'displays the Electron browser instead of running headlessly', dev: 'runs cypress in development and bypasses binary check', @@ -108,11 +140,25 @@ const descriptions = { cacheList: 'list cached binary versions', cacheClear: 'delete all cached binaries', group: 'a named group for recorded runs in the Cypress dashboard', - parallel: 'enables concurrent runs and automatic load balancing of specs across multiple machines or processes', - ciBuildId: 'the unique identifier for a run on your CI provider. typically a "BUILD_ID" env var. this value is automatically detected for most CI providers', + parallel: + 'enables concurrent runs and automatic load balancing of specs across multiple machines or processes', + ciBuildId: + 'the unique identifier for a run on your CI provider. typically a "BUILD_ID" env var. this value is automatically detected for most CI providers', } -const knownCommands = ['version', 'run', 'open', 'install', 'verify', '-v', '--version', 'help', '-h', '--help', 'cache'] +const knownCommands = [ + 'version', + 'run', + 'open', + 'install', + 'verify', + '-v', + '--version', + 'help', + '-h', + '--help', + 'cache', +] const text = (description) => { if (!descriptions[description]) { @@ -123,9 +169,11 @@ const text = (description) => { } function includesVersion (args) { - return _.includes(args, 'version') || + return ( + _.includes(args, 'version') || _.includes(args, '--version') || _.includes(args, '-v') + ) } function showVersions () { @@ -147,6 +195,14 @@ module.exports = { args = process.argv } + if (!util.isValidCypressEnvValue(process.env.CYPRESS_ENV)) { + debug('invalid CYPRESS_ENV value', process.env.CYPRESS_ENV) + + return errors.exitWithError(errors.errors.invalidCypressEnv)( + `CYPRESS_ENV=${process.env.CYPRESS_ENV}` + ) + } + const program = new commander.Command() // bug in commaner not printing name @@ -177,10 +233,14 @@ module.exports = { .option('-k, --key ', text('key')) .option('-s, --spec ', text('spec')) .option('-r, --reporter ', text('reporter')) - .option('-o, --reporter-options ', text('reporterOptions')) + .option( + '-o, --reporter-options ', + text('reporterOptions') + ) .option('-p, --port ', text('port')) .option('-e, --env ', text('env')) .option('-c, --config ', text('config')) + .option('-C, --config-file ', text('configFile')) .option('-b, --browser ', text('browserRunMode')) .option('-P, --project ', text('project')) .option('--parallel', text('parallel')) @@ -203,6 +263,7 @@ module.exports = { .option('-p, --port ', text('port')) .option('-e, --env ', text('env')) .option('-c, --config ', text('config')) + .option('-C, --config-file ', text('configFile')) .option('-d, --detached [bool]', text('detached'), coerceFalse) .option('-b, --browser ', text('browserOpenMode')) .option('-P, --project ', text('project')) @@ -218,7 +279,9 @@ module.exports = { program .command('install') .usage('[options]') - .description('Installs the Cypress executable matching this package\'s version') + .description( + 'Installs the Cypress executable matching this package\'s version' + ) .option('-f, --force', text('forceInstall')) .action((opts) => { require('./tasks/install') @@ -229,7 +292,9 @@ module.exports = { program .command('verify') .usage('[options]') - .description('Verifies that Cypress is installed correctly and executable') + .description( + 'Verifies that Cypress is installed correctly and executable' + ) .option('--dev', text('dev'), coerceFalse) .action((opts) => { const defaultOpts = { force: true, welcomeMessage: false } diff --git a/cli/lib/errors.js b/cli/lib/errors.js index 1a83b96ce43..ee45a1c21c4 100644 --- a/cli/lib/errors.js +++ b/cli/lib/errors.js @@ -36,7 +36,9 @@ const failedUnzip = { const missingApp = (binaryDir) => { return { - description: `No version of Cypress is installed in: ${chalk.cyan(binaryDir)}`, + description: `No version of Cypress is installed in: ${chalk.cyan( + binaryDir + )}`, solution: stripIndent` \nPlease reinstall Cypress by running: ${chalk.cyan('cypress install')} `, @@ -59,7 +61,8 @@ const binaryNotExecutable = (executable) => { const notInstalledCI = (executable) => { return { - description: 'The cypress npm package is installed, but the Cypress binary is missing.', + description: + 'The cypress npm package is installed, but the Cypress binary is missing.', solution: stripIndent`\n We expected the binary to be installed here: ${chalk.cyan(executable)} @@ -114,7 +117,7 @@ const smokeTestFailure = (smokeTestCommand, timedOut) => { const invalidSmokeTestDisplayError = { code: 'INVALID_SMOKE_TEST_DISPLAY_ERROR', description: 'Cypress verification failed.', - solution (msg) { + solution (msg) { return stripIndent` Cypress failed to start after spawning a new Xvfb server. @@ -152,7 +155,8 @@ const missingDependency = { } const invalidCacheDirectory = { - description: 'Cypress cannot write to the cache directory due to file permissions', + description: + 'Cypress cannot write to the cache directory due to file permissions', solution: stripIndent` See discussion and possible solutions at ${chalk.blue(util.getGitHubIssueUrl(1281))} @@ -165,7 +169,8 @@ const versionMismatch = { } const unexpected = { - description: 'An unexpected error occurred while verifying the Cypress executable.', + description: + 'An unexpected error occurred while verifying the Cypress executable.', solution: stripIndent` Please search Cypress documentation for possible solutions: @@ -179,10 +184,19 @@ const unexpected = { `, } +const invalidCypressEnv = { + description: + chalk.red('The environment variable with the reserved name "CYPRESS_ENV" is set.'), + solution: chalk.red('Unset the "CYPRESS_ENV" environment variable and run Cypress again.'), + exitCode: 11, +} + const removed = { CYPRESS_BINARY_VERSION: { description: stripIndent` - The environment variable CYPRESS_BINARY_VERSION has been renamed to CYPRESS_INSTALL_BINARY as of version ${chalk.green('3.0.0')} + The environment variable CYPRESS_BINARY_VERSION has been renamed to CYPRESS_INSTALL_BINARY as of version ${chalk.green( + '3.0.0' + )} `, solution: stripIndent` You should set CYPRESS_INSTALL_BINARY instead. @@ -190,7 +204,9 @@ const removed = { }, CYPRESS_SKIP_BINARY_INSTALL: { description: stripIndent` - The environment variable CYPRESS_SKIP_BINARY_INSTALL has been removed as of version ${chalk.green('3.0.0')} + The environment variable CYPRESS_SKIP_BINARY_INSTALL has been removed as of version ${chalk.green( + '3.0.0' + )} `, solution: stripIndent` To skip the binary install, set CYPRESS_INSTALL_BINARY=0 @@ -210,8 +226,7 @@ const CYPRESS_RUN_BINARY = { } function getPlatformInfo () { - return util.getOsVersionAsync() - .then((version) => { + return util.getOsVersionAsync().then((version) => { return stripIndent` Platform: ${os.platform()} (${version}) Cypress Version: ${util.pkgVersion()} @@ -220,8 +235,7 @@ function getPlatformInfo () { } function addPlatformInformation (info) { - return getPlatformInfo() - .then((platform) => { + return getPlatformInfo().then((platform) => { return merge(info, { platform }) }) } @@ -231,18 +245,18 @@ function addPlatformInformation (info) { * and if possible a way to solve it. Resolves with a string. */ function formErrorText (info, msg, prevMessage) { - return addPlatformInformation(info) - .then((obj) => { + return addPlatformInformation(info).then((obj) => { const formatted = [] function add (msg) { - formatted.push( - stripIndents(msg) - ) + formatted.push(stripIndents(msg)) } - la(is.unemptyString(obj.description), - 'expected error description to be text', obj.description) + la( + is.unemptyString(obj.description), + 'expected error description to be text', + obj.description + ) // assuming that if there the solution is a function it will handle // error message and (optional previous error message) @@ -258,8 +272,11 @@ function formErrorText (info, msg, prevMessage) { `) } else { - la(is.unemptyString(obj.solution), - 'expected error solution to be text', obj.solution) + la( + is.unemptyString(obj.solution), + 'expected error solution to be text', + obj.solution + ) add(` ${obj.description} @@ -312,13 +329,30 @@ const raise = (info) => { const throwFormErrorText = (info) => { return (msg, prevMessage) => { - return formErrorText(info, msg, prevMessage) - .then(raise(info)) + return formErrorText(info, msg, prevMessage).then(raise(info)) + } +} + +/** + * Forms full error message with error and OS details, prints to the error output + * and then exits the process. + * @param {ErrorInformation} info Error information {description, solution} + * @example return exitWithError(errors.invalidCypressEnv)('foo') + */ +const exitWithError = (info) => { + return (msg) => { + return formErrorText(info, msg).then((text) => { + // eslint-disable-next-line no-console + console.error(text) + process.exit(info.exitCode || 1) + }) } } module.exports = { raise, + exitWithError, + // formError, formErrorText, throwFormErrorText, hr, @@ -334,6 +368,7 @@ module.exports = { unexpected, failedDownload, failedUnzip, + invalidCypressEnv, invalidCacheDirectory, removed, CYPRESS_RUN_BINARY, diff --git a/cli/lib/exec/open.js b/cli/lib/exec/open.js index 5187d5c3563..9f527751606 100644 --- a/cli/lib/exec/open.js +++ b/cli/lib/exec/open.js @@ -19,6 +19,10 @@ module.exports = { args.push('--config', options.config) } + if (options.configFile !== undefined) { + args.push('--config-file', options.configFile) + } + if (options.browser) { args.push('--browser', options.browser) } diff --git a/cli/lib/exec/run.js b/cli/lib/exec/run.js index a3b29c2884c..dd0df97498d 100644 --- a/cli/lib/exec/run.js +++ b/cli/lib/exec/run.js @@ -26,6 +26,10 @@ const processRunOptions = (options = {}) => { args.push('--config', options.config) } + if (options.configFile !== undefined) { + args.push('--config-file', options.configFile) + } + if (options.port) { args.push('--port', options.port) } diff --git a/cli/lib/exec/spawn.js b/cli/lib/exec/spawn.js index dbb9a650747..8b192708e2b 100644 --- a/cli/lib/exec/spawn.js +++ b/cli/lib/exec/spawn.js @@ -9,6 +9,7 @@ const debugElectron = require('debug')('cypress:electron') const util = require('../util') const state = require('../tasks/state') const xvfb = require('./xvfb') +const verify = require('../tasks/verify') const { throwFormErrorText, errors } = require('../errors') const isXlibOrLibudevRe = /^(?:Xlib|libudev)/ @@ -105,6 +106,10 @@ module.exports = { const electronArgs = _.clone(args) const node11WindowsFix = isPlatform('win32') + if (verify.needsSandbox()) { + electronArgs.push('--no-sandbox') + } + // strip dev out of child process options let stdioOptions = _.pick(options, 'env', 'detached', 'stdio') @@ -132,15 +137,36 @@ module.exports = { const child = cp.spawn(executable, electronArgs, stdioOptions) - child.on('close', resolve) + function resolveOn (event) { + return function (code, signal) { + debug('child event fired %o', { event, code, signal }) + resolve(code) + } + } + + child.on('close', resolveOn('close')) + child.on('exit', resolveOn('exit')) child.on('error', reject) - child.stdin && child.stdin.pipe(process.stdin) - child.stdout && child.stdout.pipe(process.stdout) + // if stdio options is set to 'pipe', then + // we should set up pipes: + // process STDIN (read stream) => child STDIN (writeable) + // child STDOUT => process STDOUT + // child STDERR => process STDERR with additional filtering + if (child.stdin) { + debug('piping process STDIN into child STDIN') + process.stdin.pipe(child.stdin) + } + + if (child.stdout) { + debug('piping child STDOUT to process STDOUT') + child.stdout.pipe(process.stdout) + } // if this is defined then we are manually piping for linux // to filter out the garbage - child.stderr && + if (child.stderr) { + debug('piping child STDERR to process STDERR') child.stderr.on('data', (data) => { const str = data.toString() @@ -158,15 +184,17 @@ module.exports = { // else pass it along! process.stderr.write(data) }) + } // https://github.com/cypress-io/cypress/issues/1841 + // https://github.com/cypress-io/cypress/issues/5241 // In some versions of node, it will throw on windows // when you close the parent process after piping // into the child process. unpiping does not seem // to have any effect. so we're just catching the // error here and not doing anything. process.stdin.on('error', (err) => { - if (err.code === 'EPIPE') { + if (['EPIPE', 'ENOTCONN'].includes(err.code)) { return } diff --git a/cli/lib/exec/versions.js b/cli/lib/exec/versions.js index 58ed2e1726b..bb3589ef749 100644 --- a/cli/lib/exec/versions.js +++ b/cli/lib/exec/versions.js @@ -8,7 +8,6 @@ const { throwFormErrorText, errors } = require('../errors') const getVersions = () => { return Promise.try(() => { - if (util.getEnv('CYPRESS_RUN_BINARY')) { let envBinaryPath = path.resolve(util.getEnv('CYPRESS_RUN_BINARY')) diff --git a/cli/lib/exec/xvfb.js b/cli/lib/exec/xvfb.js index c5e9ce4656f..463a69f5631 100644 --- a/cli/lib/exec/xvfb.js +++ b/cli/lib/exec/xvfb.js @@ -8,7 +8,7 @@ const { throwFormErrorText, errors } = require('../errors') const util = require('../util') const xvfb = Promise.promisifyAll(new Xvfb({ - timeout: 5000, // milliseconds + timeout: 30000, // milliseconds onStderrData (data) { if (debugXvfb.enabled) { debugXvfb(data.toString()) diff --git a/cli/lib/tasks/download.js b/cli/lib/tasks/download.js index 3bdda4ffd47..5afd2a7b95c 100644 --- a/cli/lib/tasks/download.js +++ b/cli/lib/tasks/download.js @@ -176,7 +176,6 @@ const verifyDownloadedFile = (filename, expectedSize, expectedChecksum) => { debug('downloaded file lacks checksum or size to verify') return Promise.resolve() - } // downloads from given url diff --git a/cli/lib/tasks/install.js b/cli/lib/tasks/install.js index 6215a08e8e1..26cb7b23ccf 100644 --- a/cli/lib/tasks/install.js +++ b/cli/lib/tasks/install.js @@ -27,7 +27,6 @@ const alreadyInstalledMsg = () => { } const displayCompletionMsg = () => { - // check here to see if we are globally installed if (util.isInstalledGlobally()) { // if we are display a warning @@ -103,7 +102,6 @@ const downloadAndUnzip = ({ version, installDir, downloadDir }) => { { title: util.titleize('Finishing Installation'), task: (ctx, task) => { - const cleanup = () => { debug('removing zip file %s', downloadDestination) @@ -129,7 +127,6 @@ const downloadAndUnzip = ({ version, installDir, downloadDir }) => { } const start = (options = {}) => { - // handle deprecated / removed if (util.getEnv('CYPRESS_BINARY_VERSION')) { return throwFormErrorText(errors.removed.CYPRESS_BINARY_VERSION)() @@ -152,7 +149,6 @@ const start = (options = {}) => { // let this environment variable reset the binary version we need if (util.getEnv('CYPRESS_INSTALL_BINARY')) { - // because passed file paths are often double quoted // and might have extra whitespace around, be robust and trim the string const trimAndRemoveDoubleQuotes = true @@ -175,7 +171,6 @@ const start = (options = {}) => { // if this doesn't match the expected version // then print warning to the user if (envVarVersion !== needVersion) { - // reset the version to the env var version needVersion = envVarVersion } @@ -211,7 +206,6 @@ const start = (options = {}) => { return state.getBinaryPkgVersionAsync(binaryDir) }) .then((binaryVersion) => { - if (!binaryVersion) { debug('no binary installed under cli version') diff --git a/cli/lib/tasks/state.js b/cli/lib/tasks/state.js index 4f449c4f47f..0b284e9808a 100644 --- a/cli/lib/tasks/state.js +++ b/cli/lib/tasks/state.js @@ -1,6 +1,7 @@ const _ = require('lodash') const os = require('os') const path = require('path') +const untildify = require('untildify') const debug = require('debug')('cypress:cli') const fs = require('../fs') @@ -53,14 +54,35 @@ const getVersionDir = (version = util.pkgVersion()) => { return path.join(getCacheDir(), version) } +/** + * When executing "npm postinstall" hook, the working directory is set to + * "/node_modules/cypress", which can be surprising when using relative paths. + */ +const isInstallingFromPostinstallHook = () => { + // individual folders + const cwdFolders = process.cwd().split(path.sep) + const length = cwdFolders.length + + return cwdFolders[length - 2] === 'node_modules' && cwdFolders[length - 1] === 'cypress' +} + const getCacheDir = () => { let cache_directory = util.getCacheDir() if (util.getEnv('CYPRESS_CACHE_FOLDER')) { - const envVarCacheDir = util.getEnv('CYPRESS_CACHE_FOLDER') + const envVarCacheDir = untildify(util.getEnv('CYPRESS_CACHE_FOLDER')) debug('using environment variable CYPRESS_CACHE_FOLDER %s', envVarCacheDir) - cache_directory = path.resolve(envVarCacheDir) + + if (!path.isAbsolute(envVarCacheDir) && isInstallingFromPostinstallHook()) { + const packageRootFolder = path.join('..', '..', envVarCacheDir) + + cache_directory = path.resolve(packageRootFolder) + debug('installing from postinstall hook, original root folder is %s', packageRootFolder) + debug('and resolved cache directory is %s', cache_directory) + } else { + cache_directory = path.resolve(envVarCacheDir) + } } return cache_directory diff --git a/cli/lib/tasks/unzip.js b/cli/lib/tasks/unzip.js index a7a7124290e..c341d62eb3a 100644 --- a/cli/lib/tasks/unzip.js +++ b/cli/lib/tasks/unzip.js @@ -14,7 +14,6 @@ const util = require('../util') // expose this function for simple testing const unzip = ({ zipFilePath, installDir, progress }) => { - debug('unzipping from %s', zipFilePath) debug('into', installDir) diff --git a/cli/lib/tasks/verify.js b/cli/lib/tasks/verify.js index bfafcb34c27..9e359abd271 100644 --- a/cli/lib/tasks/verify.js +++ b/cli/lib/tasks/verify.js @@ -7,6 +7,7 @@ const { stripIndent } = require('common-tags') const Promise = require('bluebird') const logSymbols = require('log-symbols') const path = require('path') +const os = require('os') const { throwFormErrorText, errors } = require('../errors') const util = require('../util') @@ -80,6 +81,12 @@ const runSmokeTest = (binaryDir, options) => { const random = _.random(0, 1000) const args = ['--smoke-test', `--ping=${random}`] + if (needsSandbox()) { + // electron requires --no-sandbox to run as root + debug('disabling Electron sandbox') + args.unshift('--no-sandbox') + } + if (options.dev) { executable = 'node' args.unshift( @@ -112,14 +119,18 @@ const runSmokeTest = (binaryDir, options) => { .then((result) => { // TODO: when execa > 1.1 is released // change this to `result.all` for both stderr and stdout - const smokeTestReturned = result.stdout + // use lodash to be robust during tests against null result or missing stdout + const smokeTestStdout = _.get(result, 'stdout', '') - debug('smoke test stdout "%s"', smokeTestReturned) + debug('smoke test stdout "%s"', smokeTestStdout) - if (!util.stdoutLineMatches(String(random), smokeTestReturned)) { + if (!util.stdoutLineMatches(String(random), smokeTestStdout)) { debug('Smoke test failed because could not find %d in:', random, result) - return throwFormErrorText(errors.smokeTestFailure(smokeTestCommand, false))(result.stderr || result.stdout) + const smokeTestStderr = _.get(result, 'stderr', '') + const errorText = smokeTestStderr || smokeTestStdout + + return throwFormErrorText(errors.smokeTestFailure(smokeTestCommand, false))(errorText) } }) } @@ -212,7 +223,6 @@ function testBinary (version, binaryDir, options) { const maybeVerify = (installedVersion, binaryDir, options) => { return state.getBinaryVerifiedAsync(binaryDir) .then((isVerified) => { - debug('is Verified ?', isVerified) let shouldVerify = !isVerified @@ -308,7 +318,6 @@ const start = (options = {}) => { return state.getBinaryPkgVersionAsync(binaryDir) }) .then((binaryVersion) => { - if (!binaryVersion) { debug('no Cypress binary found for cli version ', packageVersion) @@ -347,7 +356,21 @@ const start = (options = {}) => { }) } +const isLinuxLike = () => os.platform() !== 'win32' + +/** + * Returns true if running on a system where Electron needs "--no-sandbox" flag. + * @see https://crbug.com/638180 + * + * On Debian we had problems running in sandbox even for non-root users. + * @see https://github.com/cypress-io/cypress/issues/5434 + * Seems there is a lot of discussion around this issue among Electron users + * @see https://github.com/electron/electron/issues/17972 +*/ +const needsSandbox = () => isLinuxLike() + module.exports = { start, VERIFY_TEST_RUNNER_TIMEOUT_MS, + needsSandbox, } diff --git a/cli/lib/util.js b/cli/lib/util.js index 9d004483567..84f872db4ce 100644 --- a/cli/lib/util.js +++ b/cli/lib/util.js @@ -119,6 +119,25 @@ function stdoutLineMatches (expectedLine, stdout) { return lines.some(lineMatches) } +/** + * Confirms if given value is a valid CYPRESS_ENV value. Undefined values + * are valid, because the system can set the default one. + * + * @param {string} value + * @example util.isValidCypressEnvValue(process.env.CYPRESS_ENV) + */ +function isValidCypressEnvValue (value) { + if (_.isUndefined(value)) { + // will get default value + return true + } + + // names of config environments, see "packages/server/config/app.yml" + const names = ['development', 'test', 'staging', 'production'] + + return _.includes(names, value) +} + /** * Prints NODE_OPTIONS using debug() module, but only * if DEBUG=cypress... is set @@ -158,7 +177,7 @@ const dequote = (str) => { const util = { normalizeModuleOptions, - + isValidCypressEnvValue, printNodeOptions, isCi () { @@ -307,7 +326,6 @@ const util = { } return os.release() - }) }, diff --git a/cli/package.json b/cli/package.json index 30e1e8fa178..493856c4098 100644 --- a/cli/package.json +++ b/cli/package.json @@ -28,6 +28,7 @@ "dependencies": { "@cypress/listr-verbose-renderer": "0.4.1", "@cypress/xvfb": "1.2.4", + "@types/sizzle": "2.3.2", "arch": "2.1.1", "bluebird": "3.5.0", "cachedir": "1.3.0", @@ -54,6 +55,7 @@ "request-progress": "3.0.0", "supports-color": "5.5.0", "tmp": "0.1.0", + "untildify": "3.0.3", "url": "0.11.0", "yauzl": "2.10.0" }, @@ -63,7 +65,7 @@ "@types/bluebird": "3.5.18", "@types/chai": "4.0.8", "@types/chai-jquery": "1.1.38", - "@types/jquery": "3.3.6", + "@types/jquery": "3.3.31", "@types/lodash": "4.14.122", "@types/minimatch": "3.0.3", "@types/mocha": "5.2.7", @@ -71,7 +73,7 @@ "@types/sinon-chai": "3.2.2", "babel-cli": "6.26.0", "babel-preset-es2015": "6.24.1", - "bin-up": "1.2.0", + "bin-up": "1.2.2", "chai": "3.5.0", "chai-as-promised": "7.1.1", "chai-string": "1.4.0", @@ -85,8 +87,9 @@ "proxyquire": "2.1.0", "shelljs": "0.8.3", "sinon": "7.2.2", - "snap-shot-it": "7.8.0", - "spawn-mock": "1.0.0" + "snap-shot-it": "7.9.0", + "spawn-mock": "1.0.0", + "strip-ansi": "4.0.0" }, "files": [ "bin", diff --git a/cli/schema/cypress.schema.json b/cli/schema/cypress.schema.json index 30469611877..1cf147dae47 100644 --- a/cli/schema/cypress.schema.json +++ b/cli/schema/cypress.schema.json @@ -43,9 +43,12 @@ "description": "The reporter options used. Supported options depend on the reporter. See https://on.cypress.io/reporters#Reporter-Options" }, "testFiles": { - "type": "string", + "type": [ + "string", + "array" + ], "default": "**/*.*", - "description": "A String glob pattern of the test files to load" + "description": "A String or Array of string glob patterns of the test files to load. See https://on.cypress.io/configuration#Global" }, "watchForFileChanges": { "type": "boolean", @@ -199,6 +202,14 @@ "type": "string", "default": null, "description": "A 6 character string use to identify this project in the Cypress Dashboard. See https://on.cypress.io/dashboard-service#Identification" + }, + "nodeVersion": { + "enum": [ + "system", + "bundled" + ], + "default": "bundled", + "description": "If set to 'system', Cypress will try to find a Node.js executable on your path to use when executing your plugins. Otherwise, Cypress will use the Node version bundled with Cypress." } } } diff --git a/cli/scripts/post-install.js b/cli/scripts/post-install.js index 394cea6aefd..81784fcd60c 100644 --- a/cli/scripts/post-install.js +++ b/cli/scripts/post-install.js @@ -20,6 +20,12 @@ includeTypes.forEach((folder) => { shell.cp('-R', source, 'types') }) +// jQuery v3.3.x includes "dist" folder that just references back to itself +// causing dtslint to think there are double definitions. Remove that folder. +const typesJqueryDistFolder = join('types', 'jquery', 'dist') + +shell.rm('-rf', typesJqueryDistFolder) + // fix paths to Chai, jQuery and other types to be relative shell.sed( '-i', diff --git a/cli/test/.eslintrc b/cli/test/.eslintrc.json similarity index 100% rename from cli/test/.eslintrc rename to cli/test/.eslintrc.json diff --git a/cli/test/lib/cli_spec.js b/cli/test/lib/cli_spec.js index 952441ad113..42ed40e53a4 100644 --- a/cli/test/lib/cli_spec.js +++ b/cli/test/lib/cli_spec.js @@ -73,6 +73,58 @@ describe('cli', () => { }) }) + context('CYPRESS_ENV', () => { + /** + * Replaces line "Platform: ..." with "Platform: xxx" + * @param {string} s + */ + const replacePlatform = (s) => { + return s.replace(/Platform: .+/, 'Platform: xxx') + } + + /** + * Replaces line "Cypress Version: ..." with "Cypress Version: 1.2.3" + * @param {string} s + */ + const replaceCypressVersion = (s) => { + return s.replace(/Cypress Version: .+/, 'Cypress Version: 1.2.3') + } + + const sanitizePlatform = (text) => { + return text + .split(os.eol) + .map(replacePlatform) + .map(replaceCypressVersion) + .join(os.eol) + } + + it('allows staging environment', () => { + const options = { + env: { + CYPRESS_ENV: 'staging', + }, + // we are only interested in the exit code + filter: ['code', 'stderr'], + } + + return execa('bin/cypress', ['help'], options).then(snapshot) + }) + + it('catches environment "foo"', () => { + const options = { + env: { + CYPRESS_ENV: 'foo', + }, + // we are only interested in the exit code + filter: ['code', 'stderr'], + } + + return execa('bin/cypress', ['help'], options) + .then(sanitizePlatform) + .then(snapshot) + }) + }) + context('cypress version', () => { const binaryDir = '/binary/dir' diff --git a/cli/test/lib/cypress_spec.js b/cli/test/lib/cypress_spec.js index 404878dbf98..506c5e3ab38 100644 --- a/cli/test/lib/cypress_spec.js +++ b/cli/test/lib/cypress_spec.js @@ -54,6 +54,18 @@ describe('cypress', function () { expect(args).to.deep.eq({ config: JSON.stringify(config) }) }) }) + + it('passes configFile: false', () => { + const opts = { + configFile: false, + } + + return cypress.open(opts) + .then(getStartArgs) + .then((args) => { + expect(args).to.deep.eq(opts) + }) + }) }) context('.run', function () { @@ -123,5 +135,17 @@ describe('cypress', function () { it('resolves with contents of tmp file', () => { return cypress.run().then(snapshot) }) + + it('passes configFile: false', () => { + const opts = { + configFile: false, + } + + return cypress.run(opts) + .then(getStartArgs) + .then((args) => { + expect(args).to.deep.eq(opts) + }) + }) }) }) diff --git a/cli/test/lib/exec/.eslintrc b/cli/test/lib/exec/.eslintrc.json similarity index 100% rename from cli/test/lib/exec/.eslintrc rename to cli/test/lib/exec/.eslintrc.json diff --git a/cli/test/lib/exec/open_spec.js b/cli/test/lib/exec/open_spec.js index f91844184dc..62315f5e338 100644 --- a/cli/test/lib/exec/open_spec.js +++ b/cli/test/lib/exec/open_spec.js @@ -56,6 +56,24 @@ describe('exec open', function () { }) }) + it('spawns with --config-file false', function () { + return open.start({ configFile: false }) + .then(() => { + expect(spawn.start).to.be.calledWith( + ['--config-file', false] + ) + }) + }) + + it('spawns with --config-file set', function () { + return open.start({ configFile: 'special-cypress.json' }) + .then(() => { + expect(spawn.start).to.be.calledWith( + ['--config-file', 'special-cypress.json'] + ) + }) + }) + it('spawns with cwd as --project if not installed globally', function () { util.isInstalledGlobally.returns(false) diff --git a/cli/test/lib/exec/run_spec.js b/cli/test/lib/exec/run_spec.js index a4c3d9f11bf..f798d8722c2 100644 --- a/cli/test/lib/exec/run_spec.js +++ b/cli/test/lib/exec/run_spec.js @@ -30,6 +30,14 @@ describe('exec run', function () { snapshot(args) }) + it('passes --config-file false option', () => { + const args = run.processRunOptions({ + configFile: false, + }) + + snapshot(args) + }) + it('does not remove --record option when using --browser', () => { const args = run.processRunOptions({ record: 'foo', @@ -74,6 +82,24 @@ describe('exec run', function () { }) }) + it('spawns with --config-file false', function () { + return run.start({ configFile: false }) + .then(() => { + expect(spawn.start).to.be.calledWith( + ['--run-project', process.cwd(), '--config-file', false] + ) + }) + }) + + it('spawns with --config-file set', function () { + return run.start({ configFile: 'special-cypress.json' }) + .then(() => { + expect(spawn.start).to.be.calledWith( + ['--run-project', process.cwd(), '--config-file', 'special-cypress.json'] + ) + }) + }) + it('spawns with --record false', function () { return run.start({ record: false }) .then(() => { diff --git a/cli/test/lib/exec/spawn_spec.js b/cli/test/lib/exec/spawn_spec.js index 75f46650ef5..d66a12f85a7 100644 --- a/cli/test/lib/exec/spawn_spec.js +++ b/cli/test/lib/exec/spawn_spec.js @@ -11,6 +11,7 @@ const mockedEnv = require('mocked-env') const state = require(`${lib}/tasks/state`) const xvfb = require(`${lib}/exec/xvfb`) const spawn = require(`${lib}/exec/spawn`) +const verify = require(`${lib}/tasks/verify`) const util = require(`${lib}/util.js`) const expect = require('chai').expect @@ -39,7 +40,10 @@ describe('lib/exec/spawn', function () { }, } - sinon.stub(process, 'stdin').value(new EE) + // process.stdin is both an event emitter and a readable stream + this.processStdin = new EE() + this.processStdin.pipe = sinon.stub().returns(undefined) + sinon.stub(process, 'stdin').value(this.processStdin) sinon.stub(cp, 'spawn').returns(this.spawnedProcess) sinon.stub(xvfb, 'start').resolves() sinon.stub(xvfb, 'stop').resolves() @@ -73,8 +77,17 @@ describe('lib/exec/spawn', function () { }) context('.start', function () { + // ️️⚠️ NOTE ⚠️ + // when asserting the calls made to spawn the child Cypress process + // we have to be _very_ careful. Spawn uses process.env object, if an assertion + // fails, it will print the entire process.env object to the logs, which + // might contain sensitive environment variables. Think about what the + // failed assertion might print to the public CI logs and limit + // the environment variables when running tests on CI. + it('passes args + options to spawn', function () { this.spawnedProcess.on.withArgs('close').yieldsAsync(0) + sinon.stub(verify, 'needsSandbox').returns(false) return spawn.start('--foo', { foo: 'bar' }) .then(() => { @@ -89,8 +102,30 @@ describe('lib/exec/spawn', function () { }) }) + it('uses --no-sandbox when needed', function () { + this.spawnedProcess.on.withArgs('close').yieldsAsync(0) + sinon.stub(verify, 'needsSandbox').returns(true) + + return spawn.start('--foo', { foo: 'bar' }) + .then(() => { + // skip the options argument: we do not need anything about it + // and also less risk that a failed assertion would dump the + // entire ENV object with possible sensitive variables + const args = cp.spawn.firstCall.args.slice(0, 2) + const expectedCliArgs = [ + '--foo', + '--cwd', + cwd, + '--no-sandbox', + ] + + expect(args).to.deep.equal(['/path/to/cypress', expectedCliArgs]) + }) + }) + it('uses npm command when running in dev mode', function () { this.spawnedProcess.on.withArgs('close').yieldsAsync(0) + sinon.stub(verify, 'needsSandbox').returns(false) const p = path.resolve('..', 'scripts', 'start.js') @@ -119,6 +154,23 @@ describe('lib/exec/spawn', function () { }) }) + context('closes', function () { + ['close', 'exit'].forEach((event) => { + it(`if '${event}' event fired`, function () { + this.spawnedProcess.on.withArgs(event).yieldsAsync(0) + + return spawn.start('--foo') + }) + }) + + it('if exit event fired and close event fired', function () { + this.spawnedProcess.on.withArgs('exit').yieldsAsync(0) + this.spawnedProcess.on.withArgs('close').yieldsAsync(0) + + return spawn.start('--foo') + }) + }) + it('does not start xvfb when its not needed', function () { this.spawnedProcess.on.withArgs('close').yieldsAsync(0) @@ -292,6 +344,9 @@ describe('lib/exec/spawn', function () { return spawn.start() .then(() => { expect(cp.spawn.firstCall.args[2].stdio).to.deep.eq('pipe') + // parent process STDIN was piped to child process STDIN + expect(this.processStdin.pipe, 'process.stdin').to.have.been.calledOnce + .and.to.have.been.calledWith(this.spawnedProcess.stdin) }) }) @@ -401,24 +456,28 @@ describe('lib/exec/spawn', function () { }) }) - it('catches process.stdin errors and returns when code=EPIPE', function () { - this.spawnedProcess.on.withArgs('close').yieldsAsync(0) + // https://github.com/cypress-io/cypress/issues/1841 + // https://github.com/cypress-io/cypress/issues/5241 + ;['EPIPE', 'ENOTCONN'].forEach((errCode) => { + it(`catches process.stdin errors and returns when code=${errCode}`, function () { + this.spawnedProcess.on.withArgs('close').yieldsAsync(0) - return spawn.start() - .then(() => { - let called = false + return spawn.start() + .then(() => { + let called = false - const fn = () => { - called = true - const err = new Error() + const fn = () => { + called = true + const err = new Error() - err.code = 'EPIPE' + err.code = errCode - return process.stdin.emit('error', err) - } + return process.stdin.emit('error', err) + } - expect(fn).not.to.throw() - expect(called).to.be.true + expect(fn).not.to.throw() + expect(called).to.be.true + }) }) }) diff --git a/cli/test/lib/exec/xvfb_spec.js b/cli/test/lib/exec/xvfb_spec.js index 55db48b0f5d..a5da2853ef4 100644 --- a/cli/test/lib/exec/xvfb_spec.js +++ b/cli/test/lib/exec/xvfb_spec.js @@ -71,7 +71,6 @@ describe('lib/exec/xvfb', function () { }) context('#isNeeded', function () { - it('does not need xvfb on osx', function () { os.platform.returns('darwin') diff --git a/cli/test/lib/tasks/install_spec.js b/cli/test/lib/tasks/install_spec.js index c9c50bae9b8..8e9a9a5b229 100644 --- a/cli/test/lib/tasks/install_spec.js +++ b/cli/test/lib/tasks/install_spec.js @@ -59,7 +59,6 @@ describe('/lib/tasks/install', function () { }) describe('skips install', function () { - it('when environment variable is set', function () { process.env.CYPRESS_INSTALL_BINARY = '0' @@ -76,7 +75,6 @@ describe('/lib/tasks/install', function () { }) describe('override version', function () { - it('warns when specifying cypress version in env', function () { const version = '0.12.1' @@ -182,7 +180,6 @@ describe('/lib/tasks/install', function () { describe('when version is already installed', function () { beforeEach(function () { state.getBinaryPkgVersionAsync.resolves(packageVersion) - }) it('doesn\'t attempt to download', function () { @@ -191,7 +188,6 @@ describe('/lib/tasks/install', function () { expect(download.start).not.to.be.called expect(state.getBinaryPkgVersionAsync).to.be.calledWith('/cache/Cypress/1.2.3/Cypress.app') }) - }) it('logs \'skipping install\' when explicit cypress install', function () { @@ -202,7 +198,6 @@ describe('/lib/tasks/install', function () { normalize(this.stdout.toString()) ) }) - }) it('logs when already installed when run from postInstall', function () { @@ -249,7 +244,6 @@ describe('/lib/tasks/install', function () { }) it('logs message and starts download', function () { - expect(download.start).to.be.calledWithMatch({ version: packageVersion, }) @@ -301,7 +295,6 @@ describe('/lib/tasks/install', function () { }) it('logs message and starts download', function () { - expect(download.start).to.be.calledWithMatch({ version: packageVersion, }) @@ -467,5 +460,4 @@ describe('/lib/tasks/install', function () { }) }) }) - }) diff --git a/cli/test/lib/tasks/state_spec.js b/cli/test/lib/tasks/state_spec.js index 435ce8c770a..b998027b263 100644 --- a/cli/test/lib/tasks/state_spec.js +++ b/cli/test/lib/tasks/state_spec.js @@ -5,6 +5,7 @@ const path = require('path') const Promise = require('bluebird') const proxyquire = require('proxyquire') const mockfs = require('mock-fs') +const debug = require('debug')('test') const fs = require(`${lib}/fs`) const logger = require(`${lib}/logger`) @@ -258,6 +259,39 @@ describe('lib/tasks/state', function () { expect(ret).to.eql(path.resolve('local-cache/folder')) }) + + it('CYPRESS_CACHE_FOLDER resolves from relative path during postinstall', () => { + process.env.CYPRESS_CACHE_FOLDER = './local-cache/folder' + // simulates current folder when running "npm postinstall" hook + sinon.stub(process, 'cwd').returns('/my/project/folder/node_modules/cypress') + const ret = state.getCacheDir() + + debug('returned cache dir %s', ret) + expect(ret).to.eql(path.resolve('/my/project/folder/local-cache/folder')) + }) + + it('CYPRESS_CACHE_FOLDER resolves from absolute path during postinstall', () => { + process.env.CYPRESS_CACHE_FOLDER = '/cache/folder/Cypress' + // simulates current folder when running "npm postinstall" hook + sinon.stub(process, 'cwd').returns('/my/project/folder/node_modules/cypress') + const ret = state.getCacheDir() + + debug('returned cache dir %s', ret) + expect(ret).to.eql(path.resolve('/cache/folder/Cypress')) + }) + + it('resolves ~ with user home folder', () => { + const homeDir = os.homedir() + + process.env.CYPRESS_CACHE_FOLDER = '~/.cache/Cypress' + + const ret = state.getCacheDir() + + debug('cache dir is "%s"', ret) + expect(path.isAbsolute(ret), ret).to.be.true + expect(ret, '~ has been resolved').to.not.contain('~') + expect(ret, 'replaced ~ with home directory').to.equal(`${homeDir}/.cache/Cypress`) + }) }) context('.parseRealPlatformBinaryFolderAsync', function () { diff --git a/cli/test/lib/tasks/verify_spec.js b/cli/test/lib/tasks/verify_spec.js index 007dc6e0526..73169f5c57e 100644 --- a/cli/test/lib/tasks/verify_spec.js +++ b/cli/test/lib/tasks/verify_spec.js @@ -53,11 +53,12 @@ context('lib/tasks/verify', () => { sinon.stub(xvfb, 'stop').resolves() sinon.stub(xvfb, 'isNeeded').returns(false) sinon.stub(Promise.prototype, 'delay').resolves() + sinon.stub(process, 'geteuid').returns(1000) sinon.stub(_, 'random').returns('222') util.exec - .withArgs(executablePath, ['--smoke-test', '--ping=222']) + .withArgs(executablePath, ['--no-sandbox', '--smoke-test', '--ping=222']) .resolves(spawnedProcess) }) @@ -70,7 +71,6 @@ context('lib/tasks/verify', () => { }) it('logs error and exits when no version of Cypress is installed', () => { - return verify .start() .then(() => { @@ -86,6 +86,46 @@ context('lib/tasks/verify', () => { }) }) + it('adds --no-sandbox when user is root', () => { + // make it think the executable exists + createfs({ + alreadyVerified: false, + executable: mockfs.file({ mode: 0o777 }), + packageVersion, + }) + + process.geteuid.returns(0) // user is root + util.exec.resolves({ + stdout: '222', + stderr: '', + }) + + return verify.start() + .then(() => { + expect(util.exec).to.be.calledWith(executablePath, ['--no-sandbox', '--smoke-test', '--ping=222']) + }) + }) + + it('adds --no-sandbox when user is non-root', () => { + // make it think the executable exists + createfs({ + alreadyVerified: false, + executable: mockfs.file({ mode: 0o777 }), + packageVersion, + }) + + process.geteuid.returns(1000) // user is non-root + util.exec.resolves({ + stdout: '222', + stderr: '', + }) + + return verify.start() + .then(() => { + expect(util.exec).to.be.calledWith(executablePath, ['--no-sandbox', '--smoke-test', '--ping=222']) + }) + }) + it('is noop when binary is already verified', () => { // make it think the executable exists and is verified createfs({ @@ -158,7 +198,6 @@ context('lib/tasks/verify', () => { .then(() => { snapshot(normalize(slice(stdout.toString()))) }) - }) it('logs error when child process returns incorrect stdout (stderr when exists)', () => { @@ -185,7 +224,6 @@ context('lib/tasks/verify', () => { .then(() => { snapshot(normalize(slice(stdout.toString()))) }) - }) it('logs error when child process returns incorrect stdout (stdout when no stderr)', () => { @@ -211,7 +249,6 @@ context('lib/tasks/verify', () => { .then(() => { snapshot(normalize(slice(stdout.toString()))) }) - }) it('sets ELECTRON_ENABLE_LOGGING without mutating process.env', () => { @@ -651,7 +688,7 @@ context('lib/tasks/verify', () => { }) util.exec - .withArgs(realEnvBinaryPath, ['--smoke-test', '--ping=222']) + .withArgs(realEnvBinaryPath, ['--no-sandbox', '--smoke-test', '--ping=222']) .resolves(spawnedProcess) return verify.start().then(() => { @@ -680,8 +717,35 @@ context('lib/tasks/verify', () => { }) }) }) + + // tests for when Electron needs "--no-sandbox" CLI flag + context('.needsSandbox', () => { + it('needs --no-sandbox on Linux as a root', () => { + os.platform.returns('linux') + process.geteuid.returns(0) // user is root + expect(verify.needsSandbox()).to.be.true + }) + + it('needs --no-sandbox on Linux as a non-root', () => { + os.platform.returns('linux') + process.geteuid.returns(1000) // user is non-root + expect(verify.needsSandbox()).to.be.true + }) + + it('needs --no-sandbox on Mac as a non-root', () => { + os.platform.returns('darwin') + process.geteuid.returns(1000) // user is non-root + expect(verify.needsSandbox()).to.be.true + }) + + it('does not need --no-sandbox on Windows', () => { + os.platform.returns('win32') + expect(verify.needsSandbox()).to.be.false + }) + }) }) +// TODO this needs documentation with examples badly. function createfs ({ alreadyVerified, executable, packageVersion, customDir }) { let mockFiles = { [customDir ? customDir : '/cache/Cypress/1.2.3/Cypress.app']: { diff --git a/cli/test/lib/util_spec.js b/cli/test/lib/util_spec.js index 73238da52e6..30be63f3a4c 100644 --- a/cli/test/lib/util_spec.js +++ b/cli/test/lib/util_spec.js @@ -321,7 +321,6 @@ describe('util', () => { context('.printNodeOptions', () => { describe('NODE_OPTIONS is not set', () => { - it('does nothing if debug is not enabled', () => { const log = sinon.spy() diff --git a/cli/test/spec_helper.js b/cli/test/spec_helper.js index e795b33e873..a5df843f798 100644 --- a/cli/test/spec_helper.js +++ b/cli/test/spec_helper.js @@ -9,9 +9,7 @@ const { MockChildProcess } = require('spawn-mock') const _kill = MockChildProcess.prototype.kill const patchMockSpawn = () => { - MockChildProcess.prototype.kill = function (...args) { - this.emit('exit') return _kill.apply(this, args) diff --git a/cli/types/cypress-npm-api.d.ts b/cli/types/cypress-npm-api.d.ts index 2faea0e9c0e..e7375ce9c37 100644 --- a/cli/types/cypress-npm-api.d.ts +++ b/cli/types/cypress-npm-api.d.ts @@ -43,9 +43,9 @@ declare module 'cypress' { */ reporter: string, /** - * A String glob pattern of the test files to load. + * A String or Array of string glob pattern of the test files to load. */ - testFiles: string + testFiles: string | string[] // // timeouts @@ -224,7 +224,7 @@ declare module 'cypress' { }) ``` */ - interface CypressRunOptions { + interface CypressRunOptions extends CypressCommonOptions { /** * Specify different browser to run tests in, either by name or by filesystem path */ @@ -233,14 +233,6 @@ declare module 'cypress' { * Specify a unique identifier for a run to enable grouping or parallelization */ ciBuildId: string - /** - * Specify configuration - */ - config: Partial - /** - * Specify environment variables - */ - env: object /** * Group recorded tests together under a single run name */ @@ -265,10 +257,6 @@ declare module 'cypress' { * Override default port */ port: number - /** - * Path to a specific project - */ - project: string /** * Whether to record the test run */ @@ -302,23 +290,15 @@ declare module 'cypress' { }) ``` */ - interface CypressOpenOptions { + interface CypressOpenOptions extends CypressCommonOptions { /** * Specify a filesystem path to a custom browser */ browser: string - /** - * Specify configuration - */ - config: Partial /** * Open Cypress in detached mode */ detached: boolean - /** - * Specify environment variables - */ - env: object /** * Run in global mode */ @@ -327,6 +307,28 @@ declare module 'cypress' { * Override default port */ port: number + } + + /** + * Options available for `cypress.open` and `cypress.run` + */ + interface CypressCommonOptions { + /** + * Specify configuration + */ + config: Partial + /** + * Path to the config file to be used. + * + * If `false` is passed, no config file will be used. + * + * @default "cypress.json" + */ + configFile: string | false + /** + * Specify environment variables + */ + env: object /** * Path to a specific project */ @@ -443,6 +445,7 @@ declare module 'cypress' { /** * Results returned by the test run. + * @see https://on.cypress.io/module-api */ interface CypressRunResult { startedTestsAt: dateTimeISO @@ -463,6 +466,29 @@ declare module 'cypress' { cypressVersion: string // TODO add resolved object to the configuration config: CypressConfiguration + /** + * If Cypress fails to run at all (for example, if there are no spec files to run), + * then it will set failures to 1 and will have actual error message in the + * property "message". Check this property before checking other properties. + * + * @type {number} + * @example + ``` + const result = await cypress.run() + if (result.failures) { + console.error(result.message) + process.exit(result.failures) + } + ``` + */ + failures?: number + /** + * If returned result has "failures" set, then this property gives + * the error message. + * + * @type {string} + */ + message?: string } /** diff --git a/cli/types/index.d.ts b/cli/types/index.d.ts index 540db9629ac..c87c5578112 100644 --- a/cli/types/index.d.ts +++ b/cli/types/index.d.ts @@ -19,6 +19,10 @@ /// /// +// jQuery includes dependency "sizzle" that provides types +// so we include it too in "node_modules/sizzle". +// This way jQuery can load it using 'reference types="sizzle"' directive + // "moment" types are with "node_modules/moment" /// @@ -288,7 +292,6 @@ declare namespace Cypress { add(name: string, fn: (...args: any[]) => void): void add(name: string, options: CommandOptions, fn: (...args: any[]) => void): void overwrite(name: string, fn: (...args: any[]) => void): void - overwrite(name: string, options: CommandOptions, fn: (...args: any[]) => void): void } /** @@ -616,7 +619,61 @@ declare namespace Cypress { * * @see https://on.cypress.io/dblclick */ - dblclick(options?: Partial): Chainable + dblclick(options?: Partial): Chainable + /** + * Double-click a DOM element at specific corner / side. + * + * @param {String} position - The position where the click should be issued. + * The `center` position is the default position. + * @see https://on.cypress.io/dblclick + * @example + * cy.get('button').dblclick('topRight') + */ + dblclick(position: string, options?: Partial): Chainable + /** + * Double-click a DOM element at specific coordinates + * + * @param {number} x The distance in pixels from the element’s left to issue the click. + * @param {number} y The distance in pixels from the element’s top to issue the click. + * @see https://on.cypress.io/dblclick + * @example + ``` + // The click below will be issued inside of the element + // (15px from the left and 40px from the top). + cy.get('button').dblclick(15, 40) + ``` + */ + dblclick(x: number, y: number, options?: Partial): Chainable + /** + * Right-click a DOM element. + * + * @see https://on.cypress.io/rightclick + */ + rightclick(options?: Partial): Chainable + /** + * Right-click a DOM element at specific corner / side. + * + * @param {String} position - The position where the click should be issued. + * The `center` position is the default position. + * @see https://on.cypress.io/click + * @example + * cy.get('button').rightclick('topRight') + */ + rightclick(position: string, options?: Partial): Chainable + /** + * Right-click a DOM element at specific coordinates + * + * @param {number} x The distance in pixels from the element’s left to issue the click. + * @param {number} y The distance in pixels from the element’s top to issue the click. + * @see https://on.cypress.io/rightclick + * @example + ``` + // The click below will be issued inside of the element + // (15px from the left and 40px from the top). + cy.get('button').rightclick(15, 40) + ``` + */ + rightclick(x: number, y: number, options?: Partial): Chainable /** * Set a debugger and log what the previous command yields. @@ -2044,11 +2101,26 @@ declare namespace Cypress { * @default "cypress/integration" */ integrationFolder: string + /** + * If set to `system`, Cypress will try to find a `node` executable on your path to use when executing your plugins. Otherwise, Cypress will use the Node version bundled with Cypress. + * @default "bundled" + */ + nodeVersion: "system" | "bundled" /** * Path to plugins file. (Pass false to disable) * @default "cypress/plugins/index.js" */ pluginsFile: string + /** + * If `nodeVersion === 'system'` and a `node` executable is found, this will be the full filesystem path to that executable. + * @default null + */ + resolvedNodePath: string + /** + * The version of `node` that is being used to execute plugins. + * @example 1.2.3 + */ + resolvedNodeVersion: string /** * Path to folder where screenshots will be saved from [cy.screenshot()](https://on.cypress.io/screenshot) command or after a headless or CI run’s test failure * @default "cypress/screenshots" @@ -2170,11 +2242,19 @@ declare namespace Cypress { height: number } + type Padding = + | number + | [number] + | [number, number] + | [number, number, number] + | [number, number, number, number] + interface ScreenshotOptions { blackout: string[] capture: 'runner' | 'viewport' | 'fullPage' clip: Dimensions disableTimersAndAnimations: boolean + padding: Padding scale: boolean beforeScreenshot(doc: Document): void afterScreenshot(doc: Document): void @@ -2349,6 +2429,11 @@ declare namespace Cypress { * }) */ auth: Auth + + /** + * Query parameters to append to the `url` of the request. + */ + qs: object } /** @@ -4364,7 +4449,7 @@ declare namespace Cypress { type Encodings = 'ascii' | 'base64' | 'binary' | 'hex' | 'latin1' | 'utf8' | 'utf-8' | 'ucs2' | 'ucs-2' | 'utf16le' | 'utf-16le' type PositionType = "topLeft" | "top" | "topRight" | "left" | "center" | "right" | "bottomLeft" | "bottom" | "bottomRight" - type ViewportPreset = 'macbook-15' | 'macbook-13' | 'macbook-11' | 'ipad-2' | 'ipad-mini' | 'iphone-6+' | 'iphone-6' | 'iphone-5' | 'iphone-4' | 'iphone-3' + type ViewportPreset = 'macbook-15' | 'macbook-13' | 'macbook-11' | 'ipad-2' | 'ipad-mini' | 'iphone-xr' | 'iphone-x' | 'iphone-6+' | 'iphone-6' | 'iphone-5' | 'iphone-4' | 'iphone-3' | 'samsung-s10' | 'samsung-note9' interface Offset { top: number, left: number diff --git a/cli/types/tests/chainer-examples.ts b/cli/types/tests/chainer-examples.ts index 020b447e7e0..4825f497c03 100644 --- a/cli/types/tests/chainer-examples.ts +++ b/cli/types/tests/chainer-examples.ts @@ -452,3 +452,7 @@ cy.writeFile('../file.path', '', { flag: 'a+', encoding: 'utf-8' }) + +cy.get('foo').click() +cy.get('foo').rightclick() +cy.get('foo').dblclick() diff --git a/cli/types/tests/cypress-npm-api-test.ts b/cli/types/tests/cypress-npm-api-test.ts index 494236a4d89..d2045f72454 100644 --- a/cli/types/tests/cypress-npm-api-test.ts +++ b/cli/types/tests/cypress-npm-api-test.ts @@ -1,3 +1,5 @@ +// type tests for Cypress NPM module +// https://on.cypress.io/module-api import cypress from 'cypress' cypress.run // $ExpectType (options?: Partial | undefined) => Promise @@ -7,5 +9,16 @@ cypress.run({}).then(results => { }) cypress.run().then(results => { results // $ExpectType CypressRunResult + results.failures // $ExpectType number | undefined + results.message // $ExpectType string | undefined }) cypress.open() // $ExpectType Promise +cypress.run() // $ExpectType Promise + +cypress.open({ + configFile: false +}) + +cypress.run({ + configFile: "abc123" +}) diff --git a/cli/types/tests/cypress-tests.ts b/cli/types/tests/cypress-tests.ts index 7b7ccb23eb0..99fbeb34397 100644 --- a/cli/types/tests/cypress-tests.ts +++ b/cli/types/tests/cypress-tests.ts @@ -61,9 +61,6 @@ namespace CypressCommandsTests { Cypress.Commands.overwrite('newCommand', () => { return }) - Cypress.Commands.overwrite('newCommand', { prevSubject: true }, () => { - return - }) } namespace CypressLogsTest { diff --git a/package.json b/package.json index 58db9a6a33c..f4232e57fbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cypress", - "version": "3.4.1", + "version": "3.6.0", "description": "Cypress.io end to end testing tool", "private": true, "scripts": { @@ -17,6 +17,7 @@ "bump": "node ./scripts/binary.js bump", "check-deps": "node ./scripts/check-deps.js --verbose", "check-deps-pre": "node ./scripts/check-deps.js --verbose --prescript", + "check-next-dev-version": "node scripts/check-next-dev-version.js", "check-node-version": "node scripts/check-node-version.js", "check-terminal": "node scripts/check-terminal.js", "clean-deps": "npm run all clean-deps && rm -rf node_modules", @@ -47,7 +48,7 @@ "set-next-ci-version": "node ./scripts/binary.js setNextVersion", "prestart": "npm run check-deps-pre", "start": "node ./cli/bin/cypress open --dev --global", - "stop-only": "npx stop-only --skip .cy,.publish,.projects,node_modules,dist,dist-test,fixtures,lib,bower_components,spec_helper.coffee", + "stop-only": "npx stop-only --skip .cy,.publish,.projects,node_modules,dist,dist-test,fixtures,lib,bower_components,src --exclude e2e.coffee", "stop-only-all": "npm run stop-only -- --folder packages", "test": "echo '⚠️ This root monorepo is only for local development and new contributions. There are no tests.'", "test-debug-package": "node ./scripts/test-debug-package.js", @@ -64,10 +65,10 @@ } }, "devDependencies": { - "@cypress/bumpercar": "2.0.9", + "@cypress/bumpercar": "2.0.12", "@cypress/commit-message-install": "2.7.0", "@cypress/env-or-json-file": "2.0.0", - "@cypress/eslint-plugin-dev": "4.0.0", + "@cypress/eslint-plugin-dev": "5.0.0", "@cypress/github-commit-status-check": "1.5.0", "@cypress/npm-run-all": "4.0.5", "@cypress/questions-remain": "1.0.1", @@ -99,10 +100,8 @@ "chalk": "2.4.2", "check-dependencies": "1.1.0", "check-more-types": "2.24.0", - "cloudflare-cli": "3.2.2", "coffeelint": "1.16.2", "common-tags": "1.8.0", - "console.table": "0.10.0", "debug": "4.1.1", "decaffeinate": "6.0.1", "del": "3.0.0", @@ -110,7 +109,7 @@ "eslint": "6.1.0", "eslint-plugin-cypress": "2.6.0", "eslint-plugin-json-format": "2.0.0", - "eslint-plugin-mocha": "5.3.0", + "eslint-plugin-mocha": "6.1.0", "eslint-plugin-react": "7.14.2", "execa": "1.0.0", "execa-wrap": "1.4.0", @@ -133,7 +132,7 @@ "jscodeshift": "0.6.3", "konfig": "0.2.1", "lazy-ass": "1.6.0", - "lint-staged": "8.2.1", + "lint-staged": "9.4.1", "lodash": "4.17.15", "make-empty-github-commit": "1.2.0", "mocha": "3.5.3", @@ -149,10 +148,12 @@ "print-arch": "1.0.0", "proxyquire": "2.1.0", "ramda": "0.24.1", + "request": "2.88.0", + "request-promise": "4.2.4", "shelljs": "0.8.3", "shx": "0.3.2", "sinon": "7.3.2", - "snap-shot-it": "7.8.0", + "snap-shot-it": "7.9.0", "stop-only": "3.0.1", "strip-ansi": "4.0.0", "terminal-banner": "1.1.0", @@ -161,7 +162,7 @@ "vinyl-paths": "2.1.0" }, "engines": { - "node": ">=8.9.3" + "node": "12.0.0" }, "productName": "Cypress", "license": "MIT", diff --git a/packages/desktop-gui/.eslintrc b/packages/desktop-gui/.eslintrc.json similarity index 100% rename from packages/desktop-gui/.eslintrc rename to packages/desktop-gui/.eslintrc.json diff --git a/packages/desktop-gui/README.md b/packages/desktop-gui/README.md index ac6bbd9c512..9ef54dc8bd9 100644 --- a/packages/desktop-gui/README.md +++ b/packages/desktop-gui/README.md @@ -6,14 +6,14 @@ The Desktop GUI is the react application that is rendered by Electron. This acts **The Desktop GUI has the following responsibilities:** -- Allow users to login through GitHub. +- Allow users to log in through GitHub. - Allow users to add projects to be tested in Cypress. - Display existing projects and allow the removal of projects. - Initialize the server to run on a specific project. - Allow users to choose a specific browser to run tests within. - Display the resolved configuration of a running project. - Display the list of specs of a running project. -- Initialize the run of a specific spec file or all tests chosen by the user. +- Initialize the run of a specific spec file or all spec files chosen by the user. - Notify users of updates to Cypress and initialize update process. - Set up projects to be recorded. diff --git a/packages/desktop-gui/cypress/.eslintrc b/packages/desktop-gui/cypress/.eslintrc.json similarity index 100% rename from packages/desktop-gui/cypress/.eslintrc rename to packages/desktop-gui/cypress/.eslintrc.json diff --git a/packages/desktop-gui/cypress/fixtures/config.json b/packages/desktop-gui/cypress/fixtures/config.json index fe243710d4e..ae30abd14b2 100644 --- a/packages/desktop-gui/cypress/fixtures/config.json +++ b/packages/desktop-gui/cypress/fixtures/config.json @@ -62,6 +62,8 @@ "report": false, "reporter": "spec", "requestTimeout": 5000, + "resolvedNodePath": null, + "resolvedNodeVersion": "1.2.3", "hosts": { "*.foobar.com": "127.0.0.1" }, @@ -224,6 +226,10 @@ "from": "default", "value": 30000 }, + "pluginsFile": { + "from": "default", + "value": "cypress/plugins/index.js" + }, "port": { "from": "default", "value": 2020 diff --git a/packages/desktop-gui/cypress/integration/login_spec.js b/packages/desktop-gui/cypress/integration/login_spec.js index 484f5d77a52..91ec33e4dc4 100644 --- a/packages/desktop-gui/cypress/integration/login_spec.js +++ b/packages/desktop-gui/cypress/integration/login_spec.js @@ -18,7 +18,6 @@ describe('Login', function () { cy.stub(this.ipc, 'openProject').resolves(this.config) cy.stub(this.ipc, 'getSpecs').yields(null, this.specs) cy.stub(this.ipc, 'externalOpen') - cy.stub(this.ipc, 'clearGithubCookies') cy.stub(this.ipc, 'logOut').resolves() cy.stub(this.ipc, 'onAuthMessage').callsFake((function (_this) { @@ -124,14 +123,6 @@ describe('Login', function () { cy.get('.nav').contains('Log In') }) - it('calls clear:github:cookies', function () { - cy.get('nav a').contains('Jane').click() - - cy.contains('Log Out').click().then(function () { - expect(this.ipc.clearGithubCookies).to.be.called - }) - }) - it('calls log:out', function () { cy.get('nav a').contains('Jane').click() diff --git a/packages/desktop-gui/cypress/integration/runs_list_spec.js b/packages/desktop-gui/cypress/integration/runs_list_spec.js index bd7ad379950..6a9eca105c2 100644 --- a/packages/desktop-gui/cypress/integration/runs_list_spec.js +++ b/packages/desktop-gui/cypress/integration/runs_list_spec.js @@ -338,7 +338,7 @@ describe('Runs List', function () { }) it('shows login message', () => { - cy.get('.login h1').should('contain', 'Log in') + cy.get('.login h1').should('contain', 'Log In') }) it('clicking Log In to Dashboard opens login', () => { diff --git a/packages/desktop-gui/cypress/integration/settings_spec.js b/packages/desktop-gui/cypress/integration/settings_spec.js index 29c1380efb1..86df12f18f6 100644 --- a/packages/desktop-gui/cypress/integration/settings_spec.js +++ b/packages/desktop-gui/cypress/integration/settings_spec.js @@ -1,4 +1,6 @@ -describe('Settings', function () { +const { _ } = Cypress + +describe('Settings', () => { beforeEach(function () { cy.fixture('user').as('user') cy.fixture('config').as('config') @@ -9,9 +11,8 @@ describe('Settings', function () { cy.fixture('keys').as('keys') this.goToSettings = () => { - return cy - .get('.navbar-default') - .get('a').contains('Settings').click() + cy.get('.navbar-default') + cy.get('a').contains('Settings').click() } cy.visitIndex().then(function (win) { @@ -44,7 +45,7 @@ describe('Settings', function () { }) }) - describe('any case / project is set up for ci', function () { + describe('any case / project is set up for ci', () => { beforeEach(function () { this.openProject.resolve(this.config) this.projectStatuses[0].id = this.config.projectId @@ -67,7 +68,7 @@ describe('Settings', function () { cy.contains(this.config.projectId).should('not.exist') }) - describe('when config panel is opened', function () { + describe('when config panel is opened', () => { beforeEach(() => { cy.contains('Configuration').click() }) @@ -81,7 +82,7 @@ describe('Settings', function () { }) it('wraps config line in proper classes', () => { - cy.get('.line').first().within(function () { + cy.get('.line').first().within(() => { cy.contains('animationDistanceThreshold').should('have.class', 'key') cy.contains(':').should('have.class', 'colon') cy.contains('5').should('have.class', 'default') @@ -89,15 +90,15 @@ describe('Settings', function () { }) }) - it('displays \'true\' values', () => { + it('displays "true" values', () => { cy.get('.line').contains('true') }) - it('displays \'null\' values', () => { + it('displays "null" values', () => { cy.get('.line').contains('null') }) - it('displays \'object\' values for env and hosts', () => { + it('displays "object" values for env and hosts', () => { cy.get('.nested-obj').eq(0) .contains('fixturesFolder') @@ -105,14 +106,14 @@ describe('Settings', function () { .contains('*.foobar.com') }) - it('displays \'array\' values for blacklistHosts', () => { + it('displays "array" values for blacklistHosts', () => { cy.get('.nested-arr') .parent() .should('contain', '[') .and('contain', ']') .and('not.contain', '0') .and('not.contain', '1') - .find('.line .config').should(function ($lines) { + .find('.line .config').should(($lines) => { expect($lines).to.have.length(2) expect($lines).to.contain('www.google-analytics.com') @@ -127,7 +128,7 @@ describe('Settings', function () { }) }) - describe('when project id panel is opened', function () { + describe('when project id panel is opened', () => { beforeEach(() => { cy.contains('Project ID').click() }) @@ -137,7 +138,7 @@ describe('Settings', function () { }) }) - describe('when record key panel is opened', function () { + describe('when record key panel is opened', () => { beforeEach(() => { cy.contains('Record Key').click() }) @@ -152,7 +153,7 @@ describe('Settings', function () { }) }) - it('loads the project\'s record key', function () { + it('loads the projects record key', function () { expect(this.ipc.getRecordKeys).to.be.called }) @@ -160,7 +161,7 @@ describe('Settings', function () { cy.get('.settings-record-key .fa-spinner') }) - describe('when record key loads', function () { + describe('when record key loads', () => { beforeEach(function () { this.getRecordKeys.resolve(this.keys) }) @@ -178,7 +179,7 @@ describe('Settings', function () { }) }) - describe('when there are no keys', function () { + describe('when there are no keys', () => { beforeEach(function () { this.getRecordKeys.resolve([]) }) @@ -194,7 +195,7 @@ describe('Settings', function () { }) }) - describe('when the user is logged out', function () { + describe('when the user is logged out', () => { beforeEach(function () { this.getRecordKeys.resolve([]) @@ -205,7 +206,7 @@ describe('Settings', function () { cy.get('.empty-well').should('contain', 'must be logged in') }) - it('opens login modal after clicking \'Log In\'', function () { + it('opens login modal after clicking \'Log In\'', () => { cy.get('.empty-well button').click() cy.get('.login') }) @@ -226,7 +227,7 @@ describe('Settings', function () { }) }) - describe('when proxy settings panel is opened', function () { + describe('when proxy settings panel is opened', () => { beforeEach(() => { cy.contains('Proxy Settings').click() }) @@ -241,7 +242,7 @@ describe('Settings', function () { }) }) - it('with Windows proxy settings indicates proxy and the source', function () { + it('with Windows proxy settings indicates proxy and the source', () => { cy.setAppStore({ projectRoot: '/foo/bar', proxySource: 'win32', @@ -255,7 +256,7 @@ describe('Settings', function () { cy.get('.settings-proxy tr:nth-child(2) > td > code').should('contain', 'a, b, c, d') }) - it('with environment proxy settings indicates proxy and the source', function () { + it('with environment proxy settings indicates proxy and the source', () => { cy.setAppStore({ projectRoot: '/foo/bar', proxyServer: 'http://foo-bar.baz', @@ -268,7 +269,7 @@ describe('Settings', function () { cy.get('.settings-proxy tr:nth-child(2) > td > code').should('contain', 'a, b, c, d') }) - it('with no bypass list but a proxy set shows \'none\' in bypass list', function () { + it('with no bypass list but a proxy set shows \'none\' in bypass list', () => { cy.setAppStore({ projectRoot: '/foo/bar', proxyServer: 'http://foo-bar.baz', @@ -278,7 +279,7 @@ describe('Settings', function () { }) }) - context('on:focus:tests clicked', function () { + context('on:focus:tests clicked', () => { beforeEach(function () { this.ipc.onFocusTests.yield() }) @@ -289,7 +290,7 @@ describe('Settings', function () { }) }) - context('on config changes', function () { + context('on config changes', () => { beforeEach(function () { this.projectStatuses[0].id = this.config.projectId this.getProjectStatus.resolve(this.projectStatuses[0]) @@ -316,7 +317,45 @@ describe('Settings', function () { }) }) - describe('errors', function () { + describe('when node version panel is opened', () => { + const bundledNodeVersion = '1.2.3' + const systemNodePath = '/foo/bar/node' + const systemNodeVersion = '4.5.6' + + beforeEach(function () { + this.navigateWithConfig = function (config) { + this.openProject.resolve(_.defaults(config, this.config)) + this.projectStatuses[0].id = this.config.projectId + this.getProjectStatus.resolve(this.projectStatuses[0]) + this.goToSettings() + } + }) + + it('with bundled node informs user we\'re using bundled node', function () { + this.navigateWithConfig({}) + + cy.contains(`Node.js Version (${bundledNodeVersion})`).click() + cy.get('.node-version') + .should('contain', 'bundled with Cypress') + .should('not.contain', systemNodePath) + .should('not.contain', systemNodeVersion) + }) + + it('with custom node displays path to custom node', function () { + this.navigateWithConfig({ + resolvedNodePath: systemNodePath, + resolvedNodeVersion: systemNodeVersion, + }) + + cy.contains(`Node.js Version (${systemNodeVersion})`).click() + cy.get('.node-version') + .should('contain', systemNodePath) + .should('contain', systemNodeVersion) + .should('not.contain', bundledNodeVersion) + }) + }) + + describe('errors', () => { beforeEach(function () { this.err = { message: 'Port \'2020\' is already in use.', @@ -357,7 +396,7 @@ describe('Settings', function () { }) }) - context('when project is not set up for CI', function () { + context('when project is not set up for CI', () => { it('does not show ci Keys section when project has no id', function () { const newConfig = this.util.deepClone(this.config) @@ -379,7 +418,7 @@ describe('Settings', function () { }) }) - context('when you are not a user of this project\'s org', function () { + context('when you are not a user of this projects org', () => { beforeEach(function () { this.openProject.resolve(this.config) }) @@ -392,4 +431,36 @@ describe('Settings', function () { cy.contains('h5', 'Record Keys').should('not.exist') }) }) + + context('when configFile is false', () => { + beforeEach(function () { + this.openProject.resolve(Cypress._.assign({ + configFile: false, + }, this.config)) + + this.goToSettings() + + cy.contains('Configuration').click() + }) + + it('notes that cypress.json is disabled', () => { + cy.contains('set from cypress.json file (currently disabled by --config-file false)') + }) + }) + + context('when configFile is set', function () { + beforeEach(function () { + this.openProject.resolve(Cypress._.assign({ + configFile: 'special-cypress.json', + }, this.config)) + + this.goToSettings() + + cy.contains('Configuration').click() + }) + + it('notes that a custom config is in use', () => { + cy.contains('set from custom config file special-cypress.json') + }) + }) }) diff --git a/packages/desktop-gui/cypress/integration/setup_project_modal_spec.js b/packages/desktop-gui/cypress/integration/setup_project_modal_spec.js index 4429075c872..83f1f0a2260 100644 --- a/packages/desktop-gui/cypress/integration/setup_project_modal_spec.js +++ b/packages/desktop-gui/cypress/integration/setup_project_modal_spec.js @@ -447,6 +447,12 @@ describe('Set Up Project', function () { cy.get('.modal').contains('Log In to Dashboard') }) + it('closes login modal', () => { + cy.get('.modal').contains('Log In to Dashboard') + cy.get('.close').click() + cy.get('.btn').contains('Set up project').click() + }) + describe('when login succeeds', function () { beforeEach(function () { cy.stub(this.ipc, 'beginAuth').resolves(this.user) @@ -454,6 +460,7 @@ describe('Set Up Project', function () { }) it('shows setup', () => { + cy.get('.login-content > .btn').click() cy.contains('h4', 'Set up project') }) }) diff --git a/packages/desktop-gui/lib/.eslintrc b/packages/desktop-gui/lib/.eslintrc.json similarity index 100% rename from packages/desktop-gui/lib/.eslintrc rename to packages/desktop-gui/lib/.eslintrc.json diff --git a/packages/desktop-gui/package.json b/packages/desktop-gui/package.json index 904d54c9727..f6063ee7295 100644 --- a/packages/desktop-gui/package.json +++ b/packages/desktop-gui/package.json @@ -19,12 +19,12 @@ "devDependencies": { "@cypress/icons": "0.7.0", "@cypress/json-schemas": "5.32.2", - "@cypress/react-tooltip": "0.5.2", - "bin-up": "1.2.0", + "@cypress/react-tooltip": "0.5.3", + "bin-up": "1.2.2", "bluebird": "3.5.3", "bootstrap-sass": "3.4.1", "classnames": "2.2.6", - "cross-env": "5.2.0", + "cross-env": "5.2.1", "fira": "cypress-io/fira#fb63362742eea8cdce0d90825ab9264d77719e3d", "font-awesome": "4.7.0", "gravatar": "1.8.0", diff --git a/packages/desktop-gui/src/app/nav.jsx b/packages/desktop-gui/src/app/nav.jsx index 43cce7741cd..b0d56ec5d90 100644 --- a/packages/desktop-gui/src/app/nav.jsx +++ b/packages/desktop-gui/src/app/nav.jsx @@ -118,7 +118,6 @@ export default class Nav extends Component { Log Out ) - } _select = (item) => { @@ -128,7 +127,7 @@ export default class Nav extends Component { } _showLogin () { - authStore.setShowingLogin(true) + authStore.openLogin() } _openDocs (e) { diff --git a/packages/desktop-gui/src/app/nav.scss b/packages/desktop-gui/src/app/nav.scss index 73750200135..924493843b9 100644 --- a/packages/desktop-gui/src/app/nav.scss +++ b/packages/desktop-gui/src/app/nav.scss @@ -24,6 +24,7 @@ background-color:#ececec; box-shadow: 0 0 3px 0 rgba(0,0,0,0.20); border-color: #c8c8c8; + z-index: 10; .nav { > .open { diff --git a/packages/desktop-gui/src/auth/auth-api.js b/packages/desktop-gui/src/auth/auth-api.js index e8010b84be3..0da2a4fba17 100644 --- a/packages/desktop-gui/src/auth/auth-api.js +++ b/packages/desktop-gui/src/auth/auth-api.js @@ -39,7 +39,6 @@ class AuthApi { logOut () { authStore.setUser(null) - ipc.clearGithubCookies() ipc.logOut() .catch((err) => { err.name = 'An unexpected error occurred while logging out' diff --git a/packages/desktop-gui/src/auth/auth-store.js b/packages/desktop-gui/src/auth/auth-store.js index e3186cbb62e..94c895e23c3 100644 --- a/packages/desktop-gui/src/auth/auth-store.js +++ b/packages/desktop-gui/src/auth/auth-store.js @@ -19,9 +19,20 @@ class AuthStore { this.message = message } - @action setShowingLogin (isShowing) { + @action openLogin (onCloseCb) { + this.onCloseCb = onCloseCb + + this.setMessage(null) + this.isShowingLogin = true + } + + @action closeLogin () { + if (this.onCloseCb) { + this.onCloseCb(this.isAuthenticated) + } + this.setMessage(null) - this.isShowingLogin = isShowing + this.isShowingLogin = false } @action setUser (user) { diff --git a/packages/desktop-gui/src/auth/login-form.jsx b/packages/desktop-gui/src/auth/login-form.jsx index 0668b645938..39bf0ce9897 100644 --- a/packages/desktop-gui/src/auth/login-form.jsx +++ b/packages/desktop-gui/src/auth/login-form.jsx @@ -1,10 +1,13 @@ import cs from 'classnames' +import { observer } from 'mobx-react' import React, { Component } from 'react' import authApi from './auth-api' +import authStore from './auth-store' import ipc from '../lib/ipc' import MarkdownRenderer from '../lib/markdown-renderer' +@observer class LoginForm extends Component { static defaultProps = { onSuccess () {}, @@ -16,7 +19,7 @@ class LoginForm extends Component { } render () { - const { message } = this.props + const { message } = authStore return (
@@ -28,7 +31,7 @@ class LoginForm extends Component { onClick={this._login} disabled={this.state.isLoggingIn} > - {this._buttonContent()} + {this._buttonContent(message)} { message &&

@@ -53,11 +56,9 @@ class LoginForm extends Component { selection.addRange(range) } - _buttonContent () { - const message = this.props.message || {} - + _buttonContent (message) { if (this.state.isLoggingIn) { - if (message.name === 'AUTH_COULD_NOT_LAUNCH_BROWSER') { + if (message && message.name === 'AUTH_COULD_NOT_LAUNCH_BROWSER') { return ( {' '} @@ -69,7 +70,7 @@ class LoginForm extends Component { return ( {' '} - {message.browserOpened ? 'Waiting for browser login...' : 'Opening browser...'} + {message && message.browserOpened ? 'Waiting for browser login...' : 'Opening browser...'} ) } @@ -79,7 +80,6 @@ class LoginForm extends Component { Log In to Dashboard ) - } _error () { diff --git a/packages/desktop-gui/src/auth/login-modal.jsx b/packages/desktop-gui/src/auth/login-modal.jsx index bda7ae051c1..cd2966eca84 100644 --- a/packages/desktop-gui/src/auth/login-modal.jsx +++ b/packages/desktop-gui/src/auth/login-modal.jsx @@ -8,7 +8,7 @@ import authStore from './auth-store' import ipc from '../lib/ipc' const close = () => { - authStore.setShowingLogin(false) + authStore.closeLogin() } // LoginContent is a separate component so that it pings the api @@ -70,7 +70,7 @@ class LoginContent extends Component { x

Log In

Logging in gives you access to the Cypress Dashboard Service. You can set up projects to be recorded and see test data from your project.

- this.setState({ succeeded: true })} /> + this.setState({ succeeded: true })} />
) } diff --git a/packages/desktop-gui/src/auth/login.scss b/packages/desktop-gui/src/auth/login.scss index 3271dd94235..367f366897b 100644 --- a/packages/desktop-gui/src/auth/login.scss +++ b/packages/desktop-gui/src/auth/login.scss @@ -63,7 +63,7 @@ cursor: pointer; } - &.warning { + &.warning p { color: $red-primary; } } diff --git a/packages/desktop-gui/src/dropdown/dropdown.jsx b/packages/desktop-gui/src/dropdown/dropdown.jsx index 412caf8bec4..ebce51d0249 100644 --- a/packages/desktop-gui/src/dropdown/dropdown.jsx +++ b/packages/desktop-gui/src/dropdown/dropdown.jsx @@ -60,7 +60,6 @@ class Dropdown extends Component { {this._buttonContent()} ) - } _buttonContent () { diff --git a/packages/desktop-gui/src/lib/config-file-formatted.jsx b/packages/desktop-gui/src/lib/config-file-formatted.jsx new file mode 100644 index 00000000000..4f6bccb6d98 --- /dev/null +++ b/packages/desktop-gui/src/lib/config-file-formatted.jsx @@ -0,0 +1,18 @@ +import React from 'react' +import { isUndefined } from 'lodash' + +const configFileFormatted = (configFile) => { + if (configFile === false) { + return <>cypress.json file (currently disabled by --config-file false) + } + + if (isUndefined(configFile) || configFile === 'cypress.json') { + return <>cypress.json file + } + + return <>custom config file {configFile} +} + +export { + configFileFormatted, +} diff --git a/packages/desktop-gui/src/lib/ipc.js b/packages/desktop-gui/src/lib/ipc.js index 874d819aea7..5eb2512449c 100644 --- a/packages/desktop-gui/src/lib/ipc.js +++ b/packages/desktop-gui/src/lib/ipc.js @@ -11,7 +11,6 @@ const ipc = { handleUnauthed () { authStore.setUser(null) - ipc.clearGithubCookies() ipc.logOut() }, } @@ -32,7 +31,6 @@ const register = (eventName, isPromiseApi = true) => { register('add:project') register('begin:auth') register('on:auth:message', false) -register('clear:github:cookies') register('close:browser') register('close:project') register('external:open') diff --git a/packages/desktop-gui/src/lib/utils.js b/packages/desktop-gui/src/lib/utils.js index 95ac38990d1..5fbb8b70ecd 100644 --- a/packages/desktop-gui/src/lib/utils.js +++ b/packages/desktop-gui/src/lib/utils.js @@ -110,5 +110,4 @@ export function stripSharedDirsFromDir2 (dir1, dir2, osName) { }) .join(sep) .value() - } diff --git a/packages/desktop-gui/src/project/error-message.jsx b/packages/desktop-gui/src/project/error-message.jsx index ef2a9358a28..21891a2401c 100644 --- a/packages/desktop-gui/src/project/error-message.jsx +++ b/packages/desktop-gui/src/project/error-message.jsx @@ -3,6 +3,7 @@ import React, { Component } from 'react' import { observer } from 'mobx-react' import ipc from '../lib/ipc' +import { configFileFormatted } from '../lib/config-file-formatted' import Markdown from 'markdown-it' @@ -53,7 +54,7 @@ class ErrorMessage extends Component { } render () { - let err = this.props.error + let err = this.props.project.error return (
@@ -72,7 +73,7 @@ class ErrorMessage extends Component { {err.portInUse && (

-

To fix, stop the other running process or change the port in cypress.json

+

To fix, stop the other running process or change the port in {configFileFormatted(this.props.project.configFile)}

)} diff --git a/packages/desktop-gui/src/project/onboarding.jsx b/packages/desktop-gui/src/project/onboarding.jsx index 992f0131fa7..57e011f9be4 100644 --- a/packages/desktop-gui/src/project/onboarding.jsx +++ b/packages/desktop-gui/src/project/onboarding.jsx @@ -116,7 +116,6 @@ class OnBoarding extends Component { ) - }) } diff --git a/packages/desktop-gui/src/project/project-model.js b/packages/desktop-gui/src/project/project-model.js index 21f7edeb1c5..454991c08ce 100644 --- a/packages/desktop-gui/src/project/project-model.js +++ b/packages/desktop-gui/src/project/project-model.js @@ -18,6 +18,7 @@ const validProps = cacheProps.concat([ 'isChosen', 'isLoading', 'isNew', + 'configFile', 'browsers', 'onBoardingModalOpen', 'browserState', @@ -25,6 +26,8 @@ const validProps = cacheProps.concat([ 'parentTestsFolderDisplay', 'integrationExampleName', 'scaffoldedFiles', + 'resolvedNodePath', + 'resolvedNodeVersion', ]) export default class Project { @@ -58,6 +61,8 @@ export default class Project { @observable parentTestsFolderDisplay @observable integrationExampleName @observable scaffoldedFiles = [] + @observable resolvedNodePath + @observable resolvedNodeVersion // should never change after first set @observable path // not observable diff --git a/packages/desktop-gui/src/project/project.jsx b/packages/desktop-gui/src/project/project.jsx index 6dce5c32de8..39476896ed9 100644 --- a/packages/desktop-gui/src/project/project.jsx +++ b/packages/desktop-gui/src/project/project.jsx @@ -43,7 +43,7 @@ class Project extends Component { ) } - if (this.props.project.error) return + if (this.props.project.error) return return ( <> diff --git a/packages/desktop-gui/src/project/project.scss b/packages/desktop-gui/src/project/project.scss index f906087d398..33b145d7e52 100644 --- a/packages/desktop-gui/src/project/project.scss +++ b/packages/desktop-gui/src/project/project.scss @@ -4,4 +4,5 @@ flex-grow: 2; margin-bottom: 0; width: 100%; + overflow: auto; } diff --git a/packages/desktop-gui/src/projects/projects-api.js b/packages/desktop-gui/src/projects/projects-api.js index 32c1ba1bf39..1b7532422d0 100644 --- a/packages/desktop-gui/src/projects/projects-api.js +++ b/packages/desktop-gui/src/projects/projects-api.js @@ -144,7 +144,13 @@ const openProject = (project) => { } const updateConfig = (config) => { - project.update({ id: config.projectId }) + project.update({ + id: config.projectId, + name: config.projectName, + configFile: config.configFile, + ..._.pick(config, ['resolvedNodeVersion', 'resolvedNodePath']), + }) + project.update({ name: config.projectName }) project.setOnBoardingConfig(config) project.setBrowsers(config.browsers) diff --git a/packages/desktop-gui/src/runs/permission-message.jsx b/packages/desktop-gui/src/runs/permission-message.jsx index 2b4264889de..2182835b79f 100644 --- a/packages/desktop-gui/src/runs/permission-message.jsx +++ b/packages/desktop-gui/src/runs/permission-message.jsx @@ -45,7 +45,6 @@ class PermissionMessage extends Component { } return this._noResult() - } _button () { @@ -101,7 +100,6 @@ class PermissionMessage extends Component { {this._button()}
) - } _noResult () { diff --git a/packages/desktop-gui/src/runs/project-not-setup.jsx b/packages/desktop-gui/src/runs/project-not-setup.jsx index 7cc0c108b72..c5b538ecfd3 100644 --- a/packages/desktop-gui/src/runs/project-not-setup.jsx +++ b/packages/desktop-gui/src/runs/project-not-setup.jsx @@ -4,7 +4,9 @@ import { observer } from 'mobx-react' import BootstrapModal from 'react-bootstrap-modal' import ipc from '../lib/ipc' +import { configFileFormatted } from '../lib/config-file-formatted' import SetupProject from './setup-project-modal' +import authStore from '../auth/auth-store' @observer export default class ProjectNotSetup extends Component { @@ -71,8 +73,8 @@ export default class ProjectNotSetup extends Component { {' '} Runs cannot be displayed -

We were unable to find an existing project matching the projectId in your cypress.json.

-

To see runs for a current project, add the correct projectId to your cypress.json

+

We were unable to find an existing project matching the projectId in your {configFileFormatted(this.props.project.configFile)}.

+

To see runs for a current project, add the correct projectId to your {configFileFormatted(this.props.project.configFile)}.

- or -

-
- Sakura - Naruto - - -
-
@@ -289,7 +296,7 @@
-
+ @@ -297,6 +304,13 @@ + + + + +
@@ -356,7 +370,7 @@ 5 - @@ -428,6 +442,8 @@ + +
@@ -549,6 +565,12 @@
+
+ Sakura + Naruto + + +
iframe:
diff --git a/packages/driver/test/cypress/fixtures/global-error.html b/packages/driver/test/cypress/fixtures/global-error.html new file mode 100644 index 00000000000..cbea4d35e11 --- /dev/null +++ b/packages/driver/test/cypress/fixtures/global-error.html @@ -0,0 +1,28 @@ + + + + + + Page Title + + + + + + diff --git a/packages/driver/test/cypress/fixtures/issue-2956.html b/packages/driver/test/cypress/fixtures/issue-2956.html new file mode 100644 index 00000000000..a0edfbfce47 --- /dev/null +++ b/packages/driver/test/cypress/fixtures/issue-2956.html @@ -0,0 +1,131 @@ + + + + + + + + + + +
+
+ +
+
+
+
+
+ +
+ + diff --git a/packages/driver/test/cypress/fixtures/screenshots.html b/packages/driver/test/cypress/fixtures/screenshots.html index 7fcd2c413f6..50cd98fc549 100644 --- a/packages/driver/test/cypress/fixtures/screenshots.html +++ b/packages/driver/test/cypress/fixtures/screenshots.html @@ -13,6 +13,12 @@ border: solid 1px black; margin: 20px; } + .empty-element { + height: 0px; + width: 0px; + border: 0px; + margin: 0px; + } .short-element { height: 100px; margin-left: 40px; @@ -20,6 +26,8 @@ } .tall-element { height: 320px; + + background: linear-gradient(red, yellow, blue); } .multiple { border: none; @@ -28,6 +36,7 @@ +
diff --git a/packages/driver/test/cypress/fixtures/scrolling.html b/packages/driver/test/cypress/fixtures/scrolling.html index b3d93447f21..8975c7c7ca9 100644 --- a/packages/driver/test/cypress/fixtures/scrolling.html +++ b/packages/driver/test/cypress/fixtures/scrolling.html @@ -11,7 +11,6 @@ height: 100px; width: 100px; overflow: hidden; - border: 1px solid yellow; } body, h5 { @@ -55,15 +54,15 @@
both here
-
+
Vertical Scroll
-
+
Horizontal Scroll
-
+
Both Scroll
diff --git a/packages/driver/test/cypress/integration/commands/actions/check_spec.coffee b/packages/driver/test/cypress/integration/commands/actions/check_spec.coffee index 4ef2f9e67da..e0f5ed52258 100644 --- a/packages/driver/test/cypress/integration/commands/actions/check_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/actions/check_spec.coffee @@ -75,6 +75,11 @@ describe "src/cy/commands/actions/check", -> done("should not fire change event") cy.get(checkbox).check() + + ## readonly should only be limited to inputs, not checkboxes + it "can check readonly checkboxes", -> + cy.get('#readonly-checkbox').check().then ($checkbox) -> + expect($checkbox).to.be.checked it "does not require visibility with force: true", -> checkbox = ":checkbox[name='birds']" @@ -427,8 +432,8 @@ describe "src/cy/commands/actions/check", -> it "passes in coords", -> cy.get("[name=colors][value=blue]").check().then ($input) -> lastLog = @lastLog - { fromWindow }= Cypress.dom.getElementCoordinatesByPosition($input) - expect(lastLog.get("coords")).to.deep.eq(fromWindow) + { fromElWindow }= Cypress.dom.getElementCoordinatesByPosition($input) + expect(lastLog.get("coords")).to.deep.eq(fromElWindow) it "ends command when checkbox is already checked", -> cy.get("[name=colors][value=blue]").check().check().then -> @@ -440,13 +445,13 @@ describe "src/cy/commands/actions/check", -> cy.get("[name=colors][value=blue]").check().then ($input) -> lastLog = @lastLog - { fromWindow }= Cypress.dom.getElementCoordinatesByPosition($input) + { fromElWindow }= Cypress.dom.getElementCoordinatesByPosition($input) console = lastLog.invoke("consoleProps") expect(console.Command).to.eq "check" expect(console["Applied To"]).to.eq lastLog.get("$el").get(0) expect(console.Elements).to.eq 1 expect(console.Coords).to.deep.eq( - _.pick(fromWindow, "x", "y") + _.pick(fromElWindow, "x", "y") ) it "#consoleProps when checkbox is already checked", -> @@ -831,13 +836,13 @@ describe "src/cy/commands/actions/check", -> cy.get("[name=colors][value=blue]").uncheck().then ($input) -> lastLog = @lastLog - { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($input) + { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($input) console = lastLog.invoke("consoleProps") expect(console.Command).to.eq "uncheck" expect(console["Applied To"]).to.eq lastLog.get("$el").get(0) expect(console.Elements).to.eq(1) expect(console.Coords).to.deep.eq( - _.pick(fromWindow, "x", "y") + _.pick(fromElWindow, "x", "y") ) it "#consoleProps when checkbox is already unchecked", -> diff --git a/packages/driver/test/cypress/integration/commands/actions/click_spec.js b/packages/driver/test/cypress/integration/commands/actions/click_spec.js index 0d6b920a436..d4a3bed63e8 100644 --- a/packages/driver/test/cypress/integration/commands/actions/click_spec.js +++ b/packages/driver/test/cypress/integration/commands/actions/click_spec.js @@ -1,11 +1,42 @@ -const $ = Cypress.$.bind(Cypress) -const { _ } = Cypress -const { Promise } = Cypress +const { _, $, Promise } = Cypress +const { getCommandLogWithText, + findReactInstance, + withMutableReporterState, + clickCommandLog, + attachListeners, + shouldBeCalledWithCount, + shouldBeCalled, + shouldBeCalledOnce, + shouldNotBeCalled, +} = require('../../../support/utils') const fail = function (str) { throw new Error(str) } +const mouseClickEvents = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'] +const mouseHoverEvents = [ + 'pointerout', + 'pointerleave', + 'pointerover', + 'pointerenter', + 'mouseout', + 'mouseleave', + 'mouseover', + 'mouseenter', + 'pointermove', + 'mousemove', +] +const focusEvents = ['focus', 'focusin'] + +const attachFocusListeners = attachListeners(focusEvents) +const attachMouseClickListeners = attachListeners(mouseClickEvents) +const attachMouseHoverListeners = attachListeners(mouseHoverEvents) +const attachMouseDblclickListeners = attachListeners(['dblclick']) +const attachContextmenuListeners = attachListeners(['contextmenu']) + +const overlayStyle = { position: 'fixed', top: 0, width: '100%', height: '100%', opacity: 0.5 } + describe('src/cy/commands/actions/click', () => { beforeEach(() => { cy.visit('/fixtures/dom.html') @@ -16,7 +47,7 @@ describe('src/cy/commands/actions/click', () => { const $btn = cy.$$('#button') $btn.on('click', (e) => { - const { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) const obj = _.pick(e.originalEvent, 'bubbles', 'cancelable', 'view', 'button', 'buttons', 'which', 'relatedTarget', 'altKey', 'ctrlKey', 'shiftKey', 'metaKey', 'detail', 'type') @@ -36,8 +67,8 @@ describe('src/cy/commands/actions/click', () => { type: 'click', }) - expect(e.clientX).to.be.closeTo(fromViewport.x, 1) - expect(e.clientY).to.be.closeTo(fromViewport.y, 1) + expect(e.clientX).to.be.closeTo(fromElViewport.x, 1) + expect(e.clientY).to.be.closeTo(fromElViewport.y, 1) done() }) @@ -64,7 +95,7 @@ describe('src/cy/commands/actions/click', () => { $btn.get(0).addEventListener('mousedown', (e) => { // calculate after scrolling - const { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) const obj = _.pick(e, 'bubbles', 'cancelable', 'view', 'button', 'buttons', 'which', 'relatedTarget', 'altKey', 'ctrlKey', 'shiftKey', 'metaKey', 'detail', 'type') @@ -84,8 +115,8 @@ describe('src/cy/commands/actions/click', () => { type: 'mousedown', }) - expect(e.clientX).to.be.closeTo(fromViewport.x, 1) - expect(e.clientY).to.be.closeTo(fromViewport.y, 1) + expect(e.clientX).to.be.closeTo(fromElViewport.x, 1) + expect(e.clientY).to.be.closeTo(fromElViewport.y, 1) done() }) @@ -99,7 +130,7 @@ describe('src/cy/commands/actions/click', () => { const win = cy.state('window') $btn.get(0).addEventListener('mouseup', (e) => { - const { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) const obj = _.pick(e, 'bubbles', 'cancelable', 'view', 'button', 'buttons', 'which', 'relatedTarget', 'altKey', 'ctrlKey', 'shiftKey', 'metaKey', 'detail', 'type') @@ -119,8 +150,8 @@ describe('src/cy/commands/actions/click', () => { type: 'mouseup', }) - expect(e.clientX).to.be.closeTo(fromViewport.x, 1) - expect(e.clientY).to.be.closeTo(fromViewport.y, 1) + expect(e.clientX).to.be.closeTo(fromElViewport.x, 1) + expect(e.clientY).to.be.closeTo(fromElViewport.y, 1) done() }) @@ -134,8 +165,8 @@ describe('src/cy/commands/actions/click', () => { const $btn = cy.$$('#button') _.each('mousedown mouseup click'.split(' '), (event) => { - return $btn.get(0).addEventListener(event, () => { - return events.push(event) + $btn.get(0).addEventListener(event, () => { + events.push(event) }) }) @@ -144,94 +175,31 @@ describe('src/cy/commands/actions/click', () => { }) }) - describe('pointer-events:none', () => { - beforeEach(function () { - cy.$$('
behind #ptrNone
').appendTo(cy.$$('#dom')) - this.ptrNone = cy.$$('
#ptrNone
').appendTo(cy.$$('#dom')) - cy.$$('
#ptrNone > div
').appendTo(this.ptrNone) - - this.logs = [] - cy.on('log:added', (attrs, log) => { - this.lastLog = log - - this.logs.push(log) - }) - }) - - it('element behind pointer-events:none should still get click', () => { - cy.get('#ptr').click() // should pass with flying colors - }) - - it('should be able to force on pointer-events:none with force:true', () => { - cy.get('#ptrNone').click({ timeout: 300, force: true }) - }) - - it('should error with message about pointer-events', function () { - const onError = cy.stub().callsFake((err) => { - const { lastLog } = this - - expect(err.message).to.contain('has CSS \'pointer-events: none\'') - expect(err.message).to.not.contain('inherited from') - const consoleProps = lastLog.invoke('consoleProps') - - expect(_.keys(consoleProps)).deep.eq([ - 'Command', - 'Tried to Click', - 'But it has CSS', - 'Error', - ]) - - expect(consoleProps['But it has CSS']).to.eq('pointer-events: none') - }) - - cy.once('fail', onError) + it('sends pointer and mouse events in order', () => { + const events = [] + const $btn = cy.$$('#button') - cy.get('#ptrNone').click({ timeout: 300 }) - .then(() => { - expect(onError).calledOnce + _.each('pointerdown mousedown pointerup mouseup click'.split(' '), (event) => { + $btn.get(0).addEventListener(event, () => { + events.push(event) }) }) - it('should error with message about pointer-events and include inheritance', function () { - const onError = cy.stub().callsFake((err) => { - const { lastLog } = this - - expect(err.message).to.contain('has CSS \'pointer-events: none\', inherited from this element:') - expect(err.message).to.contain('
{ - expect(onError).calledOnce - }) + cy.get('#button').click().then(() => { + expect(events).to.deep.eq(['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) }) }) it('records correct clientX when el scrolled', (done) => { - const $btn = $('').appendTo(cy.$$('body')) + const $btn = $(``).appendTo(cy.$$('body')) const win = cy.state('window') $btn.get(0).addEventListener('click', (e) => { - const { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) - expect(win.pageXOffset).to.be.gt(0) - expect(e.clientX).to.be.closeTo(fromViewport.x, 1) + expect(win.scrollX).to.be.gt(0) + expect(e.clientX).to.be.closeTo(fromElViewport.x, 1) done() }) @@ -240,15 +208,15 @@ describe('src/cy/commands/actions/click', () => { }) it('records correct clientY when el scrolled', (done) => { - const $btn = $('').appendTo(cy.$$('body')) + const $btn = $(``).appendTo(cy.$$('body')) const win = cy.state('window') $btn.get(0).addEventListener('click', (e) => { - const { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) - expect(win.pageYOffset).to.be.gt(0) - expect(e.clientY).to.be.closeTo(fromViewport.y, 1) + expect(win.scrollY).to.be.gt(0) + expect(e.clientY).to.be.closeTo(fromElViewport.y, 1) done() }) @@ -257,25 +225,38 @@ describe('src/cy/commands/actions/click', () => { }) it('will send all events even mousedown is defaultPrevented', () => { - const events = [] - const $btn = cy.$$('#button') $btn.get(0).addEventListener('mousedown', (e) => { e.preventDefault() - expect(e.defaultPrevented).to.be.true }) - _.each('mouseup click'.split(' '), (event) => { - return $btn.get(0).addEventListener(event, () => { - return events.push(event) - }) - }) + attachMouseClickListeners({ $btn }) - cy.get('#button').click().then(() => { - expect(events).to.deep.eq(['mouseup', 'click']) + cy.get('#button').click().should('not.have.focus') + + cy.getAll('$btn', 'pointerdown mousedown pointerup mouseup click').each(shouldBeCalled) + }) + + it('will not send mouseEvents/focus if pointerdown is defaultPrevented', () => { + const $btn = cy.$$('#button') + + const onEvent = cy.stub().callsFake((e) => { + e.preventDefault() + expect(e.defaultPrevented).to.be.true }) + + $btn.get(0).addEventListener('pointerdown', onEvent) + + attachMouseClickListeners({ $btn }) + + // uncomment to manually test + // cy.wrap(onEvent).should('be.called') + cy.get('#button').click().should('not.have.focus') + + cy.getAll('$btn', 'pointerdown pointerup click').each(shouldBeCalledOnce) + cy.getAll('$btn', 'mousedown mouseup').each(shouldNotBeCalled) }) it('sends a click event', (done) => { @@ -294,14 +275,32 @@ describe('src/cy/commands/actions/click', () => { }) }) - it('causes focusable elements to receive focus', (done) => { - const $text = cy.$$(':text:first') + it('causes focusable elements to receive focus', () => { + const el = cy.$$(':text:first') + + attachFocusListeners({ el }) + + cy.get(':text:first').click().should('have.focus') + + cy.getAll('el', 'focus focusin').each(shouldBeCalledOnce) + }) - $text.focus(() => { + // https://github.com/cypress-io/cypress/issues/5430 + it('does not attempt to click element outside viewport', (done) => { + cy.timeout(100) + cy.on('fail', (err) => { + expect(err.message).contain('id="email-with-value"') + expect(err.message).contain('hidden from view') done() }) - cy.get(':text:first').click() + cy.$$('#tabindex').css(overlayStyle) + cy.get('#email-with-value').click() + }) + + it('can click element outside viewport with force:true', () => { + cy.$$('#tabindex').css(overlayStyle) + cy.get('#email-with-value').click({ force: true }) }) it('does not fire a focus, mouseup, or click event when element has been removed on mousedown', () => { @@ -309,26 +308,224 @@ describe('src/cy/commands/actions/click', () => { $btn.on('mousedown', function () { // synchronously remove this button - return $(this).remove() + $(this).remove() }) $btn.on('focus', () => { - return fail('should not have gotten focus') + fail('should not have gotten focus') }) $btn.on('focusin', () => { - return fail('should not have gotten focusin') + fail('should not have gotten focusin') }) $btn.on('mouseup', () => { - return fail('should not have gotten mouseup') + fail('should not have gotten mouseup') }) $btn.on('click', () => { - return fail('should not have gotten click') + fail('should not have gotten click') + }) + + cy.contains('button').click() + }) + + it('events when element removed on pointerdown', () => { + const btn = cy.$$('button:first') + const div = cy.$$('div#tabindex') + + attachFocusListeners({ btn }) + attachMouseClickListeners({ btn, div }) + attachMouseHoverListeners({ btn, div }) + + btn.on('pointerdown', () => { + // synchronously remove this button + btn.remove() + }) + + cy.contains('button').click() + + cy.getAll('btn', 'pointerdown').each(shouldBeCalled) + cy.getAll('btn', 'mousedown mouseup').each(shouldNotBeCalled) + + // the browser is in control of whether or not the pointerdown event + // so this test *may* not necessarily pass in all browsers, but it's + // worth adding to help specify the current expected behavior + cy.getAll('div', 'pointerdown').each(shouldNotBeCalled) + cy.getAll('div', 'pointerover pointerenter mouseover mouseenter pointerup mouseup').each(shouldBeCalled) + }) + + it('events when element removed on pointerover', () => { + const btn = cy.$$('button:first') + const div = cy.$$('div#tabindex') + + // attachFocusListeners({ btn }) + attachMouseClickListeners({ btn, div }) + attachMouseHoverListeners({ btn, div }) + + btn.on('pointerover', () => { + // synchronously remove this button + btn.remove() + }) + + cy.contains('button').click() + + cy.getAll('btn', 'pointerover pointerenter').each(shouldBeCalled) + cy.getAll('btn', 'pointerdown mousedown mouseover mouseenter').each(shouldNotBeCalled) + cy.getAll('div', 'pointerover pointerenter pointerdown mousedown pointerup mouseup click').each(shouldBeCalled) + }) + + // https://github.com/cypress-io/cypress/issues/5459 + it('events when element moved on mousedown', () => { + const btn = cy.$$('button:first') + const div = cy.$$('div#tabindex') + const root = cy.$$('#dom') + + attachFocusListeners({ btn, div }) + attachMouseClickListeners({ btn, div, root }) + attachMouseHoverListeners({ btn, div }) + + const onEvent = cy.stub().callsFake(() => { + div.css(overlayStyle) + }) + + btn.on('mousedown', onEvent) + + // uncomment to manually test + // cy.wrap(onEvent).should('be.called') + cy.contains('button').click() + + cy.getAll('btn', 'mouseover mouseenter mousedown focus').each(shouldBeCalled) + cy.getAll('btn', 'click mouseup').each(shouldNotBeCalled) + cy.getAll('div', 'mouseover mouseenter mouseup').each(shouldBeCalled) + cy.getAll('div', 'click focus').each(shouldNotBeCalled) + cy.getAll('root', 'click').each(shouldBeCalled) + }) + + it('events when element moved on mouseup', () => { + const btn = cy.$$('button:first') + const div = cy.$$('div#tabindex') + + attachFocusListeners({ btn, div }) + attachMouseClickListeners({ btn, div }) + attachMouseHoverListeners({ btn, div }) + + const onEvent = cy.stub().callsFake(() => { + div.css(overlayStyle) + }) + + btn.on('mouseup', onEvent) + + // uncomment to manually test + // cy.wrap(onEvent).should('be.called') + cy.contains('button').click() + + cy.getAll('btn', 'mouseover mouseenter mousedown focus click mouseup').each(shouldBeCalled) + cy.getAll('div', 'mouseover mouseenter').each(shouldBeCalled) + cy.getAll('div', 'focus click mouseup mousedown').each(shouldNotBeCalled) + }) + + it('events when element moved on click', () => { + const btn = cy.$$('button:first') + const div = cy.$$('div#tabindex') + + attachFocusListeners({ btn, div }) + attachMouseClickListeners({ btn, div }) + attachMouseHoverListeners({ btn, div }) + + const onEvent = cy.stub().callsFake(() => { + div.css(overlayStyle) + }) + + btn.on('click', onEvent) + + // uncomment to manually test + // cy.wrap(onEvent).should('be.called') + cy.contains('button').click() + + cy.getAll('btn', 'mouseover mouseenter mousedown focus click mouseup').each(shouldBeCalled) + cy.getAll('div', 'focus click mouseup mousedown').each(shouldNotBeCalled) + }) + + // https://github.com/cypress-io/cypress/issues/5578 + it('click when mouseup el is child of mousedown el', () => { + const btn = cy.$$('button:first') + const span = $('foooo') + + attachFocusListeners({ btn, span }) + attachMouseClickListeners({ btn, span }) + attachMouseHoverListeners({ btn, span }) + + const onEvent = cy.stub().callsFake(() => { + // clicked = true + btn.html('') + btn.append(span) }) + btn.on('mousedown', onEvent) + + // uncomment to manually test + // cy.wrap(onEvent).should('be.called') cy.contains('button').click() + + cy.getAll('btn', 'mousedown focus click mouseup').each(shouldBeCalled) + cy.getAll('span', 'mouseup').each(shouldBeCalled) + cy.getAll('span', 'focus click mousedown').each(shouldNotBeCalled) + }) + + it('click when mousedown el is child of mouseup el', () => { + const btn = cy.$$('button:first') + const span = $('foooo') + + attachFocusListeners({ btn, span }) + attachMouseClickListeners({ btn, span }) + attachMouseHoverListeners({ btn, span }) + + btn.html('') + btn.append(span) + + const onEvent = cy.stub().callsFake(() => { + span.css({ marginLeft: 50 }) + }) + + btn.on('mousedown', onEvent) + + cy.get('button:first').click() + + cy.getAll('btn', 'mousedown focus click mouseup').each(shouldBeCalled) + cy.getAll('span', 'mousedown').each(shouldBeCalled) + cy.getAll('span', 'focus click mouseup').each(shouldNotBeCalled) + }) + + it('no click when new element at coords is not ancestor', () => { + const btn = cy.$$('button:first') + const span1 = $('foooo') + const span2 = $('baaaar') + + attachFocusListeners({ btn, span1, span2 }) + attachMouseClickListeners({ btn, span1, span2 }) + attachMouseHoverListeners({ btn, span1, span2 }) + + btn.html('') + btn.append(span1) + + const onEvent = cy.stub().callsFake(() => { + btn.html('') + btn.append(span2) + }) + + btn.on('mousedown', onEvent) + + // uncomment to manually test + // cy.wrap(onEvent).should('be.called') + cy.get('button:first').click() + + cy.getAll('btn', 'mouseenter mousedown mouseup').each(shouldBeCalled) + cy.getAll('btn', 'click focus').each(shouldNotBeCalled) + cy.getAll('span1', 'mouseover mouseenter mousedown').each(shouldBeCalled) + cy.getAll('span1', 'focus click mouseup').each(shouldNotBeCalled) + cy.getAll('span2', 'mouseup mouseover mouseenter').each(shouldBeCalled) + cy.getAll('span2', 'focus click mousedown').each(shouldNotBeCalled) }) it('does not fire a click when element has been removed on mouseup', () => { @@ -336,19 +533,56 @@ describe('src/cy/commands/actions/click', () => { $btn.on('mouseup', function () { // synchronously remove this button - return $(this).remove() + $(this).remove() }) $btn.on('click', () => { - return fail('should not have gotten click') + fail('btn should not have gotten click') + }) + + cy.$$('body').on('click', (e) => { + throw new Error('should not have happened') }) cy.contains('button').click() }) - it('silences errors on unfocusable elements', () => { - cy.$$('div:first') + it('does not fire a click or mouseup when element has been removed on pointerup', () => { + const $btn = cy.$$('button:first') + + $btn.on('pointerup', function () { + // synchronously remove this button + $(this).remove() + }) + + ;['mouseup', 'click'].forEach((eventName) => { + $btn.on(eventName, () => { + fail(`should not have gotten ${eventName}`) + }) + }) + + cy.contains('button').click() + }) + + it('sends modifiers', () => { + const btn = cy.$$('button:first') + + attachMouseClickListeners({ btn }) + + cy.get('input:first').type('{ctrl}{shift}', { release: false }) + cy.get('button:first').click() + + cy.getAll('btn', 'pointerdown mousedown pointerup mouseup click').each((stub) => { + expect(stub).to.be.calledWithMatch({ + shiftKey: true, + ctrlKey: true, + metaKey: false, + altKey: false, + }) + }) + }) + it('silences errors on unfocusable elements', () => { cy.get('div:first').click({ force: true }) }) @@ -356,7 +590,7 @@ describe('src/cy/commands/actions/click', () => { let blurred = false cy.$$('input:first').blur(() => { - return blurred = true + blurred = true }) cy @@ -413,7 +647,7 @@ describe('src/cy/commands/actions/click', () => { }) const clicked = cy.spy(() => { - return stop() + stop() }) const $anchors = cy.$$('#sequential-clicks a') @@ -429,7 +663,7 @@ describe('src/cy/commands/actions/click', () => { // is called const timeout = cy.spy(cy.timeout) - return _.delay(() => { + _.delay(() => { // and we should have stopped clicking after 3 expect(clicked.callCount).to.eq(3) @@ -444,24 +678,23 @@ describe('src/cy/commands/actions/click', () => { }) it('serially clicks a collection', () => { - let clicks = 0 + const throttled = cy.stub().as('clickcount') // create a throttled click function // which proves we are clicking serially - const throttled = _.throttle(() => { - return clicks += 1 - } - , 5, { leading: false }) + const handleClick = cy.stub() + .callsFake(_.throttle(throttled, 0, { leading: false })) + .as('handleClick') - const anchors = cy.$$('#sequential-clicks a') + const $anchors = cy.$$('#sequential-clicks a') - anchors.click(throttled) + $anchors.on('click', handleClick) - // make sure we're clicking multiple anchors - expect(anchors.length).to.be.gt(1) + // make sure we're clicking multiple $anchors + expect($anchors.length).to.be.gt(1) - cy.get('#sequential-clicks a').click({ multiple: true }).then(($anchors) => { - expect($anchors.length).to.eq(clicks) + cy.get('#sequential-clicks a').click({ multiple: true }).then(($els) => { + expect($els).to.have.length(throttled.callCount) }) }) @@ -473,9 +706,7 @@ describe('src/cy/commands/actions/click', () => { cy.get('#three-buttons button').click({ multiple: true }).then(() => { const calls = cy.timeout.getCalls() - const num = _.filter(calls, (call) => { - return _.isEqual(call.args, [50, true, 'click']) - }) + const num = _.filter(calls, (call) => _.isEqual(call.args, [50, true, 'click'])) expect(num.length).to.eq(count) }) @@ -528,7 +759,6 @@ describe('src/cy/commands/actions/click', () => { }) it('places cursor at the end of [contenteditable]', () => { - cy.get('[contenteditable]:first') .invoke('html', '

').click() .then(($el) => { @@ -612,45 +842,224 @@ describe('src/cy/commands/actions/click', () => { }) }) - describe('actionability', () => { + describe('pointer-events:none', () => { + beforeEach(function () { + cy.$$('
behind #ptrNone
').appendTo(cy.$$('#dom')) + this.ptrNone = cy.$$(`
#ptrNone
`).appendTo(cy.$$('#dom')) + cy.$$('
#ptrNone > div
').appendTo(this.ptrNone) - it('can click on inline elements that wrap lines', () => { - cy.get('#overflow-link').find('.wrapped').click() + this.logs = [] + cy.on('log:added', (attrs, log) => { + this.lastLog = log + + this.logs.push(log) + }) }) - it('can click elements which are hidden until scrolled within parent container', () => { - cy.get('#overflow-auto-container').contains('quux').click() + it('element behind pointer-events:none should still get click', () => { + cy.get('#ptr').click() // should pass with flying colors }) - it('does not scroll when being forced', () => { - const scrolled = [] + it('should be able to force on pointer-events:none with force:true', () => { + cy.get('#ptrNone').click({ timeout: 300, force: true }) + }) - cy.on('scrolled', ($el, type) => { - return scrolled.push(type) - }) + it('should error with message about pointer-events', function () { + const onError = cy.stub().callsFake((err) => { + const { lastLog } = this - cy - .get('button:last').click({ force: true }) - .then(() => { - expect(scrolled).to.be.empty - }) - }) + expect(err.message).to.contain(`has CSS 'pointer-events: none'`) + expect(err.message).to.not.contain('inherited from') + const consoleProps = lastLog.invoke('consoleProps') - it('does not scroll when position sticky and display flex', () => { - const scrolled = [] + expect(_.keys(consoleProps)).deep.eq([ + 'Command', + 'Tried to Click', + 'But it has CSS', + 'Error', + ]) - cy.on('scrolled', ($el, type) => { - return scrolled.push(type) + expect(consoleProps['But it has CSS']).to.eq('pointer-events: none') }) - cy.viewport(1000, 660) + cy.once('fail', onError) - const $body = cy.$$('body') + cy.get('#ptrNone').click({ timeout: 300 }) + .then(() => { + expect(onError).calledOnce + }) + }) - $body.css({ - padding: 0, - margin: 0, - }).children().remove() + it('should error with message about pointer-events and include inheritance', function () { + const onError = cy.stub().callsFake((err) => { + const { lastLog } = this + + expect(err.message).to.contain(`has CSS 'pointer-events: none', inherited from this element:`) + expect(err.message).to.contain('
{ + expect(onError).calledOnce + }) + }) + }) + + describe('pointer-events:none', () => { + beforeEach(function () { + cy.$$('
behind #ptrNone
').appendTo(cy.$$('#dom')) + this.ptrNone = cy.$$('
#ptrNone
').appendTo(cy.$$('#dom')) + cy.$$('
#ptrNone > div
').appendTo(this.ptrNone) + + this.logs = [] + cy.on('log:added', (attrs, log) => { + this.lastLog = log + + this.logs.push(log) + }) + }) + + it('element behind pointer-events:none should still get click', () => { + cy.get('#ptr').click() // should pass with flying colors + }) + + it('should be able to force on pointer-events:none with force:true', () => { + cy.get('#ptrNone').click({ timeout: 300, force: true }) + }) + + it('should error with message about pointer-events', function () { + const onError = cy.stub().callsFake((err) => { + const { lastLog } = this + + expect(err.message).to.contain('has CSS \'pointer-events: none\'') + expect(err.message).to.not.contain('inherited from') + const consoleProps = lastLog.invoke('consoleProps') + + expect(_.keys(consoleProps)).deep.eq([ + 'Command', + 'Tried to Click', + 'But it has CSS', + 'Error', + ]) + + expect(consoleProps['But it has CSS']).to.eq('pointer-events: none') + }) + + cy.once('fail', onError) + + cy.get('#ptrNone').click({ timeout: 300 }) + .then(() => { + expect(onError).calledOnce + }) + }) + + it('should error with message about pointer-events and include inheritance', function () { + const onError = cy.stub().callsFake((err) => { + const { lastLog } = this + + expect(err.message).to.contain('has CSS \'pointer-events: none\', inherited from this element:') + expect(err.message).to.contain('
{ + expect(onError).calledOnce + }) + }) + }) + + describe('actionability', () => { + it('can click on inline elements that wrap lines', () => { + cy.get('#overflow-link').find('.wrapped').click() + }) + + // readonly should only limit typing, not clicking + it('can click on readonly inputs', () => { + cy.get('#readonly-attr').click() + }) + + it('can click on readonly submit inputs', () => { + cy.get('#readonly-submit').click() + }) + + it('can click on checkbox inputs', () => { + cy.get(':checkbox:first').click() + .then(($el) => { + expect($el).to.be.checked + }) + }) + + it('can force click on disabled checkbox inputs', () => { + cy.get(':checkbox:first') + .then(($el) => { + $el[0].disabled = true + }) + .click({ force: true }) + .then(($el) => { + expect($el).to.be.checked + }) + }) + + it('can click elements which are hidden until scrolled within parent container', () => { + cy.get('#overflow-auto-container').contains('quux').click() + }) + + it('does not scroll when being forced', () => { + const scrolled = [] + + cy.on('scrolled', ($el, type) => { + scrolled.push(type) + }) + + cy + .get('button:last').click({ force: true }) + .then(() => { + expect(scrolled).to.be.empty + }) + }) + + it('does not scroll when position sticky and display flex', () => { + const scrolled = [] + + cy.on('scrolled', ($el, type) => { + scrolled.push(type) + }) + + cy.viewport(1000, 660) + + const $body = cy.$$('body') + + $body.children().remove() const $wrap = $('
') .attr('id', 'flex-wrap') @@ -659,11 +1068,10 @@ describe('src/cy/commands/actions/click', () => { }) .prependTo($body) - $(`\ -\ -`) + $(`\ + `) .attr('id', 'nav') .css({ position: 'sticky', @@ -754,15 +1162,15 @@ describe('src/cy/commands/actions/click', () => { let clicked = false cy.on('scrolled', ($el, type) => { - return scrolled.push(type) + scrolled.push(type) }) cy.on('command:retry', () => { - return retried = true + retried = true }) $btn.on('click', () => { - return clicked = true + clicked = true }) cy.get('#button-covered-in-span').click({ force: true }).then(() => { @@ -791,7 +1199,7 @@ describe('src/cy/commands/actions/click', () => { let retried = false cy.on('scrolled', ($el, type) => { - return scrolled.push(type) + scrolled.push(type) }) cy.on('command:retry', _.after(3, () => { @@ -812,9 +1220,16 @@ describe('src/cy/commands/actions/click', () => { }) it('scrolls the window past a fixed position element when being covered', () => { + const spy = cy.spy().as('mousedown') + $('') .attr('id', 'button-covered-in-nav') + .css({ + width: 120, + height: 20, + }) .appendTo(cy.$$('#fixed-nav-test')) + .mousedown(spy) $('').css({ position: 'fixed', @@ -828,14 +1243,27 @@ describe('src/cy/commands/actions/click', () => { const scrolled = [] cy.on('scrolled', ($el, type) => { - return scrolled.push(type) + scrolled.push(type) }) // - element scrollIntoView // - element scrollIntoView (retry animation coords) // - window - cy.get('#button-covered-in-nav').click().then(() => { + cy + .get('#button-covered-in-nav').click() + .then(($btn) => { + const rect = $btn.get(0).getBoundingClientRect() + const { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + + // this button should be 120 pixels wide + expect(rect.width).to.eq(120) + + const obj = spy.firstCall.args[0] + + // clientX + clientY are relative to the document expect(scrolled).to.deep.eq(['element', 'element', 'window']) + expect(obj).property('clientX').closeTo(fromElViewport.leftCenter, 1) + expect(obj).property('clientY').closeTo(fromElViewport.topCenter, 1) }) }) @@ -865,7 +1293,7 @@ describe('src/cy/commands/actions/click', () => { const scrolled = [] cy.on('scrolled', ($el, type) => { - return scrolled.push(type) + scrolled.push(type) }) // - element scrollIntoView @@ -922,7 +1350,7 @@ describe('src/cy/commands/actions/click', () => { const scrolled = [] cy.on('scrolled', ($el, type) => { - return scrolled.push(type) + scrolled.push(type) }) // - element scrollIntoView @@ -956,7 +1384,7 @@ describe('src/cy/commands/actions/click', () => { let clicks = 0 $btn.on('click', () => { - return clicks += 1 + clicks += 1 }) cy.on('command:retry', _.after(3, () => { @@ -975,7 +1403,7 @@ describe('src/cy/commands/actions/click', () => { let retries = 0 cy.on('command:retry', () => { - return retries += 1 + retries += 1 }) cy.stub(cy, 'ensureElementIsNotAnimating') @@ -1012,14 +1440,14 @@ describe('src/cy/commands/actions/click', () => { it('passes options.animationDistanceThreshold to cy.ensureElementIsNotAnimating', () => { const $btn = cy.$$('button:first') - const { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) cy.spy(cy, 'ensureElementIsNotAnimating') cy.get('button:first').click({ animationDistanceThreshold: 1000 }).then(() => { const { args } = cy.ensureElementIsNotAnimating.firstCall - expect(args[1]).to.deep.eq([fromWindow, fromWindow]) + expect(args[1]).to.deep.eq([fromElWindow, fromElWindow]) expect(args[2]).to.eq(1000) }) @@ -1030,14 +1458,14 @@ describe('src/cy/commands/actions/click', () => { const $btn = cy.$$('button:first') - const { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) cy.spy(cy, 'ensureElementIsNotAnimating') cy.get('button:first').click().then(() => { const { args } = cy.ensureElementIsNotAnimating.firstCall - expect(args[1]).to.deep.eq([fromWindow, fromWindow]) + expect(args[1]).to.deep.eq([fromElWindow, fromElWindow]) expect(args[2]).to.eq(animationDistanceThreshold) }) @@ -1052,13 +1480,13 @@ describe('src/cy/commands/actions/click', () => { } }) - return null + null }) it('eventually passes the assertion', () => { cy.$$('button:first').click(function () { _.delay(() => { - return $(this).addClass('clicked') + $(this).addClass('clicked') } , 50) @@ -1078,7 +1506,7 @@ describe('src/cy/commands/actions/click', () => { it('eventually passes the assertion on multiple buttons', () => { cy.$$('button').click(function () { _.delay(() => { - return $(this).addClass('clicked') + $(this).addClass('clicked') } , 50) @@ -1238,8 +1666,6 @@ describe('src/cy/commands/actions/click', () => { it('can pass options along with position', (done) => { const $btn = $('').attr('id', 'button-covered-in-span').css({ height: 100, width: 100 }).prependTo(cy.$$('body')) - $('span').css({ position: 'absolute', left: $btn.offset().left + 80, top: $btn.offset().top + 80, padding: 5, display: 'inline-block', backgroundColor: 'yellow' }).appendTo(cy.$$('body')) - $btn.on('click', () => { done() }) @@ -1249,7 +1675,7 @@ describe('src/cy/commands/actions/click', () => { }) describe('relative coordinate arguments', () => { - it('can specify x and y', (done) => { + it('can specify x and y', () => { const $btn = $('') .attr('id', 'button-covered-in-span') .css({ height: 100, width: 100 }) @@ -1259,21 +1685,25 @@ describe('src/cy/commands/actions/click', () => { .css({ position: 'absolute', left: $btn.offset().left + 50, top: $btn.offset().top + 65, padding: 5, display: 'inline-block', backgroundColor: 'yellow' }) .appendTo($btn) - const clicked = _.after(2, () => { - done() + cy.on('log:changed', (log, attr) => { + if (log.name === 'click' && attr._emittedAttrs.coords) { + expect(attr._emittedAttrs.coords).property('x') + expect(attr._emittedAttrs.coords).property('y') + } }) + const clicked = cy.stub() + $span.on('click', clicked) $btn.on('click', clicked) cy.get('#button-covered-in-span').click(75, 78) + .then(() => expect(clicked).calledTwice) }) it('can pass options along with x, y', (done) => { const $btn = $('').attr('id', 'button-covered-in-span').css({ height: 100, width: 100 }).prependTo(cy.$$('body')) - $('span').css({ position: 'absolute', left: $btn.offset().left + 50, top: $btn.offset().top + 65, padding: 5, display: 'inline-block', backgroundColor: 'yellow' }).appendTo(cy.$$('body')) - $btn.on('click', () => { done() }) @@ -1282,6 +1712,25 @@ describe('src/cy/commands/actions/click', () => { }) }) + describe('iframes', () => { + // https://github.com/cypress-io/cypress/issues/5449 + it('can type into click inside iframe with hover state', () => { + cy.$$('') .appendTo(cy.$$('body')) @@ -1472,7 +1656,7 @@ describe('src/cy/commands/actions/type', () => { cy.get('#generic-iframe') .then(($iframe) => { - $iframe.load(() => { + $iframe.on('load', () => { loaded = true }) }).scrollIntoView() @@ -1500,18 +1684,13 @@ describe('src/cy/commands/actions/type', () => { }) }) - // TODO: fix this with 4.0 updates - describe.skip('element reference loss', () => { + // type follows focus + // https://github.com/cypress-io/cypress/issues/2240 + describe('element reference loss', () => { it('follows the focus of the cursor', () => { - let charCount = 0 - - cy.$$('input:first').keydown(() => { - if (charCount === 3) { - cy.$$('input').eq(1).focus() - } - - charCount++ - }) + cy.$$('input:first').keydown(_.after(4, () => { + cy.$$('input').eq(1).focus() + })) cy.get('input:first').type('foobar').then(() => { cy.get('input:first').should('have.value', 'foo') @@ -1519,11 +1698,37 @@ describe('src/cy/commands/actions/type', () => { cy.get('input').eq(1).should('have.value', 'bar') }) }) + + it('follows focus into date input', () => { + cy.$$('input:first').on('input', _.after(3, _.once((e) => { + cy.$$('input[type=date]:first').focus() + }))) + + cy.get('input:first') + .type('foo2010-10-10') + .should('have.value', 'foo') + + cy.get('input[type=date]:first').should('have.value', '2010-10-10') + }) + + it('validates input after following focus change', (done) => { + cy.on('fail', (err) => { + expect(err.message).contain('fooBAR') + expect(err.message).contain('requires a valid date') + done() + }) + + cy.$$('input:first').on('input', _.after(3, (e) => { + cy.$$('input[type=date]:first').focus() + })) + + cy.get('input:first') + .type('fooBAR') + }) }) }) describe('specialChars', () => { - context('parseSpecialCharSequences: false', () => { it('types special character sequences literally', (done) => { cy.get(':text:first').invoke('val', 'foo') @@ -1649,6 +1854,13 @@ describe('src/cy/commands/actions/type', () => { }) }) + it('can delete all with {selectall}{backspace} in non-selectionrange element', () => { + cy.get('input[type=email]:first') + .should(($el) => $el.val('sdfsdf')) + .type('{selectall}{backspace}') + .should('have.value', '') + }) + it('can backspace a selection range of characters', () => { // select the 'ar' characters cy @@ -1743,16 +1955,17 @@ describe('src/cy/commands/actions/type', () => { }) }) - it('does fire input event when value changes', (done) => { - cy.$$(':text:first').on('input', (e) => { - done() - }) + it('{del} does fire input event when value changes', () => { + const onInput = cy.stub() + + cy.$$(':text:first').on('input', onInput) // select the 'a' characters cy .get(':text:first').invoke('val', 'bar').focus().then(($input) => { $input.get(0).setSelectionRange(0, 1) }).get(':text:first').type('{del}') + .then(() => expect(onInput).to.be.calledOnce) }) it('does not fire input event when value does not change', (done) => { @@ -2340,7 +2553,7 @@ describe('src/cy/commands/actions/type', () => { }) }) - it('does not fire input event', (done) => { + it('does not fire input event when no text inserted', (done) => { cy.$$(':text:first').on('input', (e) => { done('input should not have fired') }) @@ -2350,6 +2563,15 @@ describe('src/cy/commands/actions/type', () => { }) }) + // https://github.com/cypress-io/cypress/issues/3405 + it('does fire input event when text inserted', (done) => { + cy.$$('[contenteditable]:first').on('input', (e) => { + done() + }) + + cy.get('[contenteditable]:first').type('{enter}') + }) + it('inserts new line into textarea', () => { cy.get('#input-types textarea').invoke('val', 'foo').type('bar{enter}baz{enter}quux').then(($textarea) => { expect($textarea).to.have.value('foobar\nbaz\nquux') @@ -2537,9 +2759,7 @@ describe('src/cy/commands/actions/type', () => { }) describe('modifiers', () => { - describe('activating modifiers', () => { - it('sends keydown event for modifiers in order', (done) => { const $input = cy.$$('input:text:first') const events = [] @@ -2611,6 +2831,12 @@ describe('src/cy/commands/actions/type', () => { }) }) + // https://github.com/cypress-io/cypress/issues/5622 + it('still inserts text with non-shift modifiers', () => { + cy.get('input:first').type('{ctrl}{meta}foobar') + .should('have.value', 'foobar') + }) + it('does not maintain modifiers for subsequent click commands', (done) => { const $button = cy.$$('button:first') let mouseDownEvent = null @@ -2650,6 +2876,18 @@ describe('src/cy/commands/actions/type', () => { }) }) + // https://github.com/cypress-io/cypress/issues/5439 + it('do not replace selection during modifier key', () => { + cy + .get('input:first').type('123') + .then(($el) => { + $el[0].setSelectionRange(0, 3) + }) + .type('{ctrl}') + .should('have.value', '123') + }) + + // sends keyboard events for modifiers https://github.com/cypress-io/cypress/issues/3316 it('sends keyup event for activated modifiers when typing is finished', (done) => { const $input = cy.$$('input:text:first') const events = [] @@ -2679,7 +2917,6 @@ describe('src/cy/commands/actions/type', () => { }) describe('release: false', () => { - it('maintains modifiers for subsequent type commands', (done) => { const $input = cy.$$('input:text:first') const events = [] @@ -2797,7 +3034,6 @@ describe('src/cy/commands/actions/type', () => { }) describe('case-insensitivity', () => { - it('special chars are case-insensitive', () => { cy.get(':text:first').invoke('val', 'bar').type('{leftarrow}{DeL}').then(($input) => { expect($input).to.have.value('ba') @@ -2828,6 +3064,10 @@ describe('src/cy/commands/actions/type', () => { expect($input).to.have.value('FoO') }) }) + + it('{shift} does not capitalize characters', () => { + cy.get('input:first').type('{shift}foo').should('have.value', 'foo') + }) }) describe('click events', () => { @@ -2856,95 +3096,83 @@ describe('src/cy/commands/actions/type', () => { }) it('does not issue another click event between type/type', () => { - let clicked = 0 + const clicked = cy.stub() - cy.$$(':text:first').click(() => { - clicked += 1 - }) + cy.$$(':text:first').click(clicked) cy.get(':text:first').type('f').type('o').then(() => { - expect(clicked).to.eq(1) + expect(clicked).to.be.calledOnce }) }) it('does not issue another click event if element is already in focus from click', () => { - let clicked = 0 + const clicked = cy.stub() - cy.$$(':text:first').click(() => { - clicked += 1 - }) + cy.$$(':text:first').click(clicked) cy.get(':text:first').click().type('o').then(() => { - expect(clicked).to.eq(1) + expect(clicked).to.be.calledOnce }) }) }) describe('change events', () => { it('fires when enter is pressed and value has changed', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy.get(':text:first').invoke('val', 'foo').type('bar{enter}').then(() => { - expect(changed).to.eq(1) + expect(changed).to.be.calledOnce }) }) it('fires twice when enter is pressed and then again after losing focus', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy.get(':text:first').invoke('val', 'foo').type('bar{enter}baz').blur().then(() => { - expect(changed).to.eq(2) + expect(changed).to.be.calledTwice }) }) it('fires when element loses focus due to another action (click)', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy .get(':text:first').type('foo').then(() => { - expect(changed).to.eq(0) - }).get('button:first').click().then(() => { - expect(changed).to.eq(1) + expect(changed).not.to.be.called + }) + .get('button:first').click().then(() => { + expect(changed).to.be.calledOnce }) }) it('fires when element loses focus due to another action (type)', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy .get(':text:first').type('foo').then(() => { - expect(changed).to.eq(0) - }).get('textarea:first').type('bar').then(() => { - expect(changed).to.eq(1) + expect(changed).not.to.be.called + }) + .get('textarea:first').type('bar').then(() => { + expect(changed).to.be.calledOnce }) }) it('fires when element is directly blurred', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy .get(':text:first').type('foo').blur().then(() => { - expect(changed).to.eq(1) + expect(changed).to.be.calledOnce }) }) @@ -2958,72 +3186,62 @@ describe('src/cy/commands/actions/type', () => { // expect(changed).to.eq 1 it('does not fire twice if element is already in focus between type/type', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy.get(':text:first').invoke('val', 'foo').type('f').type('o{enter}').then(() => { - expect(changed).to.eq(1) + expect(changed).to.be.calledOnce }) }) it('does not fire twice if element is already in focus between clear/type', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy.get(':text:first').invoke('val', 'foo').clear().type('o{enter}').then(() => { - expect(changed).to.eq(1) + expect(changed).to.be.calledOnce }) }) it('does not fire twice if element is already in focus between click/type', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy.get(':text:first').invoke('val', 'foo').click().type('o{enter}').then(() => { - expect(changed).to.eq(1) + expect(changed).to.be.calledOnce }) }) it('does not fire twice if element is already in focus between type/click', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy.get(':text:first').invoke('val', 'foo').type('d{enter}').click().then(() => { - expect(changed).to.eq(1) + expect(changed).to.be.calledOnce }) }) it('does not fire at all between clear/type/click', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy.get(':text:first').invoke('val', 'foo').clear().type('o').click().then(($el) => { - expect(changed).to.eq(0) + expect(changed).not.to.be.called return $el }).blur() .then(() => { - expect(changed).to.eq(1) + expect(changed).to.be.calledOnce }) }) it('does not fire if {enter} is preventedDefault', () => { - let changed = 0 + const changed = cy.stub() cy.$$(':text:first').keypress((e) => { if (e.which === 13) { @@ -3031,59 +3249,49 @@ describe('src/cy/commands/actions/type', () => { } }) - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy.get(':text:first').invoke('val', 'foo').type('b{enter}').then(() => { - expect(changed).to.eq(0) + expect(changed).not.to.be.called }) }) it('does not fire when enter is pressed and value hasnt changed', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy.get(':text:first').invoke('val', 'foo').type('b{backspace}{enter}').then(() => { - expect(changed).to.eq(0) + expect(changed).not.to.be.called }) }) it('does not fire at the end of the type', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy .get(':text:first').type('foo').then(() => { - expect(changed).to.eq(0) + expect(changed).not.to.be.called }) }) it('does not fire change event if value hasnt actually changed', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy .get(':text:first').invoke('val', 'foo').type('{backspace}{backspace}oo{enter}').blur().then(() => { - expect(changed).to.eq(0) + expect(changed).not.to.be.called }) }) it('does not fire if mousedown is preventedDefault which prevents element from losing focus', () => { - let changed = 0 + const changed = cy.stub() - cy.$$(':text:first').change(() => { - changed += 1 - }) + cy.$$(':text:first').change(changed) cy.$$('textarea:first').mousedown(() => { return false @@ -3092,108 +3300,94 @@ describe('src/cy/commands/actions/type', () => { cy .get(':text:first').invoke('val', 'foo').type('bar') .get('textarea:first').click().then(() => { - expect(changed).to.eq(0) + expect(changed).not.to.be.called }) }) it('does not fire hitting {enter} inside of a textarea', () => { - let changed = 0 + const changed = cy.stub() - cy.$$('textarea:first').change(() => { - changed += 1 - }) + cy.$$('textarea:first').change(changed) cy .get('textarea:first').type('foo{enter}bar').then(() => { - expect(changed).to.eq(0) + expect(changed).not.to.be.called }) }) it('does not fire hitting {enter} inside of [contenteditable]', () => { - let changed = 0 + const changed = cy.stub() - cy.$$('[contenteditable]:first').change(() => { - changed += 1 - }) + cy.$$('[contenteditable]:first').change(changed) cy .get('[contenteditable]:first').type('foo{enter}bar').then(() => { - expect(changed).to.eq(0) + expect(changed).not.to.be.called }) }) // [contenteditable] does not fire ANY change events ever. it('does not fire at ALL for [contenteditable]', () => { - let changed = 0 + const changed = cy.stub() - cy.$$('[contenteditable]:first').change(() => { - changed += 1 - }) + cy.$$('[contenteditable]:first').change(changed) cy .get('[contenteditable]:first').type('foo') .get('button:first').click().then(() => { - expect(changed).to.eq(0) + expect(changed).not.to.be.called }) }) it('does not fire on .clear() without blur', () => { - let changed = 0 + const changed = cy.stub() - cy.$$('input:first').change(() => { - changed += 1 - }) + cy.$$('input:first').change(changed) cy.get('input:first').invoke('val', 'foo') .clear() .then(($el) => { - expect(changed).to.eq(0) + expect(changed).not.to.be.called return $el }).type('foo') .blur() .then(() => { - expect(changed).to.eq(0) + expect(changed).not.to.be.called }) }) it('fires change for single value change inputs', () => { - let changed = 0 + const changed = cy.stub() - cy.$$('input[type="date"]:first').change(() => { - return changed++ - }) + cy.$$('input[type="date"]:first').change(changed) cy.get('input[type="date"]:first') .type('1959-09-13') .blur() .then(() => { - expect(changed).to.eql(1) + expect(changed).to.be.calledOnce }) }) it('does not fire change for non-change single value input', () => { - let changed = 0 + const changed = cy.stub() - cy.$$('input[type="date"]:first').change(() => { - return changed++ - }) + cy.$$('input[type="date"]:first').change(changed) cy.get('input[type="date"]:first') .invoke('val', '1959-09-13') .type('1959-09-13') .blur() .then(() => { - expect(changed).to.eql(0) + expect(changed).not.to.be.called }) }) it('does not fire change for type\'d change that restores value', () => { - let changed = 0 + const changed = cy.stub() - cy.$$('input:first').change(() => { - return changed++ - }) + cy.$$('input:first').change(changed) cy.get('input:first') .invoke('val', 'foo') @@ -3202,20 +3396,35 @@ describe('src/cy/commands/actions/type', () => { .type('{backspace}r') .blur() .then(() => { - expect(changed).to.eql(0) + expect(changed).not.to.be.called }) }) }) - describe('caret position', () => { + describe('single value change inputs', () => { + // https://github.com/cypress-io/cypress/issues/5476 + it('fires all keyboard events', () => { + const els = { + $date: cy.$$('input[type=date]:first'), + } + + attachKeyListeners(els) + cy.get('input[type=date]:first') + .type('2019-12-10') + + cy.getAll('$date', keyEvents.join(' ')).each(shouldBeCalledWithCount(10)) + }) + }) + + describe('caret position', () => { it('respects being formatted by input event handlers') it('accurately returns host contenteditable attr', () => { const hostEl = cy.$$('
foo
').appendTo(cy.$$('body')) cy.get('#ce-inner1').then(($el) => { - expect($selection.getHostContenteditable($el[0])).to.eq(hostEl[0]) + expect(Cypress.dom.getHostContenteditable($el[0])).to.eq(hostEl[0]) }) }) @@ -3223,7 +3432,7 @@ describe('src/cy/commands/actions/type', () => { const hostEl = cy.$$('
foo
').appendTo(cy.$$('body')) cy.get('#ce-inner1').then(($el) => { - expect($selection.getHostContenteditable($el[0])).to.eq(hostEl[0]) + expect(Cypress.dom.getHostContenteditable($el[0])).to.eq(hostEl[0]) }) }) @@ -3231,7 +3440,7 @@ describe('src/cy/commands/actions/type', () => { const hostEl = cy.$$('
foo
').appendTo(cy.$$('body')) cy.get('#ce-inner1').then(($el) => { - expect($selection.getHostContenteditable($el[0])).to.eq(hostEl[0]) + expect(Cypress.dom.getHostContenteditable($el[0])).to.eq(hostEl[0]) }) }) @@ -3239,15 +3448,15 @@ describe('src/cy/commands/actions/type', () => { const hostEl = cy.$$('
foo
').appendTo(cy.$$('body')) cy.get('#ce-inner1').then(($el) => { - expect($selection.getHostContenteditable($el[0])).to.eq(hostEl[0]) + expect(Cypress.dom.getHostContenteditable($el[0])).to.eq(hostEl[0]) }) }) - it('accurately returns same el with no falsey contenteditable="false" attr', () => { + it('accurately returns document body el with no falsey contenteditable="false" attr', () => { cy.$$('
foo
').appendTo(cy.$$('body')) cy.get('#ce-inner1').then(($el) => { - expect($selection.getHostContenteditable($el[0])).to.eq($el[0]) + expect(Cypress.dom.getHostContenteditable($el[0])).to.eq($el[0].ownerDocument.body) }) }) @@ -3265,7 +3474,6 @@ describe('src/cy/commands/actions/type', () => { }) it('inside textarea', () => { - cy.$$('body').append(Cypress.$(/*html*/`\
\ \ @@ -3277,7 +3485,6 @@ describe('src/cy/commands/actions/type', () => { }) it('inside contenteditable', () => { - cy.$$('body').append(Cypress.$(/*html*/`\
\
\ @@ -3308,7 +3515,7 @@ describe('src/cy/commands/actions/type', () => { cy.get('input:first').invoke('attr', 'maxlength', '5').type('foobar{leftarrow}') cy.window().then((win) => { - expect($selection.getSelectionBounds(Cypress.$('input:first').get(0))) + expect(Cypress.dom.getSelectionBounds(Cypress.$('input:first').get(0))) .to.deep.eq({ start: 4, end: 4 }) }) }) @@ -3317,7 +3524,7 @@ describe('src/cy/commands/actions/type', () => { cy.get('input:first').type('foo{rightarrow}{rightarrow}{rightarrow}bar{rightarrow}') cy.window().then((win) => { - expect($selection.getSelectionBounds(Cypress.$('input:first').get(0))) + expect(Cypress.dom.getSelectionBounds(Cypress.$('input:first').get(0))) .to.deep.eq({ start: 6, end: 6 }) }) }) @@ -3326,7 +3533,7 @@ describe('src/cy/commands/actions/type', () => { cy.get('input:first').type(`oo{leftarrow}{leftarrow}{leftarrow}f${'{leftarrow}'.repeat(5)}`) cy.window().then((win) => { - expect($selection.getSelectionBounds(Cypress.$('input:first').get(0))) + expect(Cypress.dom.getSelectionBounds(Cypress.$('input:first').get(0))) .to.deep.eq({ start: 0, end: 0 }) }) }) @@ -3335,7 +3542,7 @@ describe('src/cy/commands/actions/type', () => { cy.get('[contenteditable]:first').type('foobar') cy.window().then((win) => { - expect($selection.getSelectionBounds(Cypress.$('[contenteditable]:first').get(0))) + expect(Cypress.dom.getSelectionBounds(Cypress.$('[contenteditable]:first').get(0))) .to.deep.eq({ start: 6, end: 6 }) }) }) @@ -3348,7 +3555,7 @@ describe('src/cy/commands/actions/type', () => { cy.get('[contenteditable]:first').type('bar') cy.window().then((win) => { - expect($selection.getSelectionBounds(Cypress.$('[contenteditable]:first').get(0))) + expect(Cypress.dom.getSelectionBounds(Cypress.$('[contenteditable]:first').get(0))) .to.deep.eq({ start: 6, end: 6 }) }) }) @@ -3357,7 +3564,7 @@ describe('src/cy/commands/actions/type', () => { cy.get('[contenteditable]:first').type('foo{leftarrow}{leftarrow}') cy.window().then((win) => { - expect($selection.getSelectionBounds(Cypress.$('[contenteditable]:first').get(0))) + expect(Cypress.dom.getSelectionBounds(Cypress.$('[contenteditable]:first').get(0))) .to.deep.eq({ start: 1, end: 1 }) }) }) @@ -3372,7 +3579,7 @@ describe('src/cy/commands/actions/type', () => { cy.get(':text:first').type('foobar') cy.window().then((win) => { - expect($selection.getSelectionBounds(Cypress.$(':text:first').get(0))) + expect(Cypress.dom.getSelectionBounds(Cypress.$(':text:first').get(0))) .to.deep.eq({ start: 6, end: 6 }) }) }) @@ -3381,7 +3588,7 @@ describe('src/cy/commands/actions/type', () => { cy.get('#comments').type('foobar') cy.window().then((win) => { - expect($selection.getSelectionBounds(Cypress.$('#comments').get(0))) + expect(Cypress.dom.getSelectionBounds(Cypress.$('#comments').get(0))) .to.deep.eq({ start: 6, end: 6 }) }) }) @@ -3462,7 +3669,9 @@ describe('src/cy/commands/actions/type', () => { .type('{{}{enter} foo: 1{enter} bar: 2{enter} baz: 3{enter}}') .should(($el) => { expectMatchInnerText($el, expected) - }).clear() + }) + .clear() + .blur() .type('{{}\n foo: 1\n bar: 2\n baz: 3\n}') .should(($el) => { expectMatchInnerText($el, expected) @@ -3537,16 +3746,16 @@ describe('src/cy/commands/actions/type', () => { }) it('triggers 2 form submit event', function () { - let submits = 0 + const submitted = cy.stub() this.$forms.find('#single-input').submit((e) => { e.preventDefault() - submits += 1 + submitted() }) cy.get('#single-input input').type('f{enter}{enter}').then(() => { - expect(submits).to.eq(2) + expect(submitted).to.be.calledTwice }) }) @@ -3649,14 +3858,18 @@ describe('src/cy/commands/actions/type', () => { }) context('2 inputs, no \'submit\' elements, only 1 input allowing implicit submission', () => { - it('does submit event', function (done) { + it('does submit event', function () { + const submit = cy.stub().as('submit') + this.$forms.find('#no-buttons-and-only-one-input-allowing-implicit-submission').submit((e) => { e.preventDefault() - - done() + submit() }) cy.get('#no-buttons-and-only-one-input-allowing-implicit-submission input:first').type('f{enter}') + cy.then(() => { + expect(submit).to.be.calledOnce + }) }) }) @@ -3744,15 +3957,21 @@ describe('src/cy/commands/actions/type', () => { }) }) - context('2 inputs, 1 \'submit\' button[type=submit], 1 \'reset\' button[type=reset]', () => { - it('triggers form submit', function (done) { + context(`2 inputs, 1 'submit' button[type=submit], 1 'reset' button[type=reset]`, () => { + it('triggers form submit', function () { + const submit = cy.stub() + this.$forms.find('#multiple-inputs-and-reset-and-submit-buttons').submit((e) => { e.preventDefault() - - done() + submit() }) - cy.get('#multiple-inputs-and-reset-and-submit-buttons input:first').type('foo{enter}') + cy.get('#multiple-inputs-and-reset-and-submit-buttons input:first') + .type('foo{enter}') + + cy.then(() => { + expect(submit).to.be.calledOnce + }) }) it('causes click event on the button[type=submit]', function (done) { @@ -4041,7 +4260,6 @@ describe('src/cy/commands/actions/type', () => { if (log.get('name') === 'type') { expect(log.get('state')).to.eq('pending') expect(log.get('$el').get(0)).to.eq($txt.get(0)) - } }) @@ -4092,60 +4310,57 @@ describe('src/cy/commands/actions/type', () => { context('#consoleProps', () => { it('has all of the regular options', () => { cy.get('input:first').type('foobar').then(function ($input) { - const { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($input) + const { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($input) const console = this.lastLog.invoke('consoleProps') expect(console.Command).to.eq('type') expect(console.Typed).to.eq('foobar') expect(console['Applied To']).to.eq($input.get(0)) - expect(console.Coords.x).to.be.closeTo(fromWindow.x, 1) + expect(console.Coords.x).to.be.closeTo(fromElWindow.x, 1) - expect(console.Coords.y).to.be.closeTo(fromWindow.y, 1) + expect(console.Coords.y).to.be.closeTo(fromElWindow.y, 1) }) }) + // Updated not to input text when non-shift modifier is pressed + // https://github.com/cypress-io/cypress/issues/5424 it('has a table of keys', () => { - cy.get(':text:first').type('{cmd}{option}foo{enter}b{leftarrow}{del}{enter}').then(function () { - const table = this.lastLog.invoke('consoleProps').table() + cy.get(':text:first').type('{cmd}{option}foo{enter}b{leftarrow}{del}{enter}') + .then(function ($input) { + const table = this.lastLog.invoke('consoleProps').table[2]() // eslint-disable-next-line console.table(table.data, table.columns) - expect(table.columns).to.deep.eq([ - 'typed', 'which', 'keydown', 'keypress', 'textInput', 'input', 'keyup', 'change', 'modifiers', - ]) - - expect(table.name).to.eq('Key Events Table') + expect(table.name).to.eq('Keyboard Events') const expectedTable = { - 1: { typed: '', which: 91, keydown: true, modifiers: 'meta' }, - 2: { typed: '', which: 18, keydown: true, modifiers: 'alt, meta' }, - 3: { typed: 'f', which: 70, keydown: true, keypress: true, textInput: true, input: true, keyup: true, modifiers: 'alt, meta' }, - 4: { typed: 'o', which: 79, keydown: true, keypress: true, textInput: true, input: true, keyup: true, modifiers: 'alt, meta' }, - 5: { typed: 'o', which: 79, keydown: true, keypress: true, textInput: true, input: true, keyup: true, modifiers: 'alt, meta' }, - 6: { typed: '{enter}', which: 13, keydown: true, keypress: true, keyup: true, change: true, modifiers: 'alt, meta' }, - 7: { typed: 'b', which: 66, keydown: true, keypress: true, textInput: true, input: true, keyup: true, modifiers: 'alt, meta' }, - 8: { typed: '{leftarrow}', which: 37, keydown: true, keyup: true, modifiers: 'alt, meta' }, - 9: { typed: '{del}', which: 46, keydown: true, input: true, keyup: true, modifiers: 'alt, meta' }, - 10: { typed: '{enter}', which: 13, keydown: true, keypress: true, keyup: true, modifiers: 'alt, meta' }, + 1: { 'Details': '{ code: MetaLeft, which: 91 }', Typed: '{cmd}', 'Events Fired': 'keydown', 'Active Modifiers': 'meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 2: { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{option}', 'Events Fired': 'keydown', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 3: { 'Details': '{ code: KeyF, which: 70 }', Typed: 'f', 'Events Fired': 'keydown, keypress, textInput, input, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 4: { 'Details': '{ code: KeyO, which: 79 }', Typed: 'o', 'Events Fired': 'keydown, keypress, textInput, input, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 5: { 'Details': '{ code: KeyO, which: 79 }', Typed: 'o', 'Events Fired': 'keydown, keypress, textInput, input, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 6: { 'Details': '{ code: Enter, which: 13 }', Typed: '{enter}', 'Events Fired': 'keydown, keypress, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 7: { 'Details': '{ code: KeyB, which: 66 }', Typed: 'b', 'Events Fired': 'keydown, keypress, textInput, input, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 8: { 'Details': '{ code: ArrowLeft, which: 37 }', Typed: '{leftarrow}', 'Events Fired': 'keydown, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 9: { 'Details': '{ code: Delete, which: 46 }', Typed: '{del}', 'Events Fired': 'keydown, input, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 10: { 'Details': '{ code: Enter, which: 13 }', Typed: '{enter}', 'Events Fired': 'keydown, keypress, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + 11: { 'Details': '{ code: MetaLeft, which: 91 }', Typed: '{cmd}', 'Events Fired': 'keyup', 'Active Modifiers': 'alt', 'Prevented Default': null, 'Target Element': $input[0] }, + 12: { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{option}', 'Events Fired': 'keyup', 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] }, } - return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => { - expect(table.data[i]).to.deep.eq(expectedTable[i]) - }) + // uncomment for debugging + // _.each(table.data, (v, i) => expect(v).containSubset(expectedTable[i])) + expect(table.data).to.deep.eq(expectedTable) + expect($input.val()).eq('foo') }) }) - // table.data.forEach (item, i) -> - // expect(item).to.deep.eq(expectedTable[i]) - - // expect(table.data).to.deep.eq(expectedTable) - it('has no modifiers when there are none activated', () => { - cy.get(':text:first').type('f').then(function () { - const table = this.lastLog.invoke('consoleProps').table() + cy.get(':text:first').type('f').then(function ($el) { + const table = this.lastLog.invoke('consoleProps').table[2]() expect(table.data).to.deep.eq({ - 1: { typed: 'f', which: 70, keydown: true, keypress: true, textInput: true, input: true, keyup: true }, + 1: { Typed: 'f', 'Events Fired': 'keydown, keypress, textInput, input, keyup', 'Active Modifiers': null, Details: '{ code: KeyF, which: 70 }', 'Prevented Default': null, 'Target Element': $el[0] }, }) }) }) @@ -4155,14 +4370,14 @@ describe('src/cy/commands/actions/type', () => { return false }) - cy.get(':text:first').type('f').then(function () { - const table = this.lastLog.invoke('consoleProps').table() + cy.get(':text:first').type('f').then(function ($el) { + const table = this.lastLog.invoke('consoleProps').table[2]() // eslint-disable-next-line console.table(table.data, table.columns) expect(table.data).to.deep.eq({ - 1: { typed: 'f', which: 70, keydown: 'preventedDefault', keyup: true }, + 1: { Typed: 'f', 'Events Fired': 'keydown, keyup', 'Active Modifiers': null, Details: '{ code: KeyF, which: 70 }', 'Prevented Default': true, 'Target Element': $el[0] }, }) }) }) @@ -4193,16 +4408,16 @@ describe('src/cy/commands/actions/type', () => { }) it('throws when subject is not in the document', (done) => { - let typed = 0 + const typed = cy.stub() const input = cy.$$('input:first').keypress((e) => { - typed += 1 + typed() input.remove() }) cy.on('fail', (err) => { - expect(typed).to.eq(1) + expect(typed).to.be.calledOnce expect(err.message).to.include('cy.type() failed because this element') done() @@ -4231,14 +4446,14 @@ describe('src/cy/commands/actions/type', () => { }) it('throws when not textarea or text-like', (done) => { - cy.get('form').type('foo') + cy.timeout(300) + cy.get('div#nested-find').type('foo') cy.on('fail', (err) => { expect(err.message).to.include('cy.type() failed because it requires a valid typeable element.') expect(err.message).to.include('The element typed into was:') - expect(err.message).to.include('
...
') - expect(err.message).to.include('Cypress considers the \'body\', \'textarea\', any \'element\' with a \'tabindex\' or \'contenteditable\' attribute, or any \'input\' with a \'type\' attribute of \'text\', \'password\', \'email\', \'number\', \'date\', \'week\', \'month\', \'time\', \'datetime\', \'datetime-local\', \'search\', \'url\', or \'tel\' to be valid typeable elements.') - + expect(err.message).to.include('
Nested ...
') + expect(err.message).to.include(`A typeable element matches one of the following selectors:`) done() }) }) @@ -4333,7 +4548,7 @@ describe('src/cy/commands/actions/type', () => { cy.on('fail', (err) => { expect(this.logs.length).to.eq(2) - const allChars = _.keys(cy.devices.keyboard.specialChars).concat(_.keys(cy.devices.keyboard.modifierChars)).join(', ') + const allChars = _.keys(Cypress.Keyboard.getKeymap()).join(', ') expect(err.message).to.eq(`Special character sequence: '{bar}' is not recognized. Available sequences are: ${allChars} @@ -4351,7 +4566,6 @@ https://on.cypress.io/type`) cy.on('fail', (err) => { expect(this.logs.length).to.eq(2) expect(err.message).to.eq('{tab} isn\'t a supported character sequence. You\'ll want to use the command cy.tab(), which is not ready yet, but when it is done that\'s what you\'ll use.') - done() }) @@ -4362,7 +4576,6 @@ https://on.cypress.io/type`) cy.on('fail', (err) => { expect(this.logs.length).to.eq(2) expect(err.message).to.eq('cy.type() cannot accept an empty String. You need to actually type something.') - done() }) @@ -4390,13 +4603,15 @@ https://on.cypress.io/type`) }) }) - _.each(['Ω≈ç√∫˜µ≤≥÷', '2.2250738585072011e-308', '田中さんにあげて下さい', - '', '⁰⁴⁵₀₁₂', '🐵 🙈 🙉 🙊', - '', '$USER'], (val) => { - it(`allows typing some naughtly strings (${val})`, () => { - cy - .get(':text:first').type(val) - .should('have.value', val) + describe('naughty strings', () => { + _.each(['Ω≈ç√∫˜µ≤≥÷', '2.2250738585072011e-308', '田中さんにあげて下さい', + '', '⁰⁴⁵₀₁₂', '🐵 🙈 🙉 🙊', + '', '$USER'], (val) => { + it(`allows typing some naughty strings (${val})`, () => { + cy + .get(':text:first').type(val) + .should('have.value', val) + }) }) }) @@ -4413,22 +4628,23 @@ https://on.cypress.io/type`) .should('have.value', 'foobar') }) - _.each([NaN, Infinity, [], {}, null, undefined], (val) => { - it(`throws when trying to type: ${val}`, function (done) { - const logs = [] + describe('throws when trying to type', () => { + _.each([NaN, Infinity, [], {}, null, undefined], (val) => { + it(`throws when trying to type: ${val}`, function (done) { + const logs = [] - cy.on('log:added', (attrs, log) => { - return logs.push(log) - }) + cy.on('log:added', (attrs, log) => { + return logs.push(log) + }) - cy.on('fail', (err) => { - expect(this.logs.length).to.eq(2) - expect(err.message).to.eq(`cy.type() can only accept a String or Number. You passed in: '${val}'`) + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(2) + expect(err.message).to.eq(`cy.type() can only accept a String or Number. You passed in: '${val}'`) + done() + }) - done() + cy.get(':text:first').type(val) }) - - cy.get(':text:first').type(val) }) }) @@ -4438,14 +4654,12 @@ https://on.cypress.io/type`) // force the animation calculation to think we moving at a huge distance ;-) cy.stub(Cypress.utils, 'getDistanceBetween').returns(100000) - let keydowns = 0 + const keydown = cy.stub() - cy.$$(':text:first').on('keydown', () => { - keydowns += 1 - }) + cy.$$(':text:first').on('keydown', keydown) cy.on('fail', (err) => { - expect(keydowns).to.eq(0) + expect(keydown).not.to.be.called expect(err.message).to.include('cy.type() could not be issued because this element is currently animating:\n') done() @@ -4681,24 +4895,22 @@ https://on.cypress.io/type`) }) it('waits until element is no longer disabled', () => { + const clicked = cy.stub() const textarea = cy.$$('#comments').val('foo bar').prop('disabled', true) - let retried = false - let clicks = 0 + const retried = cy.stub() - textarea.on('click', () => { - clicks += 1 - }) + textarea.on('click', clicked) cy.on('command:retry', _.after(3, () => { textarea.prop('disabled', false) - retried = true + retried() })) cy.get('#comments').clear().then(() => { - expect(clicks).to.eq(1) + expect(clicked).to.be.calledOnce - expect(retried).to.be.true + expect(retried).to.be.called }) }) @@ -4718,14 +4930,12 @@ https://on.cypress.io/type`) }) .prependTo(cy.$$('body')) - let clicked = false + const clicked = cy.stub() - $input.on('click', () => { - clicked = true - }) + $input.on('click', clicked) cy.get('#input-covered-in-span').clear({ force: true }).then(() => { - expect(clicked).to.be.true + expect(clicked).to.be.called }) }) @@ -4835,16 +5045,16 @@ https://on.cypress.io/type`) }) it('throws when subject is not in the document', (done) => { - let cleared = 0 + const cleared = cy.stub() const input = cy.$$('input:first').val('123').keydown((e) => { - cleared += 1 + cleared() input.remove() }) cy.on('fail', (err) => { - expect(cleared).to.eq(1) + expect(cleared).to.be.calledOnce expect(err.message).to.include('cy.clear() failed because this element') done() @@ -4862,7 +5072,7 @@ https://on.cypress.io/type`) expect(err.message).to.include('cy.clear() failed because it requires a valid clearable element.') expect(err.message).to.include('The element cleared was:') expect(err.message).to.include('
...
') - expect(err.message).to.include('Cypress considers a \'textarea\', any \'element\' with a \'contenteditable\' attribute, or any \'input\' with a \'type\' attribute of \'text\', \'password\', \'email\', \'number\', \'date\', \'week\', \'month\', \'time\', \'datetime\', \'datetime-local\', \'search\', \'url\', or \'tel\' to be valid clearable elements.') + expect(err.message).to.include(`A clearable element matches one of the following selectors:`) done() }) @@ -4875,7 +5085,7 @@ https://on.cypress.io/type`) expect(err.message).to.include('cy.clear() failed because it requires a valid clearable element.') expect(err.message).to.include('The element cleared was:') expect(err.message).to.include('
...
') - expect(err.message).to.include('Cypress considers a \'textarea\', any \'element\' with a \'contenteditable\' attribute, or any \'input\' with a \'type\' attribute of \'text\', \'password\', \'email\', \'number\', \'date\', \'week\', \'month\', \'time\', \'datetime\', \'datetime-local\', \'search\', \'url\', or \'tel\' to be valid clearable elements.') + expect(err.message).to.include(`A clearable element matches one of the following selectors:`) done() }) @@ -4888,8 +5098,7 @@ https://on.cypress.io/type`) expect(err.message).to.include('cy.clear() failed because it requires a valid clearable element.') expect(err.message).to.include('The element cleared was:') expect(err.message).to.include('') - expect(err.message).to.include('Cypress considers a \'textarea\', any \'element\' with a \'contenteditable\' attribute, or any \'input\' with a \'type\' attribute of \'text\', \'password\', \'email\', \'number\', \'date\', \'week\', \'month\', \'time\', \'datetime\', \'datetime-local\', \'search\', \'url\', or \'tel\' to be valid clearable elements.') - + expect(err.message).to.include(`A clearable element matches one of the following selectors:`) done() }) @@ -4901,7 +5110,7 @@ https://on.cypress.io/type`) expect(err.message).to.include('cy.clear() failed because it requires a valid clearable element.') expect(err.message).to.include('The element cleared was:') expect(err.message).to.include('') - expect(err.message).to.include('Cypress considers a \'textarea\', any \'element\' with a \'contenteditable\' attribute, or any \'input\' with a \'type\' attribute of \'text\', \'password\', \'email\', \'number\', \'date\', \'week\', \'month\', \'time\', \'datetime\', \'datetime-local\', \'search\', \'url\', or \'tel\' to be valid clearable elements.') + expect(err.message).to.include(`A clearable element matches one of the following selectors:`) done() }) @@ -5075,4 +5284,29 @@ https://on.cypress.io/type`) }) }) }) + + describe('user experience', () => { + it('can print table of keys on click', () => { + cy.get('input:first').type('foo') + + .then(() => { + return withMutableReporterState(() => { + const spyTableName = cy.spy(top.console, 'groupCollapsed') + const spyTableData = cy.spy(top.console, 'table') + + const commandLogEl = getCommandLogWithText('foo') + + const reactCommandInstance = findReactInstance(commandLogEl[0]) + + reactCommandInstance.props.appState.isRunning = false + + $(commandLogEl).find('.command-wrapper').click() + + expect(spyTableName.firstCall).calledWith('Mouse Events') + expect(spyTableName.secondCall).calledWith('Keyboard Events') + expect(spyTableData).calledTwice + }) + }) + }) + }) }) diff --git a/packages/driver/test/cypress/integration/commands/angular_spec.coffee b/packages/driver/test/cypress/integration/commands/angular_spec.coffee index abdcb8a9e78..274671c0e74 100644 --- a/packages/driver/test/cypress/integration/commands/angular_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/angular_spec.coffee @@ -41,6 +41,7 @@ describe "src/cy/commands/angular", -> cy.ng("binding", "not-found") it "cancels additional finds when aborted", (done) -> + cy.timeout(1000) cy.stub(Cypress.runner, "stop") retry = _.after 2, => @@ -108,6 +109,7 @@ describe "src/cy/commands/angular", -> cy.ng("repeater", "not-found") it "cancels additional finds when aborted", (done) -> + cy.timeout(1000) cy.stub(Cypress.runner, "stop") retry = _.after 2, => @@ -221,6 +223,7 @@ describe "src/cy/commands/angular", -> done() it "cancels additional finds when aborted", (done) -> + cy.timeout(1000) cy.stub(Cypress.runner, "stop") retry = _.after 2, => diff --git a/packages/driver/test/cypress/integration/commands/assertions_spec.coffee b/packages/driver/test/cypress/integration/commands/assertions_spec.coffee deleted file mode 100644 index 6a0b3db5da0..00000000000 --- a/packages/driver/test/cypress/integration/commands/assertions_spec.coffee +++ /dev/null @@ -1,1987 +0,0 @@ -$ = Cypress.$ -_ = Cypress._ - -helpers = require("../../support/helpers") - -describe "src/cy/commands/assertions", -> - before -> - cy - .visit("/fixtures/jquery.html") - .then (win) -> - @body = win.document.body.outerHTML - - beforeEach -> - doc = cy.state("document") - - $(doc.body).empty().html(@body) - - context "#should", -> - beforeEach -> - @logs = [] - - cy.on "log:added", (attrs, log) => - @logs.push(log) - @lastLog = log - - return null - - it "returns the subject for chainability", -> - cy.noop({foo: "bar"}).should("deep.eq", {foo: "bar"}).then (obj) -> - expect(obj).to.deep.eq {foo: "bar"} - - it "can use negation", -> - cy.noop(false).should("not.be.true") - - it "works with jquery chai", -> - div = $("
asdf
") - - cy.$$("body").append(div) - - cy - .get("div.foo").should("have.class", "foo").then ($div) -> - expect($div).to.match div - $div.remove() - - it "can chain multiple assertions", -> - cy - .get("body") - .should("contain", "div") - .should("have.property", "length", 1) - - it "skips over utility commands", -> - cy.on "command:retry", _.after 2, => - cy.$$("div:first").addClass("foo") - - cy.on "command:retry", _.after 4, => - cy.$$("div:first").attr("id", "bar") - - cy.get("div:first").should("have.class", "foo").debug().and("have.id", "bar") - - it "skips over aliasing", -> - cy.on "command:retry", _.after 2, => - cy.$$("div:first").addClass("foo") - - cy.on "command:retry", _.after 4, => - cy.$$("div:first").attr("id", "bar") - - cy.get("div:first").as("div").should("have.class", "foo").debug().and("have.id", "bar") - - it "can change the subject", -> - cy.get("input:first").should("have.property", "length").should("eq", 1).then (num) -> - expect(num).to.eq(1) - - it "changes the subject with chai-jquery", -> - cy.$$("input:first").attr("id", "input") - - cy.get("input:first").should("have.attr", "id").should("eq", "input") - - it "changes the subject with JSON", -> - obj = {requestJSON: {teamIds: [2]}} - cy.noop(obj).its("requestJSON").should("have.property", "teamIds").should("deep.eq", [2]) - - ## TODO: make cy.then retry - ## https://github.com/cypress-io/cypress/issues/627 - it.skip "outer assertions retry on cy.then", -> - obj = {foo: "bar"} - - cy.wrap(obj).then -> - setTimeout -> - obj.foo = "baz" - , 1000 - - return obj - .should("deep.eq", {foo: "baz"}) - - it "does it retry when wrapped", -> - obj = { foo: "bar" } - - cy.wrap(obj).then -> - setTimeout -> - obj.foo = "baz" - , 100 - - return cy.wrap(obj) - .should("deep.eq", { foo: "baz" }) - - describe "function argument", -> - it "waits until function is true", -> - button = cy.$$("button:first") - - cy.on "command:retry", _.after 2, => - button.addClass("ready") - - cy.get("button:first").should ($button) -> - expect($button).to.have.class("ready") - - it "works with regular objects", -> - obj = {} - - cy.on "command:retry", _.after 2, => - obj.foo = "bar" - - cy.wrap(obj).should (o) -> - expect(o).to.have.property("foo").and.eq("bar") - .then -> - ## wrap + have property + and eq - expect(@logs.length).to.eq(3) - - it "logs two assertions", -> - _.delay => - cy.$$("body").addClass("foo") - , Math.random() * 300 - - _.delay => - cy.$$("body").prop("id", "bar") - , Math.random() * 300 - - cy - .get("body").should ($body) -> - expect($body).to.have.class("foo") - expect($body).to.have.id("bar") - .then -> - cy.$$("body").removeClass("foo").removeAttr("id") - - expect(@logs.length).to.eq(3) - - ## the messages should have been updated to reflect - ## the current state of the element - expect(@logs[1].get("message")).to.eq("expected **** to have class **foo**") - expect(@logs[2].get("message")).to.eq("expected **** to have id **bar**") - - it "logs assertions as children even if subject is different", -> - _.delay => - cy.$$("body").addClass("foo") - , Math.random() * 300 - - _.delay => - cy.$$("body").prop("id", "bar") - , Math.random() * 300 - - cy - .get("body").should ($body) -> - expect($body.attr("class")).to.match(/foo/) - expect($body.attr("id")).to.include("bar") - .then -> - cy.$$("body").removeClass("foo").removeAttr("id") - - types = _.map @logs, (l) -> l.get("type") - expect(types).to.deep.eq(["parent", "child", "child"]) - - expect(@logs.length).to.eq(4) - - context "remote jQuery instances", -> - beforeEach -> - @remoteWindow = cy.state("window") - - it "yields the remote jQuery instance", -> - @remoteWindow.$.fn.__foobar = fn = -> - - cy - .get("input:first").should ($input) -> - isInstanceOf = Cypress.utils.isInstanceOf($input, @remoteWindow.$) - hasProp = $input.__foobar is fn - - expect(isInstanceOf).to.be.true - expect(hasProp).to.to.true - - describe "not.exist", -> - it "resolves eventually not exist", -> - button = cy.$$("button:first") - - cy.on "command:retry", _.after 2, _.once -> - button.remove() - - cy.get("button:first").click().should("not.exist") - - it "resolves all 3 assertions", (done) -> - logs = [] - - cy.on "log:added", (attrs, log) -> - if log.get("name") is "assert" - logs.push(log) - - if logs.length is 3 - done() - - cy - .get("#does-not-exist1").should("not.exist") - .get("#does-not-exist2").should("not.exist") - .get("#does-not-exist3").should("not.exist") - - describe "have.text", -> - it "resolves the assertion", -> - cy.get("#list li").eq(0).should("have.text", "li 0").then -> - lastLog = @lastLog - - expect(lastLog.get("name")).to.eq("assert") - expect(lastLog.get("state")).to.eq("passed") - expect(lastLog.get("ended")).to.be.true - - describe "have.length", -> - it "allows valid string numbers", -> - length = cy.$$("button").length - - cy.get("button").should("have.length", ""+length) - - it "throws when should('have.length') isnt a number", (done) -> - cy.on "fail", (err) -> - expect(err.message).to.eq "You must provide a valid number to a length assertion. You passed: 'asdf'" - done() - - cy.get("button").should("have.length", "asdf") - - it "does not log extra commands on fail and properly fails command + assertions", (done) -> - cy.on "fail", (err) => - expect(@logs.length).to.eq(6) - - expect(@logs[3].get("name")).to.eq("get") - expect(@logs[3].get("state")).to.eq("failed") - expect(@logs[3].get("error")).to.eq(err) - - expect(@logs[4].get("name")).to.eq("assert") - expect(@logs[4].get("state")).to.eq("failed") - expect(@logs[4].get("error").name).to.eq("AssertionError") - - done() - - cy - .root().should("exist").and("contain", "foo") - .get("button").should("have.length", "asdf") - - it "finishes failed assertions and does not log extra commands when cy.contains fails", (done) -> - cy.on "fail", (err) => - expect(@logs.length).to.eq(2) - - expect(@logs[0].get("name")).to.eq("contains") - expect(@logs[0].get("state")).to.eq("failed") - expect(@logs[0].get("error")).to.eq(err) - - expect(@logs[1].get("name")).to.eq("assert") - expect(@logs[1].get("state")).to.eq("failed") - expect(@logs[1].get("error").name).to.eq("AssertionError") - - done() - - cy.contains("Nested Find").should("have.length", 2) - - describe "have.class", -> - it "snapshots and ends the assertion after retrying", -> - cy.on "command:retry", _.after 3, => - cy.$$("#foo").addClass("active") - - cy.contains("foo").should("have.class", "active").then -> - lastLog = @lastLog - - expect(lastLog.get("name")).to.eq("assert") - expect(lastLog.get("ended")).to.be.true - expect(lastLog.get("state")).to.eq("passed") - expect(lastLog.get("snapshots").length).to.eq(1) - expect(lastLog.get("snapshots")[0]).to.be.an("object") - - it "retries assertion until true", -> - button = cy.$$("button:first") - - retry = _.after 3, -> - button.addClass("new-class") - - cy.on "command:retry", retry - - cy.get("button:first").should("have.class", "new-class") - - describe "errors", -> - beforeEach -> - Cypress.config("defaultCommandTimeout", 50) - - it "should not be true", (done) -> - cy.on "fail", (err) -> - expect(err.message).to.eq "expected false to be true" - done() - - cy.noop(false).should("be.true") - - it "throws err when not available chainable", (done) -> - cy.on "fail", (err) -> - expect(err.message).to.eq "The chainer: 'dee' was not found. Could not build assertion." - done() - - cy.noop({}).should("dee.eq", {}) - - it "throws err when ends with a non available chainable", (done) -> - cy.on "fail", (err) -> - expect(err.message).to.eq "The chainer: 'eq2' was not found. Could not build assertion." - done() - - cy.noop({}).should("deep.eq2", {}) - - it "logs 'should' when non available chainer", (done) -> - cy.on "fail", (err) => - lastLog = @lastLog - - expect(@logs.length).to.eq(2) - expect(lastLog.get("name")).to.eq("should") - expect(lastLog.get("error")).to.eq(err) - expect(lastLog.get("state")).to.eq("failed") - expect(lastLog.get("snapshots").length).to.eq(1) - expect(lastLog.get("snapshots")[0]).to.be.an("object") - expect(lastLog.get("message")).to.eq("not.contain2, does-not-exist-foo-bar") - done() - - cy.get("div:first").should("not.contain2", "does-not-exist-foo-bar") - - it "throws when eventually times out", (done) -> - cy.on "fail", (err) -> - expect(err.message).to.eq "Timed out retrying: expected '
") - @$div.html = -> throw new Error("html called") - - it "html, not html, contain html", -> - expect(@$div).to.have.html("") ## 1 - expect(@$div).not.to.have.html("foo") ## 2 - expect(@logs.length).to.eq(2) - - l1 = @logs[0] - l2 = @logs[1] - - expect(l1.get("message")).to.eq( - "expected **
** to have HTML ****" - ) - - expect(l2.get("message")).to.eq( - "expected **
** not to have HTML **foo**" - ) - - @clearLogs() - expect(@$div).to.contain.html("**" - ) - - @clearLogs() - try - expect(@$div).to.contain.html("span") - catch err - expect(@logs[0].get("message")).to.eq( - "expected **
** to contain HTML **span**, but the HTML was ****" - ) - - it "throws when obj is not DOM", (done) -> - cy.on "fail", (err) => - expect(@logs.length).to.eq(1) - expect(@logs[0].get("error").message).to.eq( - "expected null to have HTML 'foo'" - ) - expect(err.message).to.include("> html") - expect(err.message).to.include("> null") - - done() - - expect(null).to.have.html("foo") - - it "partial match", -> - expect(@$div).to.contain.html('button') - expect(@$div).to.include.html('button') - expect(@$div).to.not.contain.html('span') - cy.get('button').should('contain.html', 'button') - - context "text", -> - beforeEach -> - @$div = $("
foo
") - @$div.text = -> throw new Error("text called") - - it "text, not text, contain text", -> - expect(@$div).to.have.text("foo") ## 1 - expect(@$div).not.to.have.text("bar") ## 2 - - expect(@logs.length).to.eq(2) - - l1 = @logs[0] - l2 = @logs[1] - - expect(l1.get("message")).to.eq( - "expected **
** to have text **foo**" - ) - - expect(l2.get("message")).to.eq( - "expected **
** not to have text **bar**" - ) - - @clearLogs() - expect(@$div).to.contain.text("f") - expect(@logs[0].get("message")).to.eq( - "expected **
** to contain text **f**" - ) - - @clearLogs() - expect(@$div).to.not.contain.text("foob") - expect(@logs[0].get("message")).to.eq( - "expected **
** not to contain text **foob**" - ) - - @clearLogs() - try - expect(@$div).to.have.text("bar") - catch err - expect(@logs[0].get("message")).to.eq( - "expected **
** to have text **bar**, but the text was **foo**" - ) - - @clearLogs() - try - expect(@$div).to.contain.text("bar") - catch err - expect(@logs[0].get("message")).to.eq( - "expected **
** to contain text **bar**, but the text was **foo**" - ) - - it "partial match", -> - expect(@$div).to.have.text('foo') - expect(@$div).to.contain.text('o') - expect(@$div).to.include.text('o') - cy.get('div').should('contain.text', 'iv').should('contain.text', 'd') - cy.get('div').should('not.contain.text', 'fizzbuzz').should('contain.text', 'Nest') - - it "throws when obj is not DOM", (done) -> - cy.on "fail", (err) => - expect(@logs.length).to.eq(1) - expect(@logs[0].get("error").message).to.eq( - "expected undefined to have text 'foo'" - ) - expect(err.message).to.include("> text") - expect(err.message).to.include("> undefined") - - done() - - expect(undefined).to.have.text("foo") - - context "value", -> - beforeEach -> - @$input = $("") - @$input.val = -> throw new Error("val called") - - it "value, not value, contain value", -> - expect(@$input).to.have.value("foo") ## 1 - expect(@$input).not.to.have.value("bar") ## 2 - - expect(@logs.length).to.eq(2) - - l1 = @logs[0] - l2 = @logs[1] - - expect(l1.get("message")).to.eq( - "expected **** to have value **foo**" - ) - - expect(l2.get("message")).to.eq( - "expected **** not to have value **bar**" - ) - - @clearLogs() - expect(@$input).to.contain.value("foo") - expect(@logs[0].get("message")).to.eq( - "expected **** to contain value **foo**" - ) - - @clearLogs() - expect(@$input).not.to.contain.value("bar") - expect(@logs[0].get("message")).to.eq( - "expected **** not to contain value **bar**" - ) - - @clearLogs() - try - expect(@$input).to.have.value("bar") - catch err - expect(@logs[0].get("message")).to.eq( - "expected **** to have value **bar**, but the value was **foo**" - ) - - @clearLogs() - try - expect(@$input).to.contain.value("bar") - catch err - expect(@logs[0].get("message")).to.eq( - "expected **** to contain value **bar**, but the value was **foo**" - ) - - it "throws when obj is not DOM", (done) -> - cy.on "fail", (err) => - expect(@logs.length).to.eq(1) - expect(@logs[0].get("error").message).to.eq( - "expected {} to have value 'foo'" - ) - expect(err.message).to.include("> value") - expect(err.message).to.include("> {}") - - done() - - expect({}).to.have.value("foo") - - it "partial match", -> - expect(@$input).to.contain.value('oo') - expect(@$input).to.not.contain.value('oof') - ## make sure "includes" is an alias of "include" - expect(@$input).to.includes.value('oo') - cy.get('input') - .invoke('val','foobar') - .should('contain.value', 'bar') - .should('contain.value', 'foo') - .should('include.value', 'foo') - cy.wrap(null).then -> - cy.$$('').prependTo(cy.$$('body')) - cy.$$('').prependTo(cy.$$('body')) - cy.get('input').should ($els) -> - expect($els).to.have.value('foo2') - expect($els).to.contain.value('foo') - expect($els).to.include.value('foo') - .should('contain.value', 'oo2') - - context "descendants", -> - beforeEach -> - @$div = $("
") - @$div.has = -> throw new Error("has called") - - it "descendants, not descendants", -> - expect(@$div).to.have.descendants("button") ## 1 - expect(@$div).not.to.have.descendants("input") ## 2 - - expect(@logs.length).to.eq(2) - - l1 = @logs[0] - l2 = @logs[1] - - expect(l1.get("message")).to.eq( - "expected **
** to have descendants **button**" - ) - - expect(l2.get("message")).to.eq( - "expected **
** not to have descendants **input**" - ) - - it "throws when obj is not DOM", (done) -> - cy.on "fail", (err) => - expect(@logs.length).to.eq(1) - expect(@logs[0].get("error").message).to.eq( - "expected {} to have descendants 'foo'" - ) - expect(err.message).to.include("> descendants") - expect(err.message).to.include("> {}") - - done() - - expect({}).to.have.descendants("foo") - - context "visible", -> - beforeEach -> - @$div = $("
div
").appendTo($("body")) - @$div.is = -> throw new Error("is called") - - @$div2 = $("
div
").appendTo($("body")) - @$div2.is = -> throw new Error("is called") - - afterEach -> - @$div.remove() - @$div2.remove() - - it "visible, not visible, adds to error", -> - expect(@$div).to.be.visible ## 1 - expect(@$div2).not.to.be.visible ## 2 - - expect(@logs.length).to.eq(2) - - l1 = @logs[0] - l2 = @logs[1] - - expect(l1.get("message")).to.eq( - "expected **
** to be **visible**" - ) - - expect(l2.get("message")).to.eq( - "expected **
** not to be **visible**" - ) - - try - expect(@$div2).to.be.visible - catch err - l6 = @logs[5] - - ## the error on this log should have this message appended to it - expect(l6.get("error").message).to.eq( - """ - expected '
' to be 'visible' - - This element '
' is not visible because it has CSS property: 'display: none' - """ - ) - - it "throws when obj is not DOM", (done) -> - cy.on "fail", (err) => - expect(@logs.length).to.eq(1) - expect(@logs[0].get("error").message).to.eq( - "expected {} to be 'visible'" - ) - expect(err.message).to.include("> visible") - expect(err.message).to.include("> {}") - - done() - - expect({}).to.be.visible - - context "hidden", -> - beforeEach -> - @$div = $("
div
").appendTo($("body")) - @$div.is = -> throw new Error("is called") - - @$div2 = $("
div
").appendTo($("body")) - @$div2.is = -> throw new Error("is called") - - afterEach -> - @$div.remove() - @$div2.remove() - - it "hidden, not hidden, adds to error", -> - expect(@$div).to.be.hidden ## 1 - expect(@$div2).not.to.be.hidden ## 2 - - expect(@logs.length).to.eq(2) - - l1 = @logs[0] - l2 = @logs[1] - - expect(l1.get("message")).to.eq( - "expected **
** to be **hidden**" - ) - - expect(l2.get("message")).to.eq( - "expected **
** not to be **hidden**" - ) - - try - expect(@$div2).to.be.hidden - catch err - l6 = @logs[5] - - ## the error on this log should have this message appended to it - expect(l6.get("error").message).to.eq("expected '
' to be 'hidden'") - - it "throws when obj is not DOM", (done) -> - cy.on "fail", (err) => - expect(@logs.length).to.eq(1) - expect(@logs[0].get("error").message).to.eq( - "expected {} to be 'hidden'" - ) - expect(err.message).to.include("> hidden") - expect(err.message).to.include("> {}") - - done() - - expect({}).to.be.hidden - - context "selected", -> - beforeEach -> - @$option = $("") - @$option.is = -> throw new Error("is called") - - @$option2 = $("") - @$option2.is = -> throw new Error("is called") - - it "selected, not selected", -> - expect(@$option).to.be.selected ## 1 - expect(@$option2).not.to.be.selected ## 2 - - expect(@logs.length).to.eq(2) - - l1 = @logs[0] - l2 = @logs[1] - - expect(l1.get("message")).to.eq( - "expected **
** to be **empty**" - ) - - expect(l2.get("message")).to.eq( - "expected **
** not to be **empty**" - ) - - expect(l3.get("message")).to.eq( - "expected **
** to be **empty**" - ) - - expect(l4.get("message")).to.eq( - "expected **
** not to be **empty**" - ) - - context "focused", -> - beforeEach -> - @div = $("
").appendTo($('body')) - @div.is = -> throw new Error("is called") - - @div2 = $("
").appendTo($('body')) - @div2.is = -> throw new Error("is called") - - it "focus, not focus, raw dom documents", -> - expect(@div).to.not.be.focused - expect(@div[0]).to.not.be.focused - @div.focus() - expect(@div).to.be.focused - expect(@div[0]).to.be.focused - - @div.blur() - expect(@div).to.not.be.focused - expect(@div[0]).to.not.be.focused - - - expect(@div2).not.to.be.focused - expect(@div2[0]).not.to.be.focused - @div.focus() - expect(@div2).not.to.be.focused - @div2.focus() - expect(@div2).to.be.focused - - expect(@logs.length).to.eq(10) - - l1 = @logs[0] - l2 = @logs[1] - l3 = @logs[2] - l4 = @logs[3] - - expect(l1.get("message")).to.eq( - "expected **** not to be **focused**" - ) - - expect(l2.get("message")).to.eq( - "expected **** not to be **focused**" - ) - - expect(l3.get("message")).to.eq( - "expected **** to be **focused**" - ) - - expect(l4.get("message")).to.eq( - "expected **** to be **focused**" - ) - - it "works with focused or focus", -> - expect(@div).to.not.have.focus - expect(@div).to.not.have.focused - expect(@div).to.not.be.focus - expect(@div).to.not.be.focused - - cy.get('#div').should('not.be.focused') - cy.get('#div').should('not.have.focus') - - it "works with multiple elements", -> - cy.get('div:last').focus() - cy.get('div').should('have.focus') - cy.get('div:last').blur() - cy.get('div').should('not.have.focus') - - it "throws when obj is not DOM", (done) -> - cy.on "fail", (err) => - expect(@logs.length).to.eq(1) - expect(@logs[0].get("error").message).to.contain( - "expected {} to be 'focused'" - ) - expect(err.message).to.include("> focus") - expect(err.message).to.include("> {}") - - done() - - expect({}).to.have.focus - - it "calls into custom focus pseudos", -> - cy.$$('button:first').focus() - stub = cy.spy($.expr.pseudos, 'focus').as('focus') - expect(cy.$$('button:first')).to.have.focus - cy.get('button:first').should('have.focus') - .then -> - expect(stub).to.be.calledTwice - - context "match", -> - beforeEach -> - @div = $("
") - @div.is = -> throw new Error("is called") - - it "passes thru non DOM", -> - expect('foo').to.match(/f/) - - expect(@logs.length).to.eq(1) - - l1 = @logs[0] - - expect(l1.get("message")).to.eq( - "expected **foo** to match /f/" - ) - - it "match, not match, raw dom documents", -> - expect(@div).to.match("div") ## 1 - expect(@div).not.to.match("button") ## 2 - - expect(@div.get(0)).to.match("div") ## 3 - expect(@div.get(0)).not.to.match("button") ## 4 - - expect(@logs.length).to.eq(4) - - l1 = @logs[0] - l2 = @logs[1] - l3 = @logs[2] - l4 = @logs[3] - - expect(l1.get("message")).to.eq( - "expected **
** to match **div**" - ) - - expect(l2.get("message")).to.eq( - "expected **
** not to match **button**" - ) - - expect(l3.get("message")).to.eq( - "expected **
** to match **div**" - ) - - expect(l4.get("message")).to.eq( - "expected **
** not to match **button**" - ) - - context "contain", -> - it "passes thru non DOM", -> - expect(['foo']).to.contain('foo') ## 1 - expect({foo: 'bar', baz: "quux"}).to.contain({foo: "bar"}) ## 2, 3 - expect('foo').to.contain('fo') ## 4 - - expect(@logs.length).to.eq(4) - - l1 = @logs[0] - l2 = @logs[1] - l3 = @logs[2] - l4 = @logs[3] - - expect(l1.get("message")).to.eq( - "expected **[ foo ]** to include **foo**" - ) - - expect(l2.get("message")).to.eq( - "expected **{ foo: bar, baz: quux }** to have a property **foo**" - ) - - expect(l3.get("message")).to.eq( - "expected **{ foo: bar, baz: quux }** to have a property **foo** of **bar**" - ) - - expect(l4.get("message")).to.eq( - "expected **foo** to include **fo**" - ) - - context "attr", -> - beforeEach -> - @$div = $("
foo
") - @$div.attr = -> throw new Error("attr called") - - @$a = $("google") - @$a.attr = -> throw new Error("attr called") - - it "attr, not attr", -> - expect(@$div).to.have.attr("foo") ## 1 - expect(@$div).to.have.attr("foo", "bar") ## 2 - expect(@$div).not.to.have.attr("bar") ## 3 - expect(@$div).not.to.have.attr("bar", "baz") ## 4 - expect(@$div).not.to.have.attr("foo", "baz") ## 5 - - expect(@$a).to.have.attr("href").and.match(/google/) ## 6, 7 - expect(@$a) - .to.have.attr("href", "https://google.com") ## 8 - .and.have.text("google") ## 9 - - try - expect(@$a).not.to.have.attr("href", "https://google.com") ## 10 - catch error - - expect(@logs.length).to.eq(10) - - l1 = @logs[0] - l2 = @logs[1] - l3 = @logs[2] - l4 = @logs[3] - l5 = @logs[4] - l6 = @logs[5] - l7 = @logs[6] - l8 = @logs[7] - l9 = @logs[8] - l10 = @logs[9] - - expect(l1.get("message")).to.eq( - "expected **
** to have attribute **foo**" - ) - - expect(l2.get("message")).to.eq( - "expected **
** to have attribute **foo** with the value **bar**" - ) - - expect(l3.get("message")).to.eq( - "expected **
** not to have attribute **bar**" - ) - - expect(l4.get("message")).to.eq( - "expected **
** not to have attribute **bar**" - ) - - expect(l5.get("message")).to.eq( - "expected **
** not to have attribute **foo** with the value **baz**" - ) - - expect(l6.get("message")).to.eq( - "expected **** to have attribute **href**" - ) - - expect(l7.get("message")).to.eq( - "expected **https://google.com** to match /google/" - ) - - expect(l8.get("message")).to.eq( - "expected **** to have attribute **href** with the value **https://google.com**" - ) - - expect(l9.get("message")).to.eq( - "expected **** to have text **google**" - ) - - expect(l10.get("message")).to.eq( - "expected **** not to have attribute **href** with the value **https://google.com**, but the value was **https://google.com**" - ) - - it "throws when obj is not DOM", (done) -> - cy.on "fail", (err) => - expect(@logs.length).to.eq(1) - expect(@logs[0].get("error").message).to.eq( - "expected {} to have attribute 'foo'" - ) - expect(err.message).to.include("> attr") - expect(err.message).to.include("> {}") - - done() - - expect({}).to.have.attr("foo") - - context "prop", -> - beforeEach -> - @$input = $("") - @$input.prop("checked", true) - @$input.prop = -> throw new Error("prop called") - - @$a = $("google") - @$a.prop = -> throw new Error("prop called") - - it "prop, not prop", -> - expect(@$input).to.have.prop("checked") ## 1 - expect(@$input).to.have.prop("checked", true) ## 2 - expect(@$input).not.to.have.prop("bar") ## 3 - expect(@$input).not.to.have.prop("bar", "baz") ## 4 - expect(@$input).not.to.have.prop("checked", "baz") ## 5 - - href = window.location.origin + "/foo" - - expect(@$a).to.have.prop("href").and.match(/foo/) ## 6, 7 - expect(@$a) - .to.have.prop("href", href) ## 8 - .and.have.text("google") ## 9 - - try - expect(@$a).not.to.have.prop("href", href) ## 10 - catch error - - expect(@logs.length).to.eq(10) - - l1 = @logs[0] - l2 = @logs[1] - l3 = @logs[2] - l4 = @logs[3] - l5 = @logs[4] - l6 = @logs[5] - l7 = @logs[6] - l8 = @logs[7] - l9 = @logs[8] - l10 = @logs[9] - - expect(l1.get("message")).to.eq( - "expected **** to have property **checked**" - ) - - expect(l2.get("message")).to.eq( - "expected **** to have property **checked** with the value **true**" - ) - - expect(l3.get("message")).to.eq( - "expected **** not to have property **bar**" - ) - - expect(l4.get("message")).to.eq( - "expected **** not to have property **bar**" - ) - - expect(l5.get("message")).to.eq( - "expected **** not to have property **checked** with the value **baz**" - ) - - expect(l6.get("message")).to.eq( - "expected **** to have property **href**" - ) - - expect(l7.get("message")).to.eq( - "expected **#{href}** to match /foo/" - ) - - expect(l8.get("message")).to.eq( - "expected **** to have property **href** with the value **#{href}**" - ) - - expect(l9.get("message")).to.eq( - "expected **** to have text **google**" - ) - - expect(l10.get("message")).to.eq( - "expected **** not to have property **href** with the value **#{href}**, but the value was **#{href}**" - ) - - it "throws when obj is not DOM", (done) -> - cy.on "fail", (err) => - expect(@logs.length).to.eq(1) - expect(@logs[0].get("error").message).to.eq( - "expected {} to have property 'foo'" - ) - expect(err.message).to.include("> prop") - expect(err.message).to.include("> {}") - - done() - - expect({}).to.have.prop("foo") - - context "css", -> - beforeEach -> - @$div = $("
div
") - @$div.css = -> throw new Error("css called") - - it "css, not css", -> - expect(@$div).to.have.css("display") ## 1 - expect(@$div).to.have.css("display", "none") ## 2 - expect(@$div).not.to.have.css("bar") ## 3 - expect(@$div).not.to.have.css("bar", "baz") ## 4 - expect(@$div).not.to.have.css("display", "inline") ## 5 - - try - expect(@$div).not.to.have.css("display", "none") ## 6 - catch error - - expect(@logs.length).to.eq(6) - - l1 = @logs[0] - l2 = @logs[1] - l3 = @logs[2] - l4 = @logs[3] - l5 = @logs[4] - l6 = @logs[5] - - expect(l1.get("message")).to.eq( - "expected **
** to have CSS property **display**" - ) - - expect(l2.get("message")).to.eq( - "expected **
** to have CSS property **display** with the value **none**" - ) - - expect(l3.get("message")).to.eq( - "expected **
** not to have CSS property **bar**" - ) - - expect(l4.get("message")).to.eq( - "expected **
** not to have CSS property **bar**" - ) - - expect(l5.get("message")).to.eq( - "expected **
** not to have CSS property **display** with the value **inline**" - ) - - expect(l6.get("message")).to.eq( - "expected **
** not to have CSS property **display** with the value **none**, but the value was **none**" - ) - - it "throws when obj is not DOM", (done) -> - cy.on "fail", (err) => - expect(@logs.length).to.eq(1) - expect(@logs[0].get("error").message).to.eq( - "expected {} to have CSS property 'foo'" - ) - expect(err.message).to.include("> css") - expect(err.message).to.include("> {}") - - done() - - expect({}).to.have.css("foo") diff --git a/packages/driver/test/cypress/integration/commands/assertions_spec.js b/packages/driver/test/cypress/integration/commands/assertions_spec.js new file mode 100644 index 00000000000..c34d5c63a6e --- /dev/null +++ b/packages/driver/test/cypress/integration/commands/assertions_spec.js @@ -0,0 +1,2482 @@ +const { $, _ } = Cypress + +describe('src/cy/commands/assertions', () => { + before(() => { + cy + .visit('/fixtures/jquery.html') + .then(function (win) { + this.body = win.document.body.outerHTML + }) + }) + + beforeEach(function () { + const doc = cy.state('document') + + $(doc.body).empty().html(this.body) + }) + + context('#should', () => { + beforeEach(function () { + this.logs = [] + + cy.on('log:added', (attrs, log) => { + this.logs.push(log) + this.lastLog = log + }) + + return null + }) + + it('returns the subject for chainability', () => { + cy.noop({ foo: 'bar' }).should('deep.eq', { foo: 'bar' }).then((obj) => { + expect(obj).to.deep.eq({ foo: 'bar' }) + }) + }) + + it('can use negation', () => { + cy.noop(false).should('not.be.true') + }) + + it('works with jquery chai', () => { + const div = $('
asdf
') + + cy.$$('body').append(div) + + cy + .get('div.foo').should('have.class', 'foo').then(($div) => { + expect($div).to.match(div) + + $div.remove() + }) + }) + + it('can chain multiple assertions', () => { + cy + .get('body') + .should('contain', 'div') + .should('have.property', 'length', 1) + }) + + it('skips over utility commands', () => { + cy.on('command:retry', _.after(2, () => { + cy.$$('div:first').addClass('foo') + })) + + cy.on('command:retry', _.after(4, () => { + cy.$$('div:first').attr('id', 'bar') + })) + + cy.get('div:first').should('have.class', 'foo').debug().and('have.id', 'bar') + }) + + it('skips over aliasing', () => { + cy.on('command:retry', _.after(2, () => { + cy.$$('div:first').addClass('foo') + })) + + cy.on('command:retry', _.after(4, () => { + cy.$$('div:first').attr('id', 'bar') + })) + + cy.get('div:first').as('div').should('have.class', 'foo').debug().and('have.id', 'bar') + }) + + it('can change the subject', () => { + cy.get('input:first').should('have.property', 'length').should('eq', 1).then((num) => { + expect(num).to.eq(1) + }) + }) + + it('changes the subject with chai-jquery', () => { + cy.$$('input:first').attr('id', 'input') + + cy.get('input:first').should('have.attr', 'id').should('eq', 'input') + }) + + it('changes the subject with JSON', () => { + const obj = { requestJSON: { teamIds: [2] } } + + cy.noop(obj).its('requestJSON').should('have.property', 'teamIds').should('deep.eq', [2]) + }) + + //# TODO: make cy.then retry + //# https://github.com/cypress-io/cypress/issues/627 + it.skip('outer assertions retry on cy.then', () => { + const obj = { foo: 'bar' } + + cy.wrap(obj).then(() => { + setTimeout(() => { + obj.foo = 'baz' + } + , 1000) + + return obj + }).should('deep.eq', { foo: 'baz' }) + }) + + it('does it retry when wrapped', () => { + const obj = { foo: 'bar' } + + cy.wrap(obj).then(() => { + setTimeout(() => { + obj.foo = 'baz' + } + , 100) + + return cy.wrap(obj) + }).should('deep.eq', { foo: 'baz' }) + }) + + describe('function argument', () => { + it('waits until function is true', () => { + const button = cy.$$('button:first') + + cy.on('command:retry', _.after(2, () => { + button.addClass('ready') + })) + + cy.get('button:first').should(($button) => { + expect($button).to.have.class('ready') + }) + }) + + it('works with regular objects', () => { + const obj = {} + + cy.on('command:retry', _.after(2, () => { + obj.foo = 'bar' + })) + + cy.wrap(obj).should((o) => { + expect(o).to.have.property('foo').and.eq('bar') + }).then(function () { + //# wrap + have property + and eq + expect(this.logs.length).to.eq(3) + }) + }) + + it('logs two assertions', () => { + _.delay(() => { + cy.$$('body').addClass('foo') + } + , Math.random() * 300) + + _.delay(() => { + cy.$$('body').prop('id', 'bar') + } + , Math.random() * 300) + + cy + .get('body').should(($body) => { + expect($body).to.have.class('foo') + + expect($body).to.have.id('bar') + }).then(function () { + cy.$$('body').removeClass('foo').removeAttr('id') + + expect(this.logs.length).to.eq(3) + + //# the messages should have been updated to reflect + //# the current state of the element + expect(this.logs[1].get('message')).to.eq('expected **** to have class **foo**') + + expect(this.logs[2].get('message')).to.eq('expected **** to have id **bar**') + }) + }) + + it('logs assertions as children even if subject is different', () => { + _.delay(() => { + cy.$$('body').addClass('foo') + } + , Math.random() * 300) + + _.delay(() => { + cy.$$('body').prop('id', 'bar') + } + , Math.random() * 300) + + cy + .get('body').should(($body) => { + expect($body.attr('class')).to.match(/foo/) + + expect($body.attr('id')).to.include('bar') + }).then(function () { + cy.$$('body').removeClass('foo').removeAttr('id') + + const types = _.map(this.logs, (l) => l.get('type')) + + expect(types).to.deep.eq(['parent', 'child', 'child']) + + expect(this.logs.length).to.eq(4) + }) + }) + + context('remote jQuery instances', () => { + beforeEach(function () { + this.remoteWindow = cy.state('window') + }) + + it('yields the remote jQuery instance', function () { + let fn + + this.remoteWindow.$.fn.__foobar = (fn = function () {}) + + cy + .get('input:first').should(function ($input) { + const isInstanceOf = Cypress.utils.isInstanceOf($input, this.remoteWindow.$) + const hasProp = $input.__foobar === fn + + expect(isInstanceOf).to.be.true + + expect(hasProp).to.to.true + }) + }) + }) + }) + + describe('not.exist', () => { + it('resolves eventually not exist', () => { + const button = cy.$$('button:first') + + cy.on('command:retry', _.after(2, _.once(() => { + button.remove() + }))) + + cy.get('button:first').click().should('not.exist') + }) + + it('resolves all 3 assertions', (done) => { + const logs = [] + + cy.on('log:added', (attrs, log) => { + if (log.get('name') === 'assert') { + logs.push(log) + + if (logs.length === 3) { + done() + } + } + }) + + cy + .get('#does-not-exist1').should('not.exist') + .get('#does-not-exist2').should('not.exist') + .get('#does-not-exist3').should('not.exist') + }) + }) + + describe('have.text', () => { + it('resolves the assertion', () => { + cy.get('#list li').eq(0).should('have.text', 'li 0').then(function () { + const { lastLog } = this + + expect(lastLog.get('name')).to.eq('assert') + expect(lastLog.get('state')).to.eq('passed') + + expect(lastLog.get('ended')).to.be.true + }) + }) + }) + + describe('have.length', () => { + it('allows valid string numbers', () => { + const { length } = cy.$$('button') + + cy.get('button').should('have.length', `${length}`) + }) + + it('throws when should(\'have.length\') isnt a number', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.eq('You must provide a valid number to a length assertion. You passed: \'asdf\'') + + done() + }) + + cy.get('button').should('have.length', 'asdf') + }) + + it('does not log extra commands on fail and properly fails command + assertions', function (done) { + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(6) + + expect(this.logs[3].get('name')).to.eq('get') + expect(this.logs[3].get('state')).to.eq('failed') + expect(this.logs[3].get('error')).to.eq(err) + + expect(this.logs[4].get('name')).to.eq('assert') + expect(this.logs[4].get('state')).to.eq('failed') + expect(this.logs[4].get('error').name).to.eq('AssertionError') + + done() + }) + + cy + .root().should('exist').and('contain', 'foo') + .get('button').should('have.length', 'asdf') + }) + + it('finishes failed assertions and does not log extra commands when cy.contains fails', function (done) { + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(2) + + expect(this.logs[0].get('name')).to.eq('contains') + expect(this.logs[0].get('state')).to.eq('failed') + expect(this.logs[0].get('error')).to.eq(err) + + expect(this.logs[1].get('name')).to.eq('assert') + expect(this.logs[1].get('state')).to.eq('failed') + expect(this.logs[1].get('error').name).to.eq('AssertionError') + + done() + }) + + cy.contains('Nested Find').should('have.length', 2) + }) + }) + + describe('have.class', () => { + it('snapshots and ends the assertion after retrying', () => { + cy.on('command:retry', _.after(3, () => { + cy.$$('#foo').addClass('active') + })) + + cy.contains('foo').should('have.class', 'active').then(function () { + const { lastLog } = this + + expect(lastLog.get('name')).to.eq('assert') + expect(lastLog.get('ended')).to.be.true + expect(lastLog.get('state')).to.eq('passed') + expect(lastLog.get('snapshots').length).to.eq(1) + + expect(lastLog.get('snapshots')[0]).to.be.an('object') + }) + }) + + it('retries assertion until true', () => { + const button = cy.$$('button:first') + + const retry = _.after(3, () => { + button.addClass('new-class') + }) + + cy.on('command:retry', retry) + + cy.get('button:first').should('have.class', 'new-class') + }) + }) + + describe('errors', () => { + beforeEach(() => { + Cypress.config('defaultCommandTimeout', 50) + }) + + it('should not be true', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.eq('expected false to be true') + + done() + }) + + cy.noop(false).should('be.true') + }) + + it('throws err when not available chainable', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.eq('The chainer: \'dee\' was not found. Could not build assertion.') + + done() + }) + + cy.noop({}).should('dee.eq', {}) + }) + + it('throws err when ends with a non available chainable', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.eq('The chainer: \'eq2\' was not found. Could not build assertion.') + + done() + }) + + cy.noop({}).should('deep.eq2', {}) + }) + + it('logs \'should\' when non available chainer', function (done) { + cy.on('fail', (err) => { + const { lastLog } = this + + expect(this.logs.length).to.eq(2) + expect(lastLog.get('name')).to.eq('should') + expect(lastLog.get('error')).to.eq(err) + expect(lastLog.get('state')).to.eq('failed') + expect(lastLog.get('snapshots').length).to.eq(1) + expect(lastLog.get('snapshots')[0]).to.be.an('object') + expect(lastLog.get('message')).to.eq('not.contain2, does-not-exist-foo-bar') + + done() + }) + + cy.get('div:first').should('not.contain2', 'does-not-exist-foo-bar') + }) + + it('throws when eventually times out', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.eq('Timed out retrying: expected \'
') + this.$div.html = function () { + throw new Error('html called') + } + }) + + it('html, not html, contain html', function () { + expect(this.$div).to.have.html('') //# 1 + expect(this.$div).not.to.have.html('foo') //# 2 + expect(this.logs.length).to.eq(2) + + const l1 = this.logs[0] + const l2 = this.logs[1] + + expect(l1.get('message')).to.eq( + 'expected **
** to have HTML ****' + ) + + expect(l2.get('message')).to.eq( + 'expected **
** not to have HTML **foo**' + ) + + this.clearLogs() + expect(this.$div).to.contain.html('**' + ) + } + + this.clearLogs() + try { + expect(this.$div).to.contain.html('span') + } catch (error1) { + expect(this.logs[0].get('message')).to.eq( + 'expected **
** to contain HTML **span**, but the HTML was ****' + ) + } + }) + + it('throws when obj is not DOM', function (done) { + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(1) + expect(this.logs[0].get('error').message).to.eq( + 'expected null to have HTML \'foo\'' + ) + + expect(err.message).to.include('> html') + expect(err.message).to.include('> null') + + done() + }) + + expect(null).to.have.html('foo') + }) + + it('partial match', function () { + expect(this.$div).to.contain.html('button') + expect(this.$div).to.include.html('button') + expect(this.$div).to.not.contain.html('span') + + cy.get('button').should('contain.html', 'button') + }) + }) + + context('text', () => { + beforeEach(function () { + this.$div = $('
foo
') + this.$div.text = function () { + throw new Error('text called') + } + }) + + it('text, not text, contain text', function () { + expect(this.$div).to.have.text('foo') //# 1 + expect(this.$div).not.to.have.text('bar') //# 2 + + expect(this.logs.length).to.eq(2) + + const l1 = this.logs[0] + const l2 = this.logs[1] + + expect(l1.get('message')).to.eq( + 'expected **
** to have text **foo**' + ) + + expect(l2.get('message')).to.eq( + 'expected **
** not to have text **bar**' + ) + + this.clearLogs() + expect(this.$div).to.contain.text('f') + expect(this.logs[0].get('message')).to.eq( + 'expected **
** to contain text **f**' + ) + + this.clearLogs() + expect(this.$div).to.not.contain.text('foob') + expect(this.logs[0].get('message')).to.eq( + 'expected **
** not to contain text **foob**' + ) + + this.clearLogs() + try { + expect(this.$div).to.have.text('bar') + } catch (error) { + expect(this.logs[0].get('message')).to.eq( + 'expected **
** to have text **bar**, but the text was **foo**' + ) + } + + this.clearLogs() + try { + expect(this.$div).to.contain.text('bar') + } catch (error1) { + expect(this.logs[0].get('message')).to.eq( + 'expected **
** to contain text **bar**, but the text was **foo**' + ) + } + }) + + it('partial match', function () { + expect(this.$div).to.have.text('foo') + expect(this.$div).to.contain.text('o') + expect(this.$div).to.include.text('o') + cy.get('div').should('contain.text', 'iv').should('contain.text', 'd') + + cy.get('div').should('not.contain.text', 'fizzbuzz').should('contain.text', 'Nest') + }) + + it('throws when obj is not DOM', function (done) { + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(1) + expect(this.logs[0].get('error').message).to.eq( + 'expected undefined to have text \'foo\'' + ) + + expect(err.message).to.include('> text') + expect(err.message).to.include('> undefined') + + done() + }) + + expect(undefined).to.have.text('foo') + }) + }) + + context('value', () => { + beforeEach(function () { + this.$input = $('') + this.$input.val = function () { + throw new Error('val called') + } + }) + + it('value, not value, contain value', function () { + expect(this.$input).to.have.value('foo') //# 1 + expect(this.$input).not.to.have.value('bar') //# 2 + + expect(this.logs.length).to.eq(2) + + const l1 = this.logs[0] + const l2 = this.logs[1] + + expect(l1.get('message')).to.eq( + 'expected **** to have value **foo**' + ) + + expect(l2.get('message')).to.eq( + 'expected **** not to have value **bar**' + ) + + this.clearLogs() + expect(this.$input).to.contain.value('foo') + expect(this.logs[0].get('message')).to.eq( + 'expected **** to contain value **foo**' + ) + + this.clearLogs() + expect(this.$input).not.to.contain.value('bar') + expect(this.logs[0].get('message')).to.eq( + 'expected **** not to contain value **bar**' + ) + + this.clearLogs() + try { + expect(this.$input).to.have.value('bar') + } catch (error) { + expect(this.logs[0].get('message')).to.eq( + 'expected **** to have value **bar**, but the value was **foo**' + ) + } + + this.clearLogs() + try { + expect(this.$input).to.contain.value('bar') + } catch (error1) { + expect(this.logs[0].get('message')).to.eq( + 'expected **** to contain value **bar**, but the value was **foo**' + ) + } + }) + + it('throws when obj is not DOM', function (done) { + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(1) + expect(this.logs[0].get('error').message).to.eq( + 'expected {} to have value \'foo\'' + ) + + expect(err.message).to.include('> value') + expect(err.message).to.include('> {}') + + done() + }) + + expect({}).to.have.value('foo') + }) + + it('partial match', function () { + expect(this.$input).to.contain.value('oo') + expect(this.$input).to.not.contain.value('oof') + //# make sure "includes" is an alias of "include" + expect(this.$input).to.includes.value('oo') + cy.get('input') + .invoke('val', 'foobar') + .should('contain.value', 'bar') + .should('contain.value', 'foo') + .should('include.value', 'foo') + + cy.wrap(null).then(() => { + cy.$$('').prependTo(cy.$$('body')) + + cy.$$('').prependTo(cy.$$('body')) + }) + + cy.get('input').should(($els) => { + expect($els).to.have.value('foo2') + expect($els).to.contain.value('foo') + + expect($els).to.include.value('foo') + }).should('contain.value', 'oo2') + }) + }) + + context('descendants', () => { + beforeEach(function () { + this.$div = $('
') + this.$div.has = function () { + throw new Error('has called') + } + }) + + it('descendants, not descendants', function () { + expect(this.$div).to.have.descendants('button') //# 1 + expect(this.$div).not.to.have.descendants('input') //# 2 + + expect(this.logs.length).to.eq(2) + + const l1 = this.logs[0] + const l2 = this.logs[1] + + expect(l1.get('message')).to.eq( + 'expected **
** to have descendants **button**' + ) + + expect(l2.get('message')).to.eq( + 'expected **
** not to have descendants **input**' + ) + }) + + it('throws when obj is not DOM', function (done) { + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(1) + expect(this.logs[0].get('error').message).to.eq( + 'expected {} to have descendants \'foo\'' + ) + + expect(err.message).to.include('> descendants') + expect(err.message).to.include('> {}') + + done() + }) + + expect({}).to.have.descendants('foo') + }) + }) + + context('visible', () => { + beforeEach(function () { + this.$div = $('
div
').appendTo($('body')) + this.$div.is = function () { + throw new Error('is called') + } + + this.$div2 = $('
div
').appendTo($('body')) + this.$div2.is = function () { + throw new Error('is called') + } + }) + + afterEach(function () { + this.$div.remove() + + this.$div2.remove() + }) + + it('visible, not visible, adds to error', function () { + expect(this.$div).to.be.visible //# 1 + expect(this.$div2).not.to.be.visible //# 2 + + expect(this.logs.length).to.eq(2) + + const l1 = this.logs[0] + const l2 = this.logs[1] + + expect(l1.get('message')).to.eq( + 'expected **
** to be **visible**' + ) + + expect(l2.get('message')).to.eq( + 'expected **
** not to be **visible**' + ) + + try { + expect(this.$div2).to.be.visible + } catch (err) { + const l6 = this.logs[5] + + //# the error on this log should have this message appended to it + expect(l6.get('error').message).to.eq( + `\ +expected '
' to be 'visible' + +This element '
' is not visible because it has CSS property: 'display: none'\ +` + ) + } + }) + + it('throws when obj is not DOM', function (done) { + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(1) + expect(this.logs[0].get('error').message).to.eq( + 'expected {} to be \'visible\'' + ) + + expect(err.message).to.include('> visible') + expect(err.message).to.include('> {}') + + done() + }) + + expect({}).to.be.visible + }) + }) + + context('hidden', () => { + beforeEach(function () { + this.$div = $('
div
').appendTo($('body')) + this.$div.is = function () { + throw new Error('is called') + } + + this.$div2 = $('
div
').appendTo($('body')) + this.$div2.is = function () { + throw new Error('is called') + } + }) + + afterEach(function () { + this.$div.remove() + + this.$div2.remove() + }) + + it('hidden, not hidden, adds to error', function () { + expect(this.$div).to.be.hidden //# 1 + expect(this.$div2).not.to.be.hidden //# 2 + + expect(this.logs.length).to.eq(2) + + const l1 = this.logs[0] + const l2 = this.logs[1] + + expect(l1.get('message')).to.eq( + 'expected **
** to be **hidden**' + ) + + expect(l2.get('message')).to.eq( + 'expected **
** not to be **hidden**' + ) + + try { + expect(this.$div2).to.be.hidden + } catch (err) { + const l6 = this.logs[5] + + //# the error on this log should have this message appended to it + expect(l6.get('error').message).to.eq('expected \'
\' to be \'hidden\'') + } + }) + + it('throws when obj is not DOM', function (done) { + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(1) + expect(this.logs[0].get('error').message).to.eq( + 'expected {} to be \'hidden\'' + ) + + expect(err.message).to.include('> hidden') + expect(err.message).to.include('> {}') + + done() + }) + + expect({}).to.be.hidden + }) + }) + + context('selected', () => { + beforeEach(function () { + this.$option = $('') + this.$option.is = function () { + throw new Error('is called') + } + + this.$option2 = $('') + this.$option2.is = function () { + throw new Error('is called') + } + }) + + it('selected, not selected', function () { + expect(this.$option).to.be.selected //# 1 + expect(this.$option2).not.to.be.selected //# 2 + + expect(this.logs.length).to.eq(2) + + const l1 = this.logs[0] + const l2 = this.logs[1] + + expect(l1.get('message')).to.eq( + 'expected **
** to be **empty**' + ) + + expect(l2.get('message')).to.eq( + 'expected **
** not to be **empty**' + ) + + expect(l3.get('message')).to.eq( + 'expected **
** to be **empty**' + ) + + expect(l4.get('message')).to.eq( + 'expected **
** not to be **empty**' + ) + }) + }) + + context('focused', () => { + beforeEach(function () { + this.div = $('
').appendTo($('body')) + this.div.is = function () { + throw new Error('is called') + } + + this.div2 = $('
').appendTo($('body')) + this.div2.is = function () { + throw new Error('is called') + } + }) + + it('focus, not focus, raw dom documents', function () { + expect(this.div).to.not.be.focused + expect(this.div[0]).to.not.be.focused + this.div.focus() + expect(this.div).to.be.focused + expect(this.div[0]).to.be.focused + + this.div.blur() + expect(this.div).to.not.be.focused + expect(this.div[0]).to.not.be.focused + + expect(this.div2).not.to.be.focused + expect(this.div2[0]).not.to.be.focused + this.div.focus() + expect(this.div2).not.to.be.focused + this.div2.focus() + expect(this.div2).to.be.focused + + expect(this.logs.length).to.eq(10) + + const l1 = this.logs[0] + const l2 = this.logs[1] + const l3 = this.logs[2] + const l4 = this.logs[3] + + expect(l1.get('message')).to.eq( + 'expected **** not to be **focused**' + ) + + expect(l2.get('message')).to.eq( + 'expected **** not to be **focused**' + ) + + expect(l3.get('message')).to.eq( + 'expected **** to be **focused**' + ) + + expect(l4.get('message')).to.eq( + 'expected **** to be **focused**' + ) + }) + + it('works with focused or focus', function () { + expect(this.div).to.not.have.focus + expect(this.div).to.not.have.focused + expect(this.div).to.not.be.focus + expect(this.div).to.not.be.focused + + cy.get('#div').should('not.be.focused') + + cy.get('#div').should('not.have.focus') + }) + + it('works with multiple elements', () => { + cy.get('div:last').focus() + cy.get('div').should('have.focus') + cy.get('div:last').blur() + + cy.get('div').should('not.have.focus') + }) + + it('throws when obj is not DOM', function (done) { + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(1) + expect(this.logs[0].get('error').message).to.contain( + 'expected {} to be \'focused\'' + ) + + expect(err.message).to.include('> focus') + expect(err.message).to.include('> {}') + + done() + }) + + expect({}).to.have.focus + }) + + it('calls into custom focus pseudos', () => { + cy.$$('button:first').focus() + const stub = cy.spy($.expr.pseudos, 'focus').as('focus') + + expect(cy.$$('button:first')).to.have.focus + + cy.get('button:first').should('have.focus') + .then(() => { + expect(stub).to.be.calledTwice + }) + }) + }) + + context('match', () => { + beforeEach(function () { + this.div = $('
') + this.div.is = function () { + throw new Error('is called') + } + }) + + it('passes thru non DOM', function () { + expect('foo').to.match(/f/) + + expect(this.logs.length).to.eq(1) + + const l1 = this.logs[0] + + expect(l1.get('message')).to.eq( + 'expected **foo** to match /f/' + ) + }) + + it('match, not match, raw dom documents', function () { + expect(this.div).to.match('div') //# 1 + expect(this.div).not.to.match('button') //# 2 + + expect(this.div.get(0)).to.match('div') //# 3 + expect(this.div.get(0)).not.to.match('button') //# 4 + + expect(this.logs.length).to.eq(4) + + const l1 = this.logs[0] + const l2 = this.logs[1] + const l3 = this.logs[2] + const l4 = this.logs[3] + + expect(l1.get('message')).to.eq( + 'expected **
** to match **div**' + ) + + expect(l2.get('message')).to.eq( + 'expected **
** not to match **button**' + ) + + expect(l3.get('message')).to.eq( + 'expected **
** to match **div**' + ) + + expect(l4.get('message')).to.eq( + 'expected **
** not to match **button**' + ) + }) + }) + + context('contain', () => { + it('passes thru non DOM', function () { + expect(['foo']).to.contain('foo') //# 1 + expect({ foo: 'bar', baz: 'quux' }).to.contain({ foo: 'bar' }) //# 2, 3 + expect('foo').to.contain('fo') //# 4 + + expect(this.logs.length).to.eq(4) + + const l1 = this.logs[0] + const l2 = this.logs[1] + const l3 = this.logs[2] + const l4 = this.logs[3] + + expect(l1.get('message')).to.eq( + 'expected **[ foo ]** to include **foo**' + ) + + expect(l2.get('message')).to.eq( + 'expected **{ foo: bar, baz: quux }** to have a property **foo**' + ) + + expect(l3.get('message')).to.eq( + 'expected **{ foo: bar, baz: quux }** to have a property **foo** of **bar**' + ) + + expect(l4.get('message')).to.eq( + 'expected **foo** to include **fo**' + ) + }) + }) + + context('attr', () => { + beforeEach(function () { + this.$div = $('
foo
') + this.$div.attr = function () { + throw new Error('attr called') + } + + this.$a = $('google') + this.$a.attr = function () { + throw new Error('attr called') + } + }) + + it('attr, not attr', function () { + expect(this.$div).to.have.attr('foo') //# 1 + expect(this.$div).to.have.attr('foo', 'bar') //# 2 + expect(this.$div).not.to.have.attr('bar') //# 3 + expect(this.$div).not.to.have.attr('bar', 'baz') //# 4 + expect(this.$div).not.to.have.attr('foo', 'baz') //# 5 + + expect(this.$a).to.have.attr('href').and.match(/google/) //# 6, 7 + expect(this.$a) + .to.have.attr('href', 'https://google.com') //# 8 + .and.have.text('google') //# 9 + + try { + expect(this.$a).not.to.have.attr('href', 'https://google.com') //# 10 + } catch (error) {} // eslint-disable-line no-empty + + expect(this.logs.length).to.eq(10) + + const l1 = this.logs[0] + const l2 = this.logs[1] + const l3 = this.logs[2] + const l4 = this.logs[3] + const l5 = this.logs[4] + const l6 = this.logs[5] + const l7 = this.logs[6] + const l8 = this.logs[7] + const l9 = this.logs[8] + const l10 = this.logs[9] + + expect(l1.get('message')).to.eq( + 'expected **
** to have attribute **foo**' + ) + + expect(l2.get('message')).to.eq( + 'expected **
** to have attribute **foo** with the value **bar**' + ) + + expect(l3.get('message')).to.eq( + 'expected **
** not to have attribute **bar**' + ) + + expect(l4.get('message')).to.eq( + 'expected **
** not to have attribute **bar**' + ) + + expect(l5.get('message')).to.eq( + 'expected **
** not to have attribute **foo** with the value **baz**' + ) + + expect(l6.get('message')).to.eq( + 'expected **** to have attribute **href**' + ) + + expect(l7.get('message')).to.eq( + 'expected **https://google.com** to match /google/' + ) + + expect(l8.get('message')).to.eq( + 'expected **** to have attribute **href** with the value **https://google.com**' + ) + + expect(l9.get('message')).to.eq( + 'expected **** to have text **google**' + ) + + expect(l10.get('message')).to.eq( + 'expected **** not to have attribute **href** with the value **https://google.com**, but the value was **https://google.com**' + ) + }) + + it('throws when obj is not DOM', function (done) { + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(1) + expect(this.logs[0].get('error').message).to.eq( + 'expected {} to have attribute \'foo\'' + ) + + expect(err.message).to.include('> attr') + expect(err.message).to.include('> {}') + + done() + }) + + expect({}).to.have.attr('foo') + }) + }) + + context('prop', () => { + beforeEach(function () { + this.$input = $('') + this.$input.prop('checked', true) + this.$input.prop = function () { + throw new Error('prop called') + } + + this.$a = $('google') + this.$a.prop = function () { + throw new Error('prop called') + } + }) + + it('prop, not prop', function () { + expect(this.$input).to.have.prop('checked') //# 1 + expect(this.$input).to.have.prop('checked', true) //# 2 + expect(this.$input).not.to.have.prop('bar') //# 3 + expect(this.$input).not.to.have.prop('bar', 'baz') //# 4 + expect(this.$input).not.to.have.prop('checked', 'baz') //# 5 + + const href = `${window.location.origin}/foo` + + expect(this.$a).to.have.prop('href').and.match(/foo/) //# 6, 7 + expect(this.$a) + .to.have.prop('href', href) //# 8 + .and.have.text('google') //# 9 + + try { + expect(this.$a).not.to.have.prop('href', href) //# 10 + } catch (error) {} // eslint-disable-line no-empty + + expect(this.logs.length).to.eq(10) + + const l1 = this.logs[0] + const l2 = this.logs[1] + const l3 = this.logs[2] + const l4 = this.logs[3] + const l5 = this.logs[4] + const l6 = this.logs[5] + const l7 = this.logs[6] + const l8 = this.logs[7] + const l9 = this.logs[8] + const l10 = this.logs[9] + + expect(l1.get('message')).to.eq( + 'expected **** to have property **checked**' + ) + + expect(l2.get('message')).to.eq( + 'expected **** to have property **checked** with the value **true**' + ) + + expect(l3.get('message')).to.eq( + 'expected **** not to have property **bar**' + ) + + expect(l4.get('message')).to.eq( + 'expected **** not to have property **bar**' + ) + + expect(l5.get('message')).to.eq( + 'expected **** not to have property **checked** with the value **baz**' + ) + + expect(l6.get('message')).to.eq( + 'expected **** to have property **href**' + ) + + expect(l7.get('message')).to.eq( + `expected **${href}** to match /foo/` + ) + + expect(l8.get('message')).to.eq( + `expected **** to have property **href** with the value **${href}**` + ) + + expect(l9.get('message')).to.eq( + 'expected **** to have text **google**' + ) + + expect(l10.get('message')).to.eq( + `expected **** not to have property **href** with the value **${href}**, but the value was **${href}**` + ) + }) + + it('throws when obj is not DOM', function (done) { + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(1) + expect(this.logs[0].get('error').message).to.eq( + 'expected {} to have property \'foo\'' + ) + + expect(err.message).to.include('> prop') + expect(err.message).to.include('> {}') + + done() + }) + + expect({}).to.have.prop('foo') + }) + }) + + context('css', () => { + beforeEach(function () { + this.$div = $('
div
') + this.$div.css = function () { + throw new Error('css called') + } + }) + + it('css, not css', function () { + expect(this.$div).to.have.css('display') //# 1 + expect(this.$div).to.have.css('display', 'none') //# 2 + expect(this.$div).not.to.have.css('bar') //# 3 + expect(this.$div).not.to.have.css('bar', 'baz') //# 4 + expect(this.$div).not.to.have.css('display', 'inline') //# 5 + + try { + expect(this.$div).not.to.have.css('display', 'none') //# 6 + } catch (error) {} // eslint-disable-line no-empty + + expect(this.logs.length).to.eq(6) + + const l1 = this.logs[0] + const l2 = this.logs[1] + const l3 = this.logs[2] + const l4 = this.logs[3] + const l5 = this.logs[4] + const l6 = this.logs[5] + + expect(l1.get('message')).to.eq( + 'expected **
** to have CSS property **display**' + ) + + expect(l2.get('message')).to.eq( + 'expected **
** to have CSS property **display** with the value **none**' + ) + + expect(l3.get('message')).to.eq( + 'expected **
** not to have CSS property **bar**' + ) + + expect(l4.get('message')).to.eq( + 'expected **
** not to have CSS property **bar**' + ) + + expect(l5.get('message')).to.eq( + 'expected **
** not to have CSS property **display** with the value **inline**' + ) + + expect(l6.get('message')).to.eq( + 'expected **
** not to have CSS property **display** with the value **none**, but the value was **none**' + ) + }) + + it('throws when obj is not DOM', function (done) { + cy.on('fail', (err) => { + expect(this.logs.length).to.eq(1) + expect(this.logs[0].get('error').message).to.eq( + 'expected {} to have CSS property \'foo\'' + ) + + expect(err.message).to.include('> css') + expect(err.message).to.include('> {}') + + done() + }) + + expect({}).to.have.css('foo') + }) + }) + }) +}) diff --git a/packages/driver/test/cypress/integration/commands/clock_spec.coffee b/packages/driver/test/cypress/integration/commands/clock_spec.coffee index fac42ee4020..142ac45c31c 100644 --- a/packages/driver/test/cypress/integration/commands/clock_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/clock_spec.coffee @@ -77,13 +77,11 @@ describe "src/cy/commands/clock", -> .then (clock) -> cy.window().then (win) -> expect(win.performance.getEntriesByType("paint")).to.deep.eq([]) - expect(win.performance.getEntriesByName("first-paint")).to.deep.eq([]) expect(win.performance.getEntries()).to.deep.eq([]) clock.restore() expect(win.performance.getEntriesByType("paint").length).to.be.at.least(1) - expect(win.performance.getEntriesByName("first-paint").length).to.be.at.least(1) expect(win.performance.getEntries().length).to.be.at.least(1) context "errors", -> diff --git a/packages/driver/test/cypress/integration/commands/connectors_spec.coffee b/packages/driver/test/cypress/integration/commands/connectors_spec.coffee index c1547b34c33..f4f03da039e 100644 --- a/packages/driver/test/cypress/integration/commands/connectors_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/connectors_spec.coffee @@ -24,9 +24,9 @@ describe "src/cy/commands/connectors", -> it "spreads a jQuery wrapper into individual arguments", -> cy.noop($("div")).spread (first, second) -> - expect(first.tagName).to.eq('DIV'); + expect(first.tagName).to.eq('DIV') expect(first.innerText).to.eq("div") - expect(second.tagName).to.eq('DIV'); + expect(second.tagName).to.eq('DIV') expect(second.innerText).to.contain("Nested Find") it "passes timeout option to spread", -> @@ -480,8 +480,8 @@ describe "src/cy/commands/connectors", -> memo + num , 0 math: { - sum: => - @obj.sum.apply(@obj, arguments) + sum: (args...) => + @obj.sum.apply(@obj, args) } } @@ -1222,7 +1222,7 @@ describe "src/cy/commands/connectors", -> it "awaits promises returned", -> count = 0 - start = new Date + start = new Date() cy.get("#list li").each ($li, i, arr) -> new Promise (resolve, reject) -> @@ -1232,7 +1232,7 @@ describe "src/cy/commands/connectors", -> , 20 .then ($lis) -> expect(count).to.eq(3) - expect(new Date - start).to.be.gt(60) + expect(new Date() - start).to.be.gt(60) it "supports array like structures", -> count = 0 diff --git a/packages/driver/test/cypress/integration/commands/cookies_spec.coffee b/packages/driver/test/cypress/integration/commands/cookies_spec.coffee index adb392e91e9..98eebfe7095 100644 --- a/packages/driver/test/cypress/integration/commands/cookies_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/cookies_spec.coffee @@ -292,6 +292,15 @@ describe "src/cy/commands/cookies", -> cy.getCookie(123) + it "throws an error if the cookie name is invalid", (done) -> + cy.on "fail", (err) => + expect(err.message).to.include("cy.getCookie() must be passed an RFC-6265-compliant cookie name.") + expect(err.message).to.include('You passed:\n\n`m=m`') + + done() + + cy.getCookie("m=m") + describe ".log", -> beforeEach -> cy.on "log:added", (attrs, log) => @@ -379,6 +388,15 @@ describe "src/cy/commands/cookies", -> { domain: "brian.dev.local", name: "foo", value: "bar", path: "/foo", secure: true, httpOnly: true, expiry: 987 } ) + it "can set multiple cookies with the same options", -> + Cypress.utils.addTwentyYears.restore() + options = {} + + cy.setCookie("foo", "bar", options) + cy.setCookie("bing", "bong", options) + + cy.getCookie("bing").its("value").should("equal", "bong") + describe "timeout", -> it "sets timeout to Cypress.config(responseTimeout)", -> Cypress.config("responseTimeout", 2500) @@ -427,7 +445,6 @@ describe "src/cy/commands/cookies", -> it "logs once on error", (done) -> error = new Error("some err message") error.name = "foo" - error.stack = "stack" Cypress.automation.rejects(error) @@ -435,9 +452,9 @@ describe "src/cy/commands/cookies", -> lastLog = @lastLog expect(@logs.length).to.eq(1) - expect(lastLog.get("error").message).to.eq "some err message" - expect(lastLog.get("error").name).to.eq "foo" - expect(lastLog.get("error").stack).to.eq error.stack + expect(lastLog.get("error").message).to.include "some err message" + expect(lastLog.get("error").name).to.eq "CypressError" + expect(lastLog.get("error").stack).to.include error.stack done() cy.setCookie("foo", "bar") @@ -453,7 +470,7 @@ describe "src/cy/commands/cookies", -> expect(lastLog.get("state")).to.eq("failed") expect(lastLog.get("name")).to.eq("setCookie") expect(lastLog.get("message")).to.eq("foo, bar") - expect(err.message).to.eq("cy.setCookie() timed out waiting '50ms' to complete.") + expect(err.message).to.include("cy.setCookie() timed out waiting '50ms' to complete.") done() cy.setCookie("foo", "bar", {timeout: 50}) @@ -480,6 +497,46 @@ describe "src/cy/commands/cookies", -> cy.setCookie("foo", 123) + context "when setting an invalid cookie", -> + it "throws an error if the cookie name is invalid", (done) -> + cy.on "fail", (err) => + expect(err.message).to.include("cy.setCookie() must be passed an RFC-6265-compliant cookie name.") + expect(err.message).to.include('You passed:\n\n`m=m`') + + done() + + ## cookie names may not contain = + ## https://stackoverflow.com/a/6109881/3474615 + cy.setCookie("m=m", "foo") + + it "throws an error if the cookie value is invalid", (done) -> + cy.on "fail", (err) => + expect(err.message).to.include('must be passed an RFC-6265-compliant cookie value.') + expect(err.message).to.include('You passed:\n\n` bar`') + + done() + + ## cookies may not contain unquoted whitespace + cy.setCookie("foo", " bar") + + it "throws an error if the backend responds with an error", (done) -> + cy.on "fail", (err) => + expect(skipErrStub).to.be.calledOnce + expect(errStub).to.be.calledTwice + expect(err.message).to.contain('unexpected error setting the requested cookie') + done() + + errStub = cy.stub(Cypress.utils, "throwErrByPath") + errStub.callThrough() + + ## stub cookie validation so this invalid cookie can make it to the backend + skipErrStub = errStub + .withArgs("setCookie.invalid_value") + .returns() + + ## browser backend should yell since this is invalid + cy.setCookie("foo", " bar") + describe ".log", -> beforeEach -> cy.on "log:added", (attrs, log) => @@ -624,6 +681,15 @@ describe "src/cy/commands/cookies", -> cy.clearCookie(123) + it "throws an error if the cookie name is invalid", (done) -> + cy.on "fail", (err) => + expect(err.message).to.include("cy.clearCookie() must be passed an RFC-6265-compliant cookie name.") + expect(err.message).to.include('You passed:\n\n`m=m`') + + done() + + cy.clearCookie("m=m") + describe ".log", -> beforeEach -> cy.on "log:added", (attrs, log) => diff --git a/packages/driver/test/cypress/integration/commands/navigation_spec.coffee b/packages/driver/test/cypress/integration/commands/navigation_spec.coffee index 66790037148..315b411aa8d 100644 --- a/packages/driver/test/cypress/integration/commands/navigation_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/navigation_spec.coffee @@ -149,6 +149,7 @@ describe "src/cy/commands/navigation", -> expect(win.foo).to.be.undefined it "throws when reload times out", (done) -> + cy.timeout(1000) locReload = cy.spy(Cypress.utils, "locReload") cy @@ -326,6 +327,7 @@ describe "src/cy/commands/navigation", -> cy.go(0) it "throws when go times out", (done) -> + cy.timeout(1000) cy .visit("/timeout?ms=100") .visit("/fixtures/jquery.html") @@ -360,19 +362,19 @@ describe "src/cy/commands/navigation", -> describe ".log", -> beforeEach -> - @logs = [] + cy.visit("/fixtures/generic.html").then -> + @logs = [] - cy.on "log:added", (attrs, log) => - if attrs.name is "go" - @lastLog = log + cy.on "log:added", (attrs, log) => + if attrs.name is "go" + @lastLog = log - @logs.push(log) + @logs.push(log) - return null + return null it "logs go", -> cy - .visit("/fixtures/generic.html") .visit("/fixtures/jquery.html") .go("back").then -> lastLog = @lastLog @@ -382,14 +384,12 @@ describe "src/cy/commands/navigation", -> it "can turn off logging", -> cy - .visit("/fixtures/generic.html") .visit("/fixtures/jquery.html") .go("back", {log: false}).then -> expect(@lastLog).to.be.undefined it "does not log 'Page Load' events", -> cy - .visit("/fixtures/generic.html") .visit("/fixtures/jquery.html") .go("back").then -> @logs.slice(0).forEach (log) -> @@ -399,7 +399,6 @@ describe "src/cy/commands/navigation", -> beforeunload = false cy - .visit("/fixtures/generic.html") .visit("/fixtures/jquery.html") .window().then (win) -> cy.on "window:before:unload", => @@ -543,11 +542,11 @@ describe "src/cy/commands/navigation", -> it "does not support file:// protocol", (done) -> Cypress.config("baseUrl", "") - + cy.on "fail", (err) -> expect(err.message).to.contain("cy.visit() failed because the 'file://...' protocol is not supported by Cypress.") done() - + cy.visit("file:///cypress/fixtures/generic.html") ## https://github.com/cypress-io/cypress/issues/1727 @@ -619,6 +618,15 @@ describe "src/cy/commands/navigation", -> }) cy.contains('"user-agent":"something special"') + it "can send querystring params", -> + qs = { "foo bar": "baz quux" } + + cy + .visit("http://localhost:3500/dump-qs", { qs }) + .then -> + cy.contains(JSON.stringify(qs)) + cy.url().should('eq', 'http://localhost:3500/dump-qs?foo%20bar=baz%20quux') + describe "can send a POST request", -> it "automatically urlencoded using an object body", -> cy.visit("http://localhost:3500/post-only", { @@ -1060,6 +1068,23 @@ describe "src/cy/commands/navigation", -> headers: "quux" }) + [ + "foo", + null, + false, + ].forEach (qs) => + str = String(qs) + + it "throws when qs is #{str}", (done) -> + cy.on "fail", (err) -> + expect(err.message).to.contain "cy.visit() requires the 'qs' option to be an object, but received: '#{str}'" + done() + + cy.visit({ + url: "http://foobarbaz", + qs + }) + it "throws when failOnStatusCode is false and retryOnStatusCodeFailure is true", (done) -> cy.on "fail", (err) -> expect(err.message).to.contain "cy.visit() was invoked with { failOnStatusCode: false, retryOnStatusCodeFailure: true }." diff --git a/packages/driver/test/cypress/integration/commands/querying_spec.coffee b/packages/driver/test/cypress/integration/commands/querying_spec.coffee index 20e05e8807c..a6303e9c22f 100644 --- a/packages/driver/test/cypress/integration/commands/querying_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/querying_spec.coffee @@ -1173,7 +1173,14 @@ describe "src/cy/commands/querying", -> .server() .route(/users/, {}).as("getUsers") .get("@getUsers.all ") + _.each ["", "foo", [], 1, null ], (value) => + it "throws when options property is not an object. Such as: #{value}", (done) -> + cy.on "fail", (err) -> + expect(err.message).to.include "only accepts an options object for its second argument. You passed #{value}" + done() + cy.get("foobar", value) + it "logs out $el when existing $el is found even on failure", (done) -> button = cy.$$("#button").hide() @@ -1195,6 +1202,14 @@ describe "src/cy/commands/querying", -> cy.contains("DOM Fixture").then ($el) -> expect($el).not.to.match("title") + it 'will not find script elements', -> + cy.$$('').appendTo(cy.$$('body')) + cy.contains('some-script-content').should('not.match', 'script') + + it 'will not find style elements', -> + cy.$$('').appendTo(cy.$$('body')) + cy.contains('some-style-content').should('not.match', 'style') + it "finds the nearest element by :contains selector", -> cy.contains("li 0").then ($el) -> expect($el.length).to.eq(1) diff --git a/packages/driver/test/cypress/integration/commands/request_spec.coffee b/packages/driver/test/cypress/integration/commands/request_spec.coffee index 44edabd6501..04161f8d878 100644 --- a/packages/driver/test/cypress/integration/commands/request_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/request_spec.coffee @@ -573,7 +573,39 @@ describe "src/cy/commands/request", -> expect(@logs.length).to.eq(1) expect(lastLog.get("error")).to.eq(err) expect(lastLog.get("state")).to.eq("failed") - expect(err.message).to.eq("cy.request() must be provided a fully qualified url - one that begins with 'http'. By default cy.request() will use either the current window's origin or the 'baseUrl' in cypress.json. Neither of those values were present.") + expect(err.message).to.eq("cy.request() must be provided a fully qualified url - one that begins with 'http'. By default cy.request() will use either the current window's origin or the 'baseUrl' in 'cypress.json'. Neither of those values were present.") + done() + + cy.request("/foo/bar") + + it "throws when url is not FQDN, notes that configFile is disabled", (done) -> + Cypress.config("baseUrl", "") + Cypress.config("configFile", false) + cy.stub(cy, "getRemoteLocation").withArgs("origin").returns("") + + cy.on "fail", (err) => + lastLog = @lastLog + + expect(@logs.length).to.eq(1) + expect(lastLog.get("error")).to.eq(err) + expect(lastLog.get("state")).to.eq("failed") + expect(err.message).to.eq("cy.request() must be provided a fully qualified url - one that begins with 'http'. By default cy.request() will use either the current window's origin or the 'baseUrl' in 'cypress.json' (currently disabled by --config-file=false). Neither of those values were present.") + done() + + cy.request("/foo/bar") + + it "throws when url is not FQDN, notes that configFile is non-default", (done) -> + Cypress.config("baseUrl", "") + Cypress.config("configFile", "foo.json") + cy.stub(cy, "getRemoteLocation").withArgs("origin").returns("") + + cy.on "fail", (err) => + lastLog = @lastLog + + expect(@logs.length).to.eq(1) + expect(lastLog.get("error")).to.eq(err) + expect(lastLog.get("state")).to.eq("failed") + expect(err.message).to.eq("cy.request() must be provided a fully qualified url - one that begins with 'http'. By default cy.request() will use either the current window's origin or the 'baseUrl' in 'foo.json'. Neither of those values were present.") done() cy.request("/foo/bar") diff --git a/packages/driver/test/cypress/integration/commands/screenshot_spec.coffee b/packages/driver/test/cypress/integration/commands/screenshot_spec.coffee index b2f64b840c9..e92266fb90c 100644 --- a/packages/driver/test/cypress/integration/commands/screenshot_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/screenshot_spec.coffee @@ -4,6 +4,9 @@ _ = Cypress._ Promise = Cypress.Promise Screenshot = Cypress.Screenshot +getViewportHeight = () -> + Math.min(cy.state("viewportHeight"), $(cy.state("window")).height()) + describe "src/cy/commands/screenshot", -> beforeEach -> cy.stub(Cypress, "automation").callThrough() @@ -30,6 +33,10 @@ describe "src/cy/commands/screenshot", -> context "runnable:after:run:async", -> it "is noop when not isTextTerminal", -> + ## backup this property so we set it back to whatever + ## is correct based on what mode we're currently in + isTextTerminal = Cypress.config("isTextTerminal") + Cypress.config("isTextTerminal", false) cy.spy(Cypress, "action").log(false) @@ -45,7 +52,7 @@ describe "src/cy/commands/screenshot", -> expect(Cypress.action).not.to.be.calledWith("cy:test:set:state") expect(Cypress.automation).not.to.be.called .finally -> - Cypress.config("isTextTerminal", true) + Cypress.config("isTextTerminal", isTextTerminal) it "is noop when no test.err", -> Cypress.config("isInteractive", false) @@ -132,7 +139,7 @@ describe "src/cy/commands/screenshot", -> .then -> expect(Cypress.automation).to.be.calledWith("take:screenshot") args = Cypress.automation.withArgs("take:screenshot").args[0][1] - args = _.omit(args, "clip", "userClip", "viewport", "takenPaths", "startTime") + args = _.omit(args, "padding", "clip", "userClip", "viewport", "takenPaths", "startTime") expect(args).to.eql({ testId: runnable.id titles: [ @@ -168,7 +175,7 @@ describe "src/cy/commands/screenshot", -> .then -> expect(Cypress.automation.withArgs("take:screenshot")).to.be.calledOnce args = Cypress.automation.withArgs("take:screenshot").args[0][1] - args = _.omit(args, "clip", "userClip", "viewport", "takenPaths", "startTime") + args = _.omit(args, "padding", "clip", "userClip", "viewport", "takenPaths", "startTime") expect(args).to.eql({ testId: runnable.id titles: [ @@ -201,7 +208,7 @@ describe "src/cy/commands/screenshot", -> .then -> expect(Cypress.automation).to.be.calledWith("take:screenshot") args = Cypress.automation.withArgs("take:screenshot").args[0][1] - args = _.omit(args, "clip", "userClip", "viewport", "takenPaths", "startTime") + args = _.omit(args, "padding", "clip", "userClip", "viewport", "takenPaths", "startTime") expect(args).to.eql({ testId: runnable.id titles: [ @@ -302,8 +309,8 @@ describe "src/cy/commands/screenshot", -> .screenshot() .then -> expect(Cypress.automation.withArgs("take:screenshot").args[0][1].viewport).to.eql({ - width: $(window.parent).width() - height: $(window.parent).height() + width: window.parent.innerWidth + height: window.parent.innerHeight }) it "can handle window w/length > 1 as a subject", -> @@ -516,18 +523,71 @@ describe "src/cy/commands/screenshot", -> expect(scrollTo.getCall(2).args.join(",")).to.equal("0,100") it "sends the right clip values for elements that need scrolling", -> + scrollTo = cy.spy(cy.state("window"), "scrollTo") + cy.get(".tall-element").screenshot() .then -> + expect(scrollTo.getCall(0).args).to.eql([0, 140]) + take = Cypress.automation.withArgs("take:screenshot") expect(take.args[0][1].clip).to.eql({ x: 20, y: 0, width: 560, height: 200 }) expect(take.args[1][1].clip).to.eql({ x: 20, y: 60, width: 560, height: 120 }) it "sends the right clip values for elements that don't need scrolling", -> + scrollTo = cy.spy(cy.state("window"), "scrollTo") + cy.get(".short-element").screenshot() .then -> + # even though we don't need to scroll, the implementation behaviour is to + # try to scroll until the element is at the top of the viewport. + expect(scrollTo.getCall(0).args).to.eql([0, 20]) + take = Cypress.automation.withArgs("take:screenshot") expect(take.args[0][1].clip).to.eql({ x: 40, y: 0, width: 200, height: 100 }) + it "applies padding to clip values for elements that need scrolling", -> + padding = 10 + + scrollTo = cy.spy(cy.state("window"), "scrollTo") + + cy.get(".tall-element").screenshot({ padding }) + .then -> + viewportHeight = getViewportHeight() + expect(scrollTo.getCall(0).args).to.eql([0, 140 - padding]) + expect(scrollTo.getCall(1).args).to.eql([0, 140 + viewportHeight - padding ]) + + take = Cypress.automation.withArgs("take:screenshot") + + expect(take.args[0][1].clip).to.eql({ + x: 20 - padding, + y: 0, + width: 560 + padding * 2, + height: viewportHeight + }) + expect(take.args[1][1].clip).to.eql({ + x: 20 - padding, + y: 60 - padding, + width: 560 + padding * 2, + height: 120 + padding * 2 + }) + + it "applies padding to clip values for elements that don't need scrolling", -> + padding = 10 + + scrollTo = cy.spy(cy.state("window"), "scrollTo") + + cy.get(".short-element").screenshot({ padding }) + .then -> + expect(scrollTo.getCall(0).args).to.eql([0, padding]) + + take = Cypress.automation.withArgs("take:screenshot") + expect(take.args[0][1].clip).to.eql({ + x: 30, + y: 0, + width: 220, + height: 120 + }) + it "works with cy.within()", -> cy.get(".short-element").within -> cy.screenshot() @@ -647,20 +707,42 @@ describe "src/cy/commands/screenshot", -> @assertErrorMessage("cy.screenshot() 'blackout' option must be an array of strings. You passed: true", done) cy.screenshot({ blackout: [true] }) + it "throws if there is a 0px tall element height", (done) -> + @assertErrorMessage("cy.screenshot() only works with a screenshot area with a height greater than zero.", done) + cy.visit("/fixtures/screenshots.html") + cy.get('.empty-element').screenshot() + + it "throws if padding is not a number", (done) -> + @assertErrorMessage("cy.screenshot() 'padding' option must be either a number or an array of numbers with a maximum length of 4. You passed: 50px", done) + cy.screenshot({ padding: '50px' }) + + it "throws if padding is not an array of numbers", (done) -> + @assertErrorMessage("cy.screenshot() 'padding' option must be either a number or an array of numbers with a maximum length of 4. You passed: bad, bad, bad, bad", done) + cy.screenshot({ padding: ['bad', 'bad', 'bad', 'bad'] }) + + it "throws if padding is not an array with a length between 1 and 4", (done) -> + @assertErrorMessage("cy.screenshot() 'padding' option must be either a number or an array of numbers with a maximum length of 4. You passed: 20, 10, 20, 10, 50", done) + cy.screenshot({ padding: [20, 10, 20, 10, 50] }) + + it "throws if padding is a large negative number that causes a 0px tall element height", (done) -> + @assertErrorMessage("cy.screenshot() only works with a screenshot area with a height greater than zero.", done) + cy.visit("/fixtures/screenshots.html") + cy.get('.tall-element').screenshot({ padding: -161 }) + it "throws if clip is not an object", (done) -> - @assertErrorMessage("cy.screenshot() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: true", done) + @assertErrorMessage("cy.screenshot() 'clip' option must be an object with the keys { width, height, x, y } and number values. You passed: true", done) cy.screenshot({ clip: true }) it "throws if clip is lacking proper keys", (done) -> - @assertErrorMessage("cy.screenshot() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: {x: 5}", done) + @assertErrorMessage("cy.screenshot() 'clip' option must be an object with the keys { width, height, x, y } and number values. You passed: {x: 5}", done) cy.screenshot({ clip: { x: 5 } }) it "throws if clip has extraneous keys", (done) -> - @assertErrorMessage("cy.screenshot() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: Object{5}", done) + @assertErrorMessage("cy.screenshot() 'clip' option must be an object with the keys { width, height, x, y } and number values. You passed: Object{5}", done) cy.screenshot({ clip: { width: 100, height: 100, x: 5, y: 5, foo: 10 } }) it "throws if clip has non-number values", (done) -> - @assertErrorMessage("cy.screenshot() 'clip' option must be an object of with the keys { width, height, x, y } and number values. You passed: Object{4}", done) + @assertErrorMessage("cy.screenshot() 'clip' option must be an object with the keys { width, height, x, y } and number values. You passed: Object{4}", done) cy.screenshot({ clip: { width: 100, height: 100, x: 5, y: "5" } }) it "throws if element capture with multiple elements", (done) -> diff --git a/packages/driver/test/cypress/integration/commands/waiting_spec.coffee b/packages/driver/test/cypress/integration/commands/waiting_spec.coffee index 5fadf4edfed..22be0c5a0c1 100644 --- a/packages/driver/test/cypress/integration/commands/waiting_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/waiting_spec.coffee @@ -632,6 +632,7 @@ describe "src/cy/commands/waiting", -> .wait("@getUsers").then (xhr) -> expect(xhr.url).to.include "/users?num=4" expect(xhr.responseBody).to.deep.eq resp + null describe "errors", -> describe "invalid 1st argument", -> @@ -915,4 +916,4 @@ describe "src/cy/commands/waiting", -> # Command: "wait" # "Waited For": _.str.clean(fn.toString()) # Retried: "3 times" - # } \ No newline at end of file + # } diff --git a/packages/driver/test/cypress/integration/commands/window_spec.coffee b/packages/driver/test/cypress/integration/commands/window_spec.coffee index 11eb83c140c..fc6ff91e4bf 100644 --- a/packages/driver/test/cypress/integration/commands/window_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/window_spec.coffee @@ -546,6 +546,20 @@ describe "src/cy/commands/window", -> cy.viewport("ipad-mini") + it "iphone-xr", (done) -> + cy.on "viewport:changed", (viewport) -> + expect(viewport).to.deep.eq {viewportWidth: 414, viewportHeight: 896} + done() + + cy.viewport("iphone-xr") + + it "iphone-x", (done) -> + cy.on "viewport:changed", (viewport) -> + expect(viewport).to.deep.eq {viewportWidth: 375, viewportHeight: 812} + done() + + cy.viewport("iphone-x") + it "iphone-6+", (done) -> cy.on "viewport:changed", (viewport) -> expect(viewport).to.deep.eq {viewportWidth: 414, viewportHeight: 736} @@ -581,6 +595,20 @@ describe "src/cy/commands/window", -> cy.viewport("iphone-5", "portrait") + it "samsung-s10", (done) -> + cy.on "viewport:changed", (viewport) -> + expect(viewport).to.deep.eq {viewportWidth: 360, viewportHeight: 760} + done() + + cy.viewport("samsung-s10") + + it "samsung-note9", (done) -> + cy.on "viewport:changed", (viewport) -> + expect(viewport).to.deep.eq {viewportWidth: 414, viewportHeight: 846} + done() + + cy.viewport("samsung-note9") + context "errors", -> beforeEach -> Cypress.config("defaultCommandTimeout", 50) @@ -596,7 +624,7 @@ describe "src/cy/commands/window", -> it "throws with passed invalid preset", (done) -> cy.on "fail", (err) => expect(@logs.length).to.eq(1) - expect(err.message).to.eq "cy.viewport() could not find a preset for: 'foo'. Available presets are: macbook-15, macbook-13, macbook-11, ipad-2, ipad-mini, iphone-6+, iphone-6, iphone-5, iphone-4, iphone-3" + expect(err.message).to.match /^cy.viewport\(\) could not find a preset for: 'foo'. Available presets are: / done() cy.viewport("foo") @@ -612,7 +640,7 @@ describe "src/cy/commands/window", -> it "throws when passed negative numbers", (done) -> cy.on "fail", (err) => expect(@logs.length).to.eq(1) - expect(err.message).to.eq "cy.viewport() width and height must be between 20px and 3000px." + expect(err.message).to.eq "cy.viewport() width and height must be between 20px and 4000px." done() cy.viewport(800, -600) @@ -620,7 +648,7 @@ describe "src/cy/commands/window", -> it "throws when passed width less than 20", (done) -> cy.on "fail", (err) => expect(@logs.length).to.eq(1) - expect(err.message).to.eq "cy.viewport() width and height must be between 20px and 3000px." + expect(err.message).to.eq "cy.viewport() width and height must be between 20px and 4000px." done() cy.viewport(19, 600) @@ -628,16 +656,16 @@ describe "src/cy/commands/window", -> it "does not throw when passed width equal to 20", -> cy.viewport(20, 600) - it "throws when passed height greater than than 3000", (done) -> + it "throws when passed height greater than than 4000", (done) -> cy.on "fail", (err) => expect(@logs.length).to.eq(1) - expect(err.message).to.eq "cy.viewport() width and height must be between 20px and 3000px." + expect(err.message).to.eq "cy.viewport() width and height must be between 20px and 4000px." done() - cy.viewport(1000, 3001) + cy.viewport(1000, 4001) - it "does not throw when passed width equal to 3000", -> - cy.viewport(200, 3000) + it "does not throw when passed width equal to 4000", -> + cy.viewport(200, 4000) it "throws when passed an empty string as width", (done) -> cy.on "fail", (err) => diff --git a/packages/driver/test/cypress/integration/commands/xhr_spec.coffee b/packages/driver/test/cypress/integration/commands/xhr_spec.coffee index f7cef4a5786..e91527ac511 100644 --- a/packages/driver/test/cypress/integration/commands/xhr_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/xhr_spec.coffee @@ -780,13 +780,18 @@ describe "src/cy/commands/xhr", -> it "sets err on log when caused by code errors", (done) -> finalThenCalled = false + uncaughtException = cy.stub().returns(true) + cy.on 'uncaught:exception', uncaughtException cy.on "fail", (err) => lastLog = @lastLog expect(@logs.length).to.eq(1) expect(lastLog.get("name")).to.eq("xhr") - expect(lastLog.get("error")).to.eq err + expect(lastLog.get("error").message).contain('foo is not defined') + ## since this is AUT code, we should allow error to be caught in 'uncaught:exception' hook + ## https://github.com/cypress-io/cypress/issues/987 + expect(uncaughtException).calledOnce done() cy diff --git a/packages/driver/test/cypress/integration/cy/snapshot_css_spec.js b/packages/driver/test/cypress/integration/cy/snapshot_css_spec.js index b5376885d80..2c32d1c33c3 100644 --- a/packages/driver/test/cypress/integration/cy/snapshot_css_spec.js +++ b/packages/driver/test/cypress/integration/cy/snapshot_css_spec.js @@ -9,11 +9,7 @@ const normalizeStyles = (styles) => { const addStyles = (styles, to) => { return new Promise((resolve) => { - $(styles) - .load(() => { - return resolve() - }) - .appendTo(cy.$$(to)) + $(styles).on('load', resolve).appendTo(cy.$$(to)) }) } @@ -101,7 +97,6 @@ describe('driver/src/cy/snapshots_css', () => { }) it('returns same id after css has been modified until a new window', () => { - cy.state('document').styleSheets[0].insertRule('.qux { color: orange; }') snapshotCss.onCssModified('http://localhost:3500/fixtures/generic_styles.css') const ids1 = snapshotCss.getStyleIds() diff --git a/packages/driver/test/cypress/integration/cy/snapshot_spec.coffee b/packages/driver/test/cypress/integration/cy/snapshot_spec.coffee index 996dcd9bdba..d38cd3ab88c 100644 --- a/packages/driver/test/cypress/integration/cy/snapshot_spec.coffee +++ b/packages/driver/test/cypress/integration/cy/snapshot_spec.coffee @@ -89,9 +89,17 @@ describe "driver/src/cy/snapshots", -> it "replaces with placeholders that have src in content", -> $("