diff --git a/.eslintignore b/.eslintignore index 84a4d0703801..28e231e712aa 100644 --- a/.eslintignore +++ b/.eslintignore @@ -34,6 +34,7 @@ system-tests/projects/e2e/cypress/integration/typescript_syntax_error_spec.ts # cli/types is linted by tslint/dtslint cli/types + # packages/example is not linted (think about changing this) packages/example diff --git a/.gitignore b/.gitignore index b3c22a35769d..d660a5be4b81 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ cypress.zip Cached Theme.pak Cached Theme Material Design.pak +# from config, compiled .js files +packages/config/lib/*.js + # from data-context, compiled .js files packages/data-context/src/**/*.js @@ -20,7 +23,6 @@ packages/data-context/src/**/*.js packages/desktop-gui/cypress/videos packages/desktop-gui/src/jsconfig.json - # from driver packages/driver/cypress/videos packages/driver/cypress/screenshots diff --git a/.node-version b/.node-version index 62df50f1eefe..d9617ea1b408 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -14.17.0 +16.5.0 \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 1fd5f170b61b..fd8b079fb9ec 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,8 @@ { // To see these extensions in VS Code: - // 1. Open the Command Palette (Ctrl+Shift+P) + // 1. Open the Command Palette: + // - Non-Mac Users: (Ctrl+Shift+P) + // - Mac Users: (Cmd+Shift+P) // 2. Select "Extensions: Show Recommended Extensions" // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f8687bfb2f7..b1b70f3d2f8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -327,10 +327,6 @@ The project utilizes [yarn workspaces](https://yarnpkg.com/lang/en/docs/workspac > **⚠ Running on Windows?** > > Many of the NPM scripts used during development use commands designed for a Linux-like shell.If you are running a Windows operating system, you may encounter many commands that are not working. To fix this behavior, you have to set a Linux-like shell as the default `npm` script shell. If you have Git for Windows installed, you can set Git Bash as the default script shell by using the following command: -> ```bash -> yarn config set script-shell "C:\\Program Files (x86)\\git\\bin\\bash.exe" -> ``` -> Git Bash may be installed in `Program Files`, if so, use the following command: >```bash > yarn config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" >``` diff --git a/__snapshots__/upload-spec.js b/__snapshots__/upload-spec.js index 320fbb9e6331..62eddbe04c0c 100644 --- a/__snapshots__/upload-spec.js +++ b/__snapshots__/upload-spec.js @@ -5,18 +5,12 @@ exports['test runner manifest'] = { "mac": { "url": "https://cdn.cypress.io/desktop/3.3.0/darwin-x64/cypress.zip" }, - "win": { - "url": "https://cdn.cypress.io/desktop/3.3.0/win32-ia32/cypress.zip" - }, "linux64": { "url": "https://cdn.cypress.io/desktop/3.3.0/linux-x64/cypress.zip" }, "darwin": { "url": "https://cdn.cypress.io/desktop/3.3.0/darwin-x64/cypress.zip" }, - "win32": { - "url": "https://cdn.cypress.io/desktop/3.3.0/win32-ia32/cypress.zip" - }, "linux": { "url": "https://cdn.cypress.io/desktop/3.3.0/linux-x64/cypress.zip" }, @@ -26,9 +20,6 @@ exports['test runner manifest'] = { "linux-x64": { "url": "https://cdn.cypress.io/desktop/3.3.0/linux-x64/cypress.zip" }, - "win32-ia32": { - "url": "https://cdn.cypress.io/desktop/3.3.0/win32-ia32/cypress.zip" - }, "win32-x64": { "url": "https://cdn.cypress.io/desktop/3.3.0/win32-x64/cypress.zip" } diff --git a/__snapshots__/util-upload-spec.js b/__snapshots__/util-upload-spec.js index f031bc0c1d99..8cd97721e743 100644 --- a/__snapshots__/util-upload-spec.js +++ b/__snapshots__/util-upload-spec.js @@ -9,10 +9,6 @@ exports['upload util isValidPlatformArch checks given strings second 1'] = { "given": "linux-x64", "expect": true }, - { - "given": "win32-ia32", - "expect": true - }, { "given": "win32-x64", "expect": true diff --git a/appveyor.yml b/appveyor.yml index 51c507ed81f5..31764edda1c4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,13 +2,13 @@ branches: only: - master - develop - - fix-test-other-projects + - 9.0-release - /win*/ # https://www.appveyor.com/docs/lang/nodejs-iojs/ environment: # use matching version of Node.js - nodejs_version: "14.17.0" + nodejs_version: "16.5.0" # encode secure variables which will NOT be used # in pull requests # https://www.appveyor.com/docs/build-configuration/#secure-variables @@ -38,7 +38,6 @@ environment: platform: - x64 - - x86 # https://www.appveyor.com/docs/build-cache/ # hmm, seems there is NPM on windows problem diff --git a/circle.yml b/circle.yml index 5a4680b319cd..6363f0e94842 100644 --- a/circle.yml +++ b/circle.yml @@ -8,7 +8,7 @@ macBuildFilters: &macBuildFilters branches: only: - develop - - tgriesser/chore/fix-release + - 9.0-release defaults: &defaults parallelism: 1 @@ -42,7 +42,7 @@ onlyMainBranches: &onlyMainBranches branches: only: - develop - - tgriesser/chore/fix-release + - 9.0-release requires: - create-build-artifacts @@ -50,7 +50,7 @@ executors: # the Docker image with Cypress dependencies and Chrome browser cy-doc: docker: - - image: cypress/browsers:node14.17.0-chrome91-ff89 + - image: cypress/browsers:node16.5.0-chrome94-ff93 # by default, we use "small" to save on CI costs. bump on a per-job basis if needed. resource_class: small environment: @@ -59,7 +59,7 @@ executors: # Docker image with non-root "node" user non-root-docker-user: docker: - - image: cypress/browsers:node14.17.0-chrome91-ff89 + - image: cypress/browsers:node16.5.0-chrome94-ff93 user: node environment: PLATFORM: linux @@ -70,7 +70,7 @@ executors: mac: macos: # Executor should have Node >= required version - xcode: "12.2.0" + xcode: "13.0.0" environment: PLATFORM: mac @@ -266,27 +266,29 @@ commands: install-required-node: # https://discuss.circleci.com/t/switch-nodejs-version-on-machine-executor-solved/26675/2 - description: Install Node version matching .node-version + description: Install Node version + parameters: + version: + type: string + default: "" steps: - run: - name: Install NVM + name: Install NVM + Node # TODO: determine why we get the missing .nvmrc file error command: | - export NODE_VERSION=$(cat .node-version) + export NODE_VERSION=<> + export NODE_VERSION=${NODE_VERSION:-$(cat .node-version)} echo "Installing Node $NODE_VERSION" + echo $NODE_VERSION > .node-version cp .node-version .nvmrc curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.35.3/install.sh | bash - - run: - # https://github.com/nvm-sh/nvm#nvmrc - name: Install Node - command: | . ./scripts/load-nvm.sh echo "before nvm install" - nvm install + nvm install $NODE_VERSION echo "before nvm use" - nvm use + nvm use $NODE_VERSION echo "before nvm alias default" - nvm alias default + nvm alias default $NODE_VERSION node --version install-chrome: @@ -556,7 +558,7 @@ commands: working_directory: /tmp/<> # force installing the freshly built binary command: | - CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm i ~/cypress/cypress.tgz && [[ -f yarn.lock ]] && yarn + CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm i --legacy-peer-deps ~/cypress/cypress.tgz && [[ -f yarn.lock ]] && yarn - run: name: Print Cypress version working_directory: /tmp/<> @@ -686,7 +688,7 @@ commands: CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip yarn add -D ~/cypress/cypress.tgz else npm install - CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm install ~/cypress/cypress.tgz + CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm install --legacy-peer-deps ~/cypress/cypress.tgz fi working_directory: /tmp/<> - run: @@ -838,7 +840,7 @@ commands: paths: - cypress/binary-url.json - build-npm-package: + build-cypress-npm-package: steps: - run: name: bump NPM version @@ -857,13 +859,12 @@ commands: - run: name: pack NPM package working_directory: cli/build - command: yarn pack + command: yarn pack --filename cypress.tgz - run: name: list created NPM package working_directory: cli/build command: ls -l - # created file should have filename cypress-.tgz - - run: cp cli/build/cypress-v*.tgz cypress.tgz + - run: cp cli/build/cypress.tgz cypress.tgz - store-npm-logs - run: pwd - run: ls -l @@ -964,7 +965,7 @@ jobs: desktop-gui-component-tests, cli-visual-tests, runner-integration-tests-chrome, - runner-ct-integration-tests-chrome + runner-ct-integration-tests-chrome, reporter-integration-tests, - run: yarn percy build:finalize @@ -1468,20 +1469,27 @@ jobs: <<: *defaults steps: - restore_cached_workspace + - install-required-node: + version: "16.10" - run: - name: Build - command: yarn workspace @cypress/schematic build:all - - run: - name: Install @angular/cli - command: yarn policies set-version 1.19.0 && yarn add --dev @angular/cli + name: Build + Install + command: | + . ../../scripts/load-nvm.sh + yarn workspace @cypress/schematic build:all + yarn policies set-version 1.19.0 + yarn add --dev @angular/cli working_directory: npm/cypress-schematic - run: name: Launch - command: yarn launch:test + command: | + . ../../scripts/load-nvm.sh + yarn launch:test working_directory: npm/cypress-schematic - run: name: Run unit tests - command: yarn test + command: | + . ../../scripts/load-nvm.sh + yarn test working_directory: npm/cypress-schematic - store-npm-logs @@ -1501,11 +1509,11 @@ jobs: steps: - restore_cached_workspace - build-binary - - build-npm-package + - build-cypress-npm-package - run: name: Check current branch to persist artifacts command: | - if [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "tgriesser/chore/fix-release" ]]; then + if [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "9.0-release" ]]; then echo "Not uploading artifacts or posting install comment for this branch." circleci-agent step halt fi @@ -1800,7 +1808,7 @@ jobs: name: Install Cypress working_directory: /tmp/cypress-test-tiny # force installing the freshly built binary - command: CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm i ~/cypress/cypress.tgz + command: CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm i --legacy-peer-deps ~/cypress/cypress.tgz - run: name: Run test project working_directory: /tmp/cypress-test-tiny @@ -1897,6 +1905,7 @@ jobs: repo: cypress-example-conduit-app browser: chrome command: "npm run cypress:run" + wait-on: http://localhost:3000 "test-binary-against-api-testing-firefox": <<: *defaults @@ -2146,7 +2155,7 @@ linux-workflow: &linux-workflow branches: only: - develop - - tgriesser/chore/fix-release + - 9.0-release requires: - build - test-kitchensink: @@ -2158,7 +2167,7 @@ linux-workflow: &linux-workflow branches: only: - develop - - tgriesser/chore/fix-release + - 9.0-release requires: - build - create-build-artifacts: @@ -2208,7 +2217,7 @@ linux-workflow: &linux-workflow branches: only: - develop - - tgriesser/chore/fix-release + - 9.0-release requires: - create-build-artifacts - test-npm-module-and-verify-binary: @@ -2216,7 +2225,7 @@ linux-workflow: &linux-workflow branches: only: - develop - - tgriesser/chore/fix-release + - 9.0-release requires: - create-build-artifacts - test-binary-against-staging: @@ -2225,7 +2234,7 @@ linux-workflow: &linux-workflow branches: only: - develop - - tgriesser/chore/fix-release + - 9.0-release requires: - create-build-artifacts @@ -2250,7 +2259,7 @@ linux-workflow: &linux-workflow branches: only: - develop - - tgriesser/chore/fix-release + - 9.0-release requires: - create-build-artifacts @@ -2322,7 +2331,7 @@ mac-workflow: &mac-workflow branches: only: - develop - - tgriesser/chore/fix-release + - 9.0-release requires: - darwin-create-build-artifacts @@ -2334,7 +2343,7 @@ mac-workflow: &mac-workflow branches: only: - develop - - tgriesser/chore/fix-release + - 9.0-release requires: - darwin-create-build-artifacts diff --git a/cli/__snapshots__/download_spec.js b/cli/__snapshots__/download_spec.js index e5a519fa9e94..eb89d7910848 100644 --- a/cli/__snapshots__/download_spec.js +++ b/cli/__snapshots__/download_spec.js @@ -28,7 +28,7 @@ URL: https://download.cypress.io/desktop?platform=OS&arch=ARCH ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` diff --git a/cli/__snapshots__/errors_spec.js b/cli/__snapshots__/errors_spec.js index 695c224ca3ad..e254269f2d30 100644 --- a/cli/__snapshots__/errors_spec.js +++ b/cli/__snapshots__/errors_spec.js @@ -5,7 +5,7 @@ a solution ---------- -Platform: test platform (Foo-OsVersion) +Platform: test platform-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -22,7 +22,7 @@ If you are using Docker, we provide containers with all required dependencies in ---------- -Platform: test platform (Foo-OsVersion) +Platform: test platform-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -35,6 +35,7 @@ exports['errors individual has the following errors 1'] = [ "incompatibleHeadlessFlags", "invalidCacheDirectory", "invalidCypressEnv", + "invalidOS", "invalidRunProjectPath", "invalidSmokeTestDisplayError", "invalidTestingType", @@ -68,7 +69,7 @@ Please refer to the error above for more detail. ---------- -Platform: test platform (Foo-OsVersion) +Platform: test platform-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -92,6 +93,6 @@ Consider opening a new issue. ---------- -Platform: test platform (Foo-OsVersion) +Platform: test platform-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` diff --git a/cli/__snapshots__/install_spec.js b/cli/__snapshots__/install_spec.js index d942db4c9c77..7d6add82dc1e 100644 --- a/cli/__snapshots__/install_spec.js +++ b/cli/__snapshots__/install_spec.js @@ -142,7 +142,7 @@ EACCES: permission denied, mkdir '/invalid' ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -246,3 +246,14 @@ Installing Cypress (version: 1.2.3) - npm install --save-dev cypress ` + +exports['error when installing on unsupported os'] = ` +Error: The Cypress App could not be installed. Your machine does not meet the operating system requirements. + +https://on.cypress.io/guides/getting-started/installing-cypress#system-requirements + +---------- + +Platform: win32-ia32 + +` diff --git a/cli/__snapshots__/spawn_spec.js b/cli/__snapshots__/spawn_spec.js index 8b1b60f16621..797395f38fdc 100644 --- a/cli/__snapshots__/spawn_spec.js +++ b/cli/__snapshots__/spawn_spec.js @@ -30,6 +30,6 @@ Consider opening a new issue. ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 0.0.0-development ` diff --git a/cli/__snapshots__/unzip_spec.js b/cli/__snapshots__/unzip_spec.js index b1fc34be5153..830187db819b 100644 --- a/cli/__snapshots__/unzip_spec.js +++ b/cli/__snapshots__/unzip_spec.js @@ -11,7 +11,7 @@ Error: end of central directory record signature not found ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` diff --git a/cli/__snapshots__/verify_spec.js b/cli/__snapshots__/verify_spec.js index 61e43ed29aca..c77e8394f6ba 100644 --- a/cli/__snapshots__/verify_spec.js +++ b/cli/__snapshots__/verify_spec.js @@ -14,7 +14,7 @@ You can also try clearing the cache with 'cypress cache clear' and reinstalling. ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -48,7 +48,7 @@ ENOENT: no such file or directory, stat '/custom/' ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -89,7 +89,7 @@ https://on.cypress.io/not-installed-ci-error ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -105,7 +105,7 @@ Cypress executable not found at: /cache/Cypress/1.2.3/Cypress.app/Contents/MacOS ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -126,7 +126,7 @@ an error about dependencies ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -144,7 +144,7 @@ Error: EPERM NOT PERMITTED ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -166,7 +166,7 @@ some stderr ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -188,7 +188,7 @@ some stderr ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -210,7 +210,7 @@ some stdout ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -232,7 +232,7 @@ ENOENT: no such file or directory, stat '/custom/' ---------- -Platform: linux (Foo-OsVersion) +Platform: linux-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -248,7 +248,7 @@ Cypress executable not found at: /cache/Cypress/1.2.3/Cypress.app/Contents/MacOS ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -264,7 +264,7 @@ Cypress executable not found at: /cache/Cypress/1.2.3/Cypress.app/Contents/MacOS ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -363,7 +363,7 @@ ENOENT: no such file or directory, stat '/custom/' ---------- -Platform: win32 (Foo-OsVersion) +Platform: win32-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -385,7 +385,7 @@ Error: test without xvfb ---------- -Platform: darwin (Foo-OsVersion) +Platform: darwin-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` @@ -412,6 +412,6 @@ Please refer to the error above for more detail. ---------- -Platform: linux (Foo-OsVersion) +Platform: linux-x64 (Foo-OsVersion) Cypress Version: 1.2.3 ` diff --git a/cli/lib/cli.js b/cli/lib/cli.js index 7a1759d87a9b..46580598c957 100644 --- a/cli/lib/cli.js +++ b/cli/lib/cli.js @@ -587,5 +587,6 @@ module.exports = { if (!module.parent) { logger.error('This CLI module should be required from another Node module') logger.error('and not executed directly') + process.exit(-1) } diff --git a/cli/lib/errors.js b/cli/lib/errors.js index cedc7cb899dd..c88b9b8dc20f 100644 --- a/cli/lib/errors.js +++ b/cli/lib/errors.js @@ -38,6 +38,13 @@ const invalidRunProjectPath = { `, } +const invalidOS = { + description: 'The Cypress App could not be installed. Your machine does not meet the operating system requirements.', + solution: stripIndent` + + ${chalk.blue('https://on.cypress.io/guides/getting-started/installing-cypress#system-requirements')}`, +} + const failedDownload = { description: 'The Cypress App could not be downloaded.', solution: stripIndent` @@ -390,6 +397,7 @@ module.exports = { missingApp, notInstalledCI, missingDependency, + invalidOS, invalidSmokeTestDisplayError, versionMismatch, binaryNotExecutable, diff --git a/cli/lib/exec/spawn.js b/cli/lib/exec/spawn.js index c4cb1b6bfde6..9286f1ad7d71 100644 --- a/cli/lib/exec/spawn.js +++ b/cli/lib/exec/spawn.js @@ -81,7 +81,7 @@ module.exports = { args = [args] } - args = [...args, '--cwd', process.cwd()] + args = [...args, '--cwd', process.cwd(), '--userNodePath', process.execPath, '--userNodeVersion', process.versions.node] _.defaults(options, { dev: false, diff --git a/cli/lib/tasks/download.js b/cli/lib/tasks/download.js index 9fd55cf21da9..c7d60f9931ba 100644 --- a/cli/lib/tasks/download.js +++ b/cli/lib/tasks/download.js @@ -24,20 +24,6 @@ const getProxyForUrlWithNpmConfig = (url) => { null } -const getRealOsArch = () => { - // os.arch() returns the arch for which this node was compiled - // we want the operating system's arch instead: x64 or x86 - - const osArch = arch() - - if (osArch === 'x86') { - // match process.platform output - return 'ia32' - } - - return osArch -} - const getBaseUrl = () => { if (util.getEnv('CYPRESS_DOWNLOAD_MIRROR')) { let baseUrl = util.getEnv('CYPRESS_DOWNLOAD_MIRROR') @@ -77,9 +63,8 @@ const getCA = () => { const prepend = (urlPath) => { const endpoint = url.resolve(getBaseUrl(), urlPath) const platform = os.platform() - const arch = getRealOsArch() - return `${endpoint}?platform=${platform}&arch=${arch}` + return `${endpoint}?platform=${platform}&arch=${arch()}` } const getUrl = (version) => { diff --git a/cli/lib/tasks/install.js b/cli/lib/tasks/install.js index 5aaed0449799..ab56fb936b0a 100644 --- a/cli/lib/tasks/install.js +++ b/cli/lib/tasks/install.js @@ -216,6 +216,12 @@ const downloadAndUnzip = ({ version, installDir, downloadDir }) => { return Promise.resolve(tasks.run()) } +const validateOS = () => { + return util.getPlatformInfo().then((platformInfo) => { + return platformInfo.match(/(darwin|linux|win32)-x64/) + }) +} + const start = (options = {}) => { debug('installing with options %j', options) @@ -271,7 +277,14 @@ const start = (options = {}) => { const cacheDir = state.getCacheDir() const binaryDir = state.getBinaryDir(pkgVersion) - return fs.ensureDirAsync(cacheDir) + return validateOS().then((isValid) => { + if (!isValid) { + return throwFormErrorText(errors.invalidOS)() + } + }) + .then(() => { + return fs.ensureDirAsync(cacheDir) + }) .catch({ code: 'EACCES' }, (err) => { return throwFormErrorText(errors.invalidCacheDirectory)(stripIndent` Failed to access ${chalk.cyan(cacheDir)}: diff --git a/cli/lib/util.js b/cli/lib/util.js index 1c966300f68f..3028c5e0cfc1 100644 --- a/cli/lib/util.js +++ b/cli/lib/util.js @@ -1,4 +1,5 @@ const _ = require('lodash') +const arch = require('arch') const os = require('os') const ospath = require('ospath') const crypto = require('crypto') @@ -430,8 +431,14 @@ const util = { getPlatformInfo () { return util.getOsVersionAsync().then((version) => { + let osArch = arch() + + if (osArch === 'x86') { + osArch = 'ia32' + } + return stripIndent` - Platform: ${os.platform()} (${version}) + Platform: ${os.platform()}-${osArch} (${version}) Cypress Version: ${util.pkgVersion()} ` }) diff --git a/cli/package.json b/cli/package.json index dccb14bb48fa..82ee4946dfd3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "unit": "cross-env BLUEBIRD_DEBUG=1 NODE_ENV=test mocha --reporter mocha-multi-reporters --reporter-options configFile=../mocha-reporter-config.json" }, "dependencies": { - "@cypress/request": "^2.88.6", + "@cypress/request": "^2.88.7", "@cypress/xvfb": "^1.2.4", "@types/node": "^14.14.31", "@types/sinonjs__fake-timers": "^6.0.2", @@ -85,7 +85,7 @@ "execa-wrap": "1.4.0", "hasha": "5.2.2", "mocha": "6.2.2", - "mock-fs": "4.13.0", + "mock-fs": "5.1.1", "mocked-env": "1.3.2", "nock": "13.0.7", "postinstall-postinstall": "2.1.0", diff --git a/cli/schema/cypress.schema.json b/cli/schema/cypress.schema.json index 7608f6a8b9ba..752dba905af3 100644 --- a/cli/schema/cypress.schema.json +++ b/cli/schema/cypress.schema.json @@ -7,6 +7,7 @@ "properties": { "baseUrl": { "type": "string", + "default": null, "description": "Url used as prefix for cy.visit() or cy.request() command’s url. Example http://localhost:3030 or https://test.my-domain.com" }, "env": { @@ -244,8 +245,8 @@ "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." + "default": "system", + "description": "DEPRECATED: If set to 'bundled', Cypress will use the Node version bundled with Cypress. Otherwise, Cypress will use the Node version that was used to launch the Cypress. This Node version is used when executing your plugins file and building spec files." }, "experimentalInteractiveRunEvents": { "type": "boolean", diff --git a/cli/test/lib/exec/spawn_spec.js b/cli/test/lib/exec/spawn_spec.js index 5ccf7a6ceb2c..fe58325145c4 100644 --- a/cli/test/lib/exec/spawn_spec.js +++ b/cli/test/lib/exec/spawn_spec.js @@ -18,6 +18,8 @@ const expect = require('chai').expect const snapshot = require('../../support/snapshot') const cwd = process.cwd() +const execPath = process.execPath +const nodeVersion = process.versions.node const defaultBinaryDir = '/default/binary/dir' @@ -98,6 +100,10 @@ describe('lib/exec/spawn', function () { '--foo', '--cwd', cwd, + '--userNodePath', + execPath, + '--userNodeVersion', + nodeVersion, ], { detached: false, stdio: ['inherit', 'inherit', 'pipe'], @@ -122,6 +128,10 @@ describe('lib/exec/spawn', function () { '--foo', '--cwd', cwd, + '--userNodePath', + execPath, + '--userNodeVersion', + nodeVersion, ] expect(args).to.deep.equal(['/path/to/cypress', expectedCliArgs]) @@ -142,6 +152,10 @@ describe('lib/exec/spawn', function () { '--foo', '--cwd', cwd, + '--userNodePath', + execPath, + '--userNodeVersion', + nodeVersion, ], { detached: false, stdio: ['inherit', 'inherit', 'pipe'], @@ -163,6 +177,10 @@ describe('lib/exec/spawn', function () { '--foo', '--cwd', cwd, + '--userNodePath', + execPath, + '--userNodeVersion', + nodeVersion, ], { detached: false, stdio: ['inherit', 'inherit', 'pipe'], diff --git a/cli/test/lib/tasks/install_spec.js b/cli/test/lib/tasks/install_spec.js index 17498088f7a3..ab8f7e579dbb 100644 --- a/cli/test/lib/tasks/install_spec.js +++ b/cli/test/lib/tasks/install_spec.js @@ -45,7 +45,6 @@ describe('/lib/tasks/install', function () { beforeEach(function () { logger.reset() - // sinon.stub(os, 'tmpdir').returns('/tmp') sinon.stub(util, 'isCi').returns(false) sinon.stub(util, 'isPostInstall').returns(false) sinon.stub(util, 'pkgVersion').returns(packageVersion) @@ -442,6 +441,23 @@ describe('/lib/tasks/install', function () { ) }) }) + + it('exits with error when installing on unsupported os', function () { + sinon.stub(util, 'getPlatformInfo').resolves('Platform: win32-ia32') + + return install.start() + .then(() => { + throw new Error('should have caught error') + }) + .catch((err) => { + logger.error(err) + + snapshot( + 'error when installing on unsupported os', + normalize(this.stdout.toString()), + ) + }) + }) }) context('._getBinaryUrlFromPrereleaseNpmUrl', function () { diff --git a/cli/test/lib/util_spec.js b/cli/test/lib/util_spec.js index 98b00b4e4f2e..b325ccab31a0 100644 --- a/cli/test/lib/util_spec.js +++ b/cli/test/lib/util_spec.js @@ -30,7 +30,7 @@ describe('util', () => { }) context('.getGitHubIssueUrl', () => { - it('returls url for issue number', () => { + it('returns url for issue number', () => { const url = util.getGitHubIssueUrl(4034) expect(url).to.equal('https://github.com/cypress-io/cypress/issues/4034') diff --git a/cli/test/support/normalize.js b/cli/test/support/normalize.js index 777f7ccfc107..f04ae809f498 100644 --- a/cli/test/support/normalize.js +++ b/cli/test/support/normalize.js @@ -2,7 +2,7 @@ const stripAnsi = require('strip-ansi') const whitespaceAtEndOfLineRe = /\s+$/g const datesRe = /(\d+:\d+:\d+)/g -const downloadQueryRe = /(\?platform=(darwin|linux|win32)&arch=(x64|ia32))/ +const downloadQueryRe = /(\?platform=(darwin|linux|win32)&arch=x64)/ const removeExcessWhiteSpace = (str) => { return str.replace(whitespaceAtEndOfLineRe, '') diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 5d71f7f838bb..66e54176f634 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -280,7 +280,7 @@ declare namespace Cypress { * Currently executing test runnable instance. */ currentTest: { - title: string, + title: string titlePath: string[] } @@ -420,9 +420,9 @@ declare namespace Cypress { * @see https://on.cypress.io/api/commands */ Commands: { - add(name: string, fn: (...args: any[]) => CanReturnChainable): void - add(name: string, options: CommandOptions, fn: (...args: any[]) => CanReturnChainable): void - overwrite(name: string, fn: (...args: any[]) => CanReturnChainable): void + add(name: T, fn: Chainable[T]): void + add(name: T, options: CommandOptions, fn: Chainable[T]): void + overwrite(name: T, fn: Chainable[T]): void } /** diff --git a/cli/types/tests/cypress-tests.ts b/cli/types/tests/cypress-tests.ts index 1c5643af3e92..6554513c69f7 100644 --- a/cli/types/tests/cypress-tests.ts +++ b/cli/types/tests/cypress-tests.ts @@ -62,17 +62,31 @@ namespace CypressIsCyTests { }) } +declare namespace Cypress { + interface Chainable { + newCommand: (arg: string) => void + } +} + namespace CypressCommandsTests { - Cypress.Commands.add('newCommand', () => { + Cypress.Commands.add('newCommand', (arg) => { + // $ExpectType string + arg return }) - Cypress.Commands.add('newCommand', { prevSubject: true }, () => { + Cypress.Commands.add('newCommand', { prevSubject: true }, (arg) => { + // $ExpectType string + arg return }) - Cypress.Commands.add('newCommand', () => { + Cypress.Commands.add('newCommand', (arg) => { + // $ExpectType string + arg return new Promise((resolve) => {}) }) - Cypress.Commands.overwrite('newCommand', () => { + Cypress.Commands.overwrite('newCommand', (arg) => { + // $ExpectType string + arg return }) } diff --git a/npm/create-cypress-tests/package.json b/npm/create-cypress-tests/package.json index 5901324bfb42..554782079ea1 100644 --- a/npm/create-cypress-tests/package.json +++ b/npm/create-cypress-tests/package.json @@ -36,7 +36,7 @@ "@types/ora": "^3.2.0", "copy": "0.3.2", "mocha": "7.1.1", - "mock-fs": "4.13.0", + "mock-fs": "5.1.1", "shx": "0.3.3", "snap-shot-it": "7.9.3", "typescript": "^4.2.3" diff --git a/npm/cypress-schematic/package.json b/npm/cypress-schematic/package.json index 42849bb5f96d..d5680ca1e937 100644 --- a/npm/cypress-schematic/package.json +++ b/npm/cypress-schematic/package.json @@ -18,15 +18,15 @@ "unlink:sandbox": "cd sandbox && yarn unlink @cypress/schematic && cd .. && yarn unlink" }, "dependencies": { - "@angular-devkit/architect": "^0.1200.0", - "@angular-devkit/core": "^12.0.0", - "@angular-devkit/schematics": "^12.0.0", - "@schematics/angular": "^12.0.0", + "@angular-devkit/architect": "^0.1202.10", + "@angular-devkit/core": "^12.2.10", + "@angular-devkit/schematics": "^12.2.10", + "@schematics/angular": "^12.2.10", "jsonc-parser": "^3.0.0", "rxjs": "~6.6.0" }, "devDependencies": { - "@angular-devkit/schematics-cli": "^12.0.0", + "@angular-devkit/schematics-cli": "^12.2.10", "@types/chai-enzyme": "0.6.7", "@types/mocha": "8.0.3", "@types/node": "^12.11.1", diff --git a/npm/webpack-preprocessor/package.json b/npm/webpack-preprocessor/package.json index 59e8ea31e898..bef3d7e86bb9 100644 --- a/npm/webpack-preprocessor/package.json +++ b/npm/webpack-preprocessor/package.json @@ -29,6 +29,7 @@ "@babel/plugin-proposal-nullish-coalescing-operator": "7.8.3", "@babel/preset-env": "^7.0.0", "@fellow/eslint-plugin-coffee": "0.4.13", + "@types/mocha": "9.0.0", "@types/webpack": "^4.41.12", "@typescript-eslint/eslint-plugin": "^4.18.0", "@typescript-eslint/parser": "^4.18.0", diff --git a/npm/webpack-preprocessor/test/unit/index.spec.js b/npm/webpack-preprocessor/test/unit/index.spec.js index ceae0032754f..51a85406e4da 100644 --- a/npm/webpack-preprocessor/test/unit/index.spec.js +++ b/npm/webpack-preprocessor/test/unit/index.spec.js @@ -353,7 +353,7 @@ describe('webpack preprocessor', function () { return true }, toJson () { - return { errors: errs } + return { warnings: [], errors: errs } }, } diff --git a/package.json b/package.json index 0b69c0cc4ea4..0d8da3297d05 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "bump": "node ./scripts/binary.js bump", "check-node-version": "node scripts/check-node-version.js", "check-terminal": "node scripts/check-terminal.js", - "clean": "lerna run clean --parallel", + "clean": "lerna run clean --parallel --no-bail", "clean-deps": "find . -depth -name node_modules -type d -exec rm -rf {} \\;", "clean-untracked-files": "git clean -d -f", "precypress:open": "yarn ensure-deps", @@ -46,7 +46,7 @@ "stop-only": "npx stop-only --skip .cy,.publish,.projects,node_modules,dist,dist-test,fixtures,lib,bower_components,src,__snapshots__ --exclude e2e.ts,cypress-tests.ts", "stop-only-all": "yarn stop-only --folder packages", "pretest": "yarn ensure-deps", - "test": "yarn lerna exec yarn test --scope cypress --scope \"'@packages/{electron,extension,https-proxy,launcher,net-stubbing,network,proxy,rewriter,runner,runner-shared,socket}'\"", + "test": "yarn lerna exec yarn test --scope cypress --scope \"'@packages/{config,electron,extension,https-proxy,launcher,net-stubbing,network,proxy,rewriter,runner,runner-shared,socket}'\"", "test-debug": "lerna exec yarn test-debug --ignore \"'@packages/{desktop-gui,driver,root,static,web-config}'\"", "pretest-e2e": "yarn ensure-deps", "test-integration": "lerna exec yarn test-integration --ignore \"'@packages/{desktop-gui,driver,root,static,web-config}'\"", @@ -73,7 +73,7 @@ "@cypress/env-or-json-file": "2.0.0", "@cypress/github-commit-status-check": "1.5.0", "@cypress/questions-remain": "1.0.1", - "@cypress/request": "2.88.6", + "@cypress/request": "2.88.7", "@cypress/request-promise": "4.2.6", "@fellow/eslint-plugin-coffee": "0.4.13", "@percy/cli": "1.0.0-beta.48", @@ -161,7 +161,7 @@ "mocha-banner": "1.1.2", "mocha-junit-reporter": "2.0.0", "mocha-multi-reporters": "1.1.7", - "mock-fs": "4.9.0", + "mock-fs": "5.1.1", "odiff-bin": "2.1.0", "parse-github-repo-url": "1.4.1", "patch-package": "6.4.7", @@ -190,7 +190,7 @@ "yarn-deduplicate": "3.1.0" }, "engines": { - "node": ">=14.17.0", + "node": ">=16.5.0", "yarn": ">=1.17.3" }, "productName": "Cypress", diff --git a/packages/config/.eslintrc.json b/packages/config/.eslintrc.json new file mode 100644 index 000000000000..047083ed4b8a --- /dev/null +++ b/packages/config/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": [ + "../../.eslintrc.json" + ], + "parser": "@typescript-eslint/parser" +} diff --git a/packages/config/README.md b/packages/config/README.md new file mode 100644 index 000000000000..76a33c9c77ab --- /dev/null +++ b/packages/config/README.md @@ -0,0 +1,11 @@ +# Config + +The `config` package contains the configuration types and validation used in both the `server` and the `driver` for setting the Cypress configuration values. + +## Testing + +### Unit Tests + +```bash +yarn workspace @packages/config test-unit +``` diff --git a/packages/config/__snapshots__/index_spec.js b/packages/config/__snapshots__/index_spec.js new file mode 100644 index 000000000000..68d882a7d293 --- /dev/null +++ b/packages/config/__snapshots__/index_spec.js @@ -0,0 +1,146 @@ +exports['src/index .getBreakingKeys returns list of breaking config keys 1'] = [ + "blacklistHosts", + "experimentalComponentTesting", + "experimentalGetCookiesSameSite", + "experimentalNetworkStubbing", + "experimentalRunEvents", + "experimentalShadowDomSupport", + "firefoxGcInterval", + "nodeVersion", + "nodeVersion" +] + +exports['src/index .getDefaultValues returns list of public config keys 1'] = { + "animationDistanceThreshold": 5, + "baseUrl": null, + "blockHosts": null, + "chromeWebSecurity": true, + "clientCertificates": [], + "component": {}, + "componentFolder": "cypress/component", + "defaultCommandTimeout": 4000, + "downloadsFolder": "cypress/downloads", + "e2e": {}, + "env": {}, + "execTimeout": 60000, + "experimentalFetchPolyfill": false, + "experimentalInteractiveRunEvents": false, + "experimentalSessionSupport": false, + "experimentalSourceRewriting": false, + "experimentalStudio": false, + "fileServerFolder": "", + "fixturesFolder": "cypress/fixtures", + "ignoreTestFiles": "*.hot-update.js", + "includeShadowDom": false, + "integrationFolder": "cypress/integration", + "modifyObstructiveCode": true, + "numTestsKeptInMemory": 50, + "pageLoadTimeout": 60000, + "pluginsFile": "cypress/plugins", + "port": null, + "projectId": null, + "redirectionLimit": 20, + "reporter": "spec", + "reporterOptions": null, + "requestTimeout": 5000, + "resolvedNodePath": null, + "resolvedNodeVersion": null, + "responseTimeout": 30000, + "retries": { + "runMode": 0, + "openMode": 0 + }, + "screenshotOnRunFailure": true, + "screenshotsFolder": "cypress/screenshots", + "slowTestThreshold": 10000, + "scrollBehavior": "top", + "supportFile": "cypress/support", + "supportFolder": false, + "taskTimeout": 60000, + "testFiles": "**/*.*", + "trashAssetsBeforeRuns": true, + "userAgent": null, + "video": true, + "videoCompression": 32, + "videosFolder": "cypress/videos", + "videoUploadOnPasses": true, + "viewportHeight": 660, + "viewportWidth": 1000, + "waitForAnimations": true, + "watchForFileChanges": true, + "autoOpen": false, + "browsers": [], + "clientRoute": "/__/", + "configFile": "cypress.json", + "devServerPublicPathRoute": "/__cypress/src", + "hosts": null, + "isTextTerminal": false, + "morgan": true, + "namespace": "__cypress", + "reporterRoute": "/__cypress/reporter", + "socketId": null, + "socketIoCookie": "__socket.io", + "socketIoRoute": "/__socket.io", + "xhrRoute": "/xhrs/" +} + +exports['src/index .getPublicConfigKeys returns list of public config keys 1'] = [ + "animationDistanceThreshold", + "baseUrl", + "blockHosts", + "chromeWebSecurity", + "clientCertificates", + "component", + "componentFolder", + "defaultCommandTimeout", + "downloadsFolder", + "e2e", + "env", + "execTimeout", + "experimentalFetchPolyfill", + "experimentalInteractiveRunEvents", + "experimentalSessionSupport", + "experimentalSourceRewriting", + "experimentalStudio", + "fileServerFolder", + "fixturesFolder", + "ignoreTestFiles", + "includeShadowDom", + "integrationFolder", + "modifyObstructiveCode", + "nodeVersion", + "numTestsKeptInMemory", + "pageLoadTimeout", + "pluginsFile", + "port", + "projectId", + "redirectionLimit", + "reporter", + "reporterOptions", + "requestTimeout", + "resolvedNodePath", + "resolvedNodeVersion", + "responseTimeout", + "retries", + "screenshotOnRunFailure", + "screenshotsFolder", + "slowTestThreshold", + "scrollBehavior", + "supportFile", + "supportFolder", + "taskTimeout", + "testFiles", + "trashAssetsBeforeRuns", + "userAgent", + "video", + "videoCompression", + "videosFolder", + "videoUploadOnPasses", + "viewportHeight", + "viewportWidth", + "waitForAnimations", + "watchForFileChanges", + "browsers", + "hosts", + "modifyObstructiveCode" +] diff --git a/packages/config/__snapshots__/validation_spec.js b/packages/config/__snapshots__/validation_spec.js new file mode 100644 index 000000000000..538d5d145fa1 --- /dev/null +++ b/packages/config/__snapshots__/validation_spec.js @@ -0,0 +1,157 @@ +exports['undefined browsers'] = ` +Missing browsers list +` + +exports['empty list of browsers'] = ` +Expected at least one browser +` + +exports['browsers list with a string'] = ` +Found an error while validating the \`browsers\` list. Expected \`name\` to be a non-empty string. Instead the value was: \`"foo"\` +` + +exports['src/validation .isValidBrowser passes valid browsers and forms error messages for invalid ones isValidBrowser 1'] = { + "name": "isValidBrowser", + "behavior": [ + { + "given": { + "name": "Chrome", + "displayName": "Chrome Browser", + "family": "chromium", + "path": "/path/to/chrome", + "version": "1.2.3", + "majorVersion": 1 + }, + "expect": true + }, + { + "given": { + "name": "FF", + "displayName": "Firefox", + "family": "firefox", + "path": "/path/to/firefox", + "version": "1.2.3", + "majorVersion": "1" + }, + "expect": true + }, + { + "given": { + "name": "Electron", + "displayName": "Electron", + "family": "chromium", + "path": "", + "version": "99.101.3", + "majorVersion": 99 + }, + "expect": true + }, + { + "given": { + "name": "No display name", + "family": "chromium" + }, + "expect": "Expected `displayName` to be a non-empty string. Instead the value was: `{\"name\":\"No display name\",\"family\":\"chromium\"}`" + }, + { + "given": { + "name": "bad family", + "displayName": "Bad family browser", + "family": "unknown family" + }, + "expect": "Expected `family` to be either chromium or firefox. Instead the value was: `{\"name\":\"bad family\",\"displayName\":\"Bad family browser\",\"family\":\"unknown family\"}`" + } + ] +} + +exports['not one of the strings error message'] = ` +Expected \`test\` to be one of these values: "foo", "bar". Instead the value was: \`"nope"\` +` + +exports['number instead of string'] = ` +Expected \`test\` to be one of these values: "foo", "bar". Instead the value was: \`42\` +` + +exports['null instead of string'] = ` +Expected \`test\` to be one of these values: "foo", "bar". Instead the value was: \`null\` +` + +exports['not one of the numbers error message'] = ` +Expected \`test\` to be one of these values: 1, 2, 3. Instead the value was: \`4\` +` + +exports['string instead of a number'] = ` +Expected \`test\` to be one of these values: 1, 2, 3. Instead the value was: \`"foo"\` +` + +exports['null instead of a number'] = ` +Expected \`test\` to be one of these values: 1, 2, 3. Instead the value was: \`null\` +` + +exports['src/validation .isStringOrFalse returns error message when value is neither string nor false 1'] = ` +Expected \`mockConfigKey\` to be a string or false. Instead the value was: \`null\` +` + +exports['src/validation .isBoolean returns error message when value is a not a string 1'] = ` +Expected \`mockConfigKey\` to be a string. Instead the value was: \`1\` +` + +exports['src/validation .isString returns error message when value is a not a string 1'] = ` +Expected \`mockConfigKey\` to be a string. Instead the value was: \`1\` +` + +exports['src/validation .isArray returns error message when value is a non-array 1'] = ` +Expected \`mockConfigKey\` to be an array. Instead the value was: \`1\` +` + +exports['not string or array'] = ` +Expected \`mockConfigKey\` to be a string or an array of strings. Instead the value was: \`null\` +` + +exports['array of non-strings'] = ` +Expected \`mockConfigKey\` to be a string or an array of strings. Instead the value was: \`[1,2,3]\` +` + +exports['src/validation .isNumberOrFalse returns error message when value is a not number or false 1'] = ` +Expected \`mockConfigKey\` to be a number or false. Instead the value was: \`null\` +` + +exports['src/validation .isPlainObject returns error message when value is a not an object 1'] = ` +Expected \`mockConfigKey\` to be a plain object. Instead the value was: \`1\` +` + +exports['src/validation .isNumber returns error message when value is a not a number 1'] = ` +Expected \`mockConfigKey\` to be a number. Instead the value was: \`"string"\` +` + +exports['invalid retry value'] = ` +Expected \`mockConfigKey\` to be a positive number or null or an object with keys "openMode" and "runMode" with values of numbers or nulls. Instead the value was: \`"1"\` +` + +exports['invalid retry object'] = ` +Expected \`mockConfigKey\` to be a positive number or null or an object with keys "openMode" and "runMode" with values of numbers or nulls. Instead the value was: \`{"fakeMode":1}\` +` + +exports['src/validation .isValidClientCertificatesSet returns error message for certs not passed as an array array 1'] = ` +Expected \`mockConfigKey\` to be a positive number or null or an object with keys "openMode" and "runMode" with values of numbers or nulls. Instead the value was: \`"1"\` +` + +exports['src/validation .isValidClientCertificatesSet returns error message for certs object without url 1'] = ` +Expected \`clientCertificates[0].url\` to be a URL matcher. Instead the value was: \`undefined\` +` + +exports['missing https protocol'] = ` +Expected \`clientCertificates[0].url\` to be an https protocol. Instead the value was: \`"http://url.com"\` +` + +exports['invalid url'] = ` +Expected \`clientCertificates[0].url\` to be a valid URL. Instead the value was: \`"not *"\` +` + +exports['not qualified url'] = ` +Expected \`mockConfigKey\` to be a fully qualified URL (starting with \`http://\` or \`https://\`). Instead the value was: \`"url.com"\` +` + +exports['empty string'] = ` +Expected \`mockConfigKey\` to be a fully qualified URL (starting with \`http://\` or \`https://\`). Instead the value was: \`""\` +` diff --git a/packages/config/lib/index.js b/packages/config/lib/index.js new file mode 100644 index 000000000000..6bace41450e7 --- /dev/null +++ b/packages/config/lib/index.js @@ -0,0 +1,104 @@ +const _ = require('lodash') +const debug = require('debug')('cypress:config:validator') + +const { options, breakingOptions } = require('./options') + +const dashesOrUnderscoresRe = /^(_-)+/ + +// takes an array and creates an index object of [keyKey]: [valueKey] +const createIndex = (arr, keyKey, valueKey) => { + return _.reduce(arr, (memo, item) => { + if (item[valueKey] !== undefined) { + memo[item[keyKey]] = item[valueKey] + } + + return memo + }, {}) +} + +const breakingKeys = _.map(breakingOptions, 'name') +const defaultValues = createIndex(options, 'name', 'defaultValue') +const publicConfigKeys = _(options).reject({ isInternal: true }).map('name').value() +const validationRules = createIndex(options, 'name', 'validation') + +module.exports = { + allowed: (obj = {}) => { + const propertyNames = publicConfigKeys.concat(breakingKeys) + + return _.pick(obj, propertyNames) + }, + + getBreakingKeys: () => { + return breakingKeys + }, + + getDefaultValues: (runtimeOptions = {}) => { + // Default values can be functions, in which case they are evaluated + // at runtime - for example, slowTestThreshold where the default value + // varies between e2e and component testing. + return _.mapValues(defaultValues, (value) => (typeof value === 'function' ? value(runtimeOptions) : value)) + }, + + getPublicConfigKeys: () => { + return publicConfigKeys + }, + + matchesConfigKey: (key) => { + if (_.has(defaultValues, key)) { + return key + } + + key = key.toLowerCase().replace(dashesOrUnderscoresRe, '') + key = _.camelCase(key) + + if (_.has(defaultValues, key)) { + return key + } + }, + + options, + + validate: (cfg, onErr) => { + debug('validating configuration') + + return _.each(cfg, (value, key) => { + const validationFn = validationRules[key] + + // key has a validation rule & value different from the default + if (validationFn && value !== defaultValues[key]) { + const result = validationFn(key, value) + + if (result !== true) { + return onErr(result) + } + } + }) + }, + + validateNoBreakingConfig: (cfg, onWarning, onErr) => { + breakingOptions.forEach(({ name, errorKey, newName, isWarning, value }) => { + if (cfg.hasOwnProperty(name)) { + if (value && cfg[name] !== value) { + // Bail if a value is specified but the config does not have that value. + return + } + + if (isWarning) { + return onWarning(errorKey, { + name, + newName, + value, + configFile: cfg.configFile, + }) + } + + return onErr(errorKey, { + name, + newName, + value, + configFile: cfg.configFile, + }) + } + }) + }, +} diff --git a/packages/server/lib/config_options.ts b/packages/config/lib/options.ts similarity index 55% rename from packages/server/lib/config_options.ts rename to packages/config/lib/options.ts index 8a39f52b4b18..a3d3e1cadb84 100644 --- a/packages/server/lib/config_options.ts +++ b/packages/config/lib/options.ts @@ -1,4 +1,62 @@ -const v = require('./util/validation') +const validate = require('./validation') + +interface ResolvedConfigOption { + name: string + defaultValue?: any + validation: Function + isFolder?: boolean + isExperimental?: boolean +} + +interface RuntimeConfigOption { + name: string + defaultValue: any + validation: Function + isInternal?: boolean +} + +interface BreakingOption { + /** + * The non-passive configuration option. + */ + name: string + /** + * String to summarize the error messaging that is logged. + */ + errorKey: string + /** + * Configuration value of the configuration option to check against. + */ + value?: string + /** + * The new configuration key that is replacing the existing configuration key. + */ + newName?: string + /** + * Whether to log the error message as a warning instead of throwing an error. + */ + isWarning?: boolean +} + +const isValidConfig = (key, config) => { + const status = validate.isPlainObject(key, config) + + if (status !== true) { + return status + } + + for (const rule of options) { + if (rule.name in config && rule.validation) { + const status = rule.validation(`${key}.${rule.name}`, config[rule.name]) + + if (status !== true) { + return status + } + } + } + + return true +} // NOTE: // If you add/remove/change a config value, make sure to update the following @@ -7,304 +65,335 @@ const v = require('./util/validation') // // Add options in alphabetical order for better readability -export const options = [ +// TODO - add boolean attribute to indicate read-only / static vs mutable options +// that can be updated during test executions +const resolvedOptions: Array = [ { name: 'animationDistanceThreshold', defaultValue: 5, - validation: v.isNumber, - }, { - name: 'autoOpen', - defaultValue: false, - isInternal: true, + validation: validate.isNumber, }, { name: 'baseUrl', defaultValue: null, - validation: v.isFullyQualifiedUrl, + validation: validate.isFullyQualifiedUrl, }, { name: 'blockHosts', defaultValue: null, - validation: v.isStringOrArrayOfStrings, - }, { - name: 'browsers', - defaultValue: [], - validation: v.isValidBrowserList, + validation: validate.isStringOrArrayOfStrings, }, { name: 'chromeWebSecurity', defaultValue: true, - validation: v.isBoolean, - }, { - name: 'clientRoute', - defaultValue: '/__/', - isInternal: true, + validation: validate.isBoolean, }, { name: 'clientCertificates', defaultValue: [], - validation: v.isValidClientCertificatesSet, + validation: validate.isValidClientCertificatesSet, }, { name: 'component', // runner-ct overrides defaultValue: {}, - validation: v.isValidConfig, + validation: isValidConfig, }, { name: 'componentFolder', defaultValue: 'cypress/component', - validation: v.isStringOrFalse, + validation: validate.isStringOrFalse, isFolder: true, - }, { - name: 'configFile', - defaultValue: 'cypress.json', - validation: v.isStringOrFalse, - // not truly internal, but can only be set via cli, - // so we don't consider it a "public" option - isInternal: true, }, { name: 'defaultCommandTimeout', defaultValue: 4000, - validation: v.isNumber, - }, { - name: 'devServerPublicPathRoute', - defaultValue: '/__cypress/src', - isInternal: true, + validation: validate.isNumber, }, { name: 'downloadsFolder', defaultValue: 'cypress/downloads', - validation: v.isString, + validation: validate.isString, isFolder: true, }, { name: 'e2e', // e2e runner overrides defaultValue: {}, - validation: v.isValidConfig, + validation: isValidConfig, }, { name: 'env', - validation: v.isPlainObject, + defaultValue: {}, + validation: validate.isPlainObject, }, { name: 'execTimeout', defaultValue: 60000, - validation: v.isNumber, + validation: validate.isNumber, }, { name: 'experimentalFetchPolyfill', defaultValue: false, - validation: v.isBoolean, + validation: validate.isBoolean, isExperimental: true, }, { name: 'experimentalInteractiveRunEvents', defaultValue: false, - validation: v.isBoolean, + validation: validate.isBoolean, isExperimental: true, }, { - name: 'experimentalSourceRewriting', + name: 'experimentalSessionSupport', defaultValue: false, - validation: v.isBoolean, + validation: validate.isBoolean, isExperimental: true, }, { - name: 'experimentalStudio', + name: 'experimentalSourceRewriting', defaultValue: false, - validation: v.isBoolean, + validation: validate.isBoolean, isExperimental: true, }, { - name: 'experimentalSessionSupport', + name: 'experimentalStudio', defaultValue: false, - validation: v.isBoolean, + validation: validate.isBoolean, isExperimental: true, }, { name: 'fileServerFolder', defaultValue: '', - validation: v.isString, + validation: validate.isString, isFolder: true, }, { name: 'fixturesFolder', defaultValue: 'cypress/fixtures', - validation: v.isStringOrFalse, + validation: validate.isStringOrFalse, isFolder: true, - }, { - name: 'hosts', - defaultValue: null, }, { name: 'ignoreTestFiles', defaultValue: '*.hot-update.js', - validation: v.isStringOrArrayOfStrings, + validation: validate.isStringOrArrayOfStrings, }, { name: 'includeShadowDom', defaultValue: false, - validation: v.isBoolean, + validation: validate.isBoolean, }, { name: 'integrationFolder', defaultValue: 'cypress/integration', - validation: v.isString, + validation: validate.isString, isFolder: true, - }, { - name: 'isTextTerminal', - defaultValue: false, - isInternal: true, - }, { - name: 'morgan', - defaultValue: true, - isInternal: true, }, { name: 'modifyObstructiveCode', defaultValue: true, - validation: v.isBoolean, - }, { - name: 'namespace', - defaultValue: '__cypress', - isInternal: true, + validation: validate.isBoolean, }, { name: 'nodeVersion', - defaultValue: 'default', - validation: v.isOneOf('default', 'bundled', 'system'), + validation: validate.isOneOf('bundled', 'system'), }, { name: 'numTestsKeptInMemory', defaultValue: 50, - validation: v.isNumber, + validation: validate.isNumber, }, { name: 'pageLoadTimeout', defaultValue: 60000, - validation: v.isNumber, + validation: validate.isNumber, }, { name: 'pluginsFile', defaultValue: 'cypress/plugins', - validation: v.isStringOrFalse, + validation: validate.isStringOrFalse, isFolder: true, }, { name: 'port', defaultValue: null, - validation: v.isNumber, + validation: validate.isNumber, }, { name: 'projectId', defaultValue: null, - validation: v.isString, + validation: validate.isString, }, { name: 'redirectionLimit', defaultValue: 20, + validation: validate.isNumber, }, { name: 'reporter', defaultValue: 'spec', - validation: v.isString, + validation: validate.isString, }, { name: 'reporterOptions', defaultValue: null, - }, { - name: 'reporterRoute', - defaultValue: '/__cypress/reporter', - isInternal: true, + validation: validate.isPlainObject, }, { name: 'requestTimeout', defaultValue: 5000, - validation: v.isNumber, + validation: validate.isNumber, }, { name: 'resolvedNodePath', + defaultValue: null, + validation: validate.isString, }, { name: 'resolvedNodeVersion', + defaultValue: null, + validation: validate.isString, }, { name: 'responseTimeout', defaultValue: 30000, - validation: v.isNumber, + validation: validate.isNumber, }, { name: 'retries', defaultValue: { runMode: 0, openMode: 0, }, - validation: v.isValidRetriesConfig, + validation: validate.isValidRetriesConfig, }, { name: 'screenshotOnRunFailure', defaultValue: true, - validation: v.isBoolean, + validation: validate.isBoolean, }, { name: 'screenshotsFolder', defaultValue: 'cypress/screenshots', - validation: v.isStringOrFalse, + validation: validate.isStringOrFalse, isFolder: true, }, { name: 'slowTestThreshold', - defaultValue: (options: Record) => options.testingType === 'component' ? 250 : 10000, - validation: v.isNumber, - }, { - name: 'socketId', - defaultValue: null, - isInternal: true, - }, { - name: 'socketIoRoute', - defaultValue: '/__socket.io', - isInternal: true, + defaultValue: (options: Record = {}) => options.testingType === 'component' ? 250 : 10000, + validation: validate.isNumber, }, { name: 'scrollBehavior', defaultValue: 'top', - validation: v.isOneOf('center', 'top', 'bottom', 'nearest', false), - }, { - name: 'socketIoCookie', - defaultValue: '__socket.io', - isInternal: true, + validation: validate.isOneOf('center', 'top', 'bottom', 'nearest', false), }, { name: 'supportFile', defaultValue: 'cypress/support', - validation: v.isStringOrFalse, + validation: validate.isStringOrFalse, isFolder: true, }, { name: 'supportFolder', + defaultValue: false, + validation: validate.isStringOrFalse, isFolder: true, }, { name: 'taskTimeout', defaultValue: 60000, - validation: v.isNumber, + validation: validate.isNumber, }, { name: 'testFiles', defaultValue: '**/*.*', - validation: v.isStringOrArrayOfStrings, + validation: validate.isStringOrArrayOfStrings, }, { name: 'trashAssetsBeforeRuns', defaultValue: true, - validation: v.isBoolean, - }, { - name: 'unitFolder', - isFolder: true, - isInternal: true, + validation: validate.isBoolean, }, { name: 'userAgent', defaultValue: null, - validation: v.isString, + validation: validate.isString, }, { name: 'video', defaultValue: true, - validation: v.isBoolean, + validation: validate.isBoolean, }, { name: 'videoCompression', defaultValue: 32, - validation: v.isNumberOrFalse, + validation: validate.isNumberOrFalse, }, { name: 'videosFolder', defaultValue: 'cypress/videos', - validation: v.isString, + validation: validate.isString, isFolder: true, }, { name: 'videoUploadOnPasses', defaultValue: true, - validation: v.isBoolean, + validation: validate.isBoolean, }, { name: 'viewportHeight', defaultValue: 660, - validation: v.isNumber, + validation: validate.isNumber, }, { name: 'viewportWidth', defaultValue: 1000, - validation: v.isNumber, + validation: validate.isNumber, }, { name: 'waitForAnimations', defaultValue: true, - validation: v.isBoolean, + validation: validate.isBoolean, }, { name: 'watchForFileChanges', defaultValue: true, - validation: v.isBoolean, + validation: validate.isBoolean, + }, +] + +const runtimeOptions: Array = [ + { + name: 'autoOpen', + defaultValue: false, + validation: validate.isBoolean, + isInternal: true, + }, { + name: 'browsers', + defaultValue: [], + validation: validate.isValidBrowserList, + }, { + name: 'clientRoute', + defaultValue: '/__/', + validation: validate.isString, + isInternal: true, + }, { + name: 'configFile', + defaultValue: 'cypress.json', + validation: validate.isStringOrFalse, + // not truly internal, but can only be set via cli, + // so we don't consider it a "public" option + isInternal: true, + }, { + name: 'devServerPublicPathRoute', + defaultValue: '/__cypress/src', + validation: validate.isString, + isInternal: true, + }, { + name: 'hosts', + defaultValue: null, + validation: validate.isPlainObject, + }, { + name: 'isTextTerminal', + defaultValue: false, + validation: validate.isBoolean, + isInternal: true, + }, { + name: 'morgan', + defaultValue: true, + validation: validate.isBoolean, + isInternal: true, + }, { + name: 'modifyObstructiveCode', + defaultValue: true, + validation: validate.isBoolean, + }, { + name: 'namespace', + defaultValue: '__cypress', + validation: validate.isString, + isInternal: true, + }, { + name: 'reporterRoute', + defaultValue: '/__cypress/reporter', + validation: validate.isString, + isInternal: true, + }, { + name: 'socketId', + defaultValue: null, + validation: validate.isString, + isInternal: true, + }, { + name: 'socketIoCookie', + defaultValue: '__socket.io', + validation: validate.isString, + isInternal: true, + }, { + name: 'socketIoRoute', + defaultValue: '/__socket.io', + validation: validate.isString, + isInternal: true, }, { name: 'xhrRoute', defaultValue: '/xhrs/', + validation: validate.isString, isInternal: true, }, ] -export const breakingOptions = [ +export const options: Array = [ + ...resolvedOptions, + ...runtimeOptions, +] + +export const breakingOptions: Array = [ { name: 'blacklistHosts', errorKey: 'RENAMED_CONFIG_OPTION', @@ -333,5 +422,15 @@ export const breakingOptions = [ name: 'firefoxGcInterval', errorKey: 'FIREFOX_GC_INTERVAL_REMOVED', isWarning: true, + }, { + name: 'nodeVersion', + value: 'system', + errorKey: 'NODE_VERSION_DEPRECATION_SYSTEM', + isWarning: true, + }, { + name: 'nodeVersion', + value: 'bundled', + errorKey: 'NODE_VERSION_DEPRECATION_BUNDLED', + isWarning: true, }, ] diff --git a/packages/server/lib/util/validation.js b/packages/config/lib/validation.js similarity index 93% rename from packages/server/lib/util/validation.js rename to packages/config/lib/validation.js index 14830fe80218..37fb3f7f878d 100644 --- a/packages/server/lib/util/validation.js +++ b/packages/config/lib/validation.js @@ -2,7 +2,6 @@ const _ = require('lodash') const debug = require('debug')('cypress:server:validation') const is = require('check-more-types') const { commaListsOr } = require('common-tags') -const configOptions = require('../config_options') const path = require('path') // validation functions take a key and a value and should: @@ -10,6 +9,7 @@ const path = require('path') // - return a error message if it fails validation const str = JSON.stringify +const { isArray, isString, isFinite: isNumber } = _ /** * Forms good Markdown-like string message. @@ -36,10 +36,6 @@ const isFalse = (value) => { return value === false } -const { isArray } = _ -const isNumber = _.isFinite -const { isString } = _ - /** * Validates a single browser object. * @returns {string|true} Returns `true` if the object is matching browser object schema. Returns an error message if it does not. @@ -129,26 +125,6 @@ const isPlainObject = (key, value) => { return errMsg(key, value, 'a plain object') } -const isValidConfig = (key, config) => { - const status = isPlainObject(key, config) - - if (status !== true) { - return status - } - - for (const rule of configOptions.options) { - if (rule.name in config && rule.validation) { - const status = rule.validation(`${key}.${rule.name}`, config[rule.name]) - - if (status !== true) { - return status - } - } - } - - return true -} - const isOneOf = (...values) => { return (key, value) => { if (values.some((v) => { @@ -171,7 +147,7 @@ const isOneOf = (...values) => { * Validates whether the supplied set of cert information is valid * @returns {string|true} Returns `true` if the information set is valid. Returns an error message if it is not. */ -const isValidClientCertificatesSet = (key, certsForUrls) => { +const isValidClientCertificatesSet = (_key, certsForUrls) => { debug('clientCerts: %o', certsForUrls) if (!Array.isArray(certsForUrls)) { @@ -266,8 +242,6 @@ module.exports = { isValidRetriesConfig, - isValidConfig, - isPlainObject, isNumber (key, value) { diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 000000000000..c267170cc047 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,31 @@ +{ + "name": "@packages/config", + "version": "0.0.0-development", + "description": "Config contains the configuration types and validation function used in the cypress electron application.", + "private": true, + "main": "lib/index.js", + "scripts": { + "build-prod": "tsc --project .", + "clean": "rm lib/options.js", + "test": "yarn test-unit", + "test-debug": "yarn test-unit --inspect-brk=5566", + "test-unit": "mocha --configFile=../../mocha-reporter-config.json -r @packages/ts/register -extension=.js,.ts test/unit/*spec.* --exit" + }, + "dependencies": { + "check-more-types": "2.24.0", + "common-tags": "1.8.0", + "debug": "4.3.2", + "lodash": "4.17.21" + }, + "devDependencies": { + "@packages/ts": "0.0.0-development", + "chai": "1.10.0", + "mocha": "7.0.1", + "sinon": "7.3.1", + "sinon-chai": "3.3.0", + "snap-shot-it": "7.9.3" + }, + "files": [ + "lib" + ] +} diff --git a/packages/config/test/unit/index_spec.js b/packages/config/test/unit/index_spec.js new file mode 100644 index 000000000000..d180ac3c2741 --- /dev/null +++ b/packages/config/test/unit/index_spec.js @@ -0,0 +1,149 @@ +const chai = require('chai') +const snapshot = require('snap-shot-it') +const sinon = require('sinon') +const sinonChai = require('sinon-chai') + +const configUtil = require('../../lib/index') + +chai.use(sinonChai) +const { expect } = chai + +describe('src/index', () => { + describe('.allowed', () => { + it('returns filter config only containing allowed keys', () => { + const keys = configUtil.allowed({ + 'baseUrl': 'https://url.com', + 'blacklistHosts': 'breaking option', + 'devServerPublicPathRoute': 'internal key', + 'random': 'not a config option', + }) + + expect(keys).to.deep.eq({ + 'baseUrl': 'https://url.com', + 'blacklistHosts': 'breaking option', + }) + }) + }) + + describe('.getBreakingKeys', () => { + it('returns list of breaking config keys', () => { + const breakingKeys = configUtil.getBreakingKeys() + + expect(breakingKeys).to.include('blacklistHosts') + snapshot(breakingKeys) + }) + }) + + describe('.getDefaultValues', () => { + it('returns list of public config keys', () => { + const defaultValues = configUtil.getDefaultValues() + + expect(defaultValues).to.deep.include({ + defaultCommandTimeout: 4000, + scrollBehavior: 'top', + watchForFileChanges: true, + }) + + expect(defaultValues.env).to.deep.eq({}) + snapshot(defaultValues) + }) + }) + + describe('.getPublicConfigKeys', () => { + it('returns list of public config keys', () => { + const publicConfigKeys = configUtil.getPublicConfigKeys() + + expect(publicConfigKeys).to.include('blockHosts') + expect(publicConfigKeys).to.not.include('devServerPublicPathRoute') + snapshot(publicConfigKeys) + }) + }) + + describe('.matchesConfigKey', () => { + it('returns key when config key has a default value', () => { + const normalizedKey = configUtil.matchesConfigKey('testFiles') + + expect(normalizedKey).to.eq('testFiles') + }) + + it('returns normalized key when config key has a default value', () => { + let normalizedKey = configUtil.matchesConfigKey('EXEC_TIMEOUT') + + expect(normalizedKey).to.eq('execTimeout') + + normalizedKey = configUtil.matchesConfigKey('Base-url') + expect(normalizedKey).to.eq('baseUrl') + + normalizedKey = configUtil.matchesConfigKey('TEST-FILES') + + expect(normalizedKey).to.eq('testFiles') + }) + + it('returns nothing when config key does not has a default value', () => { + let normalizedKey = configUtil.matchesConfigKey('random') + + expect(normalizedKey).to.be.undefined + }) + }) + + describe('.validate', () => { + it('validates config', () => { + const errorFn = sinon.spy() + + configUtil.validate({ + 'baseUrl': 'https://', + }, errorFn) + + expect(errorFn).to.have.been.callCount(0) + }) + + it('calls error callback if config is invalid', () => { + const errorFn = sinon.spy() + + configUtil.validate({ + 'baseUrl': ' ', + }, errorFn) + + expect(errorFn).to.have.been.calledWithMatch(/Expected `baseUrl`/) + }) + }) + + describe('.validateNoBreakingConfig', () => { + it('calls warning callback if config contains breaking option that warns', () => { + const warningFn = sinon.spy() + const errorFn = sinon.spy() + + configUtil.validateNoBreakingConfig({ + 'experimentalNetworkStubbing': 'should break', + configFile: 'config.js', + }, warningFn, errorFn) + + expect(warningFn).to.have.been.calledOnceWith('EXPERIMENTAL_NETWORK_STUBBING_REMOVED', { + name: 'experimentalNetworkStubbing', + newName: undefined, + value: undefined, + configFile: 'config.js', + }) + + expect(errorFn).to.have.been.callCount(0) + }) + + it('calls error callback if config contains breaking option that should throw an error', () => { + const warningFn = sinon.spy() + const errorFn = sinon.spy() + + configUtil.validateNoBreakingConfig({ + 'blacklistHosts': 'should break', + configFile: 'config.js', + }, warningFn, errorFn) + + expect(warningFn).to.have.been.callCount(0) + expect(errorFn).to.have.been.calledOnceWith('RENAMED_CONFIG_OPTION', { + name: 'blacklistHosts', + newName: 'blockHosts', + value: undefined, + configFile: 'config.js', + }) + }) + }) +}) diff --git a/packages/config/test/unit/validation_spec.js b/packages/config/test/unit/validation_spec.js new file mode 100644 index 000000000000..b3ecbbeb8b51 --- /dev/null +++ b/packages/config/test/unit/validation_spec.js @@ -0,0 +1,391 @@ +const snapshot = require('snap-shot-it') +const { expect } = require('chai') + +const validation = require('../../lib/validation') + +describe('src/validation', () => { + const mockKey = 'mockConfigKey' + + describe('.isValidClientCertificatesSet', () => { + it('returns error message for certs not passed as an array array', () => { + const result = validation.isValidRetriesConfig(mockKey, '1') + + expect(result).to.not.be.true + snapshot(result) + }) + + it('returns error message for certs object without url', () => { + const result = validation.isValidClientCertificatesSet(mockKey, [ + { name: 'cert' }, + ]) + + expect(result).to.not.be.true + snapshot(result) + }) + + it('returns error message for certs url not matching *', () => { + let result = validation.isValidClientCertificatesSet(mockKey, [ + { url: 'http://url.com' }, + ]) + + expect(result).to.not.be.true + snapshot('missing https protocol', result) + + result = validation.isValidClientCertificatesSet(mockKey, [ + { url: 'not *' }, + ]) + + expect(result).to.not.be.true + snapshot('invalid url', result) + }) + }) + + describe('.isValidBrowser', () => { + it('passes valid browsers and forms error messages for invalid ones', () => { + const browsers = [ + // valid browser + { + name: 'Chrome', + displayName: 'Chrome Browser', + family: 'chromium', + path: '/path/to/chrome', + version: '1.2.3', + majorVersion: 1, + }, + // another valid browser + { + name: 'FF', + displayName: 'Firefox', + family: 'firefox', + path: '/path/to/firefox', + version: '1.2.3', + majorVersion: '1', + }, + // Electron is a valid browser + { + name: 'Electron', + displayName: 'Electron', + family: 'chromium', + path: '', + version: '99.101.3', + majorVersion: 99, + }, + // invalid browser, missing displayName + { + name: 'No display name', + family: 'chromium', + }, + { + name: 'bad family', + displayName: 'Bad family browser', + family: 'unknown family', + }, + ] + + // data-driven testing - computers snapshot value for each item in the list passed through the function + // https://github.com/bahmutov/snap-shot-it#data-driven-testing + return snapshot.apply(null, [validation.isValidBrowser].concat(browsers)) + }) + }) + + describe('.isValidBrowserList', () => { + it('does not allow empty or not browsers', () => { + snapshot('undefined browsers', validation.isValidBrowserList('browsers')) + snapshot('empty list of browsers', validation.isValidBrowserList('browsers', [])) + + return snapshot('browsers list with a string', validation.isValidBrowserList('browsers', ['foo'])) + }) + }) + + describe('.isValidRetriesConfig', () => { + it('returns true for valid retry value', () => { + let result = validation.isValidRetriesConfig(mockKey, null) + + expect(result).to.be.true + + result = validation.isValidRetriesConfig(mockKey, 2) + expect(result).to.be.true + }) + + it('returns true for valid retry objects', () => { + let result = validation.isValidRetriesConfig(mockKey, { runMode: 1 }) + + expect(result).to.be.true + + result = validation.isValidRetriesConfig(mockKey, { openMode: 1 }) + expect(result).to.be.true + + result = validation.isValidRetriesConfig(mockKey, { + runMode: 3, + openMode: 0, + }) + + expect(result).to.be.true + }) + + it('returns error message for invalid retry config', () => { + let result = validation.isValidRetriesConfig(mockKey, '1') + + expect(result).to.not.be.true + snapshot('invalid retry value', result) + + result = validation.isValidRetriesConfig(mockKey, { fakeMode: 1 }) + expect(result).to.not.be.true + snapshot('invalid retry object', result) + }) + }) + + describe('.isPlainObject', () => { + it('returns true for value=null', () => { + const result = validation.isPlainObject(mockKey, null) + + expect(result).to.be.true + }) + + it('returns true for value=number', () => { + const result = validation.isPlainObject(mockKey, { foo: 'bar' }) + + expect(result).to.be.true + }) + + it('returns error message when value is a not an object', () => { + const result = validation.isPlainObject(mockKey, 1) + + expect(result).to.not.be.true + snapshot(result) + }) + }) + + describe('.isNumber', () => { + it('returns true for value=null', () => { + const result = validation.isNumber(mockKey, null) + + expect(result).to.be.true + }) + + it('returns true for value=number', () => { + const result = validation.isNumber(mockKey, 1) + + expect(result).to.be.true + }) + + it('returns error message when value is a not a number', () => { + const result = validation.isNumber(mockKey, 'string') + + expect(result).to.not.be.true + snapshot(result) + }) + }) + + describe('.isNumberOrFalse', () => { + it('returns true for value=number', () => { + const result = validation.isNumberOrFalse(mockKey, 1) + + expect(result).to.be.true + }) + + it('returns true for value=false', () => { + const result = validation.isNumberOrFalse(mockKey, false) + + expect(result).to.be.true + }) + + it('returns error message when value is a not number or false', () => { + const result = validation.isNumberOrFalse(mockKey, null) + + expect(result).to.not.be.true + snapshot(result) + }) + }) + + describe('.isFullyQualifiedUrl', () => { + it('returns true for value=null', () => { + const result = validation.isFullyQualifiedUrl(mockKey, null) + + expect(result).to.be.true + }) + + it('returns true for value=qualified urls', () => { + let result = validation.isFullyQualifiedUrl(mockKey, 'https://url.com') + + expect(result).to.be.true + result = validation.isFullyQualifiedUrl(mockKey, 'http://url.com') + expect(result).to.be.true + }) + + it('returns error message when value is a not qualified url', () => { + let result = validation.isFullyQualifiedUrl(mockKey, 'url.com') + + expect(result).to.not.be.true + snapshot('not qualified url', result) + + result = validation.isFullyQualifiedUrl(mockKey, '') + expect(result).to.not.be.true + snapshot('empty string', result) + }) + }) + + describe('.isBoolean', () => { + it('returns true for value=null', () => { + const result = validation.isBoolean(mockKey, null) + + expect(result).to.be.true + }) + + it('returns true for value=true', () => { + const result = validation.isBoolean(mockKey, true) + + expect(result).to.be.true + }) + + it('returns true for value=false', () => { + const result = validation.isBoolean(mockKey, false) + + expect(result).to.be.true + }) + + it('returns error message when value is a not a string', () => { + const result = validation.isString(mockKey, 1) + + expect(result).to.not.be.true + snapshot(result) + }) + }) + + describe('.isString', () => { + it('returns true for value=null', () => { + const result = validation.isString(mockKey, null) + + expect(result).to.be.true + }) + + it('returns true for value=array', () => { + const result = validation.isString(mockKey, 'string') + + expect(result).to.be.true + }) + + it('returns error message when value is a not a string', () => { + const result = validation.isString(mockKey, 1) + + expect(result).to.not.be.true + snapshot(result) + }) + }) + + describe('.isArray', () => { + it('returns true for value=null', () => { + const result = validation.isArray(mockKey, null) + + expect(result).to.be.true + }) + + it('returns true for value=array', () => { + const result = validation.isArray(mockKey, [1, 2, 3]) + + expect(result).to.be.true + }) + + it('returns error message when value is a non-array', () => { + const result = validation.isArray(mockKey, 1) + + expect(result).to.not.be.true + snapshot(result) + }) + }) + + describe('.isStringOrFalse', () => { + it('returns true for value=string', () => { + const result = validation.isStringOrFalse(mockKey, 'string') + + expect(result).to.be.true + }) + + it('returns true for value=false', () => { + const result = validation.isStringOrFalse(mockKey, false) + + expect(result).to.be.true + }) + + it('returns error message when value is neither string nor false', () => { + const result = validation.isStringOrFalse(mockKey, null) + + expect(result).to.not.be.true + snapshot(result) + }) + }) + + describe('.isStringOrArrayOfStrings', () => { + it('returns true for value=string', () => { + const result = validation.isStringOrArrayOfStrings(mockKey, 'string') + + expect(result).to.be.true + }) + + it('returns true for value=array of strings', () => { + const result = validation.isStringOrArrayOfStrings(mockKey, ['string', 'other']) + + expect(result).to.be.true + }) + + it('returns error message when value is neither string nor array of string', () => { + let result = validation.isStringOrArrayOfStrings(mockKey, null) + + expect(result).to.not.be.true + snapshot('not string or array', result) + + result = validation.isStringOrArrayOfStrings(mockKey, [1, 2, 3]) + + expect(result).to.not.be.true + snapshot('array of non-strings', result) + }) + }) + + describe('.isOneOf', () => { + it('validates a string', () => { + const validate = validation.isOneOf('foo', 'bar') + + expect(validate).to.be.a('function') + expect(validate('test', 'foo')).to.be.true + expect(validate('test', 'bar')).to.be.true + + // different value + let msg = validate('test', 'nope') + + expect(msg).to.not.be.true + snapshot('not one of the strings error message', msg) + + msg = validate('test', 42) + expect(msg).to.not.be.true + snapshot('number instead of string', msg) + + msg = validate('test', null) + expect(msg).to.not.be.true + + return snapshot('null instead of string', msg) + }) + + it('validates a number', () => { + const validate = validation.isOneOf(1, 2, 3) + + expect(validate).to.be.a('function') + expect(validate('test', 1)).to.be.true + expect(validate('test', 3)).to.be.true + + // different value + let msg = validate('test', 4) + + expect(msg).to.not.be.true + snapshot('not one of the numbers error message', msg) + + msg = validate('test', 'foo') + expect(msg).to.not.be.true + snapshot('string instead of a number', msg) + + msg = validate('test', null) + expect(msg).to.not.be.true + + return snapshot('null instead of a number', msg) + }) + }) +}) diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 000000000000..a4b49c139f31 --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../ts/tsconfig.json", + "include": [ + "lib/*", + ], +} diff --git a/packages/driver/cypress.json b/packages/driver/cypress.json index 0128042adffd..013a851c5590 100644 --- a/packages/driver/cypress.json +++ b/packages/driver/cypress.json @@ -8,5 +8,9 @@ "reporter": "cypress-multi-reporters", "reporterOptions": { "configFile": "../../mocha-reporter-config.json" + }, + "retries": { + "runMode": 2, + "openMode": 0 } } diff --git a/packages/driver/cypress/fixtures/content-in-body.html b/packages/driver/cypress/fixtures/content-in-body.html new file mode 100644 index 000000000000..12a8567715fe --- /dev/null +++ b/packages/driver/cypress/fixtures/content-in-body.html @@ -0,0 +1,21 @@ + + +

Script and Style in the body

+ +
+ +
+ +
+ +
+ + diff --git a/packages/driver/cypress/integration/commands/commands_spec.js b/packages/driver/cypress/integration/commands/commands_spec.js index 22f35bbc7f0a..fa8a278cbf93 100644 --- a/packages/driver/cypress/integration/commands/commands_spec.js +++ b/packages/driver/cypress/integration/commands/commands_spec.js @@ -81,6 +81,21 @@ describe('src/cy/commands/commands', () => { expect($ce.get(0)).to.eq(ce.get(0)) }) }) + + it('throws when attempting to add an existing command', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.eq('`Cypress.Commands.add()` is used to create new commands, but `get` is an existing Cypress command.\n\nPlease use `Cypress.Commands.overwrite()` if you would like to overwrite an existing command.\n') + expect(err.docsUrl).to.eq('https://on.cypress.io/custom-commands') + + done() + }) + + Cypress.Commands.add('get', () => { + cy + .get('[contenteditable]') + .first() + }) + }) }) context('errors', () => { diff --git a/packages/driver/cypress/integration/commands/navigation_spec.js b/packages/driver/cypress/integration/commands/navigation_spec.js index cca1cabdc816..1f5fe251584b 100644 --- a/packages/driver/cypress/integration/commands/navigation_spec.js +++ b/packages/driver/cypress/integration/commands/navigation_spec.js @@ -694,7 +694,7 @@ describe('src/cy/commands/navigation', () => { }) it('does not support file:// protocol', { - baseUrl: '', + baseUrl: null, }, (done) => { cy.on('fail', (err) => { expect(err.message).to.contain('`cy.visit()` failed because the \'file://...\' protocol is not supported by Cypress.') @@ -1439,7 +1439,7 @@ describe('src/cy/commands/navigation', () => { cy.visit('http://google.com:3500/fixtures/generic.html') }) - it('throws attemping to visit 2 unique ip addresses', function (done) { + it('throws attempting to visit 2 unique ip addresses', function (done) { const $autIframe = cy.state('$autIframe') const load = () => { diff --git a/packages/driver/cypress/integration/commands/querying_spec.js b/packages/driver/cypress/integration/commands/querying_spec.js index 4a3168a0077c..0821be717d70 100644 --- a/packages/driver/cypress/integration/commands/querying_spec.js +++ b/packages/driver/cypress/integration/commands/querying_spec.js @@ -1574,13 +1574,13 @@ describe('src/cy/commands/querying', () => { it('will not find script elements', () => { cy.$$('').appendTo(cy.$$('body')) - cy.contains('some-script-content').should('not.match', 'script') + cy.contains('some-script-content').should('not.exist') }) it('will not find style elements', () => { cy.$$('').appendTo(cy.$$('body')) - cy.contains('some-style-content').should('not.match', 'style') + cy.contains('some-style-content').should('not.exist') }) it('finds the nearest element by :contains selector', () => { @@ -1955,6 +1955,30 @@ space }) }) + describe('ignores style and script tag in body', () => { + it('style', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.include('Expected to find content: ') + + done() + }) + + cy.visit('fixtures/content-in-body.html') + cy.contains('font-size', { timeout: 500 }) + }) + + it('script', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.include('Expected to find content: ') + + done() + }) + + cy.visit('fixtures/content-in-body.html') + cy.contains('I am in the script tag in body', { timeout: 500 }) + }) + }) + describe('subject contains text nodes', () => { it('searches for content within subject', () => { const badge = cy.$$('#edge-case-contains .badge:contains(5)') diff --git a/packages/driver/cypress/integration/commands/request_spec.js b/packages/driver/cypress/integration/commands/request_spec.js index ee0b9dff2668..685f42f71175 100644 --- a/packages/driver/cypress/integration/commands/request_spec.js +++ b/packages/driver/cypress/integration/commands/request_spec.js @@ -821,7 +821,7 @@ describe('src/cy/commands/request', () => { }) it('throws when url is not FQDN', { - baseUrl: '', + baseUrl: null, }, function (done) { cy.stub(cy, 'getRemoteLocation').withArgs('origin').returns('') @@ -841,7 +841,7 @@ describe('src/cy/commands/request', () => { }) it('throws when url is not FQDN, notes that configFile is disabled', { - baseUrl: '', + baseUrl: null, configFile: false, }, function (done) { cy.stub(cy, 'getRemoteLocation').withArgs('origin').returns('') @@ -861,7 +861,7 @@ describe('src/cy/commands/request', () => { }) it('throws when url is not FQDN, notes that configFile is non-default', { - baseUrl: '', + baseUrl: null, configFile: 'foo.json', }, function (done) { cy.stub(cy, 'getRemoteLocation').withArgs('origin').returns('') diff --git a/packages/driver/cypress/integration/e2e/testConfigOverrides.spec.js b/packages/driver/cypress/integration/e2e/testConfigOverrides.spec.js index 02904f3a5445..cace5ae27275 100644 --- a/packages/driver/cypress/integration/e2e/testConfigOverrides.spec.js +++ b/packages/driver/cypress/integration/e2e/testConfigOverrides.spec.js @@ -274,7 +274,7 @@ describe('per-test config', () => { }) }) - describe('in mulitple nested suites', () => { + describe('in multiple nested suites', () => { describe('config in suite', { foo: true, }, () => { @@ -295,7 +295,7 @@ describe('per-test config', () => { }) }) - describe('emtpy config', {}, () => { + describe('empty config', {}, () => { it('empty config in test', {}, () => { expect(true).ok }) diff --git a/packages/driver/cypress/support/helpers.js b/packages/driver/cypress/support/helpers.js index 4ca9ca88bb6a..c9cce623b9b3 100644 --- a/packages/driver/cypress/support/helpers.js +++ b/packages/driver/cypress/support/helpers.js @@ -8,18 +8,6 @@ const getQueueNames = () => { return _.map(cy.queue, 'name') } -const registerCypressConfigBackupRestore = () => { - let originalConfig - - beforeEach(() => { - originalConfig = _.clone(Cypress.config()) - }) - - afterEach(() => { - Cypress.config(originalConfig) - }) -} - function allowTsModuleStubbing () { // eslint-disable-next-line no-undef __webpack_require__.d = function (exports, name, getter) { @@ -41,6 +29,5 @@ function allowTsModuleStubbing () { module.exports = { getQueueNames, getFirstSubjectByName, - registerCypressConfigBackupRestore, allowTsModuleStubbing, } diff --git a/packages/driver/package.json b/packages/driver/package.json index df5da2ff910e..b003da419daf 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -7,8 +7,7 @@ "cypress:open": "node ../../scripts/cypress open", "cypress:run": "node ../../scripts/cypress run", "postinstall": "patch-package", - "start": "node -e 'console.log(require(`chalk`).red(`\nError:\n\tRunning \\`yarn start\\` is no longer needed for driver/cypress tests.\n\tWe now automatically spawn the server in the pluginsFile.\n\tChanges to the server will be watched and reloaded automatically.`))'", - "test-integration": "yarn cypress:run" + "start": "node -e 'console.log(require(`chalk`).red(`\nError:\n\tRunning \\`yarn start\\` is no longer needed for driver/cypress tests.\n\tWe now automatically spawn the server in the pluginsFile.\n\tChanges to the server will be watched and reloaded automatically.`))'" }, "dependencies": {}, "devDependencies": { @@ -18,6 +17,7 @@ "@cypress/unique-selector": "0.4.2", "@cypress/webpack-preprocessor": "0.0.0-development", "@cypress/what-is-circular": "1.0.1", + "@packages/config": "0.0.0-development", "@packages/network": "0.0.0-development", "@packages/resolve-dist": "0.0.0-development", "@packages/runner": "0.0.0-development", diff --git a/packages/driver/src/cy/assertions.ts b/packages/driver/src/cy/assertions.ts index 7797d95093c5..1ed352035c46 100644 --- a/packages/driver/src/cy/assertions.ts +++ b/packages/driver/src/cy/assertions.ts @@ -510,7 +510,7 @@ export default { } const assert = function (...args) { - // if we've temporarily overriden assertions + // if we've temporarily overridden assertions // then just bail early with this function const fn = cy.state('overrideAssert') || assertFn diff --git a/packages/driver/src/cy/commands/agents.ts b/packages/driver/src/cy/commands/agents.ts index 2877a60b3873..d16cdc4033a5 100644 --- a/packages/driver/src/cy/commands/agents.ts +++ b/packages/driver/src/cy/commands/agents.ts @@ -281,7 +281,6 @@ export default function (Commands, Cypress, cy, state) { return Commands.addAllSync({ spy, - stub, }) } diff --git a/packages/driver/src/cy/commands/cookies.ts b/packages/driver/src/cy/commands/cookies.ts index d44b2397e41c..3c8f09e36ded 100644 --- a/packages/driver/src/cy/commands/cookies.ts +++ b/packages/driver/src/cy/commands/cookies.ts @@ -160,7 +160,7 @@ export default function (Commands, Cypress, cy, state, config) { // stuff, or handling this in the runner itself? // Cypress sessions will clear cookies on its own before each test Cypress.on('test:before:run:async', () => { - if (!Cypress.config.experimentalSessionSupport) { + if (!Cypress.config('experimentalSessionSupport')) { return getAndClear() } }) diff --git a/packages/driver/src/cy/commands/screenshot.ts b/packages/driver/src/cy/commands/screenshot.ts index 5ca9bcfff52d..53987c587c0b 100644 --- a/packages/driver/src/cy/commands/screenshot.ts +++ b/packages/driver/src/cy/commands/screenshot.ts @@ -427,7 +427,7 @@ export default function (Commands, Cypress, cy, state, config) { // if a screenshot has not been taken (by cy.screenshot()) in the test // that failed, we can bypass UI-changing and pixel-checking (simple: true) - // otheriwse, we need to do all the standard checks + // otherwise, we need to do all the standard checks // to make sure the UI is in the right place (simple: false) screenshotConfig.capture = 'runner' diff --git a/packages/driver/src/cy/listeners.ts b/packages/driver/src/cy/listeners.ts index 70a171c1fe38..5d5d652821fc 100644 --- a/packages/driver/src/cy/listeners.ts +++ b/packages/driver/src/cy/listeners.ts @@ -33,7 +33,7 @@ const eventHasReturnValue = (e) => { const val = e.returnValue // return false if val is an empty string - // of if its undinefed + // of if its undefined if (val === '' || _.isUndefined(val)) { return false } diff --git a/packages/driver/src/cy/retries.ts b/packages/driver/src/cy/retries.ts index d8e4011f2b2e..7e687737619f 100644 --- a/packages/driver/src/cy/retries.ts +++ b/packages/driver/src/cy/retries.ts @@ -101,7 +101,7 @@ export default { // are not and the retry code is happening between // runnables which is bad likely due to the issue below // - // bug in bluebird with not propagating cancelations + // bug in bluebird with not propagating cancellations // fast enough in a series of promises // https://github.com/petkaantonov/bluebird/issues/1424 return state('canceled') || state('error') || runnableHasChanged() diff --git a/packages/driver/src/cy/testConfigOverrides.ts b/packages/driver/src/cy/testConfigOverrides.ts index 5a41184d0a60..7f741251e3af 100644 --- a/packages/driver/src/cy/testConfigOverrides.ts +++ b/packages/driver/src/cy/testConfigOverrides.ts @@ -1,17 +1,72 @@ import _ from 'lodash' +import $errUtils from '../cypress/error_utils' + +// See Test Config Overrides in ../../../../cli/types/cypress.d.ts + +type ResolvedTestConfigOverride = { + /** + * The list of test config overrides and the invocation details used to add helpful + * error messaging to consumers if a test override fails validation. + */ + testConfigList: Array + /** + * The test config overrides that will apply to the test if it passes validation. + * */ + unverifiedTestConfig: Object +} -function mutateConfiguration (testConfigOverride, config, env) { - const globalConfig = _.clone(config()) - const globalEnv = _.clone(env()) +type TestConfig = { + overrides: { + browser?: Object + } + invocationDetails: { + stack: Object + } +}; + +type ConfigOverrides = { + env: Object | undefined +}; + +function setConfig (testConfigList: Array, config, localConfigOverrides: ConfigOverrides = { env: undefined }) { + testConfigList.forEach(({ overrides: testConfigOverride, invocationDetails }) => { + if (_.isArray(testConfigOverride)) { + setConfig(testConfigOverride, config, localConfigOverrides) + } else { + delete testConfigOverride.browser + + try { + config(testConfigOverride) + } catch (e) { + let err = $errUtils.errByPath('config.invalid_test_override', { + errMsg: e.message, + }) + + err.stack = $errUtils.stackWithReplacedProps({ stack: invocationDetails.stack }, err) + throw err + } + localConfigOverrides = { ...localConfigOverrides, ...testConfigOverride } + } + }) + + return localConfigOverrides +} + +function mutateConfiguration (testConfig: ResolvedTestConfigOverride, config, env) { + const { testConfigList } = testConfig || [] + + let globalConfig = _.clone(config()) - delete testConfigOverride.browser - config(testConfigOverride) + const localConfigOverrides = setConfig(testConfigList, config) - const localTestConfig = config() - const localTestConfigBackup = _.clone(localTestConfig) + // only store the global config values that updated + globalConfig = _.pick(globalConfig, Object.keys(localConfigOverrides)) + const globalEnv = _.clone(env()) + + const localConfigOverridesBackup = _.clone(localConfigOverrides) - if (testConfigOverride.env) { - env(testConfigOverride.env) + if (localConfigOverrides.env) { + env(localConfigOverrides.env) } const localTestEnv = env() @@ -23,10 +78,15 @@ function mutateConfiguration (testConfigOverride, config, env) { // TODO: (NEXT_BREAKING) always restore configuration // do not allow global mutations inside test const restoreConfigFn = function () { - _.each(localTestConfig, (val, key) => { - if (localTestConfigBackup[key] !== val) { + _.each(localConfigOverrides, (val, key) => { + if (localConfigOverridesBackup[key] !== val) { globalConfig[key] = val } + + // explicitly set to undefined if config wasn't previously defined + if (!globalConfig.hasOwnProperty(key)) { + globalConfig[key] = undefined + } }) _.each(localTestEnv, (val, key) => { @@ -35,8 +95,9 @@ function mutateConfiguration (testConfigOverride, config, env) { } }) - config.reset() + // reset test config overrides config(globalConfig) + env.reset() env(globalEnv) } @@ -46,30 +107,45 @@ function mutateConfiguration (testConfigOverride, config, env) { // this is called during test onRunnable time // in order to resolve the test config upfront before test runs -export function getResolvedTestConfigOverride (test) { +// note: must return as an object to meet the dashboard recording API +export function getResolvedTestConfigOverride (test): ResolvedTestConfigOverride { let curParent = test.parent - - const testConfig = [test._testConfig] + const testConfigList = [{ + overrides: test._testConfig, + invocationDetails: test.invocationDetails, + }] while (curParent) { if (curParent._testConfig) { - testConfig.push(curParent._testConfig) + testConfigList.unshift({ + overrides: curParent._testConfig, + invocationDetails: curParent.invocationDetails, + }) } curParent = curParent.parent } - return _.reduceRight(testConfig, (acc, opts) => _.extend(acc, opts), {}) + const testConfig = { + testConfigList: testConfigList.filter((opt) => opt.overrides !== undefined), + // collect test overrides to send to the dashboard api when @packages/server is ran in record mode + unverifiedTestConfig: _.reduce(testConfigList, (acc, opts) => _.extend(acc, opts.overrides), {}), + } + + return testConfig } class TestConfigOverride { private restoreTestConfigFn: Nullable<() => void> = null + restoreAndSetTestConfigOverrides (test, config, env) { if (this.restoreTestConfigFn) this.restoreTestConfigFn() const resolvedTestConfig = test._testConfig || {} - this.restoreTestConfigFn = mutateConfiguration(resolvedTestConfig, config, env) + if (Object.keys(resolvedTestConfig.unverifiedTestConfig).length > 0) { + this.restoreTestConfigFn = mutateConfiguration(resolvedTestConfig, config, env) + } } } diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index c8712eeb8d48..e6453921f0f9 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -1,5 +1,6 @@ // @ts-nocheck +import { validate } from '@packages/config' import _ from 'lodash' import $ from 'jquery' import * as blobUtil from 'blob-util' @@ -116,7 +117,7 @@ class $Cypress { // normalize this into boolean config.isTextTerminal = !!config.isTextTerminal - // we asumme we're interactive based on whether or + // we assume we're interactive based on whether or // not we're in a text terminal, but we keep this // as a separate property so we can potentially // slice up the behavior @@ -142,7 +143,12 @@ class $Cypress { this.state = $SetterGetter.create({}) this.originalConfig = _.cloneDeep(config) - this.config = $SetterGetter.create(config) + this.config = $SetterGetter.create(config, (config) => { + validate(config, (errMsg) => { + throw new this.state('specWindow').Error(errMsg) + }) + }) + this.env = $SetterGetter.create(env) this.getTestRetries = function () { const testRetries = this.config('retries') @@ -269,6 +275,8 @@ class $Cypress { return this.emit('stop') case 'cypress:config': + // emit config event used to: + // - trigger iframe viewport update return this.emit('config', args[0]) case 'runner:start': @@ -390,16 +398,12 @@ class $Cypress { return this.runner.onRunnableRun(...args) case 'runner:test:before:run': - // get back to a clean slate - this.cy.reset(...args) - if (this.config('isTextTerminal')) { // needed for handling test retries this.emit('mocha', 'test:before:run', args[0]) } this.emit('test:before:run', ...args) - break case 'runner:test:before:run:async': @@ -423,7 +427,6 @@ class $Cypress { } break - case 'cy:before:all:screenshots': return this.emit('before:all:screenshots', ...args) diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index b70bd6c482a3..4d5a0e2b9d10 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -142,7 +142,7 @@ export default { return enqueuedCmd = obj } - // only check for command enqueing when none + // only check for command enqueuing when none // of our args are functions else commands // like cy.then or cy.each would always fail // since they return promises and queue more @@ -224,8 +224,7 @@ export default { command.set({ subject }) - // end / snapshot our logs - // if they need it + // end / snapshot our logs if they need it command.finishLogs() // reset the nestedIndex back to null @@ -234,8 +233,7 @@ export default { // also reset recentlyReady back to null state('recentlyReady', null) - // we're finished with the current command - // so set it back to null + // we're finished with the current command so set it back to null state('current', null) state('subject', subject) diff --git a/packages/driver/src/cypress/commands.ts b/packages/driver/src/cypress/commands.ts index b5b19c220147..9a1c7a8240e4 100644 --- a/packages/driver/src/cypress/commands.ts +++ b/packages/driver/src/cypress/commands.ts @@ -3,6 +3,7 @@ import _ from 'lodash' import $errUtils from './error_utils' +import $stackUtils from './stack_utils' import { allCommands } from '../cy/commands' import { addCommand } from '../cy/net-stubbing' @@ -30,6 +31,10 @@ export default { // of commands const commands = {} const commandBackups = {} + // we track built in commands to ensure users cannot + // add custom commands with the same name + const builtInCommandNames = {} + let addingBuiltIns const store = (obj) => { commands[obj.name] = obj @@ -126,6 +131,24 @@ export default { }, add (name, options, fn) { + if (builtInCommandNames[name]) { + $errUtils.throwErrByPath('miscellaneous.invalid_new_command', { + args: { + name, + }, + stack: (new state('specWindow').Error('add command stack')).stack, + errProps: { + appendToStack: { + title: 'From Cypress Internals', + content: $stackUtils.stackWithoutMessage((new Error('add command internal stack')).stack), + } }, + }) + } + + if (addingBuiltIns) { + builtInCommandNames[name] = true + } + if (_.isFunction(options)) { fn = options options = {} @@ -163,12 +186,14 @@ export default { }, } + addingBuiltIns = true // perf loop for (let cmd of builtInCommands) { // support "export default" syntax cmd = cmd.default || cmd cmd(Commands, Cypress, cy, state, config) } + addingBuiltIns = false return Commands }, diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index 0a43e12e3fab..e6ec1719a143 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -3,11 +3,14 @@ /* eslint-disable prefer-rest-params */ import _ from 'lodash' import Promise from 'bluebird' +import debugFn from 'debug' +import { registerFetch } from 'unfetch' import $dom from '../dom' import $utils from './utils' import $errUtils from './error_utils' import $stackUtils from './stack_utils' + import $Chai from '../cy/chai' import $Xhrs from '../cy/xhrs' import $jQuery from '../cy/jquery' @@ -31,8 +34,6 @@ import { $Command } from './command' import $CommandQueue from './command_queue' import $VideoRecorder from '../cy/video-recorder' import $TestConfigOverrides from '../cy/testConfigOverrides' -import debugFn from 'debug' -import { registerFetch } from 'unfetch' const debugErrors = debugFn('cypress:driver:errors') @@ -222,7 +223,7 @@ export default { Cypress.action('app:window:before:unload', e) // return undefined so our beforeunload handler - // doesnt trigger a confirmation dialog + // doesn't trigger a confirmation dialog return undefined }, onUnload (e) { @@ -330,7 +331,7 @@ export default { // we look at whether or not nestedIndex is a number, because if it // is then we need to insert inside of our commands, else just push - // it onto the end of the queu + // it onto the end of the queue const index = _.isNumber(nestedIndex) ? nestedIndex : queue.length queue.insert(index, $Command.create(obj)) @@ -541,7 +542,7 @@ export default { _.extend(cy, { id: _.uniqueId('cy'), - // synchrounous querying + // synchronous querying $$, state, @@ -704,29 +705,35 @@ export default { return doneEarly() }, - reset (attrs, test) { - const s = state() + // reset is called before each test + reset (test) { + try { + const s = state() + + const backup = { + window: s.window, + document: s.document, + $autIframe: s.$autIframe, + specWindow: s.specWindow, + activeSessions: s.activeSessions, + } - const backup = { - window: s.window, - document: s.document, - $autIframe: s.$autIframe, - specWindow: s.specWindow, - activeSessions: s.activeSessions, - } + // reset state back to empty object + state.reset() - // reset state back to empty object - state.reset() + // and then restore these backed up props + state(backup) - // and then restore these backed up props - state(backup) + queue.reset() + queue.clear() + timers.reset() - queue.reset() - queue.clear() - timers.reset() - testConfigOverrides.restoreAndSetTestConfigOverrides(test, Cypress.config, Cypress.env) + testConfigOverrides.restoreAndSetTestConfigOverrides(test, Cypress.config, Cypress.env) - return cy.removeAllListeners() + cy.removeAllListeners() + } catch (err) { + fail(err) + } }, addCommandSync (name, fn) { @@ -1034,8 +1041,7 @@ export default { const originalDone = arguments[0] arguments[0] = (done = function (err) { - // TODO: handle no longer error - // when ended early + // TODO: handle no longer error when ended early doneEarly() originalDone(err) @@ -1056,7 +1062,7 @@ export default { // if we returned a value from fn // and enqueued some new commands - // and the value isnt currently cy + // and the value isn't currently cy // or a promise if (ret && (queue.length > currentLength) && diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index bdbc6350ce88..36bc19ee475b 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -245,6 +245,17 @@ export default { }, }, + config: { + invalid_argument: { + message: `Setting the config via ${cmd('Cypress.config')} failed with the following validation error:\n\n{{errMsg}}`, + docsUrl: 'https://on.cypress.io/config', + }, + 'invalid_test_override': { + message: `The config override passed to your test has the following validation error:\n\n{{errMsg}}`, + docsUrl: 'https://on.cypress.io/config', + }, + }, + contains: { empty_string: { message: `${cmd('contains')} cannot be passed an empty string.`, @@ -803,6 +814,10 @@ export default { message: 'Could not find a command for: `{{name}}`.\n\nAvailable commands are: {{cmds}}.\n', docsUrl: 'https://on.cypress.io/api', }, + invalid_new_command: { + message: '`Cypress.Commands.add()` is used to create new commands, but `{{name}}` is an existing Cypress command.\n\nPlease use `Cypress.Commands.overwrite()` if you would like to overwrite an existing command.\n', + docsUrl: 'https://on.cypress.io/custom-commands', + }, invalid_overwrite: { message: 'Cannot overwite command for: `{{name}}`. An existing command does not exist by that name.', docsUrl: 'https://on.cypress.io/api', diff --git a/packages/driver/src/cypress/error_utils.ts b/packages/driver/src/cypress/error_utils.ts index 35daf3ed9aeb..c8c3cda57e53 100644 --- a/packages/driver/src/cypress/error_utils.ts +++ b/packages/driver/src/cypress/error_utils.ts @@ -205,7 +205,7 @@ const throwErr = (err, options = {}) => { let { onFail, errProps } = options // assume onFail is a command if - //# onFail is present and isnt a function + //# onFail is present and isn't a function if (onFail && !_.isFunction(onFail)) { const command = onFail @@ -230,8 +230,10 @@ const throwErr = (err, options = {}) => { const throwErrByPath = (errPath, options = {}) => { const err = errByPath(errPath, options.args) - // gets rid of internal stack lines that just build the error - if (Error.captureStackTrace) { + if (options.stack) { + err.stack = $stackUtils.replacedStack(err, options.stack) + } else if (Error.captureStackTrace) { + // gets rid of internal stack lines that just build the error Error.captureStackTrace(err, throwErrByPath) } @@ -541,6 +543,7 @@ const logError = (Cypress, handlerType, err, handled = false) => { } export default { + stackWithReplacedProps, appendErrMsg, createUncaughtException, cypressErr, diff --git a/packages/driver/src/cypress/mocha.ts b/packages/driver/src/cypress/mocha.ts index f6bd59aee47d..42b0a98f8600 100644 --- a/packages/driver/src/cypress/mocha.ts +++ b/packages/driver/src/cypress/mocha.ts @@ -50,7 +50,7 @@ function overloadMochaFnForConfig (fnName, specWindow) { const fnType = fnName === 'it' || fnName === 'specify' ? 'Test' : 'Suite' - function overrideFn (fn) { + function overrideMochaFn (fn) { specWindow[fnName] = fn() specWindow[fnName]['only'] = fn('only') specWindow[fnName]['skip'] = fn('skip') @@ -58,7 +58,7 @@ function overloadMochaFnForConfig (fnName, specWindow) { if (specWindow[`x${fnName}`]) specWindow[`x${fnName}`] = specWindow[fnName]['skip'] } - overrideFn(function (subFn) { + const replacementFn = function (subFn) { return function (...args) { /** * @type {Cypress.Cypress} @@ -94,6 +94,7 @@ function overloadMochaFnForConfig (fnName, specWindow) { const ret = origFn.apply(this, mochaArgs) + // attached testConfigOverrides will executes on `runner:test:before:run` event ret._testConfig = _testConfig return ret @@ -101,7 +102,9 @@ function overloadMochaFnForConfig (fnName, specWindow) { return origFn.apply(this, args) } - }) + } + + overrideMochaFn(replacementFn) } const ui = (specWindow, _mocha, config) => { @@ -355,7 +358,7 @@ const patchSuiteAddTest = (specWindow, config) => { const test = args[0] if (!test.invocationDetails) { - test.invocationDetails = $stackUtils.getInvocationDetails(specWindow, config).details + test.invocationDetails = $stackUtils.getInvocationDetails(specWindow, config) } const ret = suiteAddTest.apply(this, args) @@ -387,7 +390,7 @@ const patchSuiteAddSuite = (specWindow, config) => { const suite = args[0] if (!suite.invocationDetails) { - suite.invocationDetails = $stackUtils.getInvocationDetails(specWindow, config).details + suite.invocationDetails = $stackUtils.getInvocationDetails(specWindow, config) } return suiteAddSuite.apply(this, args) @@ -446,7 +449,7 @@ const patchSuiteHooks = (specWindow, config) => { if (!hook.invocationDetails) { const invocationDetails = $stackUtils.getInvocationDetails(specWindow, config) - hook.invocationDetails = invocationDetails.details + hook.invocationDetails = invocationDetails invocationStack = invocationDetails.stack } diff --git a/packages/driver/src/cypress/runner.ts b/packages/driver/src/cypress/runner.ts index 97ee42be0692..cad4c7780e91 100644 --- a/packages/driver/src/cypress/runner.ts +++ b/packages/driver/src/cypress/runner.ts @@ -17,6 +17,8 @@ const mochaCtxKeysRe = /^(_runnable|test)$/ const betweenQuotesRe = /\"(.+?)\"/ const HOOKS = 'beforeAll beforeEach afterEach afterAll'.split(' ') +const TEST_BEFORE_RUN_ASYNC_EVENT = 'runner:test:before:run:async' +// event fired before hooks and test execution const TEST_BEFORE_RUN_EVENT = 'runner:test:before:run' const TEST_AFTER_RUN_EVENT = 'runner:test:after:run' @@ -48,8 +50,8 @@ const fired = (event, runnable) => { const testBeforeRunAsync = (test, Cypress) => { return Promise.try(() => { - if (!fired('runner:test:before:run:async', test)) { - return fire('runner:test:before:run:async', test, Cypress) + if (!fired(TEST_BEFORE_RUN_ASYNC_EVENT, test)) { + return fire(TEST_BEFORE_RUN_ASYNC_EVENT, test, Cypress) } }) } @@ -73,7 +75,7 @@ const testAfterRun = (test, Cypress) => { // if the test:after:run listener throws it's likely spec code // Since the test status has already been emitted this can't affect the test status. // Let's just log the error to console - // TODO: revist when we handle uncaught exceptions/rejections between tests + // TODO: revisit when we handle uncaught exceptions/rejections between tests // eslint-disable-next-line no-console console.error(e) } @@ -336,7 +338,7 @@ const isRootSuite = (suite) => { } const overrideRunnerHook = (Cypress, _runner, getTestById, getTest, setTest, getTests) => { - // bail if our _runner doesnt have a hook. + // bail if our _runner doesn't have a hook. // useful in tests if (!_runner.hook) { return @@ -581,10 +583,6 @@ const normalize = (runnable, tests, initialTests, onRunnable, onLogsById, getRun wrappedRunnable._testConfig = cfg } - if (cfg.slowTestThreshold) { - runnable.slow(cfg.slowTestThreshold) - } - wrappedRunnable._titlePath = runnable.titlePath() } @@ -918,9 +916,9 @@ const _runnerListeners = (_runner, Cypress, _emissions, getTestById, getTest, se // }) /** - * Mocha retry event is only fired in Mocha version 6+ - * https://github.com/mochajs/mocha/commit/2a76dd7589e4a1ed14dd2a33ab89f182e4c4a050 - */ + * Mocha retry event is only fired in Mocha version 6+ + * https://github.com/mochajs/mocha/commit/2a76dd7589e4a1ed14dd2a33ab89f182e4c4a050 + */ _runner.on('retry', (test, err) => { test.err = $errUtils.wrapErr(err) Cypress.action('runner:retry', wrap(test), test.err) @@ -1137,7 +1135,7 @@ export default { } const onRunnable = (r) => { - // set defualt retries at onRunnable time instead of onRunnableRun + // set default retries at onRunnable time instead of onRunnableRun return _runnables.push(r) } @@ -1217,6 +1215,7 @@ export default { const isAfterEachHook = isHook && !!hookName.match(/after each/) const retryAbleRunnable = isTest || isBeforeEachHook || isAfterEachHook const willRetry = (test._currentRetry < test._retries) && retryAbleRunnable + const isTestConfigOverride = !fired(TEST_BEFORE_RUN_EVENT, test) const fail = function () { return err @@ -1231,13 +1230,19 @@ export default { test.final = false } - if (willRetry && isBeforeEachHook) { + if (isTestConfigOverride) { + // let the runner handle the error + delete runnable.err + } + + if ((willRetry || isTestConfigOverride) && isBeforeEachHook) { delete runnable.err test._retriesBeforeEachFailedTestFn = test.fn // this prevents afterEach hooks that exist at a deeper level than the failing one from running // we will always skip remaining beforeEach hooks since they will always be same level or deeper test._skipHooksWithLevelGreaterThan = runnable.titlePath().length + setHookFailureProps(test, runnable, err) test.fn = function () { throw err @@ -1247,7 +1252,7 @@ export default { } if (willRetry && isAfterEachHook) { - // if we've already failed this attempt from an afterEach hook then we've already enqueud another attempt + // if we've already failed this attempt from an afterEach hook then we've already enqueued another attempt // so return early if (test._retriedFromAfterEachHook) { return noFail() @@ -1374,10 +1379,9 @@ export default { } // closure for calculating the actual - // runtime of a runnables fn exection duration + // runtime of a runnables fn execution duration // and also the run of the runnable:after:run:async event let lifecycleStart - let wallClockStartedAt = null let wallClockEnd = null let fnDurationStart = null let fnDurationEnd = null @@ -1387,7 +1391,7 @@ export default { // when this is a hook, capture the real start // date so we can calculate our test's duration // including all of its hooks - wallClockStartedAt = new Date() + const wallClockStartedAt = new Date() if (!test.wallClockStartedAt) { // if we don't have lifecycle timings yet @@ -1398,27 +1402,13 @@ export default { test.wallClockStartedAt = wallClockStartedAt } - // if this isnt a hook, then the name is 'test' - const hookName = runnable.type === 'hook' ? getHookName(runnable) : 'test' - - // set hook id to hook id or test id - const hookId = runnable.type === 'hook' ? runnable.hookId : runnable.id - - // if we haven't yet fired this event for this test - // that means that we need to reset the previous state - // of cy - since we now have a new 'test' and all of the - // associated _runnables will share this state - if (!fired(TEST_BEFORE_RUN_EVENT, test)) { - fire(TEST_BEFORE_RUN_EVENT, test, Cypress) + const isHook = runnable.type === 'hook' - // this is the earliest we can set test._retries since test:before:run - // will load in testConfigOverrides (per test configuration) - const retries = Cypress.getTestRetries() ?? -1 + // if this isn't a hook, then the name is 'test' + const hookName = isHook ? getHookName(runnable) : 'test' - test._retries = retries - } - - const isHook = runnable.type === 'hook' + // set hook id to hook id or test id + const hookId = isHook ? runnable.hookId : runnable.id const isAfterEachHook = isHook && hookName.match(/after each/) const isBeforeEachHook = isHook && hookName.match(/before each/) @@ -1522,22 +1512,32 @@ export default { }) } - cy.state('duringUserTestExecution', false) - - // our runnable is about to run, so let cy know. this enables - // us to always have a correct runnable set even when we are - // running lifecycle events - // and also get back a function result handler that we use as - // an async seam - cy.setRunnable(runnable, hookId) - // TODO: handle promise timeouts here! // whenever any runnable is about to run // we figure out what test its associated to // if its a hook, and then we fire the // test:before:run:async action if its not // been fired before for this test - return testBeforeRunAsync(test, Cypress) + return Promise.try(() => { + if (!fired(TEST_BEFORE_RUN_EVENT, test)) { + cy.reset(test) + test.slow(Cypress.config('slowTestThreshold')) + test._retries = Cypress.getTestRetries() ?? -1 + fire(TEST_BEFORE_RUN_EVENT, test, Cypress) + } + + cy.state('duringUserTestExecution', false) + + // our runnable is about to run, so let cy know. this enables + // us to always have a correct runnable set even when we are + // running lifecycle events + // and also get back a function result handler that we use as + // an async seam + cy.setRunnable(runnable, hookId) + }) + .then(() => { + return testBeforeRunAsync(test, Cypress) + }) .catch((err) => { // TODO: if our async tasks fail // then allow us to cause the test diff --git a/packages/driver/src/cypress/setter_getter.ts b/packages/driver/src/cypress/setter_getter.ts index 23736acaec97..d9ed920770d6 100644 --- a/packages/driver/src/cypress/setter_getter.ts +++ b/packages/driver/src/cypress/setter_getter.ts @@ -1,4 +1,4 @@ -import _ from 'lodash' +import { extend, isObject, isString } from 'lodash' const reset = (state = {}) => { // perf loop @@ -11,7 +11,7 @@ const reset = (state = {}) => { // a basic object setter / getter class export default { - create: (state = {}) => { + create: (state = {}, validate?) => { const get = (key?) => { if (key) { return state[key] @@ -24,7 +24,7 @@ export default { let obj let ret - if (_.isObject(key)) { + if (isObject(key)) { obj = key ret = obj } else { @@ -33,7 +33,9 @@ export default { ret = value } - _.extend(state, obj) + validate && validate(obj) + + extend(state, obj) return ret } @@ -44,7 +46,7 @@ export default { case 0: return get() case 1: - if (_.isString(key)) { + if (isString(key)) { return get(key) } diff --git a/packages/driver/src/cypress/stack_utils.ts b/packages/driver/src/cypress/stack_utils.ts index de0d14dd8cbd..49028d2405fd 100644 --- a/packages/driver/src/cypress/stack_utils.ts +++ b/packages/driver/src/cypress/stack_utils.ts @@ -100,17 +100,16 @@ const getInvocationDetails = (specWindow, config) => { // firefox throws a different stack than chromium // which includes stackframes from cypress_runner.js. - // So we drop the lines until we get to the spec stackframe (incldues __cypress/tests) + // So we drop the lines until we get to the spec stackframe (includes __cypress/tests) if (specWindow.Cypress && specWindow.Cypress.isBrowser('firefox')) { stack = stackWithLinesDroppedFromMarker(stack, '__cypress/tests', true) } - const details = getSourceDetailsForFirstLine(stack, config('projectRoot')) + const details = getSourceDetailsForFirstLine(stack, config('projectRoot')) || {} - return { - details, - stack, - } + details.stack = stack + + return details } } @@ -383,6 +382,7 @@ const normalizedUserInvocationStack = (userInvocationStack) => { export default { replacedStack, getCodeFrame, + getCodeFrameFromSource, getSourceStack, getStackLines, getSourceDetailsForFirstLine, diff --git a/packages/driver/src/dom/elements/find.ts b/packages/driver/src/dom/elements/find.ts index 87f47057d482..69ee4e971e27 100644 --- a/packages/driver/src/dom/elements/find.ts +++ b/packages/driver/src/dom/elements/find.ts @@ -4,7 +4,7 @@ import $document from '../document' import $jquery from '../jquery' import { getTagName } from './elementHelpers' import { isWithinShadowRoot, getShadowElementFromPoint } from './shadow' -import { normalizeWhitespaces, escapeQuotes } from './utils' +import { normalizeWhitespaces, escapeQuotes, isSelector } from './utils' /** * Find Parents relative to an initial element @@ -217,6 +217,20 @@ export const getElements = ($el) => { return els } +// Remove