diff --git a/.vscode/settings.json b/.vscode/settings.json index a08fff44ae4d..c25c1b8538c5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -45,5 +45,6 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": false } - } + }, + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/circle.yml b/circle.yml index d64e2efc3321..2ab3b356b566 100644 --- a/circle.yml +++ b/circle.yml @@ -28,6 +28,15 @@ defaults: &defaults COLUMNS: 100 LINES: 24 +# filters and requires for testing binary with Firefox +testBinaryFirefox: &testBinaryFirefox + filters: + branches: + only: + - develop + requires: + - build-npm-package + - build-binary executors: # the Docker image with Cypress dependencies and Chrome browser @@ -247,6 +256,83 @@ commands: packageName: web-config packagePath: packages/web-config/package.json + post-install-comment: + description: Post GitHub comment with a blurb on how to install pre-release version + steps: + - run: ls -la + # make sure JSON files with uploaded urls are present + - run: ls -la binary-url.json npm-package-url.json + - run: cat binary-url.json + - run: cat npm-package-url.json + - run: + name: Post pre-release install comment + command: | + node scripts/add-install-comment.js \ + --npm npm-package-url.json \ + --binary binary-url.json + + test-binary-against-repo: + description: | + Takes the built binary and NPM package, clones given example repo + and runs the new version of Cypress against it. + parameters: + repo: + description: Name of the repo like "cypress-example-kitchensink" + type: string + browser: + description: Name of the browser to use + type: enum + enum: ["electron", "chrome", "firefox"] + default: "electron" + command: + description: Test command to run to start Cypress tests + type: string + default: "npm run e2e" + wait-on: + description: Url to wait-on before starting tests + type: string + default: "" + steps: + - attach_workspace: + at: ~/ + # make sure the binary and NPM package files are present + - run: ls -l + - run: ls -l cypress.zip cypress.tgz + - run: + name: Cloning project <> + command: git clone --depth 1 https://github.com/cypress-io/<>.git /tmp/<> + - run: + command: npm install + working_directory: /tmp/<> + - run: + name: Install Cypress + working_directory: /tmp/<> + # force installing the freshly built binary + command: CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm i ~/cypress/cypress.tgz + - run: + working_directory: /tmp/<> + command: npm run build --if-present + - run: + working_directory: /tmp/<> + command: npm start --if-present + background: true + - when: + condition: <> + steps: + - run: + name: Wait-on <> + command: npx wait-on <> --timeout 120000 + - run: + working_directory: /tmp/<> + command: <> -- --browser <> + - store-npm-logs + - store_artifacts: + name: screenshots + path: /tmp/<>/cypress/screenshots + - store_artifacts: + name: videos + path: /tmp/<>/cypress/videos + jobs: ## code checkout and NPM installs build: @@ -508,6 +594,62 @@ jobs: browser: electron chunk: 8 + "server-e2e-tests-firefox-1": + <<: *defaults + steps: + - run-e2e-tests: + browser: firefox + chunk: 1 + + "server-e2e-tests-firefox-2": + <<: *defaults + steps: + - run-e2e-tests: + browser: firefox + chunk: 2 + + "server-e2e-tests-firefox-3": + <<: *defaults + steps: + - run-e2e-tests: + browser: firefox + chunk: 3 + + "server-e2e-tests-firefox-4": + <<: *defaults + steps: + - run-e2e-tests: + browser: firefox + chunk: 4 + + "server-e2e-tests-firefox-5": + <<: *defaults + steps: + - run-e2e-tests: + browser: firefox + chunk: 5 + + "server-e2e-tests-firefox-6": + <<: *defaults + steps: + - run-e2e-tests: + browser: firefox + chunk: 6 + + "server-e2e-tests-firefox-7": + <<: *defaults + steps: + - run-e2e-tests: + browser: firefox + chunk: 7 + + "server-e2e-tests-firefox-8": + <<: *defaults + steps: + - run-e2e-tests: + browser: firefox + chunk: 8 + "driver-integration-tests-chrome": <<: *defaults parallelism: 5 @@ -558,6 +700,30 @@ jobs: # path: /tmp/artifacts # - store-npm-logs + "driver-integration-tests-firefox": + <<: *defaults + parallelism: 5 + steps: + - attach_workspace: + at: ~/ + - run: + command: npm start + background: true + working_directory: packages/driver + - run: + command: $(npm bin)/wait-on http://localhost:3500 + working_directory: packages/driver + - run: + command: | + CYPRESS_KONFIG_ENV=production \ + CYPRESS_RECORD_KEY=$PACKAGES_RECORD_KEY \ + npm run cypress:run -- --record --parallel --group 5x-driver-firefox --browser firefox + working_directory: packages/driver + - store_test_results: + path: /tmp/cypress + - store_artifacts: + path: /tmp/artifacts + "desktop-gui-integration-tests-2x": <<: *defaults parallelism: 2 @@ -857,12 +1023,6 @@ jobs: name: Verify Cypress binary working_directory: /tmp/testing command: $(npm bin)/cypress verify - - run: - name: Post pre-release install comment - command: | - node scripts/add-install-comment.js \ - --npm npm-package-url.json \ - --binary binary-url.json - run: name: Running other test projects with new NPM package and binary command: | @@ -872,6 +1032,15 @@ jobs: --provider circle - store-npm-logs + post-pre-release-install-comment: + <<: *defaults + steps: + # needs uploaded NPM and test binary + - attach_workspace: + at: ~/ + - run: ls -la + - post-install-comment + "test-npm-module-and-verify-binary": <<: *defaults steps: @@ -924,36 +1093,76 @@ jobs: $(npm bin)/cypress run --record - store-npm-logs + "test-binary-against-recipes-firefox": + <<: *defaults + steps: + - test-binary-against-repo: + repo: cypress-example-recipes + command: npm run test:ci:firefox + "test-binary-against-kitchensink": <<: *defaults steps: - - attach_workspace: - at: ~/ - # make sure the binary and NPM package files are present - - run: ls -l - - run: ls -l cypress.zip cypress.tgz - - run: - name: Cloning kitchensink project - command: git clone --depth 1 https://github.com/cypress-io/cypress-example-kitchensink.git /tmp/kitchensink - - run: - command: npm install - working_directory: /tmp/kitchensink - - run: - name: Install Cypress - working_directory: /tmp/kitchensink - # force installing the freshly built binary - command: CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm i ~/cypress/cypress.tgz - - run: - working_directory: /tmp/kitchensink - command: npm run build - - run: - working_directory: /tmp/kitchensink - command: npm start - background: true - - run: - working_directory: /tmp/kitchensink - command: npm run e2e - - store-npm-logs + - test-binary-against-repo: + repo: cypress-example-kitchensink + + "test-binary-against-kitchensink-firefox": + <<: *defaults + steps: + - test-binary-against-repo: + repo: cypress-example-kitchensink + browser: firefox + + "test-binary-against-kitchensink-chrome": + <<: *defaults + steps: + - test-binary-against-repo: + repo: cypress-example-kitchensink + browser: chrome + + "test-binary-against-todomvc-firefox": + <<: *defaults + steps: + - test-binary-against-repo: + repo: cypress-example-todomvc + browser: firefox + + "test-binary-against-documentation-firefox": + <<: *defaults + steps: + - test-binary-against-repo: + repo: cypress-documentation + browser: firefox + command: "npm run cypress:run" + wait-on: "http://localhost:2222" + + + "test-binary-against-realworld-firefox": + <<: *defaults + steps: + - test-binary-against-repo: + repo: cypress-example-realworld + browser: firefox + command: "npm run cypress:run" + wait-on: "http://localhost:4100" + + "test-binary-against-api-testing-firefox": + <<: *defaults + steps: + - test-binary-against-repo: + repo: cypress-example-api-testing + browser: firefox + command: "npm run cy:run" + wait-on: "http://localhost:3000" + + "test-binary-against-piechopper-firefox": + <<: *defaults + steps: + - test-binary-against-repo: + repo: cypress-example-piechopper + browser: firefox + command: "npm run cypress:run" + wait-on: "http://localhost:8080" test-binary-as-specific-user: <<: *defaults @@ -1083,9 +1292,36 @@ linux-workflow: &linux-workflow context: test-runner:performance-tracking requires: - build + - server-e2e-tests-firefox-1: + requires: + - build + - server-e2e-tests-firefox-2: + requires: + - build + - server-e2e-tests-firefox-3: + requires: + - build + - server-e2e-tests-firefox-4: + requires: + - build + - server-e2e-tests-firefox-5: + requires: + - build + - server-e2e-tests-firefox-6: + requires: + - build + - server-e2e-tests-firefox-7: + requires: + - build + - server-e2e-tests-firefox-8: + requires: + - build - driver-integration-tests-chrome: requires: - build + - driver-integration-tests-firefox: + requires: + - build ## TODO: add these back in when flaky tests are fixed # - driver-integration-tests-electron: # requires: @@ -1146,6 +1382,15 @@ linux-workflow: &linux-workflow - develop requires: - build-binary + - post-pre-release-install-comment: + context: test-runner:commit-status-checks + filters: + branches: + only: + - develop + requires: + - upload-npm-package + - upload-binary - test-binary-and-npm-against-other-projects: context: test-runner:trigger-test-jobs filters: @@ -1172,14 +1417,26 @@ linux-workflow: &linux-workflow requires: - build-npm-package - build-binary + + - test-binary-against-recipes-firefox: + <<: *testBinaryFirefox - test-binary-against-kitchensink: - filters: - branches: - only: - - develop - requires: - - build-npm-package - - build-binary + <<: *testBinaryFirefox + - test-binary-against-kitchensink-firefox: + <<: *testBinaryFirefox + - test-binary-against-kitchensink-chrome: + <<: *testBinaryFirefox + - test-binary-against-todomvc-firefox: + <<: *testBinaryFirefox + - test-binary-against-documentation-firefox: + <<: *testBinaryFirefox + - test-binary-against-api-testing-firefox: + <<: *testBinaryFirefox + - test-binary-against-realworld-firefox: + <<: *testBinaryFirefox + - test-binary-against-piechopper-firefox: + <<: *testBinaryFirefox + - test-binary-as-specific-user: name: "test binary as a non-root user" executor: non-root-docker-user @@ -1278,6 +1535,17 @@ mac-workflow: &mac-workflow - Mac NPM package - Mac binary + - post-pre-release-install-comment: + context: test-runner:commit-status-checks + name: Post Mac pre-release install comment + filters: + branches: + only: + - develop + requires: + - Mac NPM package upload + - Mac binary upload + - test-binary-and-npm-against-other-projects: context: test-runner:trigger-test-jobs name: Test Mac binary against other projects diff --git a/cli/__snapshots__/cli_spec.js b/cli/__snapshots__/cli_spec.js index 5eaf5f414e5c..997b069ac556 100644 --- a/cli/__snapshots__/cli_spec.js +++ b/cli/__snapshots__/cli_spec.js @@ -69,7 +69,7 @@ exports['shows help for run --foo 1'] = ` -e, --env sets environment variables. separate multiple values with a comma. overrides any value in cypress.json or cypress.env.json --group a named group for recorded runs in the Cypress Dashboard -k, --key your secret Record Key. you can omit this if you set a CYPRESS_RECORD_KEY environment variable. - --headed displays the browser instead of running headlessly (defaults to true for Chrome-family browsers) + --headed displays the browser instead of running headlessly (defaults to true for Firefox and Chromium-family browsers) --headless hide the browser instead of running headed (defaults to true for Electron) --no-exit keep the browser open after tests finish --parallel enables concurrent runs and automatic load balancing of specs across multiple machines or processes diff --git a/cli/lib/cli.js b/cli/lib/cli.js index ecb7f3550157..7b611a2749e8 100644 --- a/cli/lib/cli.js +++ b/cli/lib/cli.js @@ -103,7 +103,7 @@ const descriptions = { forceInstall: 'force install the Cypress binary', global: 'force Cypress into global mode as if its globally installed', group: 'a named group for recorded runs in the Cypress Dashboard', - headed: 'displays the browser instead of running headlessly (defaults to true for Chrome-family browsers)', + headed: 'displays the browser instead of running headlessly (defaults to true for Firefox and Chromium-family browsers)', headless: 'hide the browser instead of running headed (defaults to true for Electron)', key: 'your secret Record Key. you can omit this if you set a CYPRESS_RECORD_KEY environment variable.', parallel: 'enables concurrent runs and automatic load balancing of specs across multiple machines or processes', diff --git a/cli/types/index.d.ts b/cli/types/index.d.ts index 7a1a3d877fed..099ea5f24ffd 100644 --- a/cli/types/index.d.ts +++ b/cli/types/index.d.ts @@ -33,6 +33,8 @@ // Cypress, cy, Log inherits EventEmitter. type EventEmitter2 = import("eventemitter2").EventEmitter2 +type Nullable = T | null + interface EventEmitter extends EventEmitter2 { proxyTo: (cy: Cypress.cy) => null emitMap: (eventName: string, args: any[]) => Array<(...args: any[]) => any> @@ -63,11 +65,21 @@ declare namespace Cypress { password: string } - type BrowserName = 'electron' | 'chrome' | 'chromium' | string + interface Backend { + /** + * Firefox only: Force Cypress to run garbage collection routines. + * No-op if not running in Firefox. + * + * @see https://on.cypress.io/firefox-gc-issue + */ + (task: 'firefox:force:gc'): Promise + } + + type BrowserName = 'electron' | 'chrome' | 'chromium' | 'firefox' | string - type BrowserChannel = 'stable' | 'canary' | 'beta' | 'dev' | string + type BrowserChannel = 'stable' | 'canary' | 'beta' | 'dev' | 'nightly' | string - type BrowserFamily = 'chromium' + type BrowserFamily = 'chromium' | 'firefox' /** * Describes a browser Cypress can control @@ -224,6 +236,11 @@ declare namespace Cypress { */ LocalStorage: LocalStorage + /** + * Promise wrapper for certain internal tasks. + */ + backend: Backend + /** * Returns all configuration objects. * @see https://on.cypress.io/config @@ -298,6 +315,15 @@ declare namespace Cypress { */ env(object: ObjectLike): void + /** + * Firefox only: Get the current number of tests that will run between forced garbage collections. + * + * Returns undefined if not in Firefox, returns a null or 0 if forced GC is disabled. + * + * @see https://on.cypress.io/firefox-gc-issue + */ + getFirefoxGcInterval(): number | null | undefined + /** * Checks if a variable is a valid instance of `cy` or a `cy` chainable. * @@ -308,6 +334,13 @@ declare namespace Cypress { isCy(obj: Chainable): obj is Chainable isCy(obj: any): obj is Chainable + /** + * Checks if you're running in the supplied browser family. + * e.g. isBrowser('Chrome') will be true for the browser 'Canary' + * @param name browser family name to check + */ + isBrowser(name: string): boolean + /** * Internal options for "cy.log" used in custom commands. * @@ -1113,7 +1146,7 @@ declare namespace Cypress { parentsUntil(element: E | JQuery, filter?: string, options?: Partial): Chainable> /** - * Stop cy commands from running and allow interaction with the application under test. You can then “resume” running all commands or choose to step through the “next” commands from the Command Log. + * Stop cy commands from running and allow interaction with the application under test. You can then "resume" running all commands or choose to step through the "next" commands from the Command Log. * This does not set a `debugger` in your code, unlike `.debug()` * * @see https://on.cypress.io/pause @@ -1256,7 +1289,7 @@ declare namespace Cypress { * Get the root DOM element. * The root element yielded is `` by default. * However, when calling `.root()` from a `.within()` command, - * the root element will point to the element you are “within”. + * the root element will point to the element you are "within". * * @see https://on.cypress.io/root */ @@ -2239,6 +2272,13 @@ declare namespace Cypress { * @default true */ waitForAnimations: boolean + /** + * Firefox-only: The number of tests that will run between forced garbage collections. + * If a number is supplied, it will apply to `run` mode and `open` mode. + * Set the interval to `null` or 0 to disable forced garbage collections. + * @default { runMode: 1, openMode: null } + */ + firefoxGcInterval: Nullable, openMode: Nullable }> } interface DebugOptions { @@ -4434,6 +4474,10 @@ declare namespace Cypress { * @see https://on.cypress.io/catalog-of-events#App-Events */ (action: 'test:before:run', fn: (attributes: ObjectLike, test: Mocha.ITest) => void): void + /** + * Fires before the test and all **before** and **beforeEach** hooks run. If a `Promise` is returned, it will be awaited before proceeding. + */ + (action: 'test:before:run:async', fn: (attributes: ObjectLike, test: Mocha.ITest) => void | Promise): void /** * Fires after the test and all **afterEach** and **after** hooks run. * @see https://on.cypress.io/catalog-of-events#App-Events @@ -4446,6 +4490,7 @@ declare namespace Cypress { logs(filters: any): any add(obj: any): any get(): any + get(key: string): CommandQueue[K] toJSON(): string[] create(): CommandQueue } diff --git a/package.json b/package.json index b4d905fdba78..447e2991861b 100644 --- a/package.json +++ b/package.json @@ -92,8 +92,8 @@ "@types/react-dom": "16.9.4", "@types/request-promise": "4.1.45", "@types/sinon-chai": "3.2.3", - "@typescript-eslint/eslint-plugin": "1.13.0", - "@typescript-eslint/parser": "1.13.0", + "@typescript-eslint/eslint-plugin": "2.14.0", + "@typescript-eslint/parser": "2.14.0", "ansi-styles": "3.2.1", "arg": "4.1.2", "ascii-table": "0.0.9", @@ -167,7 +167,7 @@ "terminal-banner": "1.1.0", "through": "2.3.8", "ts-node": "8.3.0", - "typescript": "3.5.3", + "typescript": "3.7.4", "vinyl-paths": "2.1.0" }, "engines": { diff --git a/packages/desktop-gui/cypress/integration/project_nav_spec.js b/packages/desktop-gui/cypress/integration/project_nav_spec.js index 4ff13c741eb2..65f731633980 100644 --- a/packages/desktop-gui/cypress/integration/project_nav_spec.js +++ b/packages/desktop-gui/cypress/integration/project_nav_spec.js @@ -148,6 +148,11 @@ describe('Project Nav', function () { }) }) + it('shows beta text for firefox', function () { + cy.get('.browsers li').contains('Firefox') + .contains('beta') + }) + it('shows info icon with tooltip for browsder with info', function () { const browserWithInfo = _.find(this.config.browsers, (b) => !!b.info) @@ -252,7 +257,7 @@ describe('Project Nav', function () { const browserArg = this.ipc.launchBrowser.getCall(0).args[0].browser expect(browserArg).to.have.keys([ - 'family', 'name', 'path', 'version', 'majorVersion', 'displayName', 'info', 'isChosen', 'custom', 'warning', + 'family', 'name', 'path', 'version', 'majorVersion', 'displayName', 'info', 'isChosen', 'custom', 'warning', 'channel', ]) expect(browserArg.path).to.include('/') @@ -412,6 +417,34 @@ describe('Project Nav', function () { localStorage.setItem('chosenBrowser', 'Custom') cy.get('.browsers-list .dropdown-chosen') .should('contain', 'Custom') + + cy.wrap(localStorage.getItem('chosenBrowser')).should('equal', 'Custom') + }) + }) + + describe('browser with info', function () { + beforeEach(function () { + this.info = 'foo info bar [baz](http://example.com/)' + this.config.browsers = [{ + name: 'electron', + family: 'electron', + displayName: 'Electron', + version: '50.0.2661.86', + path: '', + majorVersion: '50', + info: this.info, + }] + + this.openProject.resolve(this.config) + }) + + it('shows info icon with linkified tooltip', function () { + cy.get('.browsers .fa-info-circle').trigger('mouseover') + + cy.get('.cy-tooltip').should('contain', 'foo info bar baz') + cy.get('.cy-tooltip a').should('have.text', 'baz').click().then(function () { + expect(this.ipc.externalOpen).to.be.calledWith('http://example.com/') + }) }) }) }) diff --git a/packages/desktop-gui/cypress/integration/specs_list_spec.js b/packages/desktop-gui/cypress/integration/specs_list_spec.js index 93f3dfca99e4..24a4c1e9e7cb 100644 --- a/packages/desktop-gui/cypress/integration/specs_list_spec.js +++ b/packages/desktop-gui/cypress/integration/specs_list_spec.js @@ -161,16 +161,16 @@ describe('Specs List', function () { context('run all specs', function () { it('displays run all specs button', () => { - cy.contains('.btn', 'Run all specs') + cy.contains('.all-tests', 'Run all specs') }) it('has play icon', () => { - cy.contains('.btn', 'Run all specs') + cy.contains('.all-tests', 'Run all specs') .find('i').should('have.class', 'fa-play') }) it('triggers browser launch on click of button', () => { - cy.contains('.btn', 'Run all specs').click() + cy.contains('.all-tests', 'Run all specs').click() .then(function () { const launchArgs = this.ipc.launchBrowser.lastCall.args @@ -182,7 +182,7 @@ describe('Specs List', function () { describe('all specs running in browser', function () { beforeEach(() => { - cy.contains('.btn', 'Run all specs').as('allSpecs').click() + cy.contains('.all-tests', 'Run all specs').as('allSpecs').click() }) it('updates spec icon', function () { diff --git a/packages/desktop-gui/src/app/nav.scss b/packages/desktop-gui/src/app/nav.scss index d2bdcfba85d4..04affb922055 100644 --- a/packages/desktop-gui/src/app/nav.scss +++ b/packages/desktop-gui/src/app/nav.scss @@ -182,6 +182,10 @@ } } + .fa-check-circle, .fa-sync-alt { + margin-right: 3px; + } + .browser-icon { color: #4573d9; margin-right: 3px; @@ -213,6 +217,14 @@ margin-right: 4px; } +.browser-beta { + font-size: 12px; + top: -5px; + position: relative; + margin-left: 4px; + color: #d87b0b; +} + .browser-info-tooltip { background: #ececec; border-color: #c7c7c7; @@ -230,6 +242,12 @@ .close-browser { .btn { - margin-top: 7px; + margin-top: 5px; + line-height: 28px; + padding: 0 9px 0 7px; + font-size: 13px; + i { + margin-right: 3px; + } } } diff --git a/packages/desktop-gui/src/lib/browser-model.js b/packages/desktop-gui/src/lib/browser-model.js index 44c8c667bc5b..7d61f78b5b12 100644 --- a/packages/desktop-gui/src/lib/browser-model.js +++ b/packages/desktop-gui/src/lib/browser-model.js @@ -4,6 +4,7 @@ export default class Browser { @observable displayName @observable name @observable family + @observable channel @observable version @observable path @observable majorVersion @@ -16,6 +17,7 @@ export default class Browser { this.displayName = browser.displayName this.name = browser.name this.family = browser.family + this.channel = browser.channel this.version = browser.version this.path = browser.path this.majorVersion = browser.majorVersion diff --git a/packages/desktop-gui/src/project-nav/browsers.jsx b/packages/desktop-gui/src/project-nav/browsers.jsx index 64b336f2aac3..1c6e689ea1d6 100644 --- a/packages/desktop-gui/src/project-nav/browsers.jsx +++ b/packages/desktop-gui/src/project-nav/browsers.jsx @@ -60,10 +60,10 @@ export default class Browsers extends Component { let prefixText if (project.browserState === 'opening') { - icon = + icon = prefixText = 'Opening' } else if (project.browserState === 'opened') { - icon = + icon = prefixText = 'Running' } else { icon = @@ -76,13 +76,7 @@ export default class Browsers extends Component { {prefixText}{' '} {browser.displayName}{' '} {browser.majorVersion} - {browser.family === 'firefox' && - beta} + {browser.family === 'firefox' && beta} {this._info(browser)} {this._warn(browser)} diff --git a/packages/desktop-gui/src/specs/specs-list.jsx b/packages/desktop-gui/src/specs/specs-list.jsx index 927092402140..98a654708618 100644 --- a/packages/desktop-gui/src/specs/specs-list.jsx +++ b/packages/desktop-gui/src/specs/specs-list.jsx @@ -46,7 +46,7 @@ class SpecsList extends Component { - + {' '} {allSpecsSpec.displayName} diff --git a/packages/desktop-gui/src/styles/components/_general.scss b/packages/desktop-gui/src/styles/components/_general.scss index c15080368c9d..8e4c7d8d5f50 100644 --- a/packages/desktop-gui/src/styles/components/_general.scss +++ b/packages/desktop-gui/src/styles/components/_general.scss @@ -70,19 +70,19 @@ outline: 0 !important; } -.fa.green { +i.green { color: #028863; } -.fa.blue { +i.blue { color: #3454c1; } -.fa.red { +i.red { color: $red-primary; } -.fa.orange { +i.orange { color: #F5A327; } diff --git a/packages/driver/package.json b/packages/driver/package.json index 3d87332d1f69..a17d05caf495 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -64,7 +64,7 @@ "url-parse": "1.4.7", "vanilla-text-mask": "5.1.1", "wait-on": "3.3.0", - "webpack": "4.41.0", + "webpack": "4.41.2", "zone.js": "0.9.0" }, "files": [ diff --git a/packages/driver/src/cy/chai.js b/packages/driver/src/cy/chai.js index e5bcdfbd36c8..607d0690097d 100644 --- a/packages/driver/src/cy/chai.js +++ b/packages/driver/src/cy/chai.js @@ -1,3 +1,4 @@ +/* eslint-disable prefer-rest-params */ // tests in driver/test/cypress/integration/commands/assertions_spec.coffee const _ = require('lodash') @@ -8,6 +9,7 @@ const sinonChai = require('@cypress/sinon-chai') const $dom = require('../dom') const $utils = require('../cypress/utils') const $chaiJquery = require('../cypress/chai_jquery') +const chaiInspect = require('./chai/inspect') // all words between single quotes const allPropertyWordsBetweenSingleQuotes = /('.*?')/g @@ -35,6 +37,12 @@ let chaiUtils = null chai.use(sinonChai) +const getType = function (val) { + const match = /\[object (.*)\]/.exec(Object.prototype.toString.call(val)) + + return match && match[1] +} + chai.use((chai, u) => { chaiUtils = u @@ -76,12 +84,26 @@ chai.use((chai, u) => { matchProto = chai.Assertion.prototype.match lengthProto = chai.Assertion.prototype.__methods.length.method containProto = chai.Assertion.prototype.__methods.contain.method - existProto = Object.getOwnPropertyDescriptor(chai.Assertion.prototype, 'exist').get; - ({ getMessage } = chaiUtils) + existProto = Object.getOwnPropertyDescriptor(chai.Assertion.prototype, 'exist').get + const { objDisplay } = chai.util; + + ({ getMessage } = chai.util) + const _inspect = chai.util.inspect + + const { inspect, setFormatValueHook } = chaiInspect.create(chai) + + // prevent tunneling into Window objects (can throw cross-origin errors in firefox) + setFormatValueHook((ctx, val) => { + if (val && (getType(val) === 'Window')) { + return '[window]' + } + }) // remove any single quotes between our **, // except escaped quotes, empty strings and number strings. const removeOrKeepSingleQuotesBetweenStars = (message) => { + // remove any single quotes between our **, preserving escaped quotes + // and if an empty string, put the quotes back return message.replace(allBetweenFourStars, (match) => { if (valueHasLeadingOrTrailingWhitespaces.test(match)) { // Above we used \s+, but below we use \s*. @@ -123,12 +145,14 @@ chai.use((chai, u) => { } return memo - }, []) + } + , []) } const restoreAsserts = function () { - chaiUtils.getMessage = getMessage - + chai.util.inspect = _inspect + chai.util.getMessage = getMessage + chai.util.objDisplay = objDisplay chai.Assertion.prototype.assert = assertProto chai.Assertion.prototype.match = matchProto chai.Assertion.prototype.__methods.length.method = lengthProto @@ -137,9 +161,72 @@ chai.use((chai, u) => { return Object.defineProperty(chai.Assertion.prototype, 'exist', { get: existProto }) } + const overrideChaiInspect = () => { + return chai.util.inspect = inspect + } + + const overrideChaiObjDisplay = () => { + return chai.util.objDisplay = function (obj) { + const str = chai.util.inspect(obj) + const type = Object.prototype.toString.call(obj) + + if (chai.config.truncateThreshold && (str.length >= chai.config.truncateThreshold)) { + if (type === '[object Function]') { + if (!obj.name || (obj.name === '')) { + return '[Function]' + } + + return `[Function: ${obj.name}]` + } + + if (type === '[object Array]') { + return `[ Array(${obj.length}) ]` + } + + if (type === '[object Object]') { + const keys = Object.keys(obj) + const kstr = keys.length > 2 ? `${keys.splice(0, 2).join(', ')}, ...` : keys.join(', ') + + return `{ Object (${kstr}) }` + } + + return str + } + + return str + } + } + const overrideChaiAsserts = function (assertFn) { chai.Assertion.prototype.assert = createPatchedAssert(assertFn) + const _origGetmessage = function (obj, args) { + const negate = chaiUtils.flag(obj, 'negate') + const val = chaiUtils.flag(obj, 'object') + const expected = args[3] + const actual = chaiUtils.getActual(obj, args) + let msg = (negate ? args[2] : args[1]) + const flagMsg = chaiUtils.flag(obj, 'message') + + if (typeof msg === 'function') { + msg = msg() + } + + msg = msg || '' + msg = msg + .replace(/#\{this\}/g, () => { + return chaiUtils.objDisplay(val) + }) + .replace(/#\{act\}/g, () => { + return chaiUtils.objDisplay(actual) + }) + .replace(/#\{exp\}/g, () => { + return chaiUtils.objDisplay(expected) + }) + + return (flagMsg ? `${flagMsg}: ${msg}` : msg) + } + chaiUtils.getMessage = function (assert, args) { const obj = assert._obj @@ -149,7 +236,7 @@ chai.use((chai, u) => { assert._obj = $dom.stringify(obj, 'short') } - const msg = getMessage.call(this, assert, args) + const msg = _origGetmessage.call(this, assert, args) // restore the real obj if we changed it if (obj !== assert._obj) { @@ -160,9 +247,9 @@ chai.use((chai, u) => { } chai.Assertion.overwriteMethod('match', (_super) => { - return (function (regExp, ...args) { + return (function (regExp) { if (_.isRegExp(regExp) || $dom.isDom(this._obj)) { - return _super.apply(this, [regExp, ...args]) + return _super.apply(this, arguments) } const err = $utils.cypressErr($utils.errMessageByPath('chai.match_invalid_argument', { regExp })) @@ -173,11 +260,11 @@ chai.use((chai, u) => { }) const containFn1 = (_super) => { - return (function (text, ...args) { + return (function (text) { let obj = this._obj if (!($dom.isJquery(obj) || $dom.isElement(obj))) { - return _super.apply(this, [text, ...args]) + return _super.apply(this, arguments) } const escText = $utils.escapeQuotes(text) @@ -200,8 +287,8 @@ chai.use((chai, u) => { } const containFn2 = (_super) => { - return (function (...args) { - _super.apply(this, args) + return (function () { + return _super.apply(this, arguments) }) } @@ -209,11 +296,11 @@ chai.use((chai, u) => { chai.Assertion.overwriteChainableMethod('length', (_super) => { - return (function (length, ...args) { + return (function (length) { let obj = this._obj if (!($dom.isJquery(obj) || $dom.isElement(obj))) { - return _super.apply(this, [length, ...args]) + return _super.apply(this, arguments) } length = $utils.normalizeNumber(length) @@ -263,19 +350,20 @@ chai.use((chai, u) => { } }) }, + (_super) => { - return (function (...args) { - return _super.apply(this, args) + return (function () { + return _super.apply(this, arguments) }) }) return chai.Assertion.overwriteProperty('exist', (_super) => { - return (function (...args) { + return (function () { const obj = this._obj if (!($dom.isJquery(obj) || $dom.isElement(obj))) { try { - return _super.apply(this, args) + return _super.apply(this, arguments) } catch (e) { e.type = 'existence' throw e @@ -347,12 +435,12 @@ chai.use((chai, u) => { }) } - // only override assertions for this specific - // expect function instance so we do not affect - // the outside world const overrideExpect = () => { - // make the assertion + // only override assertions for this specific + // expect function instance so we do not affect + // the outside world return (val, message) => { + // make the assertion return new chai.Assertion(val, message) } } @@ -365,8 +453,8 @@ chai.use((chai, u) => { const fns = _.functions(chai.assert) _.each(fns, (name) => { - return fn[name] = function (...args) { - return chai.assert[name].apply(this, args) + return fn[name] = function () { + return chai.assert[name].apply(this, arguments) } }) @@ -392,7 +480,8 @@ chai.use((chai, u) => { // restoreOverrides() restoreAsserts() - // overrideChai() + overrideChaiInspect() + overrideChaiObjDisplay() overrideChaiAsserts(assertFn) return setSpecWindowGlobals(specWindow) diff --git a/packages/driver/src/cy/chai/inspect.js b/packages/driver/src/cy/chai/inspect.js new file mode 100644 index 000000000000..73fbf0aff76f --- /dev/null +++ b/packages/driver/src/cy/chai/inspect.js @@ -0,0 +1,431 @@ +// Changes made: added 'formatValueHook' to process value before being formatted. +// For example the hook can be used to turn `window` objects into the string '[window]' +// to avoid deep recursion. + +// This is (almost) directly from chai/lib/util (which is based on nodejs utils) +// https://github.com/joyent/node/blob/f8c335d0caf47f16d31413f89aa28eda3878e3aa/lib/util.js + +// let getName = require('get-func-name') +// let getProperties = require('./getProperties') +let getEnumerableProperties = require('chai/lib/chai/utils/getEnumerableProperties') +// let config = require('../config') + +module.exports = { + + create (chai) { + const { getName, getProperties } = chai.util + const { config } = chai + + /** + * ### .inspect(obj, [showHidden], [depth], [colors]) + * + * Echoes the value of a value. Tries to print the value out + * in the best way possible given the different types. + * + * @param {Object} obj The object to print out. + * @param {Boolean} showHidden Flag that shows hidden (not enumerable) + * properties of objects. Default is false. + * @param {Number} depth Depth in which to descend in object. Default is 2. + * @param {Boolean} colors Flag to turn on ANSI escape codes to color the + * output. Default is false (no coloring). + * @namespace Utils + * @name inspect + */ + function inspect (obj, showHidden, depth, colors) { + let ctx = { + showHidden, + seen: [], + stylize (str) { + return str + }, + } + + return formatValue(ctx, obj, (typeof depth === 'undefined' ? 2 : depth)) + } + + // Returns true if object is a DOM element. + let isDOMElement = function (object) { + if (typeof HTMLElement === 'object') { + return object instanceof HTMLElement + } + + return object && + typeof object === 'object' && + 'nodeType' in object && + object.nodeType === 1 && + typeof object.nodeName === 'string' + } + + let formatValueHook + + const setFormatValueHook = (fn) => formatValueHook = fn + + function formatValue (ctx, value, recurseTimes) { + // Provide a hook for user-specified inspect functions. + // Check that value is an object with an inspect function on it + + const hookRet = formatValueHook && formatValueHook(ctx, value) + + if (hookRet) { + return hookRet + } + + if (value && typeof value.inspect === 'function' && + // Filter out the util module, it's inspect function is special + value.inspect !== exports.inspect && + // Also filter out any prototype objects using the circular check. + !(value.constructor && value.constructor.prototype === value)) { + let ret = value.inspect(recurseTimes, ctx) + + if (typeof ret !== 'string') { + ret = formatValue(ctx, ret, recurseTimes) + } + + return ret + } + + // Primitive types cannot have properties + let primitive = formatPrimitive(ctx, value) + + if (primitive) { + return primitive + } + + // If this is a DOM element, try to get the outer HTML. + if (isDOMElement(value)) { + if ('outerHTML' in value) { + return value.outerHTML + // This value does not have an outerHTML attribute, + // it could still be an XML element + } + + // Attempt to serialize it + try { + if (document.xmlVersion) { + let xmlSerializer = new XMLSerializer() + + return xmlSerializer.serializeToString(value) + } + + // Firefox 11- do not support outerHTML + // It does, however, support innerHTML + // Use the following to render the element + let ns = 'http://www.w3.org/1999/xhtml' + let container = document.createElementNS(ns, '_') + + container.appendChild(value.cloneNode(false)) + let html = container.innerHTML + .replace('><', `>${value.innerHTML}<`) + + container.innerHTML = '' + + return html + } catch (err) { + // This could be a non-native DOM implementation, + // continue with the normal flow: + // printing the element as if it is an object. + } + } + + // Look up the keys of the object. + let visibleKeys = getEnumerableProperties(value) + let keys = ctx.showHidden ? getProperties(value) : visibleKeys + + let name; let nameSuffix + + // Some type of object without properties can be shortcut. + // In IE, errors have a single `stack` property, or if they are vanilla `Error`, + // a `stack` plus `description` property; ignore those for consistency. + if (keys.length === 0 || (isError(value) && ( + (keys.length === 1 && keys[0] === 'stack') || + (keys.length === 2 && keys[0] === 'description' && keys[1] === 'stack') + ))) { + if (typeof value === 'function') { + name = getName(value) + nameSuffix = name ? `: ${name}` : '' + + return ctx.stylize(`[Function${nameSuffix}]`, 'special') + } + + if (isRegExp(value)) { + return ctx.stylize(RegExp.prototype.toString.call(value), 'regexp') + } + + if (isDate(value)) { + return ctx.stylize(Date.prototype.toUTCString.call(value), 'date') + } + + if (isError(value)) { + return formatError(value) + } + } + + let base = '' + let array = false + let typedArray = false + let braces = ['{', '}'] + + if (isTypedArray(value)) { + typedArray = true + braces = ['[', ']'] + } + + // Make Array say that they are Array + if (isArray(value)) { + array = true + braces = ['[', ']'] + } + + // Make functions say that they are functions + if (typeof value === 'function') { + name = getName(value) + nameSuffix = name ? `: ${name}` : '' + base = ` [Function${nameSuffix}]` + } + + // Make RegExps say that they are RegExps + if (isRegExp(value)) { + base = ` ${RegExp.prototype.toString.call(value)}` + } + + // Make dates with properties first say the date + if (isDate(value)) { + base = ` ${Date.prototype.toUTCString.call(value)}` + } + + // Make error with message first say the error + if (isError(value)) { + return formatError(value) + } + + // eslint-disable-next-line eqeqeq + if (keys.length === 0 && (!array || value.length == 0)) { + return braces[0] + base + braces[1] + } + + if (recurseTimes < 0) { + if (isRegExp(value)) { + return ctx.stylize(RegExp.prototype.toString.call(value), 'regexp') + } + + return ctx.stylize('[Object]', 'special') + } + + ctx.seen.push(value) + + let output + + if (array) { + output = formatArray(ctx, value, recurseTimes, visibleKeys, keys) + } else if (typedArray) { + return formatTypedArray(value) + } else { + output = keys.map(function (key) { + return formatProperty(ctx, value, recurseTimes, visibleKeys, key, array) + }) + } + + ctx.seen.pop() + + return reduceToSingleString(output, base, braces) + } + + function formatPrimitive (ctx, value) { + switch (typeof value) { + case 'undefined': + return ctx.stylize('undefined', 'undefined') + + case 'string': { + const simple = `'${JSON.stringify(value).replace(/^"|"$/g, '') + .replace(/'/g, '\\\'') + .replace(/\\"/g, '"')}'` + + return ctx.stylize(simple, 'string') + } + + case 'number': + if (value === 0 && (1 / value) === -Infinity) { + return ctx.stylize('-0', 'number') + } + + return ctx.stylize(`${value}`, 'number') + + case 'boolean': + return ctx.stylize(`${value}`, 'boolean') + + case 'symbol': + return ctx.stylize(value.toString(), 'symbol') + default: + null + } + // For some reason typeof null is "object", so special case here. + if (value === null) { + return ctx.stylize('null', 'null') + } + } + + function formatError (value) { + return `[${Error.prototype.toString.call(value)}]` + } + + function formatArray (ctx, value, recurseTimes, visibleKeys, keys) { + let output = [] + + for (let i = 0, l = value.length; i < l; ++i) { + if (Object.prototype.hasOwnProperty.call(value, String(i))) { + output.push(formatProperty(ctx, value, recurseTimes, visibleKeys, + String(i), true)) + } else { + output.push('') + } + } + + keys.forEach(function (key) { + if (!key.match(/^\d+$/)) { + output.push(formatProperty(ctx, value, recurseTimes, visibleKeys, + key, true)) + } + }) + + return output + } + + function formatTypedArray (value) { + let str = '[ ' + + for (let i = 0; i < value.length; ++i) { + if (str.length >= config.truncateThreshold - 7) { + str += '...' + break + } + + str += `${value[i]}, ` + } + str += ' ]' + + // Removing trailing `, ` if the array was not truncated + if (str.indexOf(', ]') !== -1) { + str = str.replace(', ]', ' ]') + } + + return str + } + + function formatProperty (ctx, value, recurseTimes, visibleKeys, key, array) { + let name + let propDescriptor = Object.getOwnPropertyDescriptor(value, key) + let str + + if (propDescriptor) { + if (propDescriptor.get) { + if (propDescriptor.set) { + str = ctx.stylize('[Getter/Setter]', 'special') + } else { + str = ctx.stylize('[Getter]', 'special') + } + } else { + if (propDescriptor.set) { + str = ctx.stylize('[Setter]', 'special') + } + } + } + + if (visibleKeys.indexOf(key) < 0) { + name = `[${key}]` + } + + if (!str) { + if (ctx.seen.indexOf(value[key]) < 0) { + if (recurseTimes === null) { + str = formatValue(ctx, value[key], null) + } else { + str = formatValue(ctx, value[key], recurseTimes - 1) + } + + if (str.indexOf('\n') > -1) { + if (array) { + str = str.split('\n').map(function (line) { + return ` ${line}` + }).join('\n').substr(2) + } else { + str = `\n${str.split('\n').map(function (line) { + return ` ${line}` + }).join('\n')}` + } + } + } else { + str = ctx.stylize('[Circular]', 'special') + } + } + + if (typeof name === 'undefined') { + if (array && key.match(/^\d+$/)) { + return str + } + + name = JSON.stringify(`${key}`) + if (name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)) { + name = name.substr(1, name.length - 2) + name = ctx.stylize(name, 'name') + } else { + name = name.replace(/'/g, '\\\'') + .replace(/\\"/g, '"') + .replace(/(^"|"$)/g, '\'') + + name = ctx.stylize(name, 'string') + } + } + + return `${name}: ${str}` + } + + function reduceToSingleString (output, base, braces) { + let length = output.reduce(function (prev, cur) { + return prev + cur.length + 1 + }, 0) + + if (length > 60) { + return `${braces[0] + + (base === '' ? '' : `${base}\n `) + } ${ + output.join(',\n ') + } ${ + braces[1]}` + } + + return `${braces[0] + base} ${output.join(', ')} ${braces[1]}` + } + + function isTypedArray (ar) { + // Unfortunately there's no way to check if an object is a TypedArray + // We have to check if it's one of these types + return (typeof ar === 'object' && /\w+Array]$/.test(objectToString(ar))) + } + + function isArray (ar) { + return Array.isArray(ar) || + (typeof ar === 'object' && objectToString(ar) === '[object Array]') + } + + function isRegExp (re) { + return typeof re === 'object' && objectToString(re) === '[object RegExp]' + } + + function isDate (d) { + return typeof d === 'object' && objectToString(d) === '[object Date]' + } + + function isError (e) { + return typeof e === 'object' && objectToString(e) === '[object Error]' + } + + function objectToString (o) { + return Object.prototype.toString.call(o) + } + + return { + inspect, + setFormatValueHook, + } + }, +} diff --git a/packages/driver/src/cy/commands/cookies.js b/packages/driver/src/cy/commands/cookies.js index 615527225511..ae2f04946f51 100644 --- a/packages/driver/src/cy/commands/cookies.js +++ b/packages/driver/src/cy/commands/cookies.js @@ -80,6 +80,10 @@ module.exports = function (Commands, Cypress, cy, state, config) { const handleBackendError = (command, action, onFail) => { return (err) => { + if (!_.includes(err.stack, err.message)) { + err.stack = `${err.message}\n${err.stack}` + } + if (err.name === 'CypressError') { throw err } diff --git a/packages/driver/src/cy/commands/navigation.coffee b/packages/driver/src/cy/commands/navigation.coffee index cec1898e2f56..1bb587a038f1 100644 --- a/packages/driver/src/cy/commands/navigation.coffee +++ b/packages/driver/src/cy/commands/navigation.coffee @@ -8,6 +8,8 @@ $utils = require("../../cypress/utils") $Log = require("../../cypress/log") $Location = require("../../cypress/location") +debug = require('debug')('cypress:driver:navigation') + id = null previousDomainVisited = null hasVisitedAboutBlank = null @@ -42,6 +44,7 @@ isValidVisitMethod = (method) -> _.includes(VALID_VISIT_METHODS, method) timedOutWaitingForPageLoad = (ms, log) -> + debug('timedOutWaitingForPageLoad') $utils.throwErrByPath("navigation.timed_out", { args: { configFile: Cypress.config("configFile") @@ -91,6 +94,7 @@ aboutBlank = (win) -> navigationChanged = (Cypress, cy, state, source, arg) -> ## get the current url of our remote application url = cy.getRemoteLocation("href") + debug('navigation changed:', url) ## dont trigger for empty url's or about:blank return if _.isEmpty(url) or url is "about:blank" @@ -164,6 +168,7 @@ pageLoading = (bool, state) -> Cypress.action("app:page:loading", bool) stabilityChanged = (Cypress, state, config, stable, event) -> + debug('stabilityChanged:', stable) if currentlyVisitingAboutBlank if stable is false ## if we're currently visiting about blank @@ -235,6 +240,7 @@ stabilityChanged = (Cypress, state, config, stable, event) -> state("onPageLoadErr", onPageLoadErr) loading = -> + debug('waiting for window:load') new Promise (resolve, reject) -> cy.once "window:load", -> cy.state("onPageLoadErr", null) @@ -793,6 +799,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> error: err stack: err.stack } + noStackTrace: true }) visit = -> diff --git a/packages/driver/src/cy/commands/request.coffee b/packages/driver/src/cy/commands/request.coffee index 11bdddfe4335..2181ae0a8043 100644 --- a/packages/driver/src/cy/commands/request.coffee +++ b/packages/driver/src/cy/commands/request.coffee @@ -269,6 +269,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> stack: err.stack method: requestOpts.method url: requestOpts.url - } + }, + noStackTrace: true }) }) diff --git a/packages/driver/src/cy/commands/task.coffee b/packages/driver/src/cy/commands/task.coffee index 88fad566a035..6549fcc0dd59 100644 --- a/packages/driver/src/cy/commands/task.coffee +++ b/packages/driver/src/cy/commands/task.coffee @@ -63,6 +63,8 @@ module.exports = (Commands, Cypress, cy, state, config) -> ## re-throw if timedOut error from above throw error if error.name is "CypressError" + $utils.normalizeErrorStack(error) + if error?.isKnownError $utils.throwErrByPath("task.known_error", { onFail: options._log diff --git a/packages/driver/src/cy/errors.coffee b/packages/driver/src/cy/errors.coffee index beb2d1bcbd21..77fd16effc9f 100644 --- a/packages/driver/src/cy/errors.coffee +++ b/packages/driver/src/cy/errors.coffee @@ -55,6 +55,9 @@ create = (state, config, log) -> if l = current and current.getLastLog() l.error(err) + ## normalize error message for firefox + $utils.normalizeErrorStack(err) + return err commandRunningFailed = (err) -> diff --git a/packages/driver/src/cy/keyboard.ts b/packages/driver/src/cy/keyboard.ts index 658633b1efbf..ba8338adea8a 100644 --- a/packages/driver/src/cy/keyboard.ts +++ b/packages/driver/src/cy/keyboard.ts @@ -37,7 +37,7 @@ interface KeyDetailsPartial extends Partial { type SimulatedDefault = ( el: HTMLElement, key: KeyDetails, - options: any + options: typeOptions ) => void interface KeyDetails { @@ -236,7 +236,7 @@ const shouldIgnoreEvent = < return options[eventName] === false } -const shouldUpdateValue = (el: HTMLElement, key: KeyDetails, options) => { +const shouldUpdateValue = (el: HTMLElement, key: KeyDetails, options: typeOptions) => { if (!key.text) return false const bounds = $selection.getSelectionBounds(el) @@ -250,7 +250,7 @@ const shouldUpdateValue = (el: HTMLElement, key: KeyDetails, options) => { const isNumberInputType = $elements.isInput(el) && $elements.isInputType(el, 'number') if (isNumberInputType) { - const needsValue = options.prevVal || '' + const needsValue = options.prevValue || '' const needsValueLength = (needsValue && needsValue.length) || 0 const curVal = $elements.getNativeProp(el, 'value') const bounds = $selection.getSelectionBounds(el) @@ -269,13 +269,13 @@ const shouldUpdateValue = (el: HTMLElement, key: KeyDetails, options) => { return } - options.prevVal = needsValue + key.text + options.prevValue = needsValue + key.text return } - key.text = (options.prevVal || '') + key.text - options.prevVal = null + key.text = (options.prevValue || '') + key.text + options.prevValue = undefined } if (noneSelected) { @@ -491,7 +491,7 @@ const simulatedDefaultKeyMap: { [key: string]: SimulatedDefault } = { $selection.replaceSelectionContents(el, '\n') } - options.onEnterPressed() + options.onEnterPressed && options.onEnterPressed() }, Delete: (el, key) => { key.events.input = $selection.deleteRightOfCursor(el) @@ -600,6 +600,8 @@ export interface typeOptions { onEnterPressed?: Function onNoMatchingSpecialChars?: Function onBeforeSpecialCharAction?: Function + prevValue?: string + id?: string } export class Keyboard { @@ -776,11 +778,7 @@ export class Keyboard { el: HTMLElement, eventType: KeyEventType, keyDetails: KeyDetails, - opts: { - id: string - onEvent?: (...args) => boolean - onBeforeEvent?: (...args) => boolean - } + opts: typeOptions ) { debug('fireSimulatedEvent', eventType, keyDetails) @@ -883,8 +881,7 @@ export class Keyboard { let event: Event debug('event options:', eventType, eventOptions) - - if (eventConstructor === 'TextEvent') { + if (eventConstructor === 'TextEvent' && win[eventConstructor]) { event = document.createEvent('TextEvent') // @ts-ignore event.initTextEvent( @@ -965,7 +962,7 @@ export class Keyboard { return true } - simulatedKeydown (el: HTMLElement, _key: KeyDetails, options: any) { + simulatedKeydown (el: HTMLElement, _key: KeyDetails, options: typeOptions) { if (isModifier(_key)) { const didFlag = this.flagModifier(_key) @@ -1050,7 +1047,7 @@ export class Keyboard { this.simulatedKeyup(elToKeyup, key, options) } - simulatedKeyup (el: HTMLElement, _key: KeyDetails, options: any) { + simulatedKeyup (el: HTMLElement, _key: KeyDetails, options: typeOptions) { if (shouldIgnoreEvent('keyup', _key.events)) { debug('simulatedKeyup: ignoring event') delete _key.events.keyup diff --git a/packages/driver/src/cy/mouse.js b/packages/driver/src/cy/mouse.js index 0aac7be11b4d..03ae308c6ec9 100644 --- a/packages/driver/src/cy/mouse.js +++ b/packages/driver/src/cy/mouse.js @@ -39,45 +39,141 @@ const getMouseCoords = (state) => { return state('mouseCoords') } -const shouldFireMouseMoveEvents = (targetEl, lastHoveredEl, fromElViewport, coords) => { - // not the same element, fire mouse move events - if (lastHoveredEl !== targetEl) { - return true +const create = (state, keyboard, focused, Cypress) => { + const isFirefox = Cypress.browser.family === 'firefox' + + const sendPointerEvent = (el, evtOptions, evtName, bubbles = false, cancelable = false) => { + const constructor = el.ownerDocument.defaultView.PointerEvent + + return sendEvent(evtName, el, evtOptions, bubbles, cancelable, constructor) } + const sendMouseEvent = (el, evtOptions, evtName, bubbles = false, cancelable = false) => { + // IE doesn't have event constructors, so you should use document.createEvent('mouseevent') + // https://dom.spec.whatwg.org/#dom-document-createevent + const constructor = el.ownerDocument.defaultView.MouseEvent - const xy = (obj) => { - return _.pick(obj, 'x', 'y') + return sendEvent(evtName, el, evtOptions, bubbles, cancelable, constructor) } - // if we have the same element, but the xy coords are different - // then fire mouse move events... - return !_.isEqual(xy(fromElViewport), xy(coords)) -} + const sendPointerup = (el, evtOptions) => { + if (isFirefox && el.disabled) { + return {} + } + + return sendPointerEvent(el, evtOptions, 'pointerup', true, true) + } + const sendPointerdown = (el, evtOptions) => { + if (isFirefox && el.disabled) { + return {} + } + + return sendPointerEvent(el, evtOptions, 'pointerdown', true, true) + } + const sendPointermove = (el, evtOptions) => { + return sendPointerEvent(el, evtOptions, 'pointermove', true, true) + } + const sendPointerover = (el, evtOptions) => { + return sendPointerEvent(el, evtOptions, 'pointerover', true, true) + } + const sendPointerenter = (el, evtOptions) => { + return sendPointerEvent(el, evtOptions, 'pointerenter', false, false) + } + const sendPointerleave = (el, evtOptions) => { + return sendPointerEvent(el, evtOptions, 'pointerleave', false, false) + } + const sendPointerout = (el, evtOptions) => { + return sendPointerEvent(el, evtOptions, 'pointerout', true, true) + } + + const sendMouseup = (el, evtOptions) => { + if (isFirefox && el.disabled) { + return {} + } -const shouldMoveCursorToEndAfterMousedown = (el) => { - if (!$elements.isElement(el)) { - return false + return sendMouseEvent(el, evtOptions, 'mouseup', true, true) } + const sendMousedown = (el, evtOptions) => { + if (isFirefox && el.disabled) { + return {} + } + + return sendMouseEvent(el, evtOptions, 'mousedown', true, true) + } + const sendMousemove = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'mousemove', true, true) + } + const sendMouseover = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'mouseover', true, true) + } + const sendMouseenter = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'mouseenter', false, false) + } + const sendMouseleave = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'mouseleave', false, false) + } + const sendMouseout = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'mouseout', true, true) + } + const sendClick = (el, evtOptions, opts = {}) => { + // send the click event if firefox and force (needed for force check checkbox) + if (!opts.force && isFirefox && el.disabled) { + return {} + } - if (!($elements.isInput(el) || $elements.isTextarea(el) || $elements.isContentEditable(el))) { - return false + return sendMouseEvent(el, evtOptions, 'click', true, true) } + const sendDblclick = (el, evtOptions) => { + if (isFirefox && el.disabled) { + return {} + } - if (!$elements.isFocused(el)) { - return false + return sendMouseEvent(el, evtOptions, 'dblclick', true, true) } + const sendContextmenu = (el, evtOptions) => { + if (isFirefox && el.disabled) { + return {} + } - if ($elements.isNeedSingleValueChangeInputElement(el)) { - return false + return sendMouseEvent(el, evtOptions, 'contextmenu', true, true) } + const shouldFireMouseMoveEvents = (targetEl, lastHoveredEl, fromElViewport, coords) => { + // not the same element, fire mouse move events + if (lastHoveredEl !== targetEl) { + return true + } - return true -} + const xy = (obj) => { + return _.pick(obj, 'x', 'y') + } + + // if we have the same element, but the xy coords are different + // then fire mouse move events... + return !_.isEqual(xy(fromElViewport), xy(coords)) + } + + const shouldMoveCursorToEndAfterMousedown = (el) => { + if (!$elements.isElement(el)) { + return false + } + + if (!($elements.isInput(el) || $elements.isTextarea(el) || $elements.isContentEditable(el))) { + return false + } + + if (!$elements.isFocused(el)) { + return false + } + + if ($elements.isNeedSingleValueChangeInputElement(el)) { + return false + } + + return true + } -const create = (state, keyboard, focused) => { const mouse = { _getDefaultMouseOptions (x, y, win) { - const _activeModifiers = keyboard.getActiveModifiers(state) + const _activeModifiers = keyboard.getActiveModifiers() const modifiersEventOptions = $Keyboard.toModifiersEventOptions(_activeModifiers) const coordsEventOptions = toCoordsEventOptions(x, y, win) @@ -514,6 +610,8 @@ const create = (state, keyboard, focused) => { mouse.moveToCoords(fromElViewport) } + el = forceEl || el + const win = $dom.getWindowByElement(el) const defaultOptions = mouse._getDefaultMouseOptions(fromElViewport.x, fromElViewport.y, win) @@ -523,7 +621,7 @@ const create = (state, keyboard, focused) => { detail: 1, }, mouseEvtOptionsExtend) - let click = sendClick(el, clickEventOptions) + let click = sendClick(el, clickEventOptions, { force: !!forceEl }) return { click } }, @@ -626,72 +724,6 @@ const sendEvent = (evtName, el, evtOptions, bubbles = false, cancelable = false, } } -const sendPointerEvent = (el, evtOptions, evtName, bubbles = false, cancelable = false) => { - const Constructor = el.ownerDocument.defaultView.PointerEvent - - return sendEvent(evtName, el, evtOptions, bubbles, cancelable, Constructor) -} -const sendMouseEvent = (el, evtOptions, evtName, bubbles = false, cancelable = false) => { - // TODO: IE doesn't have event constructors, so you should use document.createEvent('mouseevent') - // https://dom.spec.whatwg.org/#dom-document-createevent - const Constructor = el.ownerDocument.defaultView.MouseEvent - - return sendEvent(evtName, el, evtOptions, bubbles, cancelable, Constructor) -} - -const sendPointerup = (el, evtOptions) => { - return sendPointerEvent(el, evtOptions, 'pointerup', true, true) -} -const sendPointerdown = (el, evtOptions) => { - return sendPointerEvent(el, evtOptions, 'pointerdown', true, true) -} -const sendPointermove = (el, evtOptions) => { - return sendPointerEvent(el, evtOptions, 'pointermove', true, true) -} -const sendPointerover = (el, evtOptions) => { - return sendPointerEvent(el, evtOptions, 'pointerover', true, true) -} -const sendPointerenter = (el, evtOptions) => { - return sendPointerEvent(el, evtOptions, 'pointerenter', false, false) -} -const sendPointerleave = (el, evtOptions) => { - return sendPointerEvent(el, evtOptions, 'pointerleave', false, false) -} -const sendPointerout = (el, evtOptions) => { - return sendPointerEvent(el, evtOptions, 'pointerout', true, true) -} - -const sendMouseup = (el, evtOptions) => { - return sendMouseEvent(el, evtOptions, 'mouseup', true, true) -} -const sendMousedown = (el, evtOptions) => { - return sendMouseEvent(el, evtOptions, 'mousedown', true, true) -} -const sendMousemove = (el, evtOptions) => { - return sendMouseEvent(el, evtOptions, 'mousemove', true, true) -} -const sendMouseover = (el, evtOptions) => { - return sendMouseEvent(el, evtOptions, 'mouseover', true, true) -} -const sendMouseenter = (el, evtOptions) => { - return sendMouseEvent(el, evtOptions, 'mouseenter', false, false) -} -const sendMouseleave = (el, evtOptions) => { - return sendMouseEvent(el, evtOptions, 'mouseleave', false, false) -} -const sendMouseout = (el, evtOptions) => { - return sendMouseEvent(el, evtOptions, 'mouseout', true, true) -} -const sendClick = (el, evtOptions) => { - return sendMouseEvent(el, evtOptions, 'click', true, true) -} -const sendDblclick = (el, evtOptions) => { - return sendMouseEvent(el, evtOptions, 'dblclick', true, true) -} -const sendContextmenu = (el, evtOptions) => { - return sendMouseEvent(el, evtOptions, 'contextmenu', true, true) -} - const formatReasonNotFired = (reason) => { return `⚠️ not fired (${reason})` } diff --git a/packages/driver/src/cy/snapshots.coffee b/packages/driver/src/cy/snapshots.coffee index 72a5e0e277ee..0337e9a1e998 100644 --- a/packages/driver/src/cy/snapshots.coffee +++ b/packages/driver/src/cy/snapshots.coffee @@ -120,56 +120,62 @@ create = ($$, state) -> ## which arrives here as number 1 ## jQuery v2 allowed to silently try setting 1[HIGHLIGHT_ATTR] doing nothing ## jQuery v3 runs in strict mode and throws an error if you attempt to set a property - isJqueryElement = $dom.isElement($elToHighlight) and $dom.isJquery($elToHighlight) - if isJqueryElement - $elToHighlight.attr(HIGHLIGHT_ATTR, true) - - ## TODO: throw error here if cy is undefined! - - $body = $$("body").clone() - - ## for the head and body, get an array of all CSS, - ## whether it's links or style tags - ## if it's same-origin, it will get the actual styles as a string - ## it it's cross-domain, it will get a reference to the link's href - { headStyleIds, bodyStyleIds } = snapshotsCss.getStyleIds() - - ## replaces iframes with placeholders - replaceIframes($body) - - ## remove tags we don't want in body - $body.find("script,link[rel='stylesheet'],style").remove() - - ## here we need to figure out if we're in a remote manual environment - ## if so we need to stringify the DOM: - ## 1. grab all inputs / textareas / options and set their value on the element - ## 2. convert DOM to string: body.prop("outerHTML") - ## 3. send this string via websocket to our server - ## 4. server rebroadcasts this to our client and its stored as a property - - ## its also possible for us to store the DOM string completely on the server - ## without ever sending it back to the browser (until its requests). - ## we could just store it in memory and wipe it out intelligently. - ## this would also prevent having to store the DOM structure on the client, - ## which would reduce memory, and some CPU operations - - ## now remove it after we clone - if isJqueryElement - $elToHighlight.removeAttr(HIGHLIGHT_ATTR) - - ## preserve attributes on the tag - htmlAttrs = getHtmlAttrs($$("html")[0]) + ## TODO: in firefox sometimes this throws a cross-origin access error + try + isJqueryElement = $dom.isElement($elToHighlight) and $dom.isJquery($elToHighlight) + + if isJqueryElement + $elToHighlight.attr(HIGHLIGHT_ATTR, true) + + ## TODO: throw error here if cy is undefined! + + $body = $$("body").clone() + + ## for the head and body, get an array of all CSS, + ## whether it's links or style tags + ## if it's same-origin, it will get the actual styles as a string + ## it it's cross-domain, it will get a reference to the link's href + { headStyleIds, bodyStyleIds } = snapshotsCss.getStyleIds() + + ## replaces iframes with placeholders + replaceIframes($body) + + ## remove tags we don't want in body + $body.find("script,link[rel='stylesheet'],style").remove() + + ## here we need to figure out if we're in a remote manual environment + ## if so we need to stringify the DOM: + ## 1. grab all inputs / textareas / options and set their value on the element + ## 2. convert DOM to string: body.prop("outerHTML") + ## 3. send this string via websocket to our server + ## 4. server rebroadcasts this to our client and its stored as a property + + ## its also possible for us to store the DOM string completely on the server + ## without ever sending it back to the browser (until its requests). + ## we could just store it in memory and wipe it out intelligently. + ## this would also prevent having to store the DOM structure on the client, + ## which would reduce memory, and some CPU operations + + ## now remove it after we clone + if isJqueryElement + $elToHighlight.removeAttr(HIGHLIGHT_ATTR) + + ## preserve attributes on the tag + htmlAttrs = getHtmlAttrs($$("html")[0]) + + snapshot = { + name + htmlAttrs + body: $body + } - snapshot = { - name - htmlAttrs - body: $body - } + snapshotsMap.set(snapshot, { headStyleIds, bodyStyleIds }) - snapshotsMap.set(snapshot, { headStyleIds, bodyStyleIds }) + return snapshot - return snapshot + catch e + null return { createSnapshot diff --git a/packages/driver/src/cy/video-recorder.ts b/packages/driver/src/cy/video-recorder.ts new file mode 100644 index 000000000000..c85334726f3d --- /dev/null +++ b/packages/driver/src/cy/video-recorder.ts @@ -0,0 +1,37 @@ +export function create (Cypress) { + // Only start recording with getUserMedia API if we're in firefox and video-enabled and run mode. + // TODO: this logic should be cleaned up or gotten from some video-specific config value + if ( + Cypress.isBrowser('firefox') + && Cypress.config('video') + && !Cypress.config('isInteractive') + // navigator.mediaDevices will be undefined if the browser does not support display capture + && window.navigator.mediaDevices + ) { + window.navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + // mediaSource "browser" is supported by a firefox user preference + // @ts-ignore + mediaSource: 'browser', + frameRate: { + exact: 30, + }, + }, + }) + .then((stream) => { + const options = { + mimeType: 'video/webm', + } + + // @ts-ignore + const mediaRecorder = new window.MediaRecorder(stream, options) + + mediaRecorder.start(200) + + mediaRecorder.addEventListener('dataavailable', (e) => { + Cypress.action('recorder:frame', e.data) + }) + }) + } +} diff --git a/packages/driver/src/cypress.js b/packages/driver/src/cypress.js index a3e9ec3f0a00..f3ee5629acff 100644 --- a/packages/driver/src/cypress.js +++ b/packages/driver/src/cypress.js @@ -1,5 +1,6 @@ const _ = require('lodash') const $ = require('jquery') +const chai = require('chai') const blobUtil = require('blob-util') const minimatch = require('minimatch') const moment = require('moment') @@ -15,6 +16,7 @@ const $Commands = require('./cypress/commands') const $Cookies = require('./cypress/cookies') const $Cy = require('./cypress/cy') const $Events = require('./cypress/events') +const $FirefoxForcedGc = require('./util/firefox_forced_gc') const $Keyboard = require('./cy/keyboard') const $SetterGetter = require('./cypress/setter_getter') const $Log = require('./cypress/log') @@ -27,6 +29,7 @@ const $Server = require('./cypress/server') const $Screenshot = require('./cypress/screenshot') const $SelectorPlayground = require('./cypress/selector_playground') const $utils = require('./cypress/utils') +const browserInfo = require('./cypress/browser') const proxies = { runner: 'getStartTime getTestsState getEmissions setNumLogs countByTestState getDisplayPropsForLog getConsolePropsForLogById getSnapshotPropsForLogById getErrorByTestId setStartTime resumeAtTest normalizeAll'.split(' '), @@ -152,9 +155,12 @@ class $Cypress { config = _.omit(config, 'env', 'remote', 'resolved', 'scaffoldedFiles', 'javascripts', 'state') + _.extend(this, browserInfo(config)) + this.state = $SetterGetter.create({}) this.config = $SetterGetter.create(config) this.env = $SetterGetter.create(env) + this.getFirefoxGcInterval = $FirefoxForcedGc.createIntervalGetter(this.config) this.Cookies = $Cookies.create(config.namespace, d) @@ -196,6 +202,8 @@ class $Cypress { this.events.proxyTo(this.cy) + $FirefoxForcedGc.install(this) + return null } @@ -204,6 +212,9 @@ class $Cypress { // other objects communicate intent // and 'action' to Cypress switch (eventName) { + case 'recorder:frame': + return this.emit('recorder:frame', args[0]) + case 'cypress:stop': return this.emit('stop') @@ -310,13 +321,25 @@ class $Cypress { break - case 'runner:fail': + case 'runner:fail': { // mocha runner calculated a failure + + const err = args[0].err + + if (err.actual) { + err.actual = chai.util.inspect(err.actual) + } + + if (err.expected) { + err.expected = chai.util.inspect(err.expected) + } + if (this.config('isTextTerminal')) { return this.emit('mocha', 'fail', ...args) } break + } case 'mocha:runnable:run': return this.runner.onRunnableRun(...args) diff --git a/packages/driver/src/cypress/browser.coffee b/packages/driver/src/cypress/browser.coffee new file mode 100644 index 000000000000..ccdcb7b91105 --- /dev/null +++ b/packages/driver/src/cypress/browser.coffee @@ -0,0 +1,22 @@ +_ = require("lodash") +$utils = require("./utils") + +isBrowser = (config, obj='') -> + if _.isString(obj) + name = obj.toLowerCase() + currentName = config.browser.name.toLowerCase() + + return name == currentName + + if _.isObject(obj) + return _.isMatch(config.browser, obj) + + $utils.throwErrByPath("browser.invalid_arg", { + args: { method: 'isBrowser', obj: $utils.stringify(obj) } + }) + +module.exports = (config) -> + { + browser: config.browser + isBrowser: _.partial(isBrowser, config) + } diff --git a/packages/driver/src/cypress/cy.js b/packages/driver/src/cypress/cy.js index 0e970238fdba..a35352d179c3 100644 --- a/packages/driver/src/cypress/cy.js +++ b/packages/driver/src/cypress/cy.js @@ -26,6 +26,7 @@ const $Stability = require('../cy/stability') const $selection = require('../dom/selection') const $Snapshots = require('../cy/snapshots') const $CommandQueue = require('./command_queue') +const $VideoRecorder = require('../cy/video-recorder') const privateProps = { props: { name: 'state', url: true }, @@ -118,6 +119,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { const queue = $CommandQueue.create() + $VideoRecorder.create(Cypress) const timeouts = $Timeouts.create(state) const stability = $Stability.create(Cypress, state) const retries = $Retries.create(Cypress, state, timeouts.timeout, timeouts.clearTimeout, stability.whenStable, onFinishAssertions) @@ -127,7 +129,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { const location = $Location.create(state) const focused = $Focused.create(state) const keyboard = $Keyboard.create(state) - const mouse = $Mouse.create(state, keyboard, focused) + const mouse = $Mouse.create(state, keyboard, focused, Cypress) const timers = $Timers.create() const { expect } = $Chai.create(specWindow, assertions.assert) @@ -675,6 +677,8 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { stopped = true + $utils.normalizeErrorStack(err) + // store the error on state now state('error', err) @@ -712,6 +716,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { // collect all of the callbacks for 'fail' rets = Cypress.action('cy:fail', err, state('runnable')) } catch (err2) { + $utils.normalizeErrorStack(err2) // and if any of these throw synchronously immediately error finish(err2) } diff --git a/packages/driver/src/cypress/error_messages.coffee b/packages/driver/src/cypress/error_messages.coffee index 8b2517f20f66..0f3e6c12bcf6 100644 --- a/packages/driver/src/cypress/error_messages.coffee +++ b/packages/driver/src/cypress/error_messages.coffee @@ -79,6 +79,9 @@ module.exports = { timed_out: "#{cmd('blur')} timed out because your browser did not receive any blur events. This is a known bug in Chrome when it is not the currently focused window." wrong_focused_element: "#{cmd('blur')} can only be called on the focused element. Currently the focused element is a: {{node}}" + browser: + invalid_arg: "Cypress.{{method}}() must be passed the name of a browser or an object to filter with. You passed: {{obj}}" + chai: length_invalid_argument: "You must provide a valid number to a length assertion. You passed: '{{length}}'" match_invalid_argument: "'match' requires its argument be a 'RegExp'. You passed: '{{regExp}}'" diff --git a/packages/driver/src/cypress/runner.js b/packages/driver/src/cypress/runner.js index c852ee6fffd3..7a42738b482c 100644 --- a/packages/driver/src/cypress/runner.js +++ b/packages/driver/src/cypress/runner.js @@ -17,7 +17,7 @@ const TEST_AFTER_RUN_EVENT = 'runner:test:after:run' const ERROR_PROPS = 'message type name stack fileName lineNumber columnNumber host uncaught actual expected showDiff isPending'.split(' ') const RUNNABLE_LOGS = 'routes agents commands'.split(' ') -const RUNNABLE_PROPS = 'id title root hookName hookId err state failedFromHookId body speed type duration wallClockStartedAt wallClockDuration timings file'.split(' ') +const RUNNABLE_PROPS = 'id order title root hookName hookId err state failedFromHookId body speed type duration wallClockStartedAt wallClockDuration timings file'.split(' ') // ## initial payload // { @@ -474,8 +474,16 @@ const normalizeAll = (suite, initialTests = {}, setTestsById, setTests, onRunnab } if (setTests) { + let i = 0 + + const testsArr = _.map(tests, (test) => { + test.order = i += 1 + + return test + }) + // same pattern here - setTests(_.values(tests)) + setTests(testsArr) } return normalizedSuite @@ -736,6 +744,8 @@ const _runnerListeners = function (_runner, Cypress, _emissions, getTestById, ge let hookName const isHook = runnable.type === 'hook' + $utils.normalizeErrorStack(err) + if (isHook) { const parentTitle = runnable.parent.title @@ -817,6 +827,9 @@ const create = function (specWindow, mocha, Cypress, cy) { // else do the same thing as mocha here err = $utils.appendErrMsg(err, append()) + // remove this error's stack since it gives no valuable context + err.stack = '' + const throwErr = function () { throw err } diff --git a/packages/driver/src/cypress/utils.coffee b/packages/driver/src/cypress/utils.coffee index 3c1ba00f0239..b9d773f95185 100644 --- a/packages/driver/src/cypress/utils.coffee +++ b/packages/driver/src/cypress/utils.coffee @@ -101,7 +101,18 @@ module.exports = { ## with the new one err.stack = stack.replace(str, err.toString()) + module.exports.normalizeErrorStack(err) + return err + + normalizeErrorStack: (e) -> + ## normalize error message + stack for firefox + errString = e.toString() + errStack = e.stack || '' + + if !errStack.slice(0, errStack.indexOf('\n')).includes(errString.slice(0, errString.indexOf('\n'))) + e.stack = "#{errString}\n#{errStack}" + return e cloneErr: (obj) -> err2 = new Error(obj.message) @@ -117,6 +128,8 @@ module.exports = { throwErr: (err, options = {}) -> if _.isString(err) err = @cypressErr(err) + if options.noStackTrace + err.stack = '' onFail = options.onFail errProps = options.errProps diff --git a/packages/driver/src/dom/.eslintrc.json b/packages/driver/src/dom/.eslintrc.json new file mode 100644 index 000000000000..e27d0693f4d7 --- /dev/null +++ b/packages/driver/src/dom/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "globals": { + "Cypress": true + } +} diff --git a/packages/driver/src/dom/coordinates.js b/packages/driver/src/dom/coordinates.js index 088adf4baec6..cf1d1be030f6 100644 --- a/packages/driver/src/dom/coordinates.js +++ b/packages/driver/src/dom/coordinates.js @@ -75,8 +75,9 @@ const getElementPositioning = ($el) => { // because its useful to any caller const rectCenter = getCenterCoordinates(rect) - const topCenter = rectCenter.y - const leftCenter = rectCenter.x + // rounding needed for firefox, which returns floating numbers + const topCenter = Math.ceil(rectCenter.y) + const leftCenter = Math.ceil(rectCenter.x) return { scrollTop: el.scrollTop, @@ -93,15 +94,15 @@ const getElementPositioning = ($el) => { leftCenter, }, fromElWindow: { - top: rect.top + win.scrollY, + top: Math.ceil(rect.top + win.scrollY), left: rect.left + win.scrollX, - topCenter: topCenter + win.scrollY, + topCenter: Math.ceil(topCenter + win.scrollY), leftCenter: leftCenter + win.scrollX, }, fromAutWindow: { - top: rectFromAut.top + autFrame.scrollY, + top: Math.ceil(rectFromAut.top + autFrame.scrollY), left: rectFromAut.left + autFrame.scrollX, - topCenter: rectFromAutCenter.y + autFrame.scrollY, + topCenter: Math.ceil(rectFromAutCenter.y + autFrame.scrollY), leftCenter: rectFromAutCenter.x + autFrame.scrollX, }, } diff --git a/packages/driver/src/dom/document.ts b/packages/driver/src/dom/document.ts index 083983037402..e8f738a64168 100644 --- a/packages/driver/src/dom/document.ts +++ b/packages/driver/src/dom/document.ts @@ -17,6 +17,12 @@ const isDocument = (obj: HTMLElement | Document): obj is Document => { // does this document have a currently active window (defaultView) const hasActiveWindow = (doc) => { + // in firefox, detached documents still have a reference to their window + // but document.location is null + if (Cypress.isBrowser('firefox') && !doc.location) { + return false + } + return !!doc.defaultView } diff --git a/packages/driver/src/dom/elements.ts b/packages/driver/src/dom/elements.ts index e7eb4324fc39..fc1641b6a4d1 100644 --- a/packages/driver/src/dom/elements.ts +++ b/packages/driver/src/dom/elements.ts @@ -7,6 +7,9 @@ import * as $jquery from './jquery' import * as $selection from './selection' import { parentHasDisplayNone } from './visibility' import * as $window from './window' +import Debug from 'debug' + +const debug = Debug('cypress:driver:elements') const { wrap } = $jquery @@ -331,7 +334,7 @@ const isNeedSingleValueChangeInputElement = (el: HTMLElement): el is HTMLSingleV return false } - return inputTypeNeedSingleValueChangeRe.test(el.type) + return inputTypeNeedSingleValueChangeRe.test((el.getAttribute('type') || '').toLocaleLowerCase()) } const canSetSelectionRangeElement = (el): el is HTMLElementCanSetSelectionRange => { @@ -350,7 +353,7 @@ const getTagName = (el) => { // - with [contenteditable] // - with document.designMode = 'on' const isContentEditable = (el: any): el is HTMLContentEditableElement => { - return getNativeProp(el, 'isContentEditable') + return getNativeProp(el, 'isContentEditable') || $document.getDocumentFromElement(el).designMode === 'on' } const isTextarea = (el): el is HTMLTextAreaElement => { @@ -602,10 +605,10 @@ const isAttached = function ($el) { const doc = $document.getDocumentFromElement(els[0]) // TODO: i guess its possible each element - // is technically bound to a differnet document + // is technically bound to a different document // but c'mon const isIn = (el) => { - return $.contains((doc as unknown) as Element, el) + return $.contains(doc, el) } // make sure the document is currently @@ -730,11 +733,15 @@ const isScrollable = ($el) => { const checkDocumentElement = (win, documentElement) => { // Check if body height is higher than window height if (win.innerHeight < documentElement.scrollHeight) { + debug('isScrollable: window scrollable on Y') + return true } // Check if body width is higher than window width if (win.innerWidth < documentElement.scrollWidth) { + debug('isScrollable: window scrollable on X') + return true } @@ -762,6 +769,8 @@ const isScrollable = ($el) => { if (el.clientHeight < el.scrollHeight) { // and our element has scroll or auto overflow or overflowX if (isScrollOrAuto(overflow) || isScrollOrAuto(overflowY)) { + debug('isScrollable: clientHeight < scrollHeight and scroll/auto overflow') + return true } } @@ -769,6 +778,8 @@ const isScrollable = ($el) => { // x axis if (el.clientWidth < el.scrollWidth) { if (isScrollOrAuto(overflow) || isScrollOrAuto(overflowX)) { + debug('isScrollable: clientWidth < scrollWidth and scroll/auto overflow') + return true } } diff --git a/packages/driver/src/dom/selection.ts b/packages/driver/src/dom/selection.ts index 4f1dd21448d3..37af392575ba 100644 --- a/packages/driver/src/dom/selection.ts +++ b/packages/driver/src/dom/selection.ts @@ -71,7 +71,13 @@ const _replaceSelectionContentsContentEditable = function (el, text) { const doc = $document.getDocumentFromElement(el) // NOTE: insertText will also handle '\n', and render newlines - $elements.callNativeMethod(doc, 'execCommand', 'insertText', true, text) + let nativeUI = true + + if (Cypress.browser.family === 'firefox') { + nativeUI = false + } + + $elements.callNativeMethod(doc, 'execCommand', 'insertText', nativeUI, text) } // Keeping around native implementation @@ -340,15 +346,7 @@ const moveCursorRight = function (el) { } } -const moveCursorUp = (el) => { - return _moveCursorUpOrDown(el, true) -} - -const moveCursorDown = (el) => { - return _moveCursorUpOrDown(el, false) -} - -const _moveCursorUpOrDown = function (el, up) { +const _moveCursorUpOrDown = function (up: boolean, el: HTMLElement) { if ($elements.isInput(el)) { // on an input, instead of moving the cursor // we want to perform the native browser action @@ -370,9 +368,44 @@ const _moveCursorUpOrDown = function (el, up) { const isTextarea = $elements.isTextarea(el) + if (isTextarea && Cypress.browser.family === 'firefox') { + const val = $elements.getNativeProp(el as HTMLTextAreaElement, 'value') + const bounds = _getSelectionBoundsFromTextarea(el as HTMLTextAreaElement) + let toPos + + if (up) { + const partial = val.slice(0, bounds.start) + const lastEOL = partial.lastIndexOf('\n') + const offset = partial.length - lastEOL - 1 + const SOL = partial.slice(0, lastEOL).lastIndexOf('\n') + 1 + const toLineLen = partial.slice(SOL, lastEOL).length + + toPos = SOL + Math.min(toLineLen, offset) + + // const baseLen = arr.slice(0, -2).join().length - 1 + // toPos = baseLen + arr.slice(-1)[0].length + } else { + const partial = val.slice(bounds.end) + const arr = partial.split('\n') + const baseLen = arr.slice(0, 1).join('\n').length + bounds.end + + toPos = baseLen + (bounds.end - val.slice(0, bounds.end).lastIndexOf('\n')) + } + + setSelectionRange(el, toPos, toPos) + + return + } + if (isTextarea || $elements.isContentEditable(el)) { const selection = _getSelectionByEl(el) + if (Cypress.browser.family === 'firefox' && !selection.isCollapsed) { + up ? selection.collapseToStart() : selection.collapseToEnd() + + return + } + return $elements.callNativeMethod(selection, 'modify', 'move', up ? 'backward' : 'forward', @@ -380,15 +413,10 @@ const _moveCursorUpOrDown = function (el, up) { } } -const moveCursorToLineStart = (el) => { - return _moveCursorToLineStartOrEnd(el, true) -} +const moveCursorUp = _.curry(_moveCursorUpOrDown)(true) +const moveCursorDown = _.curry(_moveCursorUpOrDown)(false) -const moveCursorToLineEnd = (el) => { - return _moveCursorToLineStartOrEnd(el, false) -} - -const _moveCursorToLineStartOrEnd = function (el: HTMLElement, toStart) { +const _moveCursorToLineStartOrEnd = function (toStart: boolean, el: HTMLElement) { const isInput = $elements.isInput(el) const isTextarea = $elements.isTextarea(el) const isInputOrTextArea = isInput || isTextarea @@ -396,12 +424,55 @@ const _moveCursorToLineStartOrEnd = function (el: HTMLElement, toStart) { if ($elements.isContentEditable(el) || isInputOrTextArea) { const selection = _getSelectionByEl(el) + if (Cypress.browser.family === 'firefox' && isInputOrTextArea) { + if (isInput) { + let toPos = 0 + + if (!toStart) { + toPos = $elements.getNativeProp(el as HTMLInputElement, 'value').length + } + + setSelectionRange(el, toPos, toPos) + + return + } + // const doc = $document.getDocumentFromElement(el) + // console.log(doc.activeElement) + // $elements.callNativeMethod(doc, 'execCommand', 'selectall', false) + // $elements.callNativeMethod(el, 'select') + // _getSelectionByEl(el).ca + // toStart ? _getSelectionByEl(el).collapseToStart : _getSelectionByEl(el).collapseToEnd() + + if (isTextarea) { + const bounds = _getSelectionBoundsFromTextarea(el) + const value = $elements.getNativeProp(el as HTMLTextAreaElement, 'value') + let toPos: number + + if (toStart) { + toPos = value.slice(0, bounds.start).lastIndexOf('\n') + 1 + } else { + const valSlice = value.slice(bounds.end) + const EOLNewline = valSlice.indexOf('\n') + const EOL = EOLNewline === -1 ? valSlice.length : EOLNewline + + toPos = bounds.end + EOL + } + + setSelectionRange(el, toPos, toPos) + + return + } + } + // the selection.modify API is non-standard, may work differently in other browsers, and is not in IE11. // https://developer.mozilla.org/en-US/docs/Web/API/Selection/modify return $elements.callNativeMethod(selection, 'modify', 'move', toStart ? 'backward' : 'forward', 'lineboundary') } } +const moveCursorToLineStart = _.curry(_moveCursorToLineStartOrEnd)(true) +const moveCursorToLineEnd = _.curry(_moveCursorToLineStartOrEnd)(false) + const isCollapsed = function (el) { if ($elements.isTextarea(el) || $elements.isInput(el)) { const { start, end } = getSelectionBounds(el) diff --git a/packages/driver/src/util/firefox_forced_gc.ts b/packages/driver/src/util/firefox_forced_gc.ts new file mode 100644 index 000000000000..8d1b76186f8d --- /dev/null +++ b/packages/driver/src/util/firefox_forced_gc.ts @@ -0,0 +1,58 @@ +import { get, isNumber } from 'lodash' + +export function createIntervalGetter (config) { + return () => { + if (get(config('browser'), 'family') !== 'firefox') { + return undefined + } + + const intervals = config('firefoxGcInterval') + + if (isNumber(intervals)) { + return intervals + } + + return intervals[config('isInteractive') ? 'openMode' : 'runMode'] + } +} + +export function install (Cypress: Cypress.Cypress & EventEmitter) { + if (!Cypress.isBrowser('firefox')) { + return + } + + let cyVisitedSinceLastGc = false + let testsSinceLastForcedGc = 0 + + Cypress.on('command:start', function (cmd) { + if (cmd.get('name') === 'visit') { + cyVisitedSinceLastGc = true + } + }) + + Cypress.on('test:before:run:async', function (testAttrs) { + const { order } = testAttrs + + testsSinceLastForcedGc++ + + // if this is the first test, or the last test didn't run a cy.visit... + if (order === 0 || !cyVisitedSinceLastGc) { + return + } + + const gcInterval = Cypress.getFirefoxGcInterval() + + cyVisitedSinceLastGc = false + + if (gcInterval && gcInterval > 0 && testsSinceLastForcedGc >= gcInterval) { + testsSinceLastForcedGc = 0 + Cypress.emit('before:firefox:force:gc', { gcInterval }) + + return Cypress.backend('firefox:force:gc').then(() => { + return Cypress.emit('after:firefox:force:gc', { gcInterval }) + }) + } + + return + }) +} diff --git a/packages/driver/test/cypress/fixtures/dom.html b/packages/driver/test/cypress/fixtures/dom.html index fec644b076d9..533cf1a78192 100644 --- a/packages/driver/test/cypress/fixtures/dom.html +++ b/packages/driver/test/cypress/fixtures/dom.html @@ -6,6 +6,46 @@