diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index f1c52d7ac0c3..4b5d4219c47a 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,18 +1,19 @@ - + ### Current behavior: - + ### Desired behavior: - + -### Steps to reproduce: (app code and test code) +### Test code to reproduce - + + - + ### Versions diff --git a/.node-version b/.node-version index 4044f90867df..e56b2006c570 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -12.0.0 +12.8.1 diff --git a/DEPLOY.md b/DEPLOY.md index 0150d9045731..57cee9abe955 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -151,6 +151,7 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress 12. Update the releases in [ZenHub](https://app.zenhub.com/workspaces/test-runner-5c3ea3baeb1e75374f7b0708/reports/release): - Close the current release in ZenHub. - Create a new patch release (and a new minor release, if this is a minor release) in ZenHub, and schedule them both to be completed 2 weeks from the current date. + - Move all issues that are still open from the current release to the appropriate future release. 13. Bump `version` in [`package.json`](package.json) and commit it to `develop` using a commit message like `release X.Y.Z [skip ci]` 14. Tag this commit with `vX.Y.Z` and push that tag up. 15. Merge `develop` into `master` and push that branch up. diff --git a/appveyor.yml b/appveyor.yml index 10dcf01cb3ca..e41c017becb7 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,7 +7,7 @@ branches: # https://www.appveyor.com/docs/lang/nodejs-iojs/ environment: # use matching version of Node.js - nodejs_version: "12.0.0" + nodejs_version: "12.8.1" # encode secure variables which will NOT be used # in pull requests # https://www.appveyor.com/docs/build-configuration/#secure-variables diff --git a/cli/lib/tasks/state.js b/cli/lib/tasks/state.js index 0b284e9808af..9e12427ffb07 100644 --- a/cli/lib/tasks/state.js +++ b/cli/lib/tasks/state.js @@ -108,14 +108,21 @@ const getDistDir = () => { return path.join(__dirname, '..', '..', 'dist') } +/** + * Returns full filename to the file that keeps the Test Runner verification state as JSON text. + * Note: the binary state file will be stored one level up from the given binary folder. + * @param {string} binaryDir - full path to the folder holding the binary. + */ const getBinaryStatePath = (binaryDir) => { - return path.join(binaryDir, 'binary_state.json') + return path.join(binaryDir, '..', 'binary_state.json') } const getBinaryStateContentsAsync = (binaryDir) => { - return fs.readJsonAsync(getBinaryStatePath(binaryDir)) + const fullPath = getBinaryStatePath(binaryDir) + + return fs.readJsonAsync(fullPath) .catch({ code: 'ENOENT' }, SyntaxError, () => { - debug('could not read binary_state.json file') + debug('could not read binary_state.json file at "%s"', fullPath) return {} }) @@ -132,7 +139,10 @@ const clearBinaryStateAsync = (binaryDir) => { } /** - * @param {boolean} verified + * Writes the new binary status. + * @param {boolean} verified The new test runner state after smoke test + * @param {string} binaryDir Folder holding the binary + * @returns {Promise} returns a promise */ const writeBinaryVerifiedAsync = (verified, binaryDir) => { return getBinaryStateContentsAsync(binaryDir) diff --git a/cli/package.json b/cli/package.json index 041933890ede..df66a41a518d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -37,6 +37,7 @@ "commander": "2.15.1", "common-tags": "1.8.0", "debug": "3.2.6", + "eventemitter2": "4.1.2", "execa": "0.10.0", "executable": "4.1.1", "extract-zip": "1.6.7", diff --git a/cli/test/lib/tasks/state_spec.js b/cli/test/lib/tasks/state_spec.js index b998027b263c..59301a800710 100644 --- a/cli/test/lib/tasks/state_spec.js +++ b/cli/test/lib/tasks/state_spec.js @@ -179,16 +179,18 @@ describe('lib/tasks/state', function () { }) it('can accept custom binaryDir', function () { - const customBinaryDir = '/custom/binary/dir' + // note how the binary state file is in the runner's parent folder + const customBinaryDir = '/custom/binary/1.2.3/runner' + const binaryStatePath = '/custom/binary/1.2.3/binary_state.json' sinon .stub(fs, 'pathExistsAsync') - .withArgs('/custom/binary/dir/binary_state.json') - .resolves({ verified: true }) + .withArgs(binaryStatePath) + .resolves(true) sinon .stub(fs, 'readJsonAsync') - .withArgs('/custom/binary/dir/binary_state.json') + .withArgs(binaryStatePath) .resolves({ verified: true }) return state @@ -200,6 +202,8 @@ describe('lib/tasks/state', function () { }) context('.writeBinaryVerified', function () { + const binaryStateFilename = path.join(versionDir, 'binary_state.json') + beforeEach(() => { mockfs({}) }) @@ -216,7 +220,7 @@ describe('lib/tasks/state', function () { .then( () => { return expect(fs.outputJsonAsync).to.be.calledWith( - path.join(binaryDir, 'binary_state.json'), + binaryStateFilename, { verified: true } ) }, @@ -231,7 +235,7 @@ describe('lib/tasks/state', function () { .writeBinaryVerifiedAsync(false, binaryDir) .then(() => { return expect(fs.outputJsonAsync).to.be.calledWith( - path.join(binaryDir, 'binary_state.json'), + binaryStateFilename, { verified: false }, { spaces: 2 } ) diff --git a/cli/test/lib/tasks/verify_spec.js b/cli/test/lib/tasks/verify_spec.js index 73169f5c57e7..ed24f06e08d5 100644 --- a/cli/test/lib/tasks/verify_spec.js +++ b/cli/test/lib/tasks/verify_spec.js @@ -1,5 +1,6 @@ require('../../spec_helper') +const path = require('path') const _ = require('lodash') const os = require('os') const cp = require('child_process') @@ -23,7 +24,7 @@ const snapshot = require('../../support/snapshot') const packageVersion = '1.2.3' const cacheDir = '/cache/Cypress' const executablePath = '/cache/Cypress/1.2.3/Cypress.app/Contents/MacOS/Cypress' -const binaryStatePath = '/cache/Cypress/1.2.3/Cypress.app/binary_state.json' +const binaryStatePath = '/cache/Cypress/1.2.3/binary_state.json' let stdout let spawnedProcess @@ -747,9 +748,24 @@ context('lib/tasks/verify', () => { // TODO this needs documentation with examples badly. function createfs ({ alreadyVerified, executable, packageVersion, customDir }) { + if (!customDir) { + customDir = '/cache/Cypress/1.2.3/Cypress.app' + } + + // binary state is stored one folder higher than the runner itself + // see https://github.com/cypress-io/cypress/issues/6089 + const binaryStateFolder = path.join(customDir, '..') + + const binaryState = { + verified: alreadyVerified, + } + const binaryStateText = JSON.stringify(binaryState) + let mockFiles = { - [customDir ? customDir : '/cache/Cypress/1.2.3/Cypress.app']: { - 'binary_state.json': `{"verified": ${alreadyVerified}}`, + [binaryStateFolder]: { + 'binary_state.json': binaryStateText, + }, + [customDir]: { Contents: { MacOS: executable ? { diff --git a/cli/types/cypress-npm-api.d.ts b/cli/types/cypress-npm-api.d.ts index 37409b7a0dac..3a30518b34d2 100644 --- a/cli/types/cypress-npm-api.d.ts +++ b/cli/types/cypress-npm-api.d.ts @@ -461,6 +461,11 @@ declare module 'cypress' { totalPassed: number totalPending: number totalSkipped: number + /** + * If Cypress test run is being recorded, full url will be provided. + * @see https://on.cypress.io/dashboard-introduction + */ + runUrl?: string runs: RunResult[] browserPath: string browserName: string diff --git a/cli/types/index.d.ts b/cli/types/index.d.ts index f7870a6e36d2..2b60110d6407 100644 --- a/cli/types/index.d.ts +++ b/cli/types/index.d.ts @@ -30,6 +30,15 @@ // hmm, how to load it better? /// +// Cypress, cy, Log inherits EventEmitter. +type EventEmitter2 = import("eventemitter2").EventEmitter2 + +interface EventEmitter extends EventEmitter2 { + proxyTo: (cy: Cypress.cy) => null + emitMap: (eventName: string, args: any[]) => Array<(...args: any[]) => any> + emitThen: (eventName: string, args: any[]) => Bluebird.BluebirdStatic +} + // Cypress adds chai expect and assert to global declare const expect: Chai.ExpectStatic declare const assert: Chai.AssertStatic @@ -4524,7 +4533,7 @@ cy.get('button').click() cy.get('.result').contains('Expected text') ``` */ -declare const cy: Cypress.cy +declare const cy: Cypress.cy & EventEmitter /** * Global variable `Cypress` holds common utilities and constants. @@ -4536,4 +4545,4 @@ Cypress.version // => "1.4.0" Cypress._ // => Lodash _ ``` */ -declare const Cypress: Cypress.Cypress +declare const Cypress: Cypress.Cypress & EventEmitter diff --git a/cli/types/tests/cypress-npm-api-test.ts b/cli/types/tests/cypress-npm-api-test.ts index d2045f72454d..eb5e5730e3c5 100644 --- a/cli/types/tests/cypress-npm-api-test.ts +++ b/cli/types/tests/cypress-npm-api-test.ts @@ -11,6 +11,7 @@ cypress.run().then(results => { results // $ExpectType CypressRunResult results.failures // $ExpectType number | undefined results.message // $ExpectType string | undefined + results.runUrl // $ExpectType string | undefined }) cypress.open() // $ExpectType Promise cypress.run() // $ExpectType Promise diff --git a/cli/types/tests/kitchen-sink.ts b/cli/types/tests/kitchen-sink.ts index 2a177a05002b..63b83adbe3c5 100644 --- a/cli/types/tests/kitchen-sink.ts +++ b/cli/types/tests/kitchen-sink.ts @@ -63,13 +63,20 @@ stub() expect(stub).to.have.been.calledOnce cy.wrap(stub).should('have.been.calledOnce') -// window:confirm stubbing -Cypress.on('window:confirm', () => { }) -Cypress.on('window:confirm', cy.spy()) -Cypress.on('window:confirm', cy.stub()) -cy.on('window:confirm', () => { }) -cy.on('window:confirm', cy.spy()) -cy.on('window:confirm', cy.stub()) +namespace EventInterfaceTests { + // window:confirm stubbing + Cypress.on('window:confirm', () => { }) + Cypress.on('window:confirm', cy.spy()) + Cypress.on('window:confirm', cy.stub()) + cy.on('window:confirm', () => { }) + cy.on('window:confirm', cy.spy()) + cy.on('window:confirm', cy.stub()) + + Cypress.removeListener('fail', () => {}) + Cypress.removeAllListeners('fail') + cy.removeListener('fail', () => {}) + cy.removeAllListeners('fail') +} // specifying HTTP method directly in the options object cy.request({ diff --git a/package.json b/package.json index 5d96621a9a98..fb293a359b8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cypress", - "version": "3.8.1", + "version": "3.8.2", "description": "Cypress.io end to end testing tool", "private": true, "scripts": { @@ -171,7 +171,7 @@ "vinyl-paths": "2.1.0" }, "engines": { - "node": "12.0.0" + "node": "12.8.1" }, "productName": "Cypress", "license": "MIT", diff --git a/packages/driver/.eslintrc.json b/packages/driver/.eslintrc.json index e5a34aec6abe..815d53f16e7b 100644 --- a/packages/driver/.eslintrc.json +++ b/packages/driver/.eslintrc.json @@ -1,5 +1,6 @@ { "env": { "browser": true - } + }, + "parser": "@typescript-eslint/parser" } diff --git a/packages/driver/src/cy/assertions.coffee b/packages/driver/src/cy/assertions.coffee deleted file mode 100644 index a6c9099675d5..000000000000 --- a/packages/driver/src/cy/assertions.coffee +++ /dev/null @@ -1,422 +0,0 @@ -_ = require("lodash") -Promise = require("bluebird") - -$dom = require("../dom") -$utils = require("../cypress/utils") - -## TODO -## bTagOpen + bTagClosed -## are duplicated in assertions.coffee -butRe = /,? but\b/ -bTagOpen = /\*\*/g -bTagClosed = /\*\*/g -stackTracesRe = / at .*\n/gm - -IS_DOM_TYPES = [$dom.isElement, $dom.isDocument, $dom.isWindow] - -invokeWith = (value) -> - return (fn) -> - fn(value) - -functionHadArguments = (current) -> - fn = current and current.get("args") and current.get("args")[0] - fn and _.isFunction(fn) and fn.length > 0 - -isAssertionType = (cmd) -> - cmd and cmd.is("assertion") - -isDomSubjectAndMatchesValue = (value, subject) -> - allElsAreTheSame = -> - els1 = $dom.getElements(value) - els2 = $dom.getElements(subject) - - ## no difference - _.difference(els1, els2).length is 0 - - ## iterate through each dom type until we - ## find the function for this particular value - if isDomTypeFn = _.find(IS_DOM_TYPES, invokeWith(value)) - ## then check that subject also matches this - ## and that all the els are the same - return isDomTypeFn(subject) and allElsAreTheSame() - -## Rules: -## 1. always remove value -## 2. if value is a jquery object set a subject -## 3. if actual is undefined or its not expected remove both actual + expected -parseValueActualAndExpected = (value, actual, expected) -> - obj = {actual: actual, expected: expected} - - if $dom.isJquery(value) - obj.subject = value - - if _.isUndefined(actual) or actual isnt expected - delete obj.actual - delete obj.expected - - obj - -create = (state, queue, retryFn) -> - getUpcomingAssertions = -> - current = state("current") - index = state("index") + 1 - - assertions = [] - - ## grab the rest of the queue'd commands - for cmd in queue.slice(index).get() - ## don't break on utilities, just skip over them - if cmd.is("utility") - continue - - ## grab all of the queued commands which are - ## assertions and match our current chainerId - if cmd.is("assertion") - assertions.push(cmd) - else - break - - assertions - - injectAssertionFns = (cmds) -> - _.map(cmds, injectAssertion) - - injectAssertion = (cmd) -> - return (subject) -> - ## set assertions to itself or empty array - if not cmd.get("assertions") - cmd.set("assertions", []) - - ## reset the assertion index back to 0 - ## so we can track assertions and merge - ## them up with existing ones - cmd.set("assertionIndex", 0) - - cmd.get("fn").originalFn.apply( - state("ctx"), - [subject].concat(cmd.get("args")) - ) - - finishAssertions = (assertions) -> - _.each assertions, (log) -> - log.snapshot() - - if e = log.get("_error") - log.error(e) - else - log.end() - - verifyUpcomingAssertions = (subject, options = {}, callbacks = {}) -> - cmds = getUpcomingAssertions() - - state("upcomingAssertions", cmds) - - ## we're applying the default assertion in the - ## case where there are no upcoming assertion commands - isDefaultAssertionErr = cmds.length is 0 - - options.assertions ?= [] - - _.defaults callbacks, { - ensureExistenceFor: "dom" - } - - ensureExistence = -> - ## by default, ensure existence for dom subjects, - ## but not non-dom subjects - switch callbacks.ensureExistenceFor - when "dom" - $el = determineEl(options.$el, subject) - return if not $dom.isJquery($el) - - cy.ensureElExistence($el) - - when "subject" - cy.ensureExistence(subject) - - determineEl = ($el, subject) -> - ## prefer $el unless it is strickly undefined - if not _.isUndefined($el) then $el else subject - - onPassFn = => - if _.isFunction(callbacks.onPass) - callbacks.onPass.call(@, cmds, options.assertions) - else - subject - - onFailFn = (err) => - ## when we fail for whatever reason we need to - ## check to see if we would firstly fail if - ## we don't have an el in existence. what this - ## catches are assertions downstream about an - ## elements existence but the element never - ## exists in the first place. this will probably - ## ensure the error is about existence not about - ## the downstream assertion. - try - ensureExistence() - catch e2 - err = e2 - - options.error = err - - if err.retry is false - throw err - - onFail = callbacks.onFail - onRetry = callbacks.onRetry - - if not onFail and not onRetry - throw err - - ## if our onFail throws then capture it - ## and finish the assertions and then throw - ## it again - try - if _.isFunction(onFail) - ## pass in the err and the upcoming assertion commands - onFail.call(@, err, isDefaultAssertionErr, cmds) - catch e3 - finishAssertions(options.assertions) - throw e3 - - if _.isFunction(onRetry) - retryFn(onRetry, options) - - ## bail if we have no assertions and apply - ## the default assertions if applicable - if not cmds.length - return Promise - .try(ensureExistence) - .then(onPassFn) - .catch(onFailFn) - - i = 0 - - cmdHasFunctionArg = (cmd) -> - _.isFunction(cmd.get("args")[0]) - - overrideAssert = (args...) -> - do (cmd = cmds[i]) => - setCommandLog = (log) => - ## our next log may not be an assertion - ## due to page events so make sure we wait - ## until we see page events - return if log.get("name") isnt "assert" - - ## when we do immediately unbind this function - state("onBeforeLog", null) - - insertNewLog = (log) -> - cmd.log(log) - options.assertions.push(log) - - ## its possible a single 'should' will assert multiple - ## things such as the case with have.property. we can - ## detect when that is happening because cmd will be null. - ## if thats the case then just set cmd to be the previous - ## cmd and do not increase 'i' - ## this will prevent 2 logs from ever showing up but still - ## provide errors when the 1st assertion fails. - if not cmd - cmd = cmds[i - 1] - else - i += 1 - - ## if our command has a function argument - ## then we know it may contain multiple - ## assertions - if cmdHasFunctionArg(cmd) - index = cmd.get("assertionIndex") - assertions = cmd.get("assertions") - - incrementIndex = -> - ## always increase the assertionIndex - ## so our next assertion matches up - ## to the correct index - cmd.set("assertionIndex", index += 1) - - ## if we dont have an assertion at this - ## index then insert a new log - if not assertion = assertions[index] - assertions.push(log) - incrementIndex() - - return insertNewLog(log) - else - ## else just merge this log - ## into the previous assertion log - incrementIndex() - assertion.merge(log) - - ## dont output a new log - return false - - ## if we already have a log - ## then just update its attrs from - ## the new log - if l = cmd.getLastLog() - l.merge(log) - - ## and make sure we return false - ## which prevents a new log from - ## being added - return false - else - insertNewLog(log) - - state("onBeforeLog", setCommandLog) - - ## send verify=true as the last arg - assertFn.apply(@, args.concat(true)) - - fns = injectAssertionFns(cmds) - - subjects = [] - - ## iterate through each subject - ## and force the assertion to return - ## this value so it does not get - ## invoked again - setSubjectAndSkip = -> - for subject, i in subjects - cmd = cmds[i] - cmd.set("subject", subject) - cmd.skip() - - assertions = (memo, fn, i) => - ## HACK: bluebird .reduce will not call the callback - ## if given an undefined initial value, so in order to - ## support undefined subjects, we wrap the initial value - ## in an Array and unwrap it if index = 0 - if i is 0 - memo = memo[0] - fn(memo).then (subject) -> - subjects[i] = subject - - restore = -> - state("upcomingAssertions", []) - - ## no matter what we need to - ## restore the assert fn - state("overrideAssert", undefined) - - ## store this in case our test ends early - ## and we reset between tests - state("overrideAssert", overrideAssert) - - Promise - .reduce(fns, assertions, [subject]) - .then -> - restore() - - setSubjectAndSkip() - - finishAssertions(options.assertions) - - onPassFn() - .catch (err) -> - restore() - - ## when we're told not to retry - if err.retry is false - ## finish the assertions - finishAssertions(options.assertions) - - ## and then push our command into this err - try - $utils.throwErr(err, { onFail: options._log }) - catch e - err = e - - throw err - .catch(onFailFn) - - assertFn = (passed, message, value, actual, expected, error, verifying = false) -> - ## slice off everything after a ', but' or ' but ' for passing assertions, because - ## otherwise it doesn't make sense: - ## "expected
to have a an attribute 'href', but it was 'href'" - if message and passed and butRe.test(message) - message = message.substring(0, message.search(butRe)) - - if value?.isSinonProxy - message = message.replace(stackTracesRe, "\n") - - obj = parseValueActualAndExpected(value, actual, expected) - - if $dom.isElement(value) - obj.$el = $dom.wrap(value) - - current = state("current") - - ## if we are simply verifying the upcoming - ## assertions then do not immediately end or snapshot - ## else do - if verifying - obj._error = error - else - obj.end = true - obj.snapshot = true - obj.error = error - - isChildLike = (subject, current) => - (value is subject) or - isDomSubjectAndMatchesValue(value, subject) or - ## if our current command is an assertion type - isAssertionType(current) or - ## are we currently verifying assertions? - state("upcomingAssertions")?.length > 0 or - ## did the function have arguments - functionHadArguments(current) - - _.extend obj, - name: "assert" - # end: true - # snapshot: true - message: message - passed: passed - selector: value?.selector - type: (current, subject) -> - ## if our current command has arguments assume - ## we are an assertion that's involving the current - ## subject or our value is the current subject - if isChildLike(subject, current) - "child" - else - "parent" - - consoleProps: => - obj = {Command: "assert"} - - _.extend obj, parseValueActualAndExpected(value, actual, expected) - - _.extend obj, - Message: message.replace(bTagOpen, "").replace(bTagClosed, "") - - ## think about completely gutting the whole object toString - ## which chai does by default, its so ugly and worthless - - if error - error.onFail = (err) -> - - Cypress.log(obj) - - return null - - assert = -> - ## if we've temporarily overriden assertions - ## then just bail early with this function - fn = state("overrideAssert") ? assertFn - fn.apply(@, arguments) - - return { - finishAssertions - - verifyUpcomingAssertions - - assert - } - -module.exports = { - create -} diff --git a/packages/driver/src/cy/assertions.js b/packages/driver/src/cy/assertions.js new file mode 100644 index 000000000000..7fde3d5d6059 --- /dev/null +++ b/packages/driver/src/cy/assertions.js @@ -0,0 +1,522 @@ +/* global cy Cypress */ +const _ = require('lodash') +const Promise = require('bluebird') + +const $dom = require('../dom') +const $utils = require('../cypress/utils') + +// TODO +// bTagOpen + bTagClosed +// are duplicated in assertions.coffee +const butRe = /,? but\b/ +const bTagOpen = /\*\*/g +const bTagClosed = /\*\*/g +const stackTracesRe = / at .*\n/gm + +const IS_DOM_TYPES = [$dom.isElement, $dom.isDocument, $dom.isWindow] + +const invokeWith = (value) => { + return (fn) => { + return fn(value) + } +} + +const functionHadArguments = (current) => { + const fn = current && current.get('args') && current.get('args')[0] + + return fn && _.isFunction(fn) && (fn.length > 0) +} + +const isAssertionType = (cmd) => { + return cmd && cmd.is('assertion') +} + +const isDomSubjectAndMatchesValue = (value, subject) => { + const allElsAreTheSame = () => { + const els1 = $dom.getElements(value) + const els2 = $dom.getElements(subject) + + // no difference + return _.difference(els1, els2).length === 0 + } + + // iterate through each dom type until we + // find the function for this particular value + const isDomTypeFn = _.find(IS_DOM_TYPES, invokeWith(value)) + + if (isDomTypeFn) { + // then check that subject also matches this + // and that all the els are the same + return isDomTypeFn(subject) && allElsAreTheSame() + } +} + +// Rules: +// 1. always remove value +// 2. if value is a jquery object set a subject +// 3. if actual is undefined or its not expected remove both actual + expected +const parseValueActualAndExpected = (value, actual, expected) => { + const obj = { actual, expected } + + if ($dom.isJquery(value)) { + obj.subject = value + + if (_.isUndefined(actual) || (actual !== expected)) { + delete obj.actual + delete obj.expected + } + } + + return obj +} + +const create = function (state, queue, retryFn) { + const getUpcomingAssertions = () => { + const index = state('index') + 1 + + const assertions = [] + + // grab the rest of the queue'd commands + for (let cmd of queue.slice(index).get()) { + // don't break on utilities, just skip over them + if (cmd.is('utility')) { + continue + } + + // grab all of the queued commands which are + // assertions and match our current chainerId + if (cmd.is('assertion')) { + assertions.push(cmd) + } else { + break + } + } + + return assertions + } + + const injectAssertionFns = (cmds) => { + return _.map(cmds, injectAssertion) + } + + const injectAssertion = (cmd) => { + return ((subject) => { + // set assertions to itself or empty array + if (!cmd.get('assertions')) { + cmd.set('assertions', []) + } + + // reset the assertion index back to 0 + // so we can track assertions and merge + // them up with existing ones + cmd.set('assertionIndex', 0) + + return cmd.get('fn').originalFn.apply( + state('ctx'), + [subject].concat(cmd.get('args')) + ) + }) + } + + const finishAssertions = (assertions) => { + return _.each(assertions, (log) => { + log.snapshot() + + const e = log.get('_error') + + if (e) { + return log.error(e) + } + + return log.end() + }) + } + + const verifyUpcomingAssertions = function (subject, options = {}, callbacks = {}) { + const cmds = getUpcomingAssertions() + + state('upcomingAssertions', cmds) + + // we're applying the default assertion in the + // case where there are no upcoming assertion commands + const isDefaultAssertionErr = cmds.length === 0 + + if (options.assertions == null) { + options.assertions = [] + } + + _.defaults(callbacks, { + ensureExistenceFor: 'dom', + }) + + const ensureExistence = () => { + // by default, ensure existence for dom subjects, + // but not non-dom subjects + switch (callbacks.ensureExistenceFor) { + case 'dom': { + const $el = determineEl(options.$el, subject) + + if (!$dom.isJquery($el)) { + return + } + + return cy.ensureElExistence($el) + } + case 'subject': + return cy.ensureExistence(subject) + + default: + return + } + } + + const determineEl = ($el, subject) => { + // prefer $el unless it is strickly undefined + if (!_.isUndefined($el)) { + return $el + } + + return subject + } + + const onPassFn = () => { + if (_.isFunction(callbacks.onPass)) { + return callbacks.onPass.call(this, cmds, options.assertions) + } + + return subject + } + + const onFailFn = (err) => { + // when we fail for whatever reason we need to + // check to see if we would firstly fail if + // we don't have an el in existence. what this + // catches are assertions downstream about an + // elements existence but the element never + // exists in the first place. this will probably + // ensure the error is about existence not about + // the downstream assertion. + try { + ensureExistence() + } catch (e2) { + err = e2 + } + + options.error = err + + if (err.retry === false) { + throw err + } + + const { onFail, onRetry } = callbacks + + if (!onFail && !onRetry) { + throw err + } + + // if our onFail throws then capture it + // and finish the assertions and then throw + // it again + try { + if (_.isFunction(onFail)) { + // pass in the err and the upcoming assertion commands + onFail.call(this, err, isDefaultAssertionErr, cmds) + } + } catch (e3) { + finishAssertions(options.assertions) + throw e3 + } + + if (_.isFunction(onRetry)) { + return retryFn(onRetry, options) + } + } + + // bail if we have no assertions and apply + // the default assertions if applicable + if (!cmds.length) { + return Promise + .try(ensureExistence) + .then(onPassFn) + .catch(onFailFn) + } + + let i = 0 + + const cmdHasFunctionArg = (cmd) => { + return _.isFunction(cmd.get('args')[0]) + } + + const overrideAssert = function (...args) { + let cmd = cmds[i] + const setCommandLog = (log) => { + // our next log may not be an assertion + // due to page events so make sure we wait + // until we see page events + if (log.get('name') !== 'assert') { + return + } + + // when we do immediately unbind this function + state('onBeforeLog', null) + + const insertNewLog = (log) => { + cmd.log(log) + + return options.assertions.push(log) + } + + // its possible a single 'should' will assert multiple + // things such as the case with have.property. we can + // detect when that is happening because cmd will be null. + // if thats the case then just set cmd to be the previous + // cmd and do not increase 'i' + // this will prevent 2 logs from ever showing up but still + // provide errors when the 1st assertion fails. + if (!cmd) { + cmd = cmds[i - 1] + } else { + i += 1 + } + + // if our command has a function argument + // then we know it may contain multiple + // assertions + if (cmdHasFunctionArg(cmd)) { + let index = cmd.get('assertionIndex') + let assertions = cmd.get('assertions') + + // https://github.com/cypress-io/cypress/issues/4981 + // `assertions` is undefined because assertions added by + // `should` command are not handled yet. + // So, don't increase i and go back to the last command. + if (!assertions) { + i -= 1 + cmd = cmds[i - 1] + index = cmd.get('assertionIndex') + assertions = cmd.get('assertions') + } + + // always increase the assertionIndex + // so our next assertion matches up + // to the correct index + const incrementIndex = () => { + return cmd.set('assertionIndex', index += 1) + } + + // if we dont have an assertion at this + // index then insert a new log + const assertion = assertions[index] + + if (!assertion) { + assertions.push(log) + incrementIndex() + + return insertNewLog(log) + } + + // else just merge this log + // into the previous assertion log + incrementIndex() + assertion.merge(log) + + // dont output a new log + return false + } + + // if we already have a log + // then just update its attrs from + // the new log + const l = cmd.getLastLog() + + if (l) { + l.merge(log) + + // and make sure we return false + // which prevents a new log from + // being added + return false + } + + return insertNewLog(log) + } + + state('onBeforeLog', setCommandLog) + + // send verify=true as the last arg + return assertFn.apply(this, args.concat(true)) + } + + const fns = injectAssertionFns(cmds) + + const subjects = [] + + // iterate through each subject + // and force the assertion to return + // this value so it does not get + // invoked again + const setSubjectAndSkip = () => { + subjects.forEach((subject, i) => { + const cmd = cmds[i] + + cmd.set('subject', subject) + cmd.skip() + }) + + return cmds + } + + const assertions = (memo, fn, i) => { + // HACK: bluebird .reduce will not call the callback + // if given an undefined initial value, so in order to + // support undefined subjects, we wrap the initial value + // in an Array and unwrap it if index = 0 + if (i === 0) { + memo = memo[0] + } + + return fn(memo).then((subject) => { + return subjects[i] = subject + }) + } + + const restore = () => { + state('upcomingAssertions', []) + + // no matter what we need to + // restore the assert fn + return state('overrideAssert', undefined) + } + + // store this in case our test ends early + // and we reset between tests + state('overrideAssert', overrideAssert) + + return Promise + .reduce(fns, assertions, [subject]) + .then(() => { + restore() + + setSubjectAndSkip() + + finishAssertions(options.assertions) + + return onPassFn() + }) + .catch((err) => { + restore() + + // when we're told not to retry + if (err.retry === false) { + // finish the assertions + finishAssertions(options.assertions) + + // and then push our command into this err + try { + $utils.throwErr(err, { onFail: options._log }) + } catch (e) { + err = e + } + } + + throw err + }) + .catch(onFailFn) + } + + const assertFn = (passed, message, value, actual, expected, error, verifying = false) => { + // slice off everything after a ', but' or ' but ' for passing assertions, because + // otherwise it doesn't make sense: + // "expected
to have a an attribute 'href', but it was 'href'" + if (message && passed && butRe.test(message)) { + message = message.substring(0, message.search(butRe)) + } + + if (value && value.isSinonProxy) { + message = message.replace(stackTracesRe, '\n') + } + + let obj = parseValueActualAndExpected(value, actual, expected) + + if ($dom.isElement(value)) { + obj.$el = $dom.wrap(value) + } + + // if we are simply verifying the upcoming + // assertions then do not immediately end or snapshot + // else do + if (verifying) { + obj._error = error + } else { + obj.end = true + obj.snapshot = true + obj.error = error + } + + const isChildLike = (subject, current) => { + return (value === subject) || + isDomSubjectAndMatchesValue(value, subject) || + // if our current command is an assertion type + isAssertionType(current) || + // are we currently verifying assertions? + (state('upcomingAssertions') && state('upcomingAssertions').length > 0) || + // did the function have arguments + functionHadArguments(current) + } + + _.extend(obj, { + name: 'assert', + // end: true + // snapshot: true + message, + passed, + selector: value ? value.selector : undefined, + type (current, subject) { + // if our current command has arguments assume + // we are an assertion that's involving the current + // subject or our value is the current subject + return isChildLike(subject, current) ? 'child' : 'parent' + }, + + consoleProps: () => { + obj = { Command: 'assert' } + + _.extend(obj, parseValueActualAndExpected(value, actual, expected)) + + return _.extend(obj, + { Message: message.replace(bTagOpen, '').replace(bTagClosed, '') }) + }, + }) + + // think about completely gutting the whole object toString + // which chai does by default, its so ugly and worthless + + if (error) { + error.onFail = (err) => { } + } + + Cypress.log(obj) + + return null + } + + const assert = function (...args) { + // if we've temporarily overriden assertions + // then just bail early with this function + const fn = state('overrideAssert') || assertFn + + return fn.apply(this, args) + } + + return { + finishAssertions, + + verifyUpcomingAssertions, + + assert, + } +} + +module.exports = { + create, +} diff --git a/packages/driver/src/cy/keyboard.ts b/packages/driver/src/cy/keyboard.ts index 839c721e2161..d69d4dc62915 100644 --- a/packages/driver/src/cy/keyboard.ts +++ b/packages/driver/src/cy/keyboard.ts @@ -263,7 +263,8 @@ const shouldUpdateValue = (el: HTMLElement, key: KeyDetails, options: typeOption debug('skipping inserting value since number input would be invalid', key.text, potentialValue) // when typing in a number input, only certain whitelisted chars will insert text if (!key.text.match(isValidNumberInputChar)) { - options.prevValue = '' + // https://github.com/cypress-io/cypress/issues/6055 + // Should not remove old valid values when a new one is not a valid number char, just dismiss it with return return } diff --git a/packages/driver/src/cypress/chai_jquery.coffee b/packages/driver/src/cypress/chai_jquery.coffee deleted file mode 100644 index 5fcc3c70c576..000000000000 --- a/packages/driver/src/cypress/chai_jquery.coffee +++ /dev/null @@ -1,273 +0,0 @@ -_ = require("lodash") -$ = require("jquery") -$dom = require("../dom") - -selectors = { - visible: "visible" - hidden: "hidden" - selected: "selected" - checked: "checked" - enabled: "enabled" - disabled: "disabled" - focus: "focused" - focused: "focused" -} - -attrs = { - attr: "attribute" - css: "CSS property" - prop: "property" -} - -wrap = (ctx) -> - ## reset the obj under test - ## to be re-wrapped in our own - ## jquery, so we can control - ## the methods on it - $(ctx._obj) - -$chaiJquery = (chai, chaiUtils, callbacks = {}) -> - { inspect, flag } = chaiUtils - - assertDom = (ctx, method, args...) -> - if not $dom.isDom(ctx._obj) - try - ## always fail the assertion - ## if we aren't a DOM like object - ctx.assert(false, args...) - catch err - callbacks.onInvalid(method, ctx._obj) - - assert = (ctx, method, bool, args...) -> - assertDom(ctx, method, args...) - - try - # ## reset obj to wrapped - orig = ctx._obj - ctx._obj = wrap(ctx) - - if ctx._obj.length is 0 - ctx._obj = ctx._obj.selector - - ## apply the assertion - ret = ctx.assert(bool, args...) - ctx._obj = orig - return ret - catch err - ## send it up with the obj and whether it was negated - callbacks.onError(err, method, ctx._obj, flag(ctx, "negate")) - - assertPartial = (ctx, method, actual, expected, message, notMessage, args...) -> - if ctx.__flags.contains or ctx.__flags.includes - return assert( - ctx - method - _.includes(actual, expected), - 'expected #{this}'+ ' to contain ' + message - 'expected #{this}'+ ' not to contain ' + notMessage - args... - ) - return assert( - ctx - method - actual is expected - 'expected #{this}'+ ' to have ' + message - 'expected #{this}'+ ' not to have ' + notMessage - args... - ) - chai.Assertion.addMethod "data", -> - assertDom(@, "data") - - a = new chai.Assertion(wrap(@).data()) - - if flag(@, "negate") - a = a.not - - a.property.apply(a, arguments) - - chai.Assertion.addMethod "class", (className) -> - assert( - @, - "class", - wrap(@).hasClass(className), - 'expected #{this} to have class #{exp}', - 'expected #{this} not to have class #{exp}', - className - ) - - chai.Assertion.addMethod "id", (id) -> - assert( - @, - "id", - wrap(@).prop("id") is id, - 'expected #{this} to have id #{exp}', - 'expected #{this} not to have id #{exp}', - id - ) - - chai.Assertion.addMethod "html", (html) -> - assertDom( - @, - "html", - 'expected #{this} to have HTML #{exp}', - 'expected #{this} not to have HTML #{exp}', - html - ) - - actual = wrap(@).html() - - assertPartial( - @, - "html", - actual - html - 'HTML #{exp}, but the HTML was #{act}', - 'HTML #{exp}', - html, - actual - ) - - chai.Assertion.addMethod "text", (text) -> - assertDom( - @, - "text", - 'expected #{this} to have text #{exp}', - 'expected #{this} not to have text #{exp}', - text - ) - - actual = wrap(@).text() - - assertPartial( - @, - "text", - actual - text - 'text #{exp}, but the text was #{act}', - 'text #{exp}', - text, - actual - ) - - chai.Assertion.addMethod "value", (value) -> - assertDom( - @, - "value", - 'expected #{this} to have value #{exp}', - 'expected #{this} not to have value #{exp}', - value - ) - - actual = wrap(@).val() - - assertPartial( - @, - "value", - actual - value - 'value #{exp}, but the value was #{act}', - 'value #{exp}', - value, - actual - ) - - chai.Assertion.addMethod "descendants", (selector) -> - assert( - @, - "descendants", - wrap(@).has(selector).length > 0, - 'expected #{this} to have descendants #{exp}', - 'expected #{this} not to have descendants #{exp}', - selector - ) - - chai.Assertion.overwriteProperty "empty", (_super) -> - return -> - if $dom.isDom(@_obj) - assert( - @, - "empty", - wrap(@).is(":empty"), - 'expected #{this} to be #{exp}', - 'expected #{this} not to be #{exp}', - "empty" - ) - else - _super.apply(@, arguments) - - chai.Assertion.overwriteMethod "match", (_super) -> - return (selector) -> - if $dom.isDom(@_obj) - assert( - @, - "match", - wrap(@).is(selector), - 'expected #{this} to match #{exp}', - 'expected #{this} not to match #{exp}', - selector - ) - else - _super.apply(@, arguments) - - _.each selectors, (selectorName, selector) -> - chai.Assertion.addProperty selector, -> - assert( - @, - selector, - wrap(@).is(":" + selector), - 'expected #{this} to be #{exp}', - 'expected #{this} not to be #{exp}', - selectorName - ) - - _.each attrs, (description, attr) -> - chai.Assertion.addMethod attr, (name, val) -> - assertDom( - @, - attr, - 'expected #{this} to have ' + description + ' #{exp}', - 'expected #{this} not to have ' + description + ' #{exp}', - name - ) - - actual = wrap(@)[attr](name) - - ## when we only have 1 argument dont worry about val - if arguments.length is 1 - assert( - @, - attr, - actual != undefined, - 'expected #{this} to have ' + description + ' #{exp}', - 'expected #{this} not to have ' + description + ' #{exp}', - name - ) - - ## change the subject - @_obj = actual - - else - ## if we don't have an attribute here at all we need to - ## have a different failure message - if _.isUndefined(actual) - message = "expected \#{this} to have #{description} #{inspect(name)}" - - negatedMessage = "expected \#{this} not to have #{description} #{inspect(name)}" - else - message = "expected \#{this} to have #{description} #{inspect(name)} with the value \#{exp}, but the value was \#{act}" - - negatedMessage = "expected \#{this} not to have #{description} #{inspect(name)} with the value \#{exp}, but the value was \#{act}" - - assert( - @, - attr, - actual? and actual is val, - message, - negatedMessage, - val, - actual - ) - - return @ - -module.exports = $chaiJquery diff --git a/packages/driver/src/cypress/chai_jquery.js b/packages/driver/src/cypress/chai_jquery.js new file mode 100644 index 000000000000..62f391816fc8 --- /dev/null +++ b/packages/driver/src/cypress/chai_jquery.js @@ -0,0 +1,309 @@ +const _ = require('lodash') +const $ = require('jquery') +const $dom = require('../dom') + +const selectors = { + visible: 'visible', + hidden: 'hidden', + selected: 'selected', + checked: 'checked', + enabled: 'enabled', + disabled: 'disabled', + focus: 'focused', + focused: 'focused', +} + +const attrs = { + attr: 'attribute', + css: 'CSS property', + prop: 'property', +} + +// reset the obj under test +// to be re-wrapped in our own +// jquery, so we can control +// the methods on it +const wrap = (ctx) => $(ctx._obj) + +const $chaiJquery = (chai, chaiUtils, callbacks = {}) => { + const { inspect, flag } = chaiUtils + + const assertDom = (ctx, method, ...args) => { + if (!$dom.isDom(ctx._obj)) { + try { + // always fail the assertion + // if we aren't a DOM like object + return ctx.assert(false, ...args) + } catch (err) { + return callbacks.onInvalid(method, ctx._obj) + } + } + } + + const assert = (ctx, method, bool, ...args) => { + assertDom(ctx, method, ...args) + + try { + // reset obj to wrapped + const orig = ctx._obj + + ctx._obj = wrap(ctx) + + if (ctx._obj.length === 0) { + ctx._obj = ctx._obj.selector + } + + // apply the assertion + const ret = ctx.assert(bool, ...args) + + ctx._obj = orig + + return ret + } catch (err) { + // send it up with the obj and whether it was negated + return callbacks.onError(err, method, ctx._obj, flag(ctx, 'negate')) + } + } + + const assertPartial = (ctx, method, actual, expected, message, notMessage, ...args) => { + if (ctx.__flags.contains || ctx.__flags.includes) { + return assert( + ctx, + method, + _.includes(actual, expected), + `expected #{this} to contain ${message}`, + `expected #{this} not to contain ${notMessage}`, + ...args + ) + } + + return assert( + ctx, + method, + actual === expected, + `expected #{this} to have ${message}`, + `expected #{this} not to have ${notMessage}`, + ...args + ) + } + + chai.Assertion.addMethod('data', function (...args) { + assertDom(this, 'data') + + let a = new chai.Assertion(wrap(this).data()) + + if (flag(this, 'negate')) { + a = a.not + } + + return a.property.apply(a, args) + }) + + chai.Assertion.addMethod('class', function (className) { + return assert( + this, + 'class', + wrap(this).hasClass(className), + 'expected #{this} to have class #{exp}', + 'expected #{this} not to have class #{exp}', + className + ) + }) + + chai.Assertion.addMethod('id', function (id) { + return assert( + this, + 'id', + wrap(this).prop('id') === id, + 'expected #{this} to have id #{exp}', + 'expected #{this} not to have id #{exp}', + id + ) + }) + + chai.Assertion.addMethod('html', function (html) { + assertDom( + this, + 'html', + 'expected #{this} to have HTML #{exp}', + 'expected #{this} not to have HTML #{exp}', + html + ) + + const actual = wrap(this).html() + + return assertPartial( + this, + 'html', + actual, + html, + 'HTML #{exp}, but the HTML was #{act}', + 'HTML #{exp}', + html, + actual + ) + }) + + chai.Assertion.addMethod('text', function (text) { + assertDom( + this, + 'text', + 'expected #{this} to have text #{exp}', + 'expected #{this} not to have text #{exp}', + text + ) + + const actual = wrap(this).text() + + return assertPartial( + this, + 'text', + actual, + text, + 'text #{exp}, but the text was #{act}', + 'text #{exp}', + text, + actual + ) + }) + + chai.Assertion.addMethod('value', function (value) { + assertDom( + this, + 'value', + 'expected #{this} to have value #{exp}', + 'expected #{this} not to have value #{exp}', + value + ) + + const actual = wrap(this).val() + + return assertPartial( + this, + 'value', + actual, + value, + 'value #{exp}, but the value was #{act}', + 'value #{exp}', + value, + actual + ) + }) + + chai.Assertion.addMethod('descendants', function (selector) { + return assert( + this, + 'descendants', + wrap(this).has(selector).length > 0, + 'expected #{this} to have descendants #{exp}', + 'expected #{this} not to have descendants #{exp}', + selector + ) + }) + + chai.Assertion.overwriteProperty('empty', (_super) => { + return (function (...args) { + if ($dom.isDom(this._obj)) { + return assert( + this, + 'empty', + wrap(this).is(':empty'), + 'expected #{this} to be #{exp}', + 'expected #{this} not to be #{exp}', + 'empty' + ) + } + + return _super.apply(this, args) + }) + }) + + chai.Assertion.overwriteMethod('match', (_super) => { + return (function (...args) { + const selector = args[0] + + if ($dom.isDom(this._obj)) { + return assert( + this, + 'match', + wrap(this).is(selector), + 'expected #{this} to match #{exp}', + 'expected #{this} not to match #{exp}', + selector + ) + } + + return _super.apply(this, args) + }) + }) + + _.each(selectors, (selectorName, selector) => { + return chai.Assertion.addProperty(selector, function () { + return assert( + this, + selector, + wrap(this).is(`:${selector}`), + 'expected #{this} to be #{exp}', + 'expected #{this} not to be #{exp}', + selectorName + ) + }) + }) + + _.each(attrs, (description, attr) => { + return chai.Assertion.addMethod(attr, function (name, val) { + assertDom( + this, + attr, + `expected #{this} to have ${description} #{exp}`, + `expected #{this} not to have ${description} #{exp}`, + name + ) + + const actual = wrap(this)[attr](name) + + // when we only have 1 argument dont worry about val + if (arguments.length === 1) { + assert( + this, + attr, + actual !== undefined, + `expected #{this} to have ${description} #{exp}`, + `expected #{this} not to have ${description} #{exp}`, + name + ) + + // change the subject + this._obj = actual + } else { + // if we don't have an attribute here at all we need to + // have a different failure message + let message; let negatedMessage + + if (_.isUndefined(actual)) { + message = `expected \#{this} to have ${description} ${inspect(name)}` + + negatedMessage = `expected \#{this} not to have ${description} ${inspect(name)}` + } else { + message = `expected \#{this} to have ${description} ${inspect(name)} with the value \#{exp}, but the value was \#{act}` + + negatedMessage = `expected \#{this} not to have ${description} ${inspect(name)} with the value \#{exp}, but the value was \#{act}` + } + + assert( + this, + attr, + (actual != null) && (actual === val), + message, + negatedMessage, + val, + actual + ) + } + + return this + }) + }) +} + +module.exports = $chaiJquery diff --git a/packages/driver/src/cypress/mocha.coffee b/packages/driver/src/cypress/mocha.coffee deleted file mode 100644 index 21df770d7629..000000000000 --- a/packages/driver/src/cypress/mocha.coffee +++ /dev/null @@ -1,203 +0,0 @@ -_ = require("lodash") -$utils = require("./utils") - -## in the browser mocha is coming back -## as window -mocha = require("mocha") - -Mocha = mocha.Mocha ? mocha -Test = Mocha.Test -Runner = Mocha.Runner -Runnable = Mocha.Runnable - -runnerRun = Runner::run -runnerFail = Runner::fail -runnableRun = Runnable::run -runnableClearTimeout = Runnable::clearTimeout -runnableResetTimeout = Runnable::resetTimeout - -## don't let mocha polute the global namespace -delete window.mocha -delete window.Mocha - -ui = (specWindow, _mocha) -> - ## Override mocha.ui so that the pre-require event is emitted - ## with the iframe's `window` reference, rather than the parent's. - _mocha.ui = (name) -> - @_ui = Mocha.interfaces[name] - - if not @_ui - $utils.throwErrByPath("mocha.invalid_interface", { args: { name } }) - - @_ui = @_ui(@suite) - - ## this causes the mocha globals in the spec window to be defined - ## such as describe, it, before, beforeEach, etc - @suite.emit("pre-require", specWindow, null, @) - - return @ - - _mocha.ui("bdd") - -set = (specWindow, _mocha) -> - ## Mocha is usually defined in the spec when used normally - ## in the browser or node, so we add it as a global - ## for our users too - M = specWindow.Mocha = Mocha - m = specWindow.mocha = _mocha - - ## also attach the Mocha class - ## to the mocha instance for clarity - m.Mocha = M - - ## this needs to be part of the configuration of cypress.json - ## we can't just forcibly use bdd - ui(specWindow, _mocha) - -globals = (specWindow, reporter) -> - reporter ?= -> - - _mocha = new Mocha({ - reporter: reporter - enableTimeouts: false - }) - - ## set mocha props on the specWindow - set(specWindow, _mocha) - - ## return the newly created mocha instance - return _mocha - -getRunner = (_mocha) -> - Runner::run = -> - ## reset our runner#run function - ## so the next time we call it - ## its normal again! - restoreRunnerRun() - - ## return the runner instance - return @ - - _mocha.run() - -restoreRunnableClearTimeout = -> - Runnable::clearTimeout = runnableClearTimeout - -restoreRunnableResetTimeout = -> - Runnable::resetTimeout = runnableResetTimeout - -restoreRunnerRun = -> - Runner::run = runnerRun - -restoreRunnerFail = -> - Runner::fail = runnerFail - -restoreRunnableRun = -> - Runnable::run = runnableRun - -patchRunnerFail = -> - ## matching the current Runner.prototype.fail except - ## changing the logic for determing whether this is a valid err - Runner::fail = (runnable, err) -> - ## if this isnt a correct error object then just bail - ## and call the original function - if Object.prototype.toString.call(err) isnt "[object Error]" - return runnerFail.call(@, runnable, err) - - ## else replicate the normal mocha functionality - ++@failures - - runnable.state = "failed" - - @emit("fail", runnable, err) - -patchRunnableRun = (Cypress) -> - Runnable::run = (args...) -> - runnable = @ - - Cypress.action("mocha:runnable:run", runnableRun, runnable, args) - -patchRunnableClearTimeout = -> - Runnable::clearTimeout = -> - ## call the original - runnableClearTimeout.apply(@, arguments) - - ## nuke the timer property - ## for testing purposes - @timer = null - -patchRunnableResetTimeout = -> - Runnable::resetTimeout = -> - runnable = @ - - ms = @timeout() or 1e9 - - @clearTimeout() - - getErrPath = -> - ## we've yield an explicit done callback - if runnable.async - "mocha.async_timed_out" - else - ## TODO: improve this error message. It's not that - ## a command necessarily timed out - in fact this is - ## a mocha timeout, and a command likely *didn't* - ## time out correctly, so we received this message instead. - "mocha.timed_out" - - @timer = setTimeout -> - errMessage = $utils.errMessageByPath(getErrPath(), { ms }) - runnable.callback new Error(errMessage) - runnable.timedOut = true - , ms - -restore = -> - restoreRunnerRun() - restoreRunnerFail() - restoreRunnableRun() - restoreRunnableClearTimeout() - restoreRunnableResetTimeout() - -override = (Cypress) -> - patchRunnerFail() - patchRunnableRun(Cypress) - patchRunnableClearTimeout() - patchRunnableResetTimeout() - -create = (specWindow, Cypress, reporter) -> - restore() - - override(Cypress) - - ## generate the mocha + Mocha globals - ## on the specWindow, and get the new - ## _mocha instance - _mocha = globals(specWindow, reporter) - - _runner = getRunner(_mocha) - - return { - _mocha - - createRootTest: (title, fn) -> - r = new Test(title, fn) - _runner.suite.addTest(r) - r - - getRunner: -> - _runner - - getRootSuite: -> - _mocha.suite - - options: (runner) -> - runner.options(_mocha.options) - } - -module.exports = { - restore - - globals - - create -} diff --git a/packages/driver/src/cypress/mocha.js b/packages/driver/src/cypress/mocha.js new file mode 100644 index 000000000000..d183a85c3a91 --- /dev/null +++ b/packages/driver/src/cypress/mocha.js @@ -0,0 +1,233 @@ +const $utils = require('./utils') + +// in the browser mocha is coming back +// as window +const mocha = require('mocha') + +const Mocha = mocha.Mocha != null ? mocha.Mocha : mocha +const { Test, Runner, Runnable } = Mocha + +const runnerRun = Runner.prototype.run +const runnerFail = Runner.prototype.fail +const runnableRun = Runnable.prototype.run +const runnableClearTimeout = Runnable.prototype.clearTimeout +const runnableResetTimeout = Runnable.prototype.resetTimeout + +// don't let mocha polute the global namespace +delete window.mocha +delete window.Mocha + +const ui = (specWindow, _mocha) => { + // Override mocha.ui so that the pre-require event is emitted + // with the iframe's `window` reference, rather than the parent's. + _mocha.ui = function (name) { + this._ui = Mocha.interfaces[name] + + if (!this._ui) { + $utils.throwErrByPath('mocha.invalid_interface', { args: { name } }) + } + + this._ui = this._ui(this.suite) + + // this causes the mocha globals in the spec window to be defined + // such as describe, it, before, beforeEach, etc + this.suite.emit('pre-require', specWindow, null, this) + + return this + } + + return _mocha.ui('bdd') +} + +const set = (specWindow, _mocha) => { + // Mocha is usually defined in the spec when used normally + // in the browser or node, so we add it as a global + // for our users too + const M = (specWindow.Mocha = Mocha) + const m = (specWindow.mocha = _mocha) + + // also attach the Mocha class + // to the mocha instance for clarity + m.Mocha = M + + // this needs to be part of the configuration of cypress.json + // we can't just forcibly use bdd + return ui(specWindow, _mocha) +} + +const globals = (specWindow, reporter) => { + if (reporter == null) { + reporter = () => {} + } + + const _mocha = new Mocha({ + reporter, + enableTimeouts: false, + }) + + // set mocha props on the specWindow + set(specWindow, _mocha) + + // return the newly created mocha instance + return _mocha +} + +const getRunner = function (_mocha) { + Runner.prototype.run = function () { + // reset our runner#run function + // so the next time we call it + // its normal again! + restoreRunnerRun() + + // return the runner instance + return this + } + + return _mocha.run() +} + +const restoreRunnableClearTimeout = () => { + Runnable.prototype.clearTimeout = runnableClearTimeout +} + +const restoreRunnableResetTimeout = () => { + Runnable.prototype.resetTimeout = runnableResetTimeout +} + +const restoreRunnerRun = () => { + Runner.prototype.run = runnerRun +} + +const restoreRunnerFail = () => { + Runner.prototype.fail = runnerFail +} + +const restoreRunnableRun = () => { + Runnable.prototype.run = runnableRun +} + +// matching the current Runner.prototype.fail except +// changing the logic for determing whether this is a valid err +const patchRunnerFail = () => { + Runner.prototype.fail = function (runnable, err) { + // if this isnt a correct error object then just bail + // and call the original function + if (Object.prototype.toString.call(err) !== '[object Error]') { + return runnerFail.call(this, runnable, err) + } + + // else replicate the normal mocha functionality + ++this.failures + + runnable.state = 'failed' + + this.emit('fail', runnable, err) + } +} + +const patchRunnableRun = (Cypress) => { + Runnable.prototype.run = function (...args) { + const runnable = this + + Cypress.action('mocha:runnable:run', runnableRun, runnable, args) + } +} + +const patchRunnableClearTimeout = () => { + Runnable.prototype.clearTimeout = function (...args) { + // call the original + runnableClearTimeout.apply(this, args) + + this.timer = null + } +} + +const patchRunnableResetTimeout = () => { + Runnable.prototype.resetTimeout = function () { + const runnable = this + + const ms = this.timeout() || 1e9 + + this.clearTimeout() + + const getErrPath = function () { + // we've yield an explicit done callback + if (runnable.async) { + return 'mocha.async_timed_out' + } + + // TODO: improve this error message. It's not that + // a command necessarily timed out - in fact this is + // a mocha timeout, and a command likely *didn't* + // time out correctly, so we received this message instead. + return 'mocha.timed_out' + } + + this.timer = setTimeout(() => { + const errMessage = $utils.errMessageByPath(getErrPath(), { ms }) + + runnable.callback(new Error(errMessage)) + runnable.timedOut = true + }, ms) + } +} + +const restore = () => { + restoreRunnerRun() + restoreRunnerFail() + restoreRunnableRun() + restoreRunnableClearTimeout() + restoreRunnableResetTimeout() +} + +const override = (Cypress) => { + patchRunnerFail() + patchRunnableRun(Cypress) + patchRunnableClearTimeout() + patchRunnableResetTimeout() +} + +const create = (specWindow, Cypress, reporter) => { + restore() + + override(Cypress) + + // generate the mocha + Mocha globals + // on the specWindow, and get the new + // _mocha instance + const _mocha = globals(specWindow, reporter) + + const _runner = getRunner(_mocha) + + return { + _mocha, + + createRootTest (title, fn) { + const r = new Test(title, fn) + + _runner.suite.addTest(r) + + return r + }, + + getRunner () { + return _runner + }, + + getRootSuite () { + return _mocha.suite + }, + + options (runner) { + return runner.options(_mocha.options) + }, + } +} + +module.exports = { + restore, + + globals, + + create, +} diff --git a/packages/driver/src/dom/transform.ts b/packages/driver/src/dom/transform.ts new file mode 100644 index 000000000000..f4076497db6b --- /dev/null +++ b/packages/driver/src/dom/transform.ts @@ -0,0 +1,283 @@ +import _ from 'lodash' +import { isDocument } from './document' + +export const detectVisibility = ($el: any) => { + const list = extractTransformInfoFromElements($el) + + if (existsInvisibleBackface(list)) { + return elIsBackface(list) ? 'backface' : 'visible' + } + + return elIsTransformedToZero(list) ? 'transformed' : 'visible' +} + +type BackfaceVisibility = 'hidden' | 'visible' +type TransformStyle = 'flat' | 'preserve-3d' +type Matrix2D = [ + number, number, number, + number, number, number, +] +type Matrix3D = [ + number, number, number, number, + number, number, number, number, + number, number, number, number, + number, number, number, number, +] + +type Vector3 = [number, number, number] + +interface TransformInfo { + backfaceVisibility: BackfaceVisibility + transformStyle: TransformStyle + transform: string +} + +const extractTransformInfoFromElements = ($el: any, list: TransformInfo[] = []): TransformInfo[] => { + list.push(extractTransformInfo($el)) + + const $parent = $el.parent() + + if (!$parent.length || isDocument($parent)) { + return list + } + + return extractTransformInfoFromElements($parent, list) +} + +const extractTransformInfo = ($el): TransformInfo => { + const el = $el[0] + const style = getComputedStyle(el) + + return { + backfaceVisibility: style.getPropertyValue('backface-visibility') as BackfaceVisibility, + transformStyle: style.getPropertyValue('transform-style') as TransformStyle, + transform: style.getPropertyValue('transform'), + } +} + +const existsInvisibleBackface = (list: TransformInfo[]) => { + return !!_.find(list, { backfaceVisibility: 'hidden' }) +} + +const numberRegex = /-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/g +const defaultNormal: Vector3 = [0, 0, 1] +const viewVector: Vector3 = [0, 0, -1] +const identityMatrix3D: Matrix3D = [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, +] + +// It became 1e-5 from 1e-10. Because 30deg + 30deg + 30deg is 6.0568e-7 and it caused a false negative. +const TINY_NUMBER = 1e-5 + +const nextPreserve3d = (i: number, list: TransformInfo[]) => { + return i + 1 < list.length && + list[i + 1].transformStyle === 'preserve-3d' +} + +const finalNormal = (startIndex: number, list: TransformInfo[]) => { + let i = startIndex + let normal = findNormal(parseMatrix3D(list[i].transform)) + + while (nextPreserve3d(i, list)) { + i++ + normal = findNormal(parseMatrix3D(list[i].transform), normal) + } + + return normal +} + +const elIsBackface = (list: TransformInfo[]) => { + // When the direct parent of the target has style, preserve-3d + if (list.length > 1 && list[1].transformStyle === 'preserve-3d') { + // When the target is backface-invisible a2-1-1 ~ a2-1-4 + if (list[0].backfaceVisibility === 'hidden') { + let normal = finalNormal(0, list) + + if (checkBackface(normal)) { + return true + } + } else { + // When the direct parent of the target is backface-invisible + if (list[1].backfaceVisibility === 'hidden') { + // If it is not none, it is visible. Check a2-3-1 + if (list[0].transform === 'none') { + let normal = finalNormal(1, list) + + if (checkBackface(normal)) { + return true + } + } + } + + // Check 90deg a2-2-3, a2-2-4. + let normal = finalNormal(0, list) + + return isElementOrthogonalWithView(normal) + } + } else { + for (let i = 0; i < list.length; i++) { + // Ignore preserve-3d when it is not a direct parent. + // Why? -> https://github.com/cypress-io/cypress/pull/5916 + if (i > 0 && list[i].transformStyle === 'preserve-3d') { + continue + } + + if (list[i].backfaceVisibility === 'hidden' && list[i].transform.startsWith('matrix3d')) { + let normal = findNormal(parseMatrix3D(list[i].transform)) + + if (checkBackface(normal)) { + return true + } + } + } + } + + return false +} + +// This function uses a simplified version of backface culling. +// https://en.wikipedia.org/wiki/Back-face_culling +// +// We defined view vector, (0, 0, -1), - eye to screen. +// and default normal vector of an element, (0, 0, 1) +// When dot product of them are >= 0, item is visible. +const checkBackface = (normal: Vector3) => { + // Simplified dot product. + // viewVector[0] and viewVector[1] are always 0. So, they're ignored. + let dot = viewVector[2] * normal[2] + + // Because of the floating point number rounding error, + // cos(90deg) isn't 0. It's 6.12323e-17. + // And it sometimes causes errors when dot product value is something like -6.12323e-17. + // So, we're setting the dot product result to 0 when its absolute value is less than SMALL_NUMBER(10^-10). + if (Math.abs(dot) < TINY_NUMBER) { + dot = 0 + } + + return dot >= 0 +} + +const parseMatrix3D = (transform: string): Matrix3D => { + if (transform === 'none') { + return identityMatrix3D + } + + if (transform.startsWith('matrix3d')) { + const matrix: Matrix3D = transform.substring(8).match(numberRegex)!.map((n) => { + return parseFloat(n) + }) as Matrix3D + + return matrix + } + + return toMatrix3d(transform.match(numberRegex)!.map((n) => parseFloat(n)) as Matrix2D) +} + +const parseMatrix2D = (transform: string): Matrix2D => { + return transform.match(numberRegex)!.map((n) => parseFloat(n)) as Matrix2D +} + +const findNormal = (matrix: Matrix3D, normal: Vector3 = defaultNormal): Vector3 => { + const m = matrix // alias for shorter formula + const v = normal // alias for shorter formula + const computedNormal: Vector3 = [ + m[0] * v[0] + m[4] * v[1] + m[8] * v[2], + m[1] * v[0] + m[5] * v[1] + m[9] * v[2], + m[2] * v[0] + m[6] * v[1] + m[10] * v[2], + ] + + return toUnitVector(computedNormal) +} + +const toMatrix3d = (m2d: Matrix2D): Matrix3D => { + return [ + m2d[0], m2d[1], 0, 0, + m2d[2], m2d[3], 0, 0, + 0, 0, 1, 0, + m2d[4], m2d[5], 0, 1, + ] +} + +const toUnitVector = (v: Vector3): Vector3 => { + const length = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) + + return [v[0] / length, v[1] / length, v[2] / length] +} + +// This function checks 2 things that can happen: scale and rotate to 0 in width or height. +const elIsTransformedToZero = (list: TransformInfo[]) => { + if (list[1].transformStyle === 'preserve-3d') { + const normal = finalNormal(0, list) + + return isElementOrthogonalWithView(normal) + } + + return !!_.find(list, (info) => isTransformedToZero(info)) +} + +const isTransformedToZero = ({ transform }: TransformInfo) => { + if (transform === 'none') { + return false + } + + // To understand how this part works, + // you need to understand tranformation matrix first. + // Matrix is hard to explain with only text. So, check these articles. + // + // https://www.useragentman.com/blog/2011/01/07/css3-matrix-transform-for-the-mathematically-challenged/ + // https://en.wikipedia.org/wiki/Rotation_matrix#In_three_dimensions + // + if (transform.startsWith('matrix3d')) { + const matrix3d = parseMatrix3D(transform) + + if (is3DMatrixScaledTo0(matrix3d)) { + return true + } + + const normal = findNormal(matrix3d) + + return isElementOrthogonalWithView(normal) + } + + const m = parseMatrix2D(transform) + + if (is2DMatrixScaledTo0(m)) { + return true + } + + return false +} + +const is3DMatrixScaledTo0 = (m3d: Matrix3D) => { + const xAxisScaledTo0 = m3d[0] === 0 && m3d[4] === 0 && m3d[8] === 0 + const yAxisScaledTo0 = m3d[1] === 0 && m3d[5] === 0 && m3d[9] === 0 + const zAxisScaledTo0 = m3d[2] === 0 && m3d[6] === 0 && m3d[10] === 0 + + if (xAxisScaledTo0 || yAxisScaledTo0 || zAxisScaledTo0) { + return true + } + + return false +} + +const is2DMatrixScaledTo0 = (m: Matrix2D) => { + const xAxisScaledTo0 = m[0] === 0 && m[2] === 0 + const yAxisScaledTo0 = m[1] === 0 && m[3] === 0 + + if (xAxisScaledTo0 || yAxisScaledTo0) { + return true + } + + return false +} + +const isElementOrthogonalWithView = (normal: Vector3) => { + // Simplified dot product. + // [0] and [1] are always 0 + const dot = viewVector[2] * normal[2] + + return Math.abs(dot) < TINY_NUMBER +} diff --git a/packages/driver/src/dom/visibility.js b/packages/driver/src/dom/visibility.js index 805cf62c8f3b..0984dde6cf98 100644 --- a/packages/driver/src/dom/visibility.js +++ b/packages/driver/src/dom/visibility.js @@ -4,6 +4,7 @@ const $jquery = require('./jquery') const $document = require('./document') const $elements = require('./elements') const $coordinates = require('./coordinates') +const $transform = require('./transform') const fixedOrAbsoluteRe = /(fixed|absolute)/ @@ -64,11 +65,7 @@ const isHidden = (el, name = 'isHidden()') => { // when an element is scaled to 0 in one axis // it is not visible to users. // So, it is hidden. - if (elIsHiddenByTransform($el)) { - return true - } - - if (elIsBackface($el)) { + if ($transform.detectVisibility($el) !== 'visible') { return true } @@ -100,17 +97,31 @@ const elHasNoEffectiveWidthOrHeight = ($el) => { // display:none elements, and generally any elements that are not directly rendered, // an empty list is returned. + const el = $el[0] + const style = getComputedStyle(el) + const transform = style.getPropertyValue('transform') + const width = elOffsetWidth($el) + const height = elOffsetHeight($el) + const overflowHidden = elHasOverflowHidden($el) + + return isZeroLengthAndTransformNone(width, height, transform) || + isZeroLengthAndOverflowHidden(width, height, overflowHidden) || + (el.getClientRects().length <= 0) +} + +const isZeroLengthAndTransformNone = (width, height, transform) => { // From https://github.com/cypress-io/cypress/issues/5974, // we learned that when an element has non-'none' transform style value like "translate(0, 0)", // it is visible even with `height: 0` or `width: 0`. // That's why we're checking `transform === 'none'` together with elOffsetWidth/Height. - const style = elComputedStyle($el) - const transform = style.getPropertyValue('transform') + return (width <= 0 && transform === 'none') || + (height <= 0 && transform === 'none') +} - return (elOffsetWidth($el) <= 0 && transform === 'none') || - (elOffsetHeight($el) <= 0 && transform === 'none') || - ($el[0].getClientRects().length <= 0) +const isZeroLengthAndOverflowHidden = (width, height, overflowHidden) => { + return (width <= 0 && overflowHidden) || + (height <= 0 && overflowHidden) } const elHasNoOffsetWidthOrHeight = ($el) => { @@ -133,117 +144,10 @@ const elHasVisibilityHidden = ($el) => { return $el.css('visibility') === 'hidden' } -const elComputedStyle = ($el) => { - const el = $el[0] - - return getComputedStyle(el) -} - -const numberRegex = /-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/g -// This is a simplified version of backface culling. -// https://en.wikipedia.org/wiki/Back-face_culling -// -// We defined view normal vector, (0, 0, -1), - eye to screen. -// and default normal vector, (0, 0, 1) -// When dot product of them are >= 0, item is visible. -const elIsBackface = ($el) => { - const style = elComputedStyle($el) - const backface = style.getPropertyValue('backface-visibility') - const backfaceInvisible = backface === 'hidden' - const transform = style.getPropertyValue('transform') - - if (!backfaceInvisible || !transform.startsWith('matrix3d')) { - return false - } - - const m3d = transform.substring(8).match(numberRegex) - const defaultNormal = [0, 0, -1] - const elNormal = findNormal(m3d) - // Simplified dot product. - // [0] and [1] are always 0 - const dot = defaultNormal[2] * elNormal[2] - - return dot >= 0 -} - -const findNormal = (m) => { - const length = Math.sqrt(+m[8] * +m[8] + +m[9] * +m[9] + +m[10] * +m[10]) - - return [+m[8] / length, +m[9] / length, +m[10] / length] -} - const elHasVisibilityCollapse = ($el) => { return $el.css('visibility') === 'collapse' } -// This function checks 2 things that can happen: scale and rotate -const elIsHiddenByTransform = ($el) => { - const style = elComputedStyle($el) - const transform = style.getPropertyValue('transform') - - if (transform === 'none') { - return false - } - - // To understand how this part works, - // you need to understand tranformation matrix first. - // Matrix is hard to explain with only text. So, check these articles. - // - // https://www.useragentman.com/blog/2011/01/07/css3-matrix-transform-for-the-mathematically-challenged/ - // https://en.wikipedia.org/wiki/Rotation_matrix#In_three_dimensions - // - if (transform.startsWith('matrix3d')) { - const matrix3d = transform.substring(8).match(numberRegex) - - if (is3DMatrixScaledTo0(matrix3d)) { - return true - } - - return isElementOrthogonalWithView(matrix3d) - } - - const m = transform.match(numberRegex) - - if (is2DMatrixScaledTo0(m)) { - return true - } - - return false -} - -const is3DMatrixScaledTo0 = (m3d) => { - const xAxisScaledTo0 = +m3d[0] === 0 && +m3d[4] === 0 && +m3d[8] === 0 - const yAxisScaledTo0 = +m3d[1] === 0 && +m3d[5] === 0 && +m3d[9] === 0 - const zAxisScaledTo0 = +m3d[2] === 0 && +m3d[6] === 0 && +m3d[10] === 0 - - if (xAxisScaledTo0 || yAxisScaledTo0 || zAxisScaledTo0) { - return true - } - - return false -} - -const is2DMatrixScaledTo0 = (m) => { - const xAxisScaledTo0 = +m[0] === 0 && +m[2] === 0 - const yAxisScaledTo0 = +m[1] === 0 && +m[3] === 0 - - if (xAxisScaledTo0 || yAxisScaledTo0) { - return true - } - - return false -} - -const isElementOrthogonalWithView = (matrix3d) => { - const defaultNormal = [0, 0, -1] - const elNormal = findNormal(matrix3d) - // Simplified dot product. - // [0] and [1] are always 0 - const dot = defaultNormal[2] * elNormal[2] - - return Math.abs(dot) <= 1e-10 -} - const elHasDisplayNone = ($el) => { return $el.css('display') === 'none' } @@ -400,14 +304,6 @@ const elIsHiddenByAncestors = function ($el, $origEl = $el) { return !elDescendentsHavePositionFixedOrAbsolute($parent, $origEl) } - if (elIsHiddenByTransform($parent)) { - return true - } - - if (elIsBackface($parent)) { - return true - } - // continue to recursively walk up the chain until we reach body or html return elIsHiddenByAncestors($parent, $origEl) } @@ -524,11 +420,13 @@ const getReasonIsHidden = function ($el) { return `This element '${node}' is not visible because it has an effective width and height of: '${width} x ${height}' pixels.` } - if (elIsHiddenByTransform($el)) { + const transformResult = $transform.detectVisibility($el) + + if (transformResult === 'transformed') { return `This element '${node}' is not visible because it is hidden by transform.` } - if (elIsBackface($el)) { + if (transformResult === 'backface') { return `This element '${node}' is not visible because it is rotated and its backface is hidden.` } diff --git a/packages/driver/test/cypress/fixtures/issue-5682.html b/packages/driver/test/cypress/fixtures/issue-5682.html new file mode 100644 index 000000000000..2ff8b8589c48 --- /dev/null +++ b/packages/driver/test/cypress/fixtures/issue-5682.html @@ -0,0 +1,220 @@ + + + + + + +
+

Basic cases

+ +

b-1: No transform

+
No transform
+ +

b-2, b-3: Rotate < 90deg + backface-invisible

+
rotateX(45deg)
+
rotateY(-45deg)
+ +

b-4, b-5: Rotate > 90deg + backface-invisible

+
rotateX(135deg)
+
rotateY(-135deg)
+ +

b-6, b-7: Rotate 90deg + backface-invisible

+
rotateX(90deg)
+
rotateY(-90deg)
+ +

b-8: Rotate > 135deg + backface-visible

+
Not hidden + rotateX(150deg)
+
+ +
+

Affected by ancestors

+ +
+

CASE 1: All flat

+ +

Parent Flipped

+
+
Hidden by parent
+
+ +

Parent Visible

+
+
Not hidden by parent
+
+ +

Parent rotate 180deg + Child backface-invisible

+
+
Parent rotated 180deg
+
+ +

Parent rotate 90deg + Child rotate 190deg with backface-invisible

+
+ Parent rotateX(90deg) +
Target rotateX(190deg)
+
+
+ +
+

CASE 2: Direct parent preserve-3d elements

+ +

a2-1-1: Parent Flipped visible + target hidden

+
+ Parent rotateX(180deg) +
No transform
+
+ +

a2-1-2: Parents rotated 60deg each visible + target hidden

+
+ Grandparent rotateX(60deg) +
+ Parent rotateX(60deg) +
No transform
+
+
+ +

a2-1-3: Parent Flipped hidden + target hidden

+
+ Parent rotateX(180deg) +
No transform
+
+ +

a2-1-4: Parent 60deg visible + target hidden 60deg

+
+ Parent rotateX(60deg) +
rotateX(60deg)
+
+ +

a2-2-1: Parent Flipped visible + target visible

+
+ Parent rotateX(180deg) +
No transform
+
+ +

a2-2-2: Parents Flipped visible + target visible

+
+ Parent rotateX(60deg) +
+ Parent rotateX(60deg) +
No transform
+
+
+ +

a2-2-3: Parents rotated 45deg visible + target visible

+
+ Grandparent rotateX(45deg) +
+ Parent rotateX(45deg) +
No transform
+
+
+ +

a2-2-4: Parents rotated 30deg visible + target visible 30deg

+
+ Grandparent rotateX(30deg) +
+ Parent rotateX(30deg) +
rotateX(30deg)
+
+
+ +

a2-3-1: Parent Flipped hidden + identity transform

+
+ Parent rotateX(180deg) +
Identity transform
+
+ +

a2-3-2: Parent Flipped hidden + no transform

+
+ Parent rotateX(180deg) +
No transform
+
+ +

a2-3-3: Grandparent rotated 45deg + parent rotated 45deg hidden + target visible

+
+ Grandparent rotateX(45deg) +
+ Parent rotateX(45deg) +
No transform
+
+
+
+ +

Issue case

+
+
+
+ Front +
+
+ Back +
+
+ + +
+
+ + + diff --git a/packages/driver/test/cypress/integration/commands/actions/type_spec.js b/packages/driver/test/cypress/integration/commands/actions/type_spec.js index ab10ac8fd5ac..70e770ba256b 100644 --- a/packages/driver/test/cypress/integration/commands/actions/type_spec.js +++ b/packages/driver/test/cypress/integration/commands/actions/type_spec.js @@ -1321,6 +1321,13 @@ describe('src/cy/commands/actions/type', () => { .should('have.value', '-123.12') }) + // https://github.com/cypress-io/cypress/issues/6055 + it('can type negative numbers and dismiss invalid characters', () => { + cy.get('#number-without-value') + .type('-a42') + .should('have.value', '-42') + }) + it('can type {del}', () => { cy.get('#number-with-value') .type('{selectAll}{del}') diff --git a/packages/driver/test/cypress/integration/commands/assertions_spec.js b/packages/driver/test/cypress/integration/commands/assertions_spec.js index 4e578a784695..8ecdacda7d01 100644 --- a/packages/driver/test/cypress/integration/commands/assertions_spec.js +++ b/packages/driver/test/cypress/integration/commands/assertions_spec.js @@ -211,6 +211,29 @@ describe('src/cy/commands/assertions', () => { }) }) + it('can be chained', () => { + cy.wrap('ab') + .should((subject) => { + expect(subject).to.be.a('string') + expect(subject).to.contain('a') + }) + .should((subject) => { + expect(subject).to.contain('b') + expect(subject).to.have.length(2) + }) + .and((subject) => { + expect(subject).to.eq('ab') + expect(subject).not.to.contain('c') + }) + .then(function () { + expect(this.logs.length).to.eq(8) + + this.logs.slice(1).forEach((log) => { + expect(log.get('name')).to.eq('assert') + }) + }) + }) + context('remote jQuery instances', () => { beforeEach(function () { this.remoteWindow = cy.state('window') diff --git a/packages/driver/test/cypress/integration/commands/navigation_spec.coffee b/packages/driver/test/cypress/integration/commands/navigation_spec.coffee index 1a8638f45c8e..59ea23865025 100644 --- a/packages/driver/test/cypress/integration/commands/navigation_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/navigation_spec.coffee @@ -194,7 +194,7 @@ describe "src/cy/commands/navigation", -> it "does not log 'Page Load' events", -> cy.reload().then -> @logs.slice(0).forEach (log) -> - expect(log.get("event")).to.be.false + expect(log.get("name")).not.eq('page load') it "logs before + after", -> beforeunload = false @@ -392,7 +392,7 @@ describe "src/cy/commands/navigation", -> .visit("/fixtures/jquery.html") .go("back").then -> @logs.slice(0).forEach (log) -> - expect(log.get("event")).to.be.false + expect(log.get("name")).not.eq('page load') it "logs before + after", -> beforeunload = false @@ -781,7 +781,7 @@ describe "src/cy/commands/navigation", -> .visit("/fixtures/jquery.html") .then -> @logs.slice(0).forEach (log) -> - expect(log.get("event")).to.be.false + expect(log.get("name")).not.eq('page load') it "logs immediately before resolving", -> expected = false diff --git a/packages/driver/test/cypress/integration/dom/visibility_spec.js b/packages/driver/test/cypress/integration/dom/visibility_spec.js index c2dd482fb987..265213bfc35a 100644 --- a/packages/driver/test/cypress/integration/dom/visibility_spec.js +++ b/packages/driver/test/cypress/integration/dom/visibility_spec.js @@ -442,15 +442,6 @@ describe('src/cypress/dom/visibility', () => {
TRANSFORMERS
\ -`) - - this.$parentsWithBackfaceVisibilityHidden = add(`\ -
- front -
-
- back -
\ `) this.$ancestorTransformMakesElOutOfBoundsOfAncestor = add(`\ @@ -929,74 +920,60 @@ describe('src/cypress/dom/visibility', () => { expect(el.find('#tr-p-2')).to.be.hidden }) - }) - it('is hidden when outside parents transform scale', function () { - expect(this.$parentWithTransformScaleElOutsideScale.find('span')).to.be.hidden - }) + describe('invisible when overflow: hidden', () => { + it('height: 0 + overflow', () => { + const el = add('

Test

') - it('is visible when inside of parents transform scale', function () { - expect(this.$parentWithTransformScaleElInsideScale.find('span')).to.be.visible - }) + expect(el.find('#h0th')).to.be.hidden + }) - it('is hidden when out of ancestor\'s bounds due to ancestor\'s transform', function () { - expect(this.$ancestorTransformMakesElOutOfBoundsOfAncestor.find('span')).to.be.hidden - }) + it('height: 0 + overflow-x', () => { + const el = add('

Test

') - it('is visible when in ancestor\'s bounds due to ancestor\'s transform', function () { - expect(this.$ancestorTransformMakesElInBoundsOfAncestor.find('#inbounds')).to.be.visible - }) - }) + expect(el.find('#h0th')).to.be.hidden + }) - describe('css backface-visibility', () => { - describe('element visibility by backface-visibility and rotation', () => { - const add = (el) => { - return $(el).appendTo(cy.$$('body')) - } + it('height: 0 + overflow-y', () => { + const el = add('

Test

') - it('is visible when there is no transform', () => { - const el = add('
No transform
') + expect(el.find('#h0th')).to.be.hidden + }) - expect(el).to.be.visible - }) + it('width: 0 + overflow', () => { + const el = add('

Test

') - it('is visible when an element is rotated < 90 degrees', () => { - const el = add('
rotateX(45deg)
') + expect(el.find('#h0th')).to.be.hidden + }) - expect(el).to.be.visible + it('width: 0 + overflow-x', () => { + const el = add('

Test

') - const el2 = add('
rotateY(-45deg)
') + expect(el.find('#h0th')).to.be.hidden + }) - expect(el2).to.be.visible - }) - - it('is invisible when an element is rotated > 90 degrees', () => { - const el = add('
rotateX(135deg)
') - - expect(el).to.be.hidden - - const el2 = add('
rotateY(-135deg)
') + it('width: 0 + overflow-y', () => { + const el = add('

Test

') - expect(el2).to.be.hidden + expect(el.find('#h0th')).to.be.hidden + }) }) + }) - it('is invisible when an element is rotated in 90 degrees', () => { - const el = add('
rotateX(90deg)
') - - expect(el).to.be.hidden - - const el2 = add('
rotateY(-90deg)
') + it('is hidden when outside parents transform scale', function () { + expect(this.$parentWithTransformScaleElOutsideScale.find('span')).to.be.hidden + }) - expect(el2).to.be.hidden - }) + it('is visible when inside of parents transform scale', function () { + expect(this.$parentWithTransformScaleElInsideScale.find('span')).to.be.visible }) - it('is visible when backface not visible', function () { - expect(this.$parentsWithBackfaceVisibilityHidden.find('#front')).to.be.visible + it('is hidden when out of ancestor\'s bounds due to ancestor\'s transform', function () { + expect(this.$ancestorTransformMakesElOutOfBoundsOfAncestor.find('span')).to.be.hidden }) - it('is hidden when backface visible', function () { - expect(this.$parentsWithBackfaceVisibilityHidden.find('#back')).to.be.hidden + it('is visible when in ancestor\'s bounds due to ancestor\'s transform', function () { + expect(this.$ancestorTransformMakesElInBoundsOfAncestor.find('#inbounds')).to.be.visible }) }) diff --git a/packages/driver/test/cypress/integration/issues/510_spec.coffee b/packages/driver/test/cypress/integration/issues/510_spec.coffee deleted file mode 100644 index 46185a4e5c79..000000000000 --- a/packages/driver/test/cypress/integration/issues/510_spec.coffee +++ /dev/null @@ -1,7 +0,0 @@ -before -> - cy - .visit("http://localhost:3500/fixtures/jquery.html") - -it "XHR should be available", -> - -it "XHR should be available2", -> diff --git a/packages/driver/test/cypress/integration/issues/510_spec.js b/packages/driver/test/cypress/integration/issues/510_spec.js new file mode 100644 index 000000000000..181e0a656b95 --- /dev/null +++ b/packages/driver/test/cypress/integration/issues/510_spec.js @@ -0,0 +1,7 @@ +before(() => { + cy.visit('http://localhost:3500/fixtures/jquery.html') +}) + +it('XHR should be available', () => {}) + +it('XHR should be available2', () => {}) diff --git a/packages/driver/test/cypress/integration/issues/565_spec.coffee b/packages/driver/test/cypress/integration/issues/565_spec.coffee deleted file mode 100644 index aad0ad374c91..000000000000 --- a/packages/driver/test/cypress/integration/issues/565_spec.coffee +++ /dev/null @@ -1,9 +0,0 @@ -## https://github.com/cypress-io/cypress/issues/565 -describe "issue 565", -> - before -> - cy - .viewport(400, 400) - .visit("/fixtures/issue-565.html") - - it "can click the first tr", -> - cy.get("td:first").click() diff --git a/packages/driver/test/cypress/integration/issues/565_spec.js b/packages/driver/test/cypress/integration/issues/565_spec.js new file mode 100644 index 000000000000..35c90e203f05 --- /dev/null +++ b/packages/driver/test/cypress/integration/issues/565_spec.js @@ -0,0 +1,12 @@ +// https://github.com/cypress-io/cypress/issues/565 +describe('issue 565', () => { + before(() => { + cy + .viewport(400, 400) + .visit('/fixtures/issue-565.html') + }) + + it('can click the first tr', () => { + cy.get('td:first').click() + }) +}) diff --git a/packages/driver/test/cypress/integration/issues/5682_spec.js b/packages/driver/test/cypress/integration/issues/5682_spec.js new file mode 100644 index 000000000000..ba70a83ed1cf --- /dev/null +++ b/packages/driver/test/cypress/integration/issues/5682_spec.js @@ -0,0 +1,80 @@ +describe('issue #5682 - backface visibility', () => { + beforeEach(() => { + cy.visit('/fixtures/issue-5682.html') + }) + + describe('basic cases', () => { + it('is visible when there is no transform', () => { + cy.get('#b-1').should('be.visible') + }) + + it('is visible when an element is rotated < 90 degrees', () => { + cy.get('#b-2').should('be.visible') + cy.get('#b-3').should('be.visible') + }) + + it('is invisible when an element is rotated > 90 degrees', () => { + cy.get('#b-4').should('not.be.visible') + cy.get('#b-5').should('not.be.visible') + }) + + it('is invisible when an element is rotated in exact 90 degrees', () => { + cy.get('#b-6').should('not.be.visible') + cy.get('#b-7').should('not.be.visible') + }) + + it('is visible when an element is not backface-visibility: hidden but rotated > 90 degrees', () => { + cy.get('#b-8').should('be.visible') + }) + }) + + describe('affected by ancestors', () => { + describe('CASE 1: all transform-style: flat', () => { + it('is invisible when parent is hidden', () => { + cy.get('#a1-1').should('not.be.visible') + }) + + it('is visible when parent is visible', () => { + cy.get('#a1-2').should('be.visible') + }) + + it('is visible when an element is backface-invisible whose parent is rotated > 90deg', () => { + cy.get('#a1-3').should('be.visible') + }) + + it('is invisible when an element is rotated 190deg whose parent is rotated 90deg', () => { + cy.get('#a1-4').should('not.be.visible') + }) + }) + + describe('CASE 2: when direct parents have preserve-3d', () => { + it('target hidden + parents', () => { + cy.get('#a2-1-1').should('not.be.visible') + cy.get('#a2-1-2').should('not.be.visible') + cy.get('#a2-1-3').should('not.be.visible') + cy.get('#a2-1-4').should('not.be.visible') + }) + + it('target visible + parent visible', () => { + cy.get('#a2-2-1').should('be.visible') + cy.get('#a2-2-2').should('be.visible') + cy.get('#a2-2-3').should('not.be.visible') + cy.get('#a2-2-4').should('not.be.visible') + }) + + it('target visible + parent hidden', () => { + cy.get('#a2-3-1').should('be.visible') + cy.get('#a2-3-2').should('not.be.visible') + cy.get('#a2-3-3').should('not.be.visible') + }) + }) + + it('issue case', () => { + cy.get('.front').should('be.visible') + cy.get('.back').should('not.be.visible') + cy.get('.container').click() + cy.get('.front').should('not.be.visible') + cy.get('.back').should('be.visible') + }) + }) +}) diff --git a/packages/driver/test/cypress/integration/issues/573_spec.coffee b/packages/driver/test/cypress/integration/issues/573_spec.coffee deleted file mode 100644 index f3b3bea90abd..000000000000 --- a/packages/driver/test/cypress/integration/issues/573_spec.coffee +++ /dev/null @@ -1,51 +0,0 @@ -run = -> - cy.window() - .then { timeout: 60000 }, (win) -> - new Cypress.Promise (resolve) -> - i = win.document.createElement("iframe") - i.onload = resolve - ## ?foo is necessary for firefox b/c it won't load a nested - ## iframe with an identical url - i.src = "/basic_auth?foo" - win.document.body.appendChild(i) - .get("iframe").should ($iframe) -> - expect($iframe.contents().text()).to.include("basic auth worked") - .window().then { timeout: 60000 }, (win) -> - new Cypress.Promise (resolve, reject) -> - xhr = new win.XMLHttpRequest() - xhr.open("GET", "/basic_auth") - xhr.onload = -> - try - expect(@responseText).to.include("basic auth worked") - resolve(win) - catch err - reject(err) - xhr.send() - .then { timeout: 60000 }, (win) -> - new Cypress.Promise (resolve, reject) -> - ## ensure other origins do not have auth headers attached - xhr = new win.XMLHttpRequest() - xhr.open("GET", "http://localhost:3501/basic_auth") - xhr.onload = -> - try - expect(@status).to.eq(401) - resolve(win) - catch err - reject(err) - xhr.send() - -# cy.visit("http://admin:admin@the-internet.herokuapp.com/basic_auth") - -describe "basic auth", -> - it "can visit with username/pw in url", -> - cy.visit("http://cypress:password123@localhost:3500/basic_auth") - run() - - it "can visit with auth options", -> - cy.visit("http://localhost:3500/basic_auth", { - auth: { - username: "cypress" - password: "password123" - } - }) - run() diff --git a/packages/driver/test/cypress/integration/issues/573_spec.js b/packages/driver/test/cypress/integration/issues/573_spec.js new file mode 100644 index 000000000000..8fa62577e935 --- /dev/null +++ b/packages/driver/test/cypress/integration/issues/573_spec.js @@ -0,0 +1,76 @@ +const run = () => { + cy.window() + .then({ timeout: 60000 }, (win) => { + return new Cypress.Promise((resolve) => { + const i = win.document.createElement('iframe') + + i.onload = resolve + // ?foo is necessary for firefox b/c it won't load a nested + // iframe with an identical url + i.src = '/basic_auth?foo' + + return win.document.body.appendChild(i) + }) + }) + .get('iframe').should(($iframe) => { + expect($iframe.contents().text()).to.include('basic auth worked') + }) + .window().then({ timeout: 60000 }, (win) => { + return new Cypress.Promise(((resolve, reject) => { + const xhr = new win.XMLHttpRequest() + + xhr.open('GET', '/basic_auth') + xhr.onload = function () { + try { + expect(this.responseText).to.include('basic auth worked') + + return resolve(win) + } catch (err) { + return reject(err) + } + } + + return xhr.send() + })) + }) + .then({ timeout: 60000 }, (win) => { + return new Cypress.Promise(((resolve, reject) => { + // ensure other origins do not have auth headers attached + const xhr = new win.XMLHttpRequest() + + xhr.open('GET', 'http://localhost:3501/basic_auth') + xhr.onload = function () { + try { + expect(this.status).to.eq(401) + + return resolve(win) + } catch (err) { + return reject(err) + } + } + + return xhr.send() + })) + }) +} + +// cy.visit("http://admin:admin@the-internet.herokuapp.com/basic_auth") + +describe('basic auth', () => { + it('can visit with username/pw in url', () => { + cy.visit('http://cypress:password123@localhost:3500/basic_auth') + + run() + }) + + it('can visit with auth options', () => { + cy.visit('http://localhost:3500/basic_auth', { + auth: { + username: 'cypress', + password: 'password123', + }, + }) + + run() + }) +}) diff --git a/packages/driver/test/cypress/integration/issues/652_spec.coffee b/packages/driver/test/cypress/integration/issues/652_spec.coffee deleted file mode 100644 index 64b506695604..000000000000 --- a/packages/driver/test/cypress/integration/issues/652_spec.coffee +++ /dev/null @@ -1,17 +0,0 @@ -## https://github.com/cypress-io/cypress/issues/652 -describe "issue 652", -> - before -> - cy.visit("/fixtures/issue-652.html") - - it 'should visit all the hashes', -> - # cy.wait(0) - cy.visit('/fixtures/issue-652.html#one') - # cy.wait(0) - cy.visit('/fixtures/issue-652.html#two') - # cy.wait(0) - cy.visit('/fixtures/issue-652.html#three') - - cy.get('#visited') - .should('contain', 'one') - .should('contain', 'two') - .should('contain', 'three') diff --git a/packages/driver/test/cypress/integration/issues/652_spec.js b/packages/driver/test/cypress/integration/issues/652_spec.js new file mode 100644 index 000000000000..548bd17506db --- /dev/null +++ b/packages/driver/test/cypress/integration/issues/652_spec.js @@ -0,0 +1,20 @@ +// https://github.com/cypress-io/cypress/issues/652 +describe('issue 652', () => { + before(() => { + cy.visit('/fixtures/issue-652.html') + }) + + it('should visit all the hashes', () => { + // cy.wait(0) + cy.visit('/fixtures/issue-652.html#one') + // cy.wait(0) + cy.visit('/fixtures/issue-652.html#two') + // cy.wait(0) + cy.visit('/fixtures/issue-652.html#three') + + cy.get('#visited') + .should('contain', 'one') + .should('contain', 'two') + .should('contain', 'three') + }) +}) diff --git a/packages/electron/package.json b/packages/electron/package.json index 490f3933af9e..b12fbfce454e 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -26,7 +26,7 @@ "minimist": "1.2.0" }, "devDependencies": { - "electron": "7.1.4", + "electron": "7.1.7", "mocha": "3.5.3" }, "files": [ diff --git a/packages/reporter/src/commands/commands.scss b/packages/reporter/src/commands/commands.scss index fcbf533d9b1d..c2eb1c9fa404 100644 --- a/packages/reporter/src/commands/commands.scss +++ b/packages/reporter/src/commands/commands.scss @@ -377,6 +377,17 @@ } } + .command-name-log { + .command-message-text { + white-space: initial; + word-wrap: break-word; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 50; + -webkit-box-orient: vertical; + } + } + .command-controls { i { padding: 2px; @@ -389,7 +400,7 @@ line-height: 1.75; margin-left: 5px; - + } i:hover { diff --git a/packages/server/lib/browsers/protocol.js b/packages/server/lib/browsers/protocol.js index 5188581c24b8..5504f0298100 100644 --- a/packages/server/lib/browsers/protocol.js +++ b/packages/server/lib/browsers/protocol.js @@ -25,10 +25,7 @@ function _getDelayMsForRetry (i) { function _connectAsync (opts) { return Promise.fromCallback((cb) => { - connect.createRetryingSocket({ - getDelayMsForRetry: _getDelayMsForRetry, - ...opts, - }, cb) + connect.createRetryingSocket(opts, cb) }) .then((sock) => { // can be closed, just needed to test the connection @@ -39,6 +36,41 @@ function _connectAsync (opts) { }) } +/** + * Tries to find the starting page (probably blank tab) + * among all targets returned by CRI.List call. + * + * @returns {string} web socket debugger url + */ +const findStartPage = (targets) => { + debug('CRI List %o', { numTargets: targets.length, targets }) + // activate the first available id + // find the first target page that's a real tab + // and not the dev tools or background page. + // since we open a blank page first, it has a special url + const newTabTargetFields = { + type: 'page', + url: 'about:blank', + } + + const target = _.find(targets, newTabTargetFields) + + la(target, 'could not find CRI target') + + debug('found CRI target %o', target) + + return target.webSocketDebuggerUrl +} + +const findStartPageTarget = (connectOpts) => { + debug('CRI.List %o', connectOpts) + + // what happens if the next call throws an error? + // it seems to leave the browser instance open + // need to clone connectOpts, CRI modifies it + return CRI.List(_.clone(connectOpts)).then(findStartPage) +} + /** * Waits for the port to respond with connection to Chrome Remote Interface * @param {number} port Port number to connect to @@ -47,42 +79,45 @@ const getWsTargetFor = (port) => { debug('Getting WS connection to CRI on port %d', port) la(is.port(port), 'expected port number', port) + let retryIndex = 0 + // force ipv4 // https://github.com/cypress-io/cypress/issues/5912 const connectOpts = { host: '127.0.0.1', port, + getDelayMsForRetry: (i) => { + retryIndex = i + + return _getDelayMsForRetry(i) + }, } return _connectAsync(connectOpts) - .tapCatch((err) => { - debug('failed to connect to CDP %o', { connectOpts, err }) - }) .then(() => { - debug('CRI.List on port %d', port) + const retry = () => { + debug('attempting to find CRI target... %o', { retryIndex }) - // what happens if the next call throws an error? - // it seems to leave the browser instance open - return CRI.List(connectOpts) - }) - .then((targets) => { - debug('CRI List %o', { numTargets: targets.length, targets }) - // activate the first available id - // find the first target page that's a real tab - // and not the dev tools or background page. - // since we open a blank page first, it has a special url - const newTabTargetFields = { - type: 'page', - url: 'about:blank', - } + return findStartPageTarget(connectOpts) + .catch((err) => { + retryIndex++ + const delay = _getDelayMsForRetry(retryIndex) - const target = _.find(targets, newTabTargetFields) + debug('error finding CRI target, maybe retrying %o', { delay, err }) - la(target, 'could not find CRI target') + if (typeof delay === 'undefined') { + throw err + } - debug('found CRI target %o', target) + return Promise.delay(delay) + .then(retry) + }) + } - return target.webSocketDebuggerUrl + return retry() + }) + .tapCatch((err) => { + debug('failed to connect to CDP %o', { connectOpts, err }) }) } diff --git a/packages/server/lib/cypress.js b/packages/server/lib/cypress.js index 119971d86778..bdcee462e4c4 100644 --- a/packages/server/lib/cypress.js +++ b/packages/server/lib/cypress.js @@ -153,7 +153,19 @@ module.exports = { const options = argsUtils.toObject(argv) + debug('from argv %o got options %o', argv, options) + + if (options.headless) { + // --headless is same as --headed false + if (options.headed) { + throw new Error('Impossible options: both headless and headed are true') + } + + options.headed = false + } + if (options.runProject && !options.headed) { + debug('scaling electron app in headless mode') // scale the electron browser window // to force retina screens to not // upsample their images when offscreen @@ -196,7 +208,7 @@ module.exports = { }, startInMode (mode, options) { - debug('starting in mode %s', mode) + debug('starting in mode %s with options %o', mode, options) switch (mode) { case 'version': diff --git a/packages/server/lib/util/ci_provider.js b/packages/server/lib/util/ci_provider.js index 94278a35cac4..fcce183ccc42 100644 --- a/packages/server/lib/util/ci_provider.js +++ b/packages/server/lib/util/ci_provider.js @@ -214,6 +214,7 @@ const _providerCiParams = () => { 'CI_BUILD_ID', // build id and job id are aliases 'CI_JOB_ID', 'CI_JOB_URL', + 'CI_JOB_NAME', // other information 'GITLAB_HOST', 'CI_PROJECT_ID', diff --git a/packages/server/test/integration/cypress_spec.js b/packages/server/test/integration/cypress_spec.js index 349a0ee5b27d..366f07c2e883 100644 --- a/packages/server/test/integration/cypress_spec.js +++ b/packages/server/test/integration/cypress_spec.js @@ -348,6 +348,32 @@ describe('lib/cypress', () => { }) }) + it('sets --headed false if --headless', function () { + sinon.spy(cypress, 'startInMode') + + return cypress.start([`--run-project=${this.todosPath}`, '--headless']) + .then(() => { + expect(browsers.open).to.be.calledWithMatch(ELECTRON_BROWSER) + this.expectExitWith(0) + + // check how --headless option sets --headed + expect(cypress.startInMode).to.be.calledOnce + expect(cypress.startInMode).to.be.calledWith('run') + const startInModeOptions = cypress.startInMode.firstCall.args[1] + + expect(startInModeOptions).to.include({ + headless: true, + headed: false, + }) + }) + }) + + it('throws an error if both --headed and --headless are true', function () { + // error is thrown synchronously + expect(() => cypress.start([`--run-project=${this.todosPath}`, '--headless', '--headed'])) + .to.throw('Impossible options: both headless and headed are true') + }) + describe('strips --', () => { beforeEach(() => { sinon.spy(argsUtil, 'toObject') diff --git a/packages/server/test/unit/browsers/protocol_spec.ts b/packages/server/test/unit/browsers/protocol_spec.ts index 9c490cb19a3d..6f90e10ccae2 100644 --- a/packages/server/test/unit/browsers/protocol_spec.ts +++ b/packages/server/test/unit/browsers/protocol_spec.ts @@ -1,5 +1,6 @@ import '../../spec_helper' import _ from 'lodash' +import Bluebird from 'bluebird' import 'chai-as-promised' // for the types! import chalk from 'chalk' import { connect } from '@packages/network' @@ -12,9 +13,12 @@ import snapshot from 'snap-shot-it' import stripAnsi from 'strip-ansi' import { stripIndents } from 'common-tags' -describe('lib/browsers/protocol', function () { - context('._getDelayMsForRetry', function () { - it('retries as expected for up to 20 seconds', function () { +describe('lib/browsers/protocol', () => { + // protocol connects explicitly to this host + const host = '127.0.0.1' + + context('._getDelayMsForRetry', () => { + it('retries as expected for up to 20 seconds', () => { const log = sinon.spy(console, 'log') let delays = [] @@ -38,8 +42,8 @@ describe('lib/browsers/protocol', function () { }) }) - context('.getWsTargetFor', function () { - it('rejects if CDP connection fails', function () { + context('.getWsTargetFor', () => { + it('rejects if CDP connection fails', () => { const innerErr = new Error('cdp connection failure') sinon.stub(connect, 'createRetryingSocket').callsArgWith(1, innerErr) @@ -55,12 +59,12 @@ describe('lib/browsers/protocol', function () { Error details: ` - return expect(p).to.eventually.be.rejected + expect(p).to.eventually.be.rejected .and.property('message').include(expectedError) .and.include(innerErr.message) }) - it('returns the debugger URL of the first about:blank tab', async function () { + it('returns the debugger URL of the first about:blank tab', async () => { const targets = [ { type: 'page', @@ -76,7 +80,10 @@ describe('lib/browsers/protocol', function () { const end = sinon.stub() - sinon.stub(CRI, 'List').withArgs({ host: '127.0.0.1', port: 12345 }).resolves(targets) + sinon.stub(CRI, 'List') + .withArgs({ host, port: 12345, getDelayMsForRetry: sinon.match.func }) + .resolves(targets) + sinon.stub(connect, 'createRetryingSocket').callsArgWith(1, null, { end }) const p = protocol.getWsTargetFor(12345) @@ -85,4 +92,76 @@ describe('lib/browsers/protocol', function () { expect(end).to.be.calledOnce }) }) + + context('CRI.List', () => { + const port = 1234 + const targets = [ + { + type: 'page', + url: 'chrome://newtab', + webSocketDebuggerUrl: 'foo', + }, + { + type: 'page', + url: 'about:blank', + webSocketDebuggerUrl: 'ws://debug-url', + }, + ] + + it('retries several times if starting page cannot be found', async () => { + const end = sinon.stub() + + sinon.stub(connect, 'createRetryingSocket').callsArgWith(1, null, { end }) + + const criList = sinon.stub(CRI, 'List') + .withArgs({ host, port, getDelayMsForRetry: sinon.match.func }).resolves(targets) + .onFirstCall().resolves([]) + .onSecondCall().resolves([]) + .onThirdCall().resolves(targets) + + const targetUrl = await protocol.getWsTargetFor(port) + + expect(criList).to.have.been.calledThrice + expect(targetUrl).to.equal('ws://debug-url') + }) + + it('logs correctly if retries occur while connecting to CDP and while listing CRI targets', async () => { + const log = sinon.spy(console, 'log') + + const end = sinon.stub() + + // fail 20 times to get 2 log lines from connect failures + sinon.stub(connect, 'createRetryingSocket').callsFake((opts, cb) => { + _.times(20, (i) => { + opts.getDelayMsForRetry(i, new Error) + }) + + // @ts-ignore + return cb(null, { end }) + }) + + sinon.stub(Bluebird, 'delay').resolves() + + // fail an additional 2 times on CRI.List + const criList = sinon.stub(CRI, 'List') + .withArgs({ host, port, getDelayMsForRetry: sinon.match.func }).resolves(targets) + .onFirstCall().resolves([]) + .onSecondCall().resolves([]) + .onThirdCall().resolves(targets) + + const targetUrl = await protocol.getWsTargetFor(port) + + expect(criList).to.have.been.calledThrice + expect(targetUrl).to.equal('ws://debug-url') + + // 2 from connect failing, 2 from CRI.List failing + expect(log).to.have.callCount(4) + + log.getCalls().forEach((log, i) => { + const line = stripAnsi(log.args[0]) + + expect(line).to.include(`Failed to connect to Chrome, retrying in 1 second (attempt ${i + 18}/32)`) + }) + }) + }) }) diff --git a/packages/server/test/unit/ci_provider_spec.coffee b/packages/server/test/unit/ci_provider_spec.coffee index 5d6789d86100..494c298f9c7b 100644 --- a/packages/server/test/unit/ci_provider_spec.coffee +++ b/packages/server/test/unit/ci_provider_spec.coffee @@ -411,6 +411,7 @@ describe "lib/util/ci_provider", -> CI_BUILD_ID: "ciJobId" CI_JOB_ID: "ciJobId" CI_JOB_URL: "ciJobUrl" + CI_JOB_NAME: "ciJobName" CI_PIPELINE_ID: "ciPipelineId" CI_PIPELINE_URL: "ciPipelineUrl" @@ -432,6 +433,7 @@ describe "lib/util/ci_provider", -> expectsCiParams({ ciJobId: "ciJobId" ciJobUrl: "ciJobUrl" + ciJobName: "ciJobName" ciBuildId: "ciJobId" ciPipelineId: "ciPipelineId" ciPipelineUrl: "ciPipelineUrl"