From 15593def3f8f5e668d1a3aa263a195ef437159f0 Mon Sep 17 00:00:00 2001 From: Ben Kucera <14625260+Bkucera@users.noreply.github.com> Date: Thu, 24 Jan 2019 17:42:21 -0500 Subject: [PATCH 01/17] add "be.focused" to chai_jquery --- .../driver/src/cypress/chai_jquery.coffee | 26 ++++++- .../commands/assertions_spec.coffee | 73 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/driver/src/cypress/chai_jquery.coffee b/packages/driver/src/cypress/chai_jquery.coffee index 774a5ef9cc97..4b5661060c68 100644 --- a/packages/driver/src/cypress/chai_jquery.coffee +++ b/packages/driver/src/cypress/chai_jquery.coffee @@ -24,7 +24,8 @@ $chaiJquery = (chai, chaiUtils, callbacks = {}) -> try ## always fail the assertion ## if we aren't a DOM like object - ctx.assert(false, args...) + ## depends on "negate" flag + ctx.assert(!!ctx.__flags.negate, args...) catch err callbacks.onInvalid(method, ctx._obj) @@ -133,6 +134,29 @@ $chaiJquery = (chai, chaiUtils, callbacks = {}) -> value, actual ) + + focusedAssertion = (val) -> + return -> + assertDom( + @, + "focus", + 'expected #{this} to be #{exp}' + 'expected #{this} to not be #{exp}', + 'focused' + ) + + assert( + @, + "focus", + wrap(@).is($(wrap(@)[0].ownerDocument.activeElement)), + 'expected #{this} to be #{exp}', + 'expected #{this} to not be #{exp}', + "focused" + ) + + chai.Assertion.overwriteProperty "focus", focusedAssertion + + chai.Assertion.overwriteProperty "focused", focusedAssertion chai.Assertion.addMethod "descendants", (selector) -> assert( diff --git a/packages/driver/test/cypress/integration/commands/assertions_spec.coffee b/packages/driver/test/cypress/integration/commands/assertions_spec.coffee index 246d11351c5d..062469c8cf2d 100644 --- a/packages/driver/test/cypress/integration/commands/assertions_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/assertions_spec.coffee @@ -1472,6 +1472,79 @@ describe "src/cy/commands/assertions", -> "expected **
** not to be **empty**" ) + context "focused", -> + beforeEach -> + @div = $("
").appendTo($('body')) + @div.is = -> throw new Error("is called") + + @div2 = $("
").appendTo($('body')) + @div2.is = -> throw new Error("is called") + + it "focus, not focus, raw dom documents", -> + expect(@div).to.not.be.focused + expect(@div[0]).to.not.be.focused + @div.focus() + expect(@div).to.be.focused + expect(@div[0]).to.be.focused + + @div.blur() + expect(@div).to.not.be.focused + expect(@div[0]).to.not.be.focused + + + expect(@div2).not.to.be.focused + expect(@div2[0]).not.to.be.focused + @div.focus() + expect(@div2).not.to.be.focused + @div2.focus() + expect(@div2).to.be.focused + + expect(@logs.length).to.eq(10) + + l1 = @logs[0] + l2 = @logs[1] + l3 = @logs[2] + l4 = @logs[3] + + expect(l1.get("message")).to.eq( + "expected **** to not be **focused**" + ) + + expect(l2.get("message")).to.eq( + "expected **** to not be **focused**" + ) + + expect(l3.get("message")).to.eq( + "expected **** to be **focused**" + ) + + expect(l4.get("message")).to.eq( + "expected **** to be **focused**" + ) + + it "works with focused or focus", -> + expect(@div).to.not.have.focus + expect(@div).to.not.have.focused + expect(@div).to.not.be.focus + expect(@div).to.not.be.focused + + cy.get('#div').should('not.be.focused') + cy.get('#div').should('not.have.focus') + + + it "throws when obj is not DOM", (done) -> + cy.on "fail", (err) => + expect(@logs.length).to.eq(1) + expect(@logs[0].get("error").message).to.contain( + "expected {} to not be 'focused'" + ) + expect(err.message).to.include("> focus") + expect(err.message).to.include("> {}") + + done() + + expect({}).to.not.have.focus + context "match", -> beforeEach -> @div = $("
") From 69eb24d59297581a4a3df34b5ac98a61aab1fee0 Mon Sep 17 00:00:00 2001 From: Ben Kucera <14625260+Bkucera@users.noreply.github.com> Date: Thu, 24 Jan 2019 18:00:06 -0500 Subject: [PATCH 02/17] add comment explaining the mess --- packages/driver/src/cypress/chai_jquery.coffee | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/driver/src/cypress/chai_jquery.coffee b/packages/driver/src/cypress/chai_jquery.coffee index 4b5661060c68..da59eab6d12b 100644 --- a/packages/driver/src/cypress/chai_jquery.coffee +++ b/packages/driver/src/cypress/chai_jquery.coffee @@ -148,6 +148,8 @@ $chaiJquery = (chai, chaiUtils, callbacks = {}) -> assert( @, "focus", + ## we don't use :focus becuase jquery will return null when window is not focused + ## we grab document from the element to be sure we are in the proper frame wrap(@).is($(wrap(@)[0].ownerDocument.activeElement)), 'expected #{this} to be #{exp}', 'expected #{this} to not be #{exp}', From e513ff1d37bca4800ade4f5612b753bf83a4376e Mon Sep 17 00:00:00 2001 From: Ben Kucera <14625260+Bkucera@users.noreply.github.com> Date: Thu, 24 Jan 2019 18:39:20 -0500 Subject: [PATCH 03/17] add type definitions --- cli/types/index.d.ts | 36 +++++++++++++++++++++++++++++ cli/types/tests/chainer-examples.ts | 6 +++++ 2 files changed, 42 insertions(+) diff --git a/cli/types/index.d.ts b/cli/types/index.d.ts index b4c1115fc1f2..774894067d47 100644 --- a/cli/types/index.d.ts +++ b/cli/types/index.d.ts @@ -3499,6 +3499,24 @@ declare namespace Cypress { * @see https://on.cypress.io/assertions */ (chainer: 'contain', value: string): Chainable + /** + * Assert that at least one element of the selection is focused, using `document.activeElement`. + * @example + * cy.get('#result').should('have.focus') + * cy.get('#result').should('be.focused') + * @see http://chaijs.com/plugins/chai-jquery/#containtext + * @see https://on.cypress.io/assertions + */ + (chainer: 'have.focus'): Chainable + /** + * Assert that at least one element of the selection is focused, using `document.activeElement`. + * @example + * cy.get('#result').should('be.focused') + * cy.get('#result').should('have.focus') + * @see http://chaijs.com/plugins/chai-jquery/#containtext + * @see https://on.cypress.io/assertions + */ + (chainer: 'be.focused'): Chainable /** * Assert that the selection is not empty. Note that this overrides the built-in chai assertion. If the object asserted against is not a jQuery object, the original implementation will be called. * @example @@ -3655,6 +3673,24 @@ declare namespace Cypress { * @see https://on.cypress.io/assertions */ (chainer: 'not.be.visible'): Chainable + /** + * Assert that at least one element of the selection is not focused, using `document.activeElement`. + * @example + * cy.get('#result').should('not.have.focus') + * cy.get('#result').should('not.be.focused') + * @see http://chaijs.com/plugins/chai-jquery/#containtext + * @see https://on.cypress.io/assertions + */ + (chainer: 'not.have.focus'): Chainable + /** + * Assert that at least one element of the selection is not focused, using `document.activeElement`. + * @example + * cy.get('#result').should('not.be.focused') + * cy.get('#result').should('not.have.focus') + * @see http://chaijs.com/plugins/chai-jquery/#containtext + * @see https://on.cypress.io/assertions + */ + (chainer: 'not.be.focused'): Chainable /** * Assert that the selection does not contain the given text, using `:contains()`. If the object asserted against is not a jQuery object, or if `contain` is not called as a function, the original implementation will be called. * @example diff --git a/cli/types/tests/chainer-examples.ts b/cli/types/tests/chainer-examples.ts index c375dff111e2..0744c832fd9c 100644 --- a/cli/types/tests/chainer-examples.ts +++ b/cli/types/tests/chainer-examples.ts @@ -329,6 +329,12 @@ cy.get('#result').should('be.selected') cy.get('#result').should('be.visible') +cy.get('#result').should('be.focused') +cy.get('#result').should('not.be.focused') + +cy.get('#result').should('have.focus') +cy.get('#result').should('not.have.focus') + cy.get('#result').should('be.contain', 'text') cy.get('#result').should('have.attr', 'role') From f932b13207644d49453f6baf011f5a6b5b2abf75 Mon Sep 17 00:00:00 2001 From: Ben Kucera <14625260+Bkucera@users.noreply.github.com> Date: Thu, 24 Jan 2019 22:12:59 -0500 Subject: [PATCH 04/17] refactor assertion to use jquery assertion --- packages/driver/src/config/jquery.coffee | 2 + .../driver/src/cy/commands/asserting.coffee | 2 +- packages/driver/src/cy/focused.coffee | 16 ++------ .../driver/src/cypress/chai_jquery.coffee | 41 ++++++------------- packages/driver/src/dom/elements.js | 24 +++++++++++ packages/driver/src/dom/index.js | 4 +- .../commands/assertions_spec.coffee | 6 +-- 7 files changed, 50 insertions(+), 45 deletions(-) diff --git a/packages/driver/src/config/jquery.coffee b/packages/driver/src/config/jquery.coffee index 880c98221f67..6d5e0b206979 100644 --- a/packages/driver/src/config/jquery.coffee +++ b/packages/driver/src/config/jquery.coffee @@ -5,6 +5,8 @@ $dom = require("../dom") ## force jquery to have the same visible ## and hidden logic as cypress +$.expr.filters.focus = $dom.isFocused +$.expr.filters.focused = $dom.isFocused $.expr.filters.visible = $dom.isVisible $.expr.filters.hidden = $dom.isHidden diff --git a/packages/driver/src/cy/commands/asserting.coffee b/packages/driver/src/cy/commands/asserting.coffee index 386446597410..5e2cf82c407d 100644 --- a/packages/driver/src/cy/commands/asserting.coffee +++ b/packages/driver/src/cy/commands/asserting.coffee @@ -120,7 +120,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> ## want to auto-fail on those if not exp.isCheckingExistence and $dom.isElement(subject) cy.ensureAttached(subject, "should") - + _.reduce chainers, (memo, value) => if value not of memo err = $utils.cypressErr("The chainer: '#{value}' was not found. Could not build assertion.") diff --git a/packages/driver/src/cy/focused.coffee b/packages/driver/src/cy/focused.coffee index 216339548fb5..d79eeb4dec69 100644 --- a/packages/driver/src/cy/focused.coffee +++ b/packages/driver/src/cy/focused.coffee @@ -207,20 +207,12 @@ create = (state) -> return false getFocused = -> - try - { activeElement, body } = state("document") - - ## active element is the default if its null - ## or its equal to document.body - activeElementIsDefault = -> - (not activeElement) or (activeElement is body) - - if activeElementIsDefault() - return null + { activeElement } = state("document") + if $dom.isFocused(activeElement) return $dom.wrap(activeElement) - catch - return null + + return null return { fireBlur diff --git a/packages/driver/src/cypress/chai_jquery.coffee b/packages/driver/src/cypress/chai_jquery.coffee index da59eab6d12b..403de8de2da1 100644 --- a/packages/driver/src/cypress/chai_jquery.coffee +++ b/packages/driver/src/cypress/chai_jquery.coffee @@ -2,7 +2,17 @@ _ = require("lodash") $ = require("jquery") $dom = require("../dom") -selectors = "visible hidden selected checked enabled disabled".split(" ") +selectors = { + visible: "visible" + hidden: "hidden" + selected: "selected" + checked: "checked" + enabled: "enabled" + disabled: "disabled" + focus: "focused" + focused: "focused" +} + attrs = { attr: "attribute" css: "CSS property" @@ -134,31 +144,6 @@ $chaiJquery = (chai, chaiUtils, callbacks = {}) -> value, actual ) - - focusedAssertion = (val) -> - return -> - assertDom( - @, - "focus", - 'expected #{this} to be #{exp}' - 'expected #{this} to not be #{exp}', - 'focused' - ) - - assert( - @, - "focus", - ## we don't use :focus becuase jquery will return null when window is not focused - ## we grab document from the element to be sure we are in the proper frame - wrap(@).is($(wrap(@)[0].ownerDocument.activeElement)), - 'expected #{this} to be #{exp}', - 'expected #{this} to not be #{exp}', - "focused" - ) - - chai.Assertion.overwriteProperty "focus", focusedAssertion - - chai.Assertion.overwriteProperty "focused", focusedAssertion chai.Assertion.addMethod "descendants", (selector) -> assert( @@ -198,7 +183,7 @@ $chaiJquery = (chai, chaiUtils, callbacks = {}) -> else _super.apply(@, arguments) - _.each selectors, (selector) -> + _.each _.keys(selectors), (selector) -> chai.Assertion.addProperty selector, -> assert( @, @@ -206,7 +191,7 @@ $chaiJquery = (chai, chaiUtils, callbacks = {}) -> wrap(@).is(":" + selector), 'expected #{this} to be #{exp}', 'expected #{this} not to be #{exp}', - selector + selectors[selector] ) _.each attrs, (description, attr) -> diff --git a/packages/driver/src/dom/elements.js b/packages/driver/src/dom/elements.js index d9caed1cf6b8..74e72536e177 100644 --- a/packages/driver/src/dom/elements.js +++ b/packages/driver/src/dom/elements.js @@ -319,6 +319,28 @@ const isSvg = function (el) { } } +// active element is the default if its null +// or its equal to document.body +const activeElementIsDefault = (activeElement, body) => { + return (!activeElement) || (activeElement === body) +} + +const isFocused = (el) => { + try { + const doc = $document.getDocumentFromElement(el) + + const { activeElement, body } = doc + + if (activeElementIsDefault(activeElement, body)) { + return false + } + + return doc.activeElement === el + } catch (err) { + return false + } +} + const isElement = function (obj) { try { if ($jquery.isJquery(obj)) { @@ -748,6 +770,8 @@ module.exports = { isType, + isFocused, + isNeedSingleValueChangeInputElement, canSetSelectionRangeElement, diff --git a/packages/driver/src/dom/index.js b/packages/driver/src/dom/index.js index 6fd60c8035c4..6b4fab4d345e 100644 --- a/packages/driver/src/dom/index.js +++ b/packages/driver/src/dom/index.js @@ -9,7 +9,7 @@ const { isWindow, getWindowByElement } = $window const { isDocument } = $document const { wrap, unwrap, isJquery, query } = $jquery const { isVisible, isHidden, getReasonIsHidden } = $visibility -const { isType, isFocusable, isElement, isScrollable, stringify, getElements, getContainsSelector, getFirstDeepestElement, isDetached, isAttached, isTextLike, isSelector, isDescendent, getFirstFixedOrStickyPositionParent, getFirstStickyPositionParent, getFirstScrollableParent } = $elements +const { isType, isFocusable, isElement, isScrollable, isFocused, stringify, getElements, getContainsSelector, getFirstDeepestElement, isDetached, isAttached, isTextLike, isSelector, isDescendent, getFirstFixedOrStickyPositionParent, getFirstStickyPositionParent, getFirstScrollableParent } = $elements const { getCoordsByPosition, getElementPositioning, getElementCoordinatesByPosition, getElementAtPointFromViewport, getElementCoordinatesByPositionRelativeToXY } = $coordinates const isDom = (obj) => { @@ -42,6 +42,8 @@ module.exports = { isScrollable, + isFocused, + isDetached, isAttached, diff --git a/packages/driver/test/cypress/integration/commands/assertions_spec.coffee b/packages/driver/test/cypress/integration/commands/assertions_spec.coffee index 062469c8cf2d..574066773463 100644 --- a/packages/driver/test/cypress/integration/commands/assertions_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/assertions_spec.coffee @@ -1507,11 +1507,11 @@ describe "src/cy/commands/assertions", -> l4 = @logs[3] expect(l1.get("message")).to.eq( - "expected **** to not be **focused**" + "expected **** not to be **focused**" ) expect(l2.get("message")).to.eq( - "expected **** to not be **focused**" + "expected **** not to be **focused**" ) expect(l3.get("message")).to.eq( @@ -1536,7 +1536,7 @@ describe "src/cy/commands/assertions", -> cy.on "fail", (err) => expect(@logs.length).to.eq(1) expect(@logs[0].get("error").message).to.contain( - "expected {} to not be 'focused'" + "expected {} not to be 'focused'" ) expect(err.message).to.include("> focus") expect(err.message).to.include("> {}") From 34b52d30851bfbb0f35fd0d6980cfec5bfa7a42f Mon Sep 17 00:00:00 2001 From: Jennifer Shehane Date: Fri, 25 Jan 2019 10:28:15 +0630 Subject: [PATCH 05/17] remove trailing whitespace --- cli/types/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/types/index.d.ts b/cli/types/index.d.ts index 774894067d47..cf535c1020c9 100644 --- a/cli/types/index.d.ts +++ b/cli/types/index.d.ts @@ -3512,7 +3512,7 @@ declare namespace Cypress { * Assert that at least one element of the selection is focused, using `document.activeElement`. * @example * cy.get('#result').should('be.focused') - * cy.get('#result').should('have.focus') + * cy.get('#result').should('have.focus') * @see http://chaijs.com/plugins/chai-jquery/#containtext * @see https://on.cypress.io/assertions */ From 47f03a8df51f4c3673deeb0fc049a476176b1f71 Mon Sep 17 00:00:00 2001 From: Ben Kucera <14625260+Bkucera@users.noreply.github.com> Date: Fri, 25 Jan 2019 12:34:09 -0500 Subject: [PATCH 06/17] add test for multiple elements, update typedefs --- cli/types/index.d.ts | 12 ++++-------- .../integration/commands/assertions_spec.coffee | 5 +++++ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/cli/types/index.d.ts b/cli/types/index.d.ts index cf535c1020c9..e844e2694a5f 100644 --- a/cli/types/index.d.ts +++ b/cli/types/index.d.ts @@ -3500,20 +3500,18 @@ declare namespace Cypress { */ (chainer: 'contain', value: string): Chainable /** - * Assert that at least one element of the selection is focused, using `document.activeElement`. + * Assert that at least one element of the selection is focused. * @example * cy.get('#result').should('have.focus') * cy.get('#result').should('be.focused') - * @see http://chaijs.com/plugins/chai-jquery/#containtext * @see https://on.cypress.io/assertions */ (chainer: 'have.focus'): Chainable /** - * Assert that at least one element of the selection is focused, using `document.activeElement`. + * Assert that at least one element of the selection is focused. * @example * cy.get('#result').should('be.focused') * cy.get('#result').should('have.focus') - * @see http://chaijs.com/plugins/chai-jquery/#containtext * @see https://on.cypress.io/assertions */ (chainer: 'be.focused'): Chainable @@ -3674,20 +3672,18 @@ declare namespace Cypress { */ (chainer: 'not.be.visible'): Chainable /** - * Assert that at least one element of the selection is not focused, using `document.activeElement`. + * Assert that no element of the selection is focused. * @example * cy.get('#result').should('not.have.focus') * cy.get('#result').should('not.be.focused') - * @see http://chaijs.com/plugins/chai-jquery/#containtext * @see https://on.cypress.io/assertions */ (chainer: 'not.have.focus'): Chainable /** - * Assert that at least one element of the selection is not focused, using `document.activeElement`. + * Assert that no element of the selection is focused. * @example * cy.get('#result').should('not.be.focused') * cy.get('#result').should('not.have.focus') - * @see http://chaijs.com/plugins/chai-jquery/#containtext * @see https://on.cypress.io/assertions */ (chainer: 'not.be.focused'): Chainable diff --git a/packages/driver/test/cypress/integration/commands/assertions_spec.coffee b/packages/driver/test/cypress/integration/commands/assertions_spec.coffee index 574066773463..9406b9b5d7df 100644 --- a/packages/driver/test/cypress/integration/commands/assertions_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/assertions_spec.coffee @@ -1531,6 +1531,11 @@ describe "src/cy/commands/assertions", -> cy.get('#div').should('not.be.focused') cy.get('#div').should('not.have.focus') + it "works with multiple elements", -> + cy.get('div:last').focus() + cy.get('div').should('have.focus') + cy.get('div:last').blur() + cy.get('div').should('not.have.focus') it "throws when obj is not DOM", (done) -> cy.on "fail", (err) => From 314e0f1aa85a36927791fe0b10c3a27a5f821f74 Mon Sep 17 00:00:00 2001 From: Ben Kucera <14625260+Bkucera@users.noreply.github.com> Date: Fri, 25 Jan 2019 12:48:18 -0500 Subject: [PATCH 07/17] fix failing tests: not.be.visible -> not.exist --- .../desktop-gui/cypress/integration/project_nav_spec.coffee | 6 +++--- .../cypress/integration/setup_project_modal_spec.coffee | 2 +- .../desktop-gui/cypress/integration/specs_list_spec.coffee | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/desktop-gui/cypress/integration/project_nav_spec.coffee b/packages/desktop-gui/cypress/integration/project_nav_spec.coffee index 2bf803f87bdc..3d921002a1e6 100644 --- a/packages/desktop-gui/cypress/integration/project_nav_spec.coffee +++ b/packages/desktop-gui/cypress/integration/project_nav_spec.coffee @@ -33,7 +33,7 @@ describe "Project Nav", -> @openProject.resolve(@config) it "displays projects nav", -> - cy.get(".empty").should("not.be.visible") + cy.get(".empty").should("not.exist") cy.get(".navbar-default") it "displays 'Tests' nav as active", -> @@ -171,7 +171,7 @@ describe "Project Nav", -> @ipc.launchBrowser.yield(null, {browserClosed: true}) it "hides close browser button", -> - cy.get(".close-browser").should("not.be.visible") + cy.get(".close-browser").should("not.exist") it "re-enables browser dropdown", -> cy.get(".browsers-list>a").first() @@ -220,7 +220,7 @@ describe "Project Nav", -> it "displays no dropdown btn", -> cy.get(".browsers-list") - .find(".dropdown-toggle").should("not.be.visible") + .find(".dropdown-toggle").should("not.exist") describe "browser with info", -> beforeEach -> diff --git a/packages/desktop-gui/cypress/integration/setup_project_modal_spec.coffee b/packages/desktop-gui/cypress/integration/setup_project_modal_spec.coffee index 3a1cb4a0cded..a2079c244599 100644 --- a/packages/desktop-gui/cypress/integration/setup_project_modal_spec.coffee +++ b/packages/desktop-gui/cypress/integration/setup_project_modal_spec.coffee @@ -285,7 +285,7 @@ describe "Set Up Project", -> }) it "closes modal", -> - cy.get(".modal").should("not.be.visible") + cy.get(".modal").should("not.exist") it "updates localStorage projects cache", -> expect(JSON.parse(localStorage.projects || "[]")[0].orgName).to.equal("Jane Lane") diff --git a/packages/desktop-gui/cypress/integration/specs_list_spec.coffee b/packages/desktop-gui/cypress/integration/specs_list_spec.coffee index ac4628c1d950..bbcdcda0d72b 100644 --- a/packages/desktop-gui/cypress/integration/specs_list_spec.coffee +++ b/packages/desktop-gui/cypress/integration/specs_list_spec.coffee @@ -79,7 +79,7 @@ describe "Specs List", -> it "can dismiss the modal", -> cy.contains("OK, got it!").click() - cy.get(".modal").should("not.be.visible") + cy.get(".modal").should("not.exist") .then -> expect(@ipc.onboardingClosed).to.be.called From 8d3ace69a88819313891677ef18067ec699924ec Mon Sep 17 00:00:00 2001 From: Ben Kucera <14625260+Bkucera@users.noreply.github.com> Date: Wed, 30 Jan 2019 13:56:51 -0500 Subject: [PATCH 08/17] allow should(not.be.visible) for failed selectors --- .../integration/project_nav_spec.coffee | 6 +- .../setup_project_modal_spec.coffee | 2 +- .../integration/specs_list_spec.coffee | 2 +- packages/driver/src/config/jquery.coffee | 8 +- .../driver/src/cy/commands/asserting.coffee | 2 +- .../driver/src/cypress/chai_jquery.coffee | 12 ++- packages/driver/src/dom/visibility.js | 10 +++ .../commands/assertions_spec.coffee | 81 +++++++++++++++++++ .../integration/dom/visibility_spec.coffee | 10 +++ 9 files changed, 122 insertions(+), 11 deletions(-) diff --git a/packages/desktop-gui/cypress/integration/project_nav_spec.coffee b/packages/desktop-gui/cypress/integration/project_nav_spec.coffee index 3d921002a1e6..2bf803f87bdc 100644 --- a/packages/desktop-gui/cypress/integration/project_nav_spec.coffee +++ b/packages/desktop-gui/cypress/integration/project_nav_spec.coffee @@ -33,7 +33,7 @@ describe "Project Nav", -> @openProject.resolve(@config) it "displays projects nav", -> - cy.get(".empty").should("not.exist") + cy.get(".empty").should("not.be.visible") cy.get(".navbar-default") it "displays 'Tests' nav as active", -> @@ -171,7 +171,7 @@ describe "Project Nav", -> @ipc.launchBrowser.yield(null, {browserClosed: true}) it "hides close browser button", -> - cy.get(".close-browser").should("not.exist") + cy.get(".close-browser").should("not.be.visible") it "re-enables browser dropdown", -> cy.get(".browsers-list>a").first() @@ -220,7 +220,7 @@ describe "Project Nav", -> it "displays no dropdown btn", -> cy.get(".browsers-list") - .find(".dropdown-toggle").should("not.exist") + .find(".dropdown-toggle").should("not.be.visible") describe "browser with info", -> beforeEach -> diff --git a/packages/desktop-gui/cypress/integration/setup_project_modal_spec.coffee b/packages/desktop-gui/cypress/integration/setup_project_modal_spec.coffee index a2079c244599..3a1cb4a0cded 100644 --- a/packages/desktop-gui/cypress/integration/setup_project_modal_spec.coffee +++ b/packages/desktop-gui/cypress/integration/setup_project_modal_spec.coffee @@ -285,7 +285,7 @@ describe "Set Up Project", -> }) it "closes modal", -> - cy.get(".modal").should("not.exist") + cy.get(".modal").should("not.be.visible") it "updates localStorage projects cache", -> expect(JSON.parse(localStorage.projects || "[]")[0].orgName).to.equal("Jane Lane") diff --git a/packages/desktop-gui/cypress/integration/specs_list_spec.coffee b/packages/desktop-gui/cypress/integration/specs_list_spec.coffee index bbcdcda0d72b..ac4628c1d950 100644 --- a/packages/desktop-gui/cypress/integration/specs_list_spec.coffee +++ b/packages/desktop-gui/cypress/integration/specs_list_spec.coffee @@ -79,7 +79,7 @@ describe "Specs List", -> it "can dismiss the modal", -> cy.contains("OK, got it!").click() - cy.get(".modal").should("not.exist") + cy.get(".modal").should("not.be.visible") .then -> expect(@ipc.onboardingClosed).to.be.called diff --git a/packages/driver/src/config/jquery.coffee b/packages/driver/src/config/jquery.coffee index 6d5e0b206979..8976a8ce41e5 100644 --- a/packages/driver/src/config/jquery.coffee +++ b/packages/driver/src/config/jquery.coffee @@ -5,10 +5,16 @@ $dom = require("../dom") ## force jquery to have the same visible ## and hidden logic as cypress + +## see difference between 'filters' and 'pseudos' +## https://api.jquery.com/filter/ and https://api.jquery.com/category/selectors/ $.expr.filters.focus = $dom.isFocused -$.expr.filters.focused = $dom.isFocused +$.expr.pseudos.focus = $dom.isFocused +$.expr.pseudos.focused = $dom.isFocused $.expr.filters.visible = $dom.isVisible +$.expr.pseudos.visible = $dom.isVisible $.expr.filters.hidden = $dom.isHidden +$.expr.pseudos.hidden = $dom.isHidden $.expr.cacheLength = 1 diff --git a/packages/driver/src/cy/commands/asserting.coffee b/packages/driver/src/cy/commands/asserting.coffee index 5e2cf82c407d..29926e0d78ab 100644 --- a/packages/driver/src/cy/commands/asserting.coffee +++ b/packages/driver/src/cy/commands/asserting.coffee @@ -8,7 +8,7 @@ $utils = require("../../cypress/utils") bRe = /(\*\*)(.+)(\*\*)/ bTagOpen = /\*\*/g bTagClosed = /\*\*/g -reExistence = /exist/ +reExistence = /exist|visible/ reEventually = /^eventually/ reHaveLength = /length/ diff --git a/packages/driver/src/cypress/chai_jquery.coffee b/packages/driver/src/cypress/chai_jquery.coffee index 403de8de2da1..98034dcc7cc1 100644 --- a/packages/driver/src/cypress/chai_jquery.coffee +++ b/packages/driver/src/cypress/chai_jquery.coffee @@ -30,7 +30,7 @@ $chaiJquery = (chai, chaiUtils, callbacks = {}) -> { inspect, flag } = chaiUtils assertDom = (ctx, method, args...) -> - if not $dom.isDom(ctx._obj) + if not ($dom.isDom(ctx._obj) or $dom.isJquery(ctx._obj)) try ## always fail the assertion ## if we aren't a DOM like object @@ -41,13 +41,17 @@ $chaiJquery = (chai, chaiUtils, callbacks = {}) -> 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 ctx.assert(bool, args...) + ctx._obj = orig catch err ## send it up with the obj and whether it was negated callbacks.onError(err, method, ctx._obj, flag(ctx, "negate")) @@ -183,7 +187,7 @@ $chaiJquery = (chai, chaiUtils, callbacks = {}) -> else _super.apply(@, arguments) - _.each _.keys(selectors), (selector) -> + _.each selectors, (selectorName, selector) -> chai.Assertion.addProperty selector, -> assert( @, @@ -191,7 +195,7 @@ $chaiJquery = (chai, chaiUtils, callbacks = {}) -> wrap(@).is(":" + selector), 'expected #{this} to be #{exp}', 'expected #{this} not to be #{exp}', - selectors[selector] + selectorName ) _.each attrs, (description, attr) -> diff --git a/packages/driver/src/dom/visibility.js b/packages/driver/src/dom/visibility.js index a59e731611a1..a022617d21fa 100644 --- a/packages/driver/src/dom/visibility.js +++ b/packages/driver/src/dom/visibility.js @@ -45,6 +45,12 @@ const isHidden = function (el, name) { const $el = $jquery.wrap(el) + // we check to make sure element is attached. An element that is + // detached will have 0x0 size, so this provides a more specific + // reason than the 0x0 message. + // we don't need this because a detached el will have no height or width + // $elements.isDetached($el) + //# in Cypress-land we consider the element hidden if //# either its offsetHeight or offsetWidth is 0 because //# it is impossible for the user to interact with this element @@ -312,6 +318,10 @@ const getReasonIsHidden = function ($el) { //# returns the reason in human terms why an element is considered not visible switch (false) { + + case !$elements.isDetached($el): + return `This element '${node}' is not visible becuase it has become detached from the DOM. It's possible the element has been removed and a new element has been put in its place.` + case !elHasDisplayNone($el): return `This element '${node}' is not visible because it has CSS property: 'display: none'` diff --git a/packages/driver/test/cypress/integration/commands/assertions_spec.coffee b/packages/driver/test/cypress/integration/commands/assertions_spec.coffee index 9406b9b5d7df..63e3feb00c26 100644 --- a/packages/driver/test/cypress/integration/commands/assertions_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/assertions_spec.coffee @@ -805,6 +805,59 @@ describe "src/cy/commands/assertions", -> .get("body") .get("button").should("be.visible") + describe "doesn't pass not.visible for non-dom", -> + beforeEach -> + Cypress.config('defaultCommandTimeout', 50) + + it "undefined", (done) -> + spy = cy.spy (err) -> + expect(err.message).to.contain('attempted to make') + done() + .as 'onFail' + cy.on 'fail', spy + cy.wrap().should('not.be.visible') + + it 'null', (done) -> + spy = cy.spy (err) -> + expect(err.message).to.contain('attempted to make') + done() + .as 'onFail' + cy.on 'fail', spy + cy.wrap(null).should('not.be.visible') + + it '[]', (done) -> + spy = cy.spy (err) -> + expect(err.message).to.contain('attempted to make') + done() + .as 'onFail' + cy.on 'fail', spy + cy.wrap([]).should('not.be.visible') + + it '{}', (done) -> + spy = cy.spy (err) -> + expect(err.message).to.contain('attempted to make') + done() + .as 'onFail' + cy.on 'fail', spy + cy.wrap({}).should('not.be.visible') + + it 'jquery wrapping els and selectors, not changing subject', -> + cy.wrap(cy.$$('
')).should('not.be.visible') + cy.wrap(cy.$$('
')).should('not.exist') + cy.wrap(cy.$$('
')).should('not.be.visible').should('not.exist') + cy.wrap(cy.$$('.non-existent')).should('not.be.visible') + cy.wrap(cy.$$('.non-existent')).should('not.exist') + cy.wrap(cy.$$('.non-existent')).should('not.be.visible').should('not.exist') + + ## if this test breaks, it's ok. This behavior is no-man's-land + it 'jquery wrapping nonsense', -> + cy.wrap(cy.$$(42)).should('not.be.visible') + cy.wrap(cy.$$([])).should('not.exist') + cy.wrap(cy.$$([])).should('not.be.visible').should('not.exist') + cy.wrap(cy.$$({})).should('not.be.visible').should('not.exist') + cy.wrap(cy.$$(null)).should('not.be.visible').should('not.exist') + cy.wrap(cy.$$(undefined)).should('not.be.visible').should('not.exist') + describe "#have.length", -> it "formats _obj with cypress", (done) -> cy.on "log:added", (attrs, log) -> @@ -1185,6 +1238,18 @@ describe "src/cy/commands/assertions", -> expect({}).to.be.visible + it "is not.visible when detached from the DOM", -> + el = cy.$$('
detached on click
').on 'click', -> + el.remove() + .appendTo(cy.$$('body')) + + cy.get('.detached-on-click').click().should('not.be.visible').then -> + expect(getLastLog()).to.eq('expected **** not to be **visible**') + + it "is not.visible when dom query fails", -> + cy.get('.non-existent-el').should('not.be.visible').then -> + expect(getLastLog()).to.eq('expected **.non-existent-el** not to be **visible**') + context "hidden", -> beforeEach -> @$div = $("
div
").appendTo($("body")) @@ -1550,6 +1615,19 @@ describe "src/cy/commands/assertions", -> expect({}).to.not.have.focus + it "throws when obj is not DOM 2", (done) -> + cy.on "fail", (err) => + expect(@logs.length).to.eq(1) + expect(@logs[0].get("error").message).to.contain( + "expected {} to be 'focused'" + ) + expect(err.message).to.include("> focus") + expect(err.message).to.include("> {}") + + done() + + expect({}).to.have.focus + context "match", -> beforeEach -> @div = $("
") @@ -1869,3 +1947,6 @@ describe "src/cy/commands/assertions", -> done() expect({}).to.have.css("foo") + + +getLastLog = -> cy.state('ctx').logs.slice(-1)[0].get('message') diff --git a/packages/driver/test/cypress/integration/dom/visibility_spec.coffee b/packages/driver/test/cypress/integration/dom/visibility_spec.coffee index affaf5e81ce5..1b5672019d3b 100644 --- a/packages/driver/test/cypress/integration/dom/visibility_spec.coffee +++ b/packages/driver/test/cypress/integration/dom/visibility_spec.coffee @@ -25,6 +25,12 @@ describe "src/cypress/dom/visibility", -> expect(fn).to.throw("Cypress.dom.isVisible() must be passed a basic DOM element.") + it "returns false for element not attached", -> + fn = -> + $dom.isVisible(cy.$$('
foo bar baz
')) + + expect(fn()).to.eq(false) + context "#isScrollable", -> beforeEach -> @add = (el) => @@ -116,6 +122,7 @@ describe "src/cypress/dom/visibility", -> @$visHidden = add "
    " @$parentVisHidden = add "" @$displayNone = add "" + @$isDetached = $ "
    I'm not attached
    " @$btnOpacity = add "" @$divNoWidth = add "
    width: 0
    " @$divNoHeight = add "
    height: 0
    " @@ -490,6 +497,9 @@ describe "src/cypress/dom/visibility", -> @reasonIs = ($el, str) -> expect($dom.getReasonIsHidden($el)).to.eq(str) + it "is detached from the dom", -> + @reasonIs @$isDetached, "This element '' is not visible becuase it has become detached from the DOM. It's possible the element has been removed and a new element has been put in its place." + it "has 'display: none'", -> @reasonIs @$displayNone, "This element '
    " @$displayNone = add "" - @$isDetached = $ "
    I'm not attached
    " @$btnOpacity = add "" @$divNoWidth = add "
    width: 0
    " @$divNoHeight = add "
    height: 0
    " @@ -497,9 +490,6 @@ describe "src/cypress/dom/visibility", -> @reasonIs = ($el, str) -> expect($dom.getReasonIsHidden($el)).to.eq(str) - it "is detached from the dom", -> - @reasonIs @$isDetached, "This element '' is not visible becuase it has become detached from the DOM. It's possible the element has been removed and a new element has been put in its place." - it "has 'display: none'", -> @reasonIs @$displayNone, "This element '
    diff --git a/packages/desktop-gui/src/settings/proxy-settings.jsx b/packages/desktop-gui/src/settings/proxy-settings.jsx new file mode 100644 index 000000000000..f9f5fdb3fd31 --- /dev/null +++ b/packages/desktop-gui/src/settings/proxy-settings.jsx @@ -0,0 +1,80 @@ +import { observer } from 'mobx-react' +import Tooltip from '@cypress/react-tooltip' +import { trim } from 'lodash' +import React from 'react' + +import ipc from '../lib/ipc' + +const trimQuotes = (input) => + trim(input, '"') + +const getProxySourceName = (proxySource) => { + if (proxySource === 'win32') { + return 'Windows system settings' + } + + return 'environment variables' +} + +const openHelp = (e) => { + e.preventDefault() + ipc.externalOpen('https://on.cypress.io/proxy-configuration') +} + +const renderLearnMore = () => { + return ( + + Learn more + + ) +} + +const ProxySettings = observer(({ app }) => { + if (!app.proxyServer) { + return ( +
    + {renderLearnMore()} +

    + There is no active proxy configuration. +

    +
    + ) + } + + const proxyBypassList = trimQuotes(app.proxyBypassList) + const proxySource = getProxySourceName(trimQuotes(app.proxySource)) + + return ( +
    + {renderLearnMore()} +

    Cypress auto-detected the following proxy settings from {proxySource}:

    + + + + + + + + + + + +
    Proxy Server + + {trimQuotes(app.proxyServer)} + +
    + Proxy Bypass List{' '} + + + + + {proxyBypassList ? {proxyBypassList.split(',').join(', ')} : none} +
    +
    + ) +}) + +export default ProxySettings diff --git a/packages/desktop-gui/src/settings/settings.jsx b/packages/desktop-gui/src/settings/settings.jsx index 38ba06137d2b..c793e100c522 100644 --- a/packages/desktop-gui/src/settings/settings.jsx +++ b/packages/desktop-gui/src/settings/settings.jsx @@ -6,8 +6,9 @@ import Collapse, { Panel } from 'rc-collapse' import Configuration from './configuration' import ProjectId from './project-id' import RecordKey from './record-key' +import ProxySettings from './proxy-settings' -const Settings = observer(({ project }) => ( +const Settings = observer(({ project, app }) => (
    ( + + +
    diff --git a/packages/desktop-gui/src/settings/settings.scss b/packages/desktop-gui/src/settings/settings.scss index d350ddcf6b98..3c41c96d22c7 100644 --- a/packages/desktop-gui/src/settings/settings.scss +++ b/packages/desktop-gui/src/settings/settings.scss @@ -207,3 +207,21 @@ color: #999; margin: 1em; } + +.proxy-settings { + .proxy-table { + width: 100%; + + th:first-child { + width: 150px; + } + + .no-bypass { + opacity: .8; + } + + td, th { + padding: 3px; + } + } +} diff --git a/packages/desktop-gui/src/specs/specs-list.jsx b/packages/desktop-gui/src/specs/specs-list.jsx index a11071096030..0d071ee56c57 100644 --- a/packages/desktop-gui/src/specs/specs-list.jsx +++ b/packages/desktop-gui/src/specs/specs-list.jsx @@ -11,7 +11,7 @@ import specsStore, { allSpecsSpec } from './specs-store' @observer class SpecsList extends Component { - constructor(props) { + constructor (props) { super(props) this.filterRef = React.createRef() } @@ -74,13 +74,13 @@ class SpecsList extends Component { return ( ) } - _specItem (spec) { - return spec.hasChildren ? this._folderContent(spec) : this._specContent(spec) + _specItem (spec, nestingLevel) { + return spec.hasChildren ? this._folderContent(spec, nestingLevel) : this._specContent(spec, nestingLevel) } _allSpecsIcon (allSpecsChosen) { @@ -123,22 +123,22 @@ class SpecsList extends Component { specsStore.setExpandSpecFolder(specFolderPath) } - _folderContent (spec) { + _folderContent (spec, nestingLevel) { const isExpanded = spec.isExpanded return ( -
  • +
  • -
    +
    -
    {spec.displayName}{' '}
    + {nestingLevel === 0 ? `${spec.displayName} tests` : spec.displayName}
    { isExpanded ?
      - {_.map(spec.children, (spec) => this._specItem(spec))} + {_.map(spec.children, (spec) => this._specItem(spec, nestingLevel + 1))}
    : null @@ -148,12 +148,12 @@ class SpecsList extends Component { ) } - _specContent (spec) { + _specContent (spec, nestingLevel) { return ( -
  • +
  • -
    +
    {spec.displayName}
    diff --git a/packages/desktop-gui/src/specs/specs.scss b/packages/desktop-gui/src/specs/specs.scss index 4c9b1ca8bdef..d3feff7cbaa2 100644 --- a/packages/desktop-gui/src/specs/specs.scss +++ b/packages/desktop-gui/src/specs/specs.scss @@ -1,3 +1,6 @@ +// maximum supported file structure nesting level +$max-nesting-level: 14; + #tests-list-page { position: relative; @@ -18,7 +21,6 @@ .search { padding: 8px 5px 7px 15px; - padding-right: 5px; margin-right: 15px; display: inline-block; position: relative; @@ -109,11 +111,26 @@ margin-bottom: 0; } - .file > a, .folder { - color: #7c7f84; + .file, .folder { + .file-name, + .folder-name { + display: flex; + align-items: center; - i { - margin-right: 5px; + i { + font-size: 13px; + margin-right: 5px; + flex-shrink: 0; + } + } + + @for $i from 0 through $max-nesting-level { + &.level-#{$i} { + .file-name, + .folder-name { + padding-left: 20px * $i; + } + } } } @@ -124,22 +141,23 @@ color: #636363; font-family: $font-sans; - i { - font-size: 13px; - } - - i.folder-collapse-icon { - display: inline-block; - margin-right: 4px; - } - - &>div>div { - padding-bottom: 0; - } - - .folder-display-name { - display: inline-block; - margin-right: 5px; + &.level-0 { + >div>.folder-name { + padding: 5px 20px; + background-color: #F9F9F9; + font-weight: normal; + text-transform: uppercase; + border-bottom: 1px solid #eee; + border-top: 1px solid #eee; + color: #777; + line-height: 18px; + font-family: $font-sans; + + i { + display: none; + margin-right: 5px; + } + } } } @@ -151,11 +169,6 @@ color: #637eb9; font-size: 15px; - i { - font-size: 13px; - margin-right: 6px; - } - &.active { pointer-events: none; background-color: #F5FBF7; @@ -179,22 +192,12 @@ } } - .list-as-table>.folder { - &>div>div:last-child { - padding: 0; - } - } - .list-as-table>.file, .list-as-table>.file>a { float: left; width: 100%; } .list-as-table>.file { - &:last-child>a { - // border-bottom: 0; - } - &>div:first-child { @include responsive-width(75%); } @@ -205,112 +208,4 @@ color: #bdc0c5; } } - - // need to refactor to be calculated using javascript - .outer-files-container.list-as-table>.folder, - .outer-files-container.list-as-table>.file>a { - - &>div>div:first-child { - padding: 5px 20px; - background-color: #F9F9F9; - font-weight: normal; - text-transform: uppercase; - border-bottom: 1px solid #eee; - border-top: 1px solid #eee; - color: #777; - line-height: 18px; - font-family: $font-sans; - - i { - display: none; - margin-right: 5px; - } - - i.folder-collapse-icon { - display: inline-block; - } - - &:after { - content: "tests" - } - } - - &>div>div:last-child { - - &>.list-as-table>.folder, - &>.list-as-table>.file>a { - - &>div>div:first-child { - padding-left: 20px; - } - - &>div>div:last-child { - - &>.list-as-table>.folder, - &>.list-as-table>.file>a { - - &>div>div:first-child { - padding-left: 40px; - } - - &>div>div:last-child { - - &>.list-as-table>.folder, - &>.list-as-table>.file>a { - - &>div>div:first-child { - padding-left: 60px; - } - - &>div>div:last-child { - - &>.list-as-table>.folder, - &>.list-as-table>.file>a { - - &>div>div:first-child { - padding-left: 80px; - } - - &>div>div:last-child { - - &>.list-as-table>.folder, - &>.list-as-table>.file>a { - - &>div>div:first-child { - padding-left: 100px; - } - - &>div>div:last-child { - - &>.list-as-table>.folder, - &>.list-as-table>.file>a { - - &>div>div:first-child { - padding-left: 120px; - } - - } - - } - - } - - } - - } - - } - - } - - } - - } - - } - - } - - } - } } diff --git a/packages/driver/package.json b/packages/driver/package.json index 324aa40794ea..4ff1028a22d9 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -22,18 +22,18 @@ "@cypress/underscore.inflection": "1.0.1", "@cypress/unique-selector": "0.4.2", "angular": "1.7.7", - "backbone": "1.3.3", + "backbone": "1.4.0", "basic-auth": "2.0.1", "blob-util": "1.3.0", "bluebird": "3.5.0", - "body-parser": "1.18.3", + "body-parser": "1.19.0", "bootstrap": "4.3.1", "bytes": "3.1.0", "chai": "3.5.0", "chai-as-promised": "6.0.0", - "chokidar-cli": "1.2.1", + "chokidar-cli": "1.2.2", "clone": "2.1.2", - "compression": "1.7.3", + "compression": "1.7.4", "cors": "2.8.5", "debug": "2.6.9", "errorhandler": "1.5.0", @@ -45,6 +45,7 @@ "jsdom": "13.2.0", "lodash": "4.17.11", "lolex": "3.1.0", + "methods": "1.1.2", "method-override": "2.3.10", "minimatch": "3.0.4", "minimist": "1.2.0", @@ -52,7 +53,7 @@ "moment": "2.24.0", "morgan": "1.9.1", "npm-install-version": "6.0.2", - "parse-domain": "2.1.7", + "parse-domain": "2.0.0", "setimmediate": "1.0.5", "sinon": "3.3.0", "text-mask-addons": "3.8.0", @@ -61,6 +62,6 @@ "url-parse": "1.4.4", "vanilla-text-mask": "5.1.1", "wait-on": "2.1.2", - "zone.js": "0.8.29" + "zone.js": "0.9.0" } } diff --git a/packages/driver/src/config/jquery.coffee b/packages/driver/src/config/jquery.coffee index c7117d33b186..57837ea76ebd 100644 --- a/packages/driver/src/config/jquery.coffee +++ b/packages/driver/src/config/jquery.coffee @@ -1,6 +1,6 @@ $ = require("jquery") -require("jquery.scrollto") _ = require('lodash') +require("jquery.scrollto") $dom = require("../dom") diff --git a/packages/driver/src/cy/assertions.coffee b/packages/driver/src/cy/assertions.coffee index d4afe0386ff3..a6c9099675d5 100644 --- a/packages/driver/src/cy/assertions.coffee +++ b/packages/driver/src/cy/assertions.coffee @@ -111,6 +111,10 @@ create = (state, queue, retryFn) -> 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, { @@ -156,7 +160,8 @@ create = (state, queue, retryFn) -> options.error = err - throw err if err.retry is false + if err.retry is false + throw err onFail = callbacks.onFail onRetry = callbacks.onRetry @@ -168,14 +173,18 @@ create = (state, queue, retryFn) -> ## and finish the assertions and then throw ## it again try - onFail.call(@, err) if _.isFunction(onFail) + if _.isFunction(onFail) + ## pass in the err and the upcoming assertion commands + onFail.call(@, err, isDefaultAssertionErr, cmds) catch e3 finishAssertions(options.assertions) throw e3 - retryFn(onRetry, options) if _.isFunction(onRetry) + if _.isFunction(onRetry) + retryFn(onRetry, options) - ## bail if we have no assertions + ## bail if we have no assertions and apply + ## the default assertions if applicable if not cmds.length return Promise .try(ensureExistence) @@ -276,6 +285,12 @@ create = (state, queue, retryFn) -> 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 @@ -291,7 +306,7 @@ create = (state, queue, retryFn) -> state("overrideAssert", overrideAssert) Promise - .reduce(fns, assertions, subject) + .reduce(fns, assertions, [subject]) .then -> restore() diff --git a/packages/driver/src/cy/commands/connectors.coffee b/packages/driver/src/cy/commands/connectors.coffee index b5d6894549d1..c7a67c76af03 100644 --- a/packages/driver/src/cy/commands/connectors.coffee +++ b/packages/driver/src/cy/commands/connectors.coffee @@ -18,6 +18,21 @@ returnFalseIfThenable = (key, args...) -> args[0]() return false +primitiveToObject = (memo) -> + switch + when _.isString(memo) + new String(memo) + when _.isNumber(memo) + new Number(memo) + else + memo + +getFormattedElement = ($el) -> + if $dom.isElement($el) + $dom.getElements($el) + else + $el + module.exports = (Commands, Cypress, cy, state, config) -> ## thens can return more "thenables" which are not resolved ## until they're 'really' resolved, so naturally this API @@ -120,132 +135,198 @@ module.exports = (Commands, Cypress, cy, state, config) -> } .finally(cleanup) - invokeFn = (subject, fn, args...) -> + invokeFn = (subject, str, args...) -> options = {} - getMessage = -> - if name is "invoke" - ".#{fn}(" + $utils.stringify(args) + ")" - else - ".#{fn}" - ## name could be invoke or its! name = state("current").get("name") + isCmdIts = name is "its" + isCmdInvoke = name is "invoke" + + getMessage = -> + if isCmdIts + return ".#{str}" + + return ".#{str}(" + $utils.stringify(args) + ")" + message = getMessage() + traversalErr = null + options._log = Cypress.log message: message $el: if $dom.isElement(subject) then subject else null consoleProps: -> Subject: subject - if not _.isString(fn) + if not _.isString(str) $utils.throwErrByPath("invoke_its.invalid_1st_arg", { onFail: options._log args: { cmd: name } }) - if name is "its" and args.length > 0 + if isCmdIts and args.length > 0 $utils.throwErrByPath("invoke_its.invalid_num_of_args", { onFail: options._log args: { cmd: name } }) - fail = (prop) -> - $utils.throwErrByPath("invoke_its.invalid_property", { - onFail: options._log - args: { prop, cmd: name } - }) + ## TODO: use the new error utils that are part of + ## the error message enhancements PR + propertyNotOnSubjectErr = (prop) -> + $utils.cypressErr( + $utils.errMessageByPath("invoke_its.nonexistent_prop", { + prop, + cmd: name + }) + ) - failOnPreviousNullOrUndefinedValue = (previousProp, currentProp, value) -> - $utils.throwErrByPath("invoke_its.previous_prop_nonexistent", { - args: { previousProp, currentProp, value, cmd: name } - }) + propertyValueNullOrUndefinedErr = (prop, value) -> + errMessagePath = if isCmdIts then "its" else "invoke" - failOnCurrentNullOrUndefinedValue = (prop, value) -> - $utils.throwErrByPath("invoke_its.current_prop_nonexistent", { - args: { prop, value, cmd: name } - }) + $utils.cypressErr( + $utils.errMessageByPath("#{errMessagePath}.null_or_undefined_prop_value", { + prop, + value, + cmd: name + }) + ) - getReducedProp = (str, subject) -> - getValue = (memo, prop) -> - switch - when _.isString(memo) - new String(memo) - when _.isNumber(memo) - new Number(memo) - else - memo - - _.reduce str.split("."), (memo, prop, index, array) -> - - ## if the property does not EXIST on the subject - ## then throw a specific error message - try - fail(prop) if prop not of getValue(memo, prop) - catch e - ## if the value is null or undefined then it does - ## not have properties which causes us to throw - ## an even more particular error - if _.isNull(memo) or _.isUndefined(memo) - if index > 0 - failOnPreviousNullOrUndefinedValue(array[index - 1], prop, memo) - else - failOnCurrentNullOrUndefinedValue(prop, memo) - else - throw e - return memo[prop] + subjectNullOrUndefinedErr = (prop, value) -> + errMessagePath = if isCmdIts then "its" else "invoke" - , subject + $utils.cypressErr( + $utils.errMessageByPath("#{errMessagePath}.subject_null_or_undefined", { + prop, + value, + cmd: name + }) + ) + + propertyNotOnPreviousNullOrUndefinedValueErr = (prop, value, previousProp) -> + $utils.cypressErr( + $utils.errMessageByPath("invoke_its.previous_prop_null_or_undefined", { + prop, + value, + previousProp, + cmd: name + }) + ) + + traverseObjectAtPath = (acc, pathsArray, index = 0) -> + ## traverse at this depth + prop = pathsArray[index] + previousProp = pathsArray[index - 1] + valIsNullOrUndefined = _.isNil(acc) + + ## if we're attempting to tunnel into + ## a null or undefined object... + if prop and valIsNullOrUndefined + if index is 0 + ## give an error stating the current subject is nil + traversalErr = subjectNullOrUndefinedErr(prop, acc) + else + ## else refer to the previous property so users know which prop + ## caused us to hit this dead end + traversalErr = propertyNotOnPreviousNullOrUndefinedValueErr(prop, acc, previousProp) + + return acc + + ## if we have no more properties to traverse + if not prop + if valIsNullOrUndefined + ## set traversal error that the final value is null or undefined + traversalErr = propertyValueNullOrUndefinedErr(previousProp, acc) + + ## finally return the reduced traversed accumulator here + return acc + + ## attempt to lookup this property on the acc + ## if our property does not exist then allow + ## undefined to pass through but set the traversalErr + ## since if we don't have any assertions we want to + ## provide a very specific error message and not the + ## generic existence one + if (prop not of primitiveToObject(acc)) + traversalErr = propertyNotOnSubjectErr(prop) + + return undefined + + ## if we succeeded then continue to traverse + return traverseObjectAtPath(acc[prop], pathsArray, index + 1) + + getSettledValue = (value, subject, propAtLastPath) -> + if isCmdIts + return value + + if _.isFunction(value) + return value.apply(subject, args) + + ## TODO: this logic should likely be part of + ## traverseObjectAtPath(...) rather be further + ## away from the handling of traversals. this + ## causes us to need to separately handle + ## the 'propAtLastPath' argument since we're + ## outside of the reduced accumulator. + + ## if we're not a function and we have a traversal + ## error then throw it now - since that provide a + ## more specific error regarding non-existant + ## properties or null or undefined values + if traversalErr + throw traversalErr + + ## else throw that prop isn't a function + $utils.throwErrByPath("invoke.prop_not_a_function", { + onFail: options._log + args: { + prop: propAtLastPath + type: $utils.stringifyFriendlyTypeof(value) + } + }) getValue = -> + ## reset this on each go around so previous errors + ## don't leak into new failures or upcoming assertion errors + traversalErr = null + remoteSubject = cy.getRemotejQueryInstance(subject) actualSubject = remoteSubject or subject - prop = getReducedProp(fn, actualSubject) + paths = str.split(".") - invoke = -> - switch name - when "its" - prop - when "invoke" - if _.isFunction(prop) - prop.apply(actualSubject, args) - else - $utils.throwErrByPath("invoke.invalid_type", { - onFail: options._log - args: { prop: fn } - }) - - getFormattedElement = ($el) -> - if $dom.isElement($el) - $dom.getElements($el) - else - $el + prop = traverseObjectAtPath(actualSubject, paths) - value = invoke() + value = getSettledValue(prop, actualSubject, _.last(paths)) if options._log - options._log.set + options._log.set({ consoleProps: -> obj = {} - if name is "invoke" + if isCmdInvoke obj["Function"] = message obj["With Arguments"] = args if args.length else obj["Property"] = message - _.extend obj, - On: getFormattedElement(actualSubject) + _.extend(obj, { + Subject: getFormattedElement(actualSubject) Yielded: getFormattedElement(value) + }) - obj + return obj + }) return value + ## by default we want to only add the default assertion + ## of ensuring existence for cy.its() not cy.invoke() because + ## invoking a function can legitimately return null or undefined + ensureExistenceFor = if isCmdIts then "subject" else false + ## wrap retrying into its own ## separate function retryValue = -> @@ -256,9 +337,18 @@ module.exports = (Commands, Cypress, cy, state, config) -> cy.retry(retryValue, options) do resolveValue = -> - Promise.try(retryValue).then (value) -> + Promise + .try(retryValue) + .then (value) -> cy.verifyUpcomingAssertions(value, options, { + ensureExistenceFor onRetry: resolveValue + onFail: (err, isDefaultAssertionErr, assertionLogs) -> + ## if we failed our upcoming assertions and also + ## exited early out of getting the value of our + ## subject then reset the error to this one + if traversalErr + return options.error = traversalErr }) Commands.addAll({ prevSubject: true }, { diff --git a/packages/driver/src/cy/commands/navigation.coffee b/packages/driver/src/cy/commands/navigation.coffee index c21b4006a9fe..9cd07b78b6ed 100644 --- a/packages/driver/src/cy/commands/navigation.coffee +++ b/packages/driver/src/cy/commands/navigation.coffee @@ -13,6 +13,9 @@ hasVisitedAboutBlank = null currentlyVisitingAboutBlank = null knownCommandCausedInstability = null +REQUEST_URL_OPTS = ["auth", "failOnStatusCode", "method", "body", "headers"] +VISIT_OPTS = ["url", "log", "onBeforeLoad", "onLoad", "timeout"].concat(REQUEST_URL_OPTS) + reset = (test = {}) -> knownCommandCausedInstability = false @@ -265,7 +268,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> Cypress.backend( "resolve:url", url, - _.pick(options, "auth", "failOnStatusCode", "method", "body", "headers") + _.pick(options, REQUEST_URL_OPTS) ) .then (resp = {}) -> switch @@ -472,6 +475,11 @@ module.exports = (Commands, Cypress, cy, state, config) -> if not _.isString(url) $utils.throwErrByPath("visit.invalid_1st_arg") + consoleProps = {} + + if not _.isEmpty(options) + consoleProps["Options"] = _.pick(options, VISIT_OPTS) + _.defaults(options, { auth: null failOnStatusCode: true @@ -490,8 +498,6 @@ module.exports = (Commands, Cypress, cy, state, config) -> if not _.isObject(options.headers) $utils.throwErrByPath("visit.invalid_headers") - consoleProps = {} - if options.log message = url diff --git a/packages/driver/src/cy/commands/querying.coffee b/packages/driver/src/cy/commands/querying.coffee index 05102051cb1d..9136de24676f 100644 --- a/packages/driver/src/cy/commands/querying.coffee +++ b/packages/driver/src/cy/commands/querying.coffee @@ -47,7 +47,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> getFocused = -> focused = cy.getFocused() log(focused) - + return focused do resolveFocused = (failedByNonAssertion = false) -> @@ -84,7 +84,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> options._log ?= Cypress.log message: selector - referencesAlias: aliasObj?.alias + referencesAlias: if aliasObj?.alias then {name: aliasObj.alias} aliasType: aliasType consoleProps: -> consoleProps diff --git a/packages/driver/src/cy/commands/request.coffee b/packages/driver/src/cy/commands/request.coffee index 1d13a13a640f..84cf05da6d18 100644 --- a/packages/driver/src/cy/commands/request.coffee +++ b/packages/driver/src/cy/commands/request.coffee @@ -4,8 +4,6 @@ Promise = require("bluebird") $utils = require("../../cypress/utils") $Location = require("../../cypress/location") -validHttpMethodsRe = /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)$/ - isOptional = (memo, val, key) -> if _.isNull(val) memo.push(key) @@ -21,6 +19,7 @@ REQUEST_DEFAULTS = { json: null form: null gzip: true + timeout: null followRedirect: true } @@ -28,8 +27,10 @@ REQUEST_PROPS = _.keys(REQUEST_DEFAULTS) OPTIONAL_OPTS = _.reduce(REQUEST_DEFAULTS, isOptional, []) -argIsHttpMethod = (str) -> - _.isString(str) and validHttpMethodsRe.test str.toUpperCase() +hasFormUrlEncodedContentTypeHeader = (headers) -> + header = _.findKey(headers, _.matches("application/x-www-form-urlencoded")) + + header and _.toLower(header) is "content-type" isValidJsonObj = (body) -> _.isObject(body) and not _.isFunction(body) @@ -37,6 +38,13 @@ isValidJsonObj = (body) -> whichAreOptional = (val, key) -> val is null and key in OPTIONAL_OPTS +needsFormSpecified = (options = {}) -> + { body, json, headers } = options + + ## json isn't true, and we have an object body and the user + ## specified that the content-type header is x-www-form-urlencoded + json isnt true and _.isObject(body) and hasFormUrlEncodedContentTypeHeader(headers) + module.exports = (Commands, Cypress, cy, state, config) -> # Cypress.extend # ## set defaults for all requests? @@ -59,7 +67,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> when args.length is 2 ## if our first arg is a valid ## HTTP method then set method + url - if argIsHttpMethod(args[0]) + if $utils.isValidHttpMethod(args[0]) o.method = args[0] o.url = args[1] else @@ -73,11 +81,13 @@ module.exports = (Commands, Cypress, cy, state, config) -> o.body = args[2] _.defaults(options, REQUEST_DEFAULTS, { - log: true - timeout: config("responseTimeout") + log: true, failOnStatusCode: true }) + ## if timeout is not supplied, use the configured default + options.timeout ||= config("responseTimeout") + options.method = options.method.toUpperCase() if _.has(options, "failOnStatus") @@ -89,7 +99,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> if _.has(options, "followRedirects") options.followRedirect = options.followRedirects - if not validHttpMethodsRe.test(options.method) + if not $utils.isValidHttpMethod(options.method) $utils.throwErrByPath("request.invalid_method", { args: { method: o.method } }) @@ -115,6 +125,13 @@ module.exports = (Commands, Cypress, cy, state, config) -> if not $Location.isFullyQualifiedUrl(options.url) $utils.throwErrByPath("request.url_invalid") + ## if a user has `x-www-form-urlencoded` content-type set + ## with an object body, they meant to add 'form: true' + ## so we are nice and do it for them :) + ## https://github.com/cypress-io/cypress/issues/2923 + if needsFormSpecified(options) + options.form = true + ## only set json to true if form isnt true ## and we have a valid object for body if options.form isnt true and isValidJsonObj(options.body) diff --git a/packages/driver/src/cy/commands/waiting.coffee b/packages/driver/src/cy/commands/waiting.coffee index 0fcdbf789a73..c2eb47718b5e 100644 --- a/packages/driver/src/cy/commands/waiting.coffee +++ b/packages/driver/src/cy/commands/waiting.coffee @@ -82,13 +82,22 @@ module.exports = (Commands, Cypress, cy, state, config) -> type = cy.getXhrTypeByAlias(str) + [ index, num ] = getNumRequests(state, alias) + ## if we have a command then continue to ## build up an array of referencesAlias ## because wait can reference an array of aliases if log referencesAlias = log.get("referencesAlias") ? [] aliases = [].concat(referencesAlias) - aliases.push(str) + + if str + aliases.push({ + name: str + cardinal: index + 1, + ordinal: num + }) + log.set "referencesAlias", aliases if command.get("name") isnt "route" @@ -104,8 +113,6 @@ module.exports = (Commands, Cypress, cy, state, config) -> requestTimeout = options.requestTimeout ? timeout responseTimeout = options.responseTimeout ? timeout - [ index, num ] = getNumRequests(state, alias) - waitForRequest = -> options = _.omit(options, "_runnableTimeout") options.timeout = requestTimeout ? Cypress.config("requestTimeout") @@ -136,7 +143,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> if log log.set "consoleProps", -> { - "Waited For": (log.get("referencesAlias") || []).join(", ") + "Waited For": (_.map(log.get("referencesAlias"), 'name') || []).join(", ") "Yielded": ret } diff --git a/packages/driver/src/cy/commands/xhr.coffee b/packages/driver/src/cy/commands/xhr.coffee index b3113d146d62..1a9ff4b74062 100644 --- a/packages/driver/src/cy/commands/xhr.coffee +++ b/packages/driver/src/cy/commands/xhr.coffee @@ -5,13 +5,17 @@ $utils = require("../../cypress/utils") $Server = require("../../cypress/server") $Location = require("../../cypress/location") -validHttpMethodsRe = /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)$/i - server = null getServer = -> server ? unavailableErr() +cancelPendingXhrs = -> + if server + server.cancelPendingXhrs() + + return null + reset = -> if server server.restore() @@ -128,6 +132,9 @@ startXhrServer = (cy, state, config) -> when xhr.aborted indicator = "aborted" "(aborted)" + when xhr.canceled + indicator = "aborted" + "(canceled)" when xhr.status > 0 xhr.status else @@ -136,9 +143,9 @@ startXhrServer = (cy, state, config) -> indicator ?= if /^2/.test(status) then "successful" else "bad" - { + return { + indicator, message: "#{xhr.method} #{status} #{stripOrigin(xhr.url)}" - indicator: indicator } }) @@ -180,6 +187,15 @@ startXhrServer = (cy, state, config) -> if log = logs[xhr.id] log.snapshot("aborted").error(err) + onXhrCancel: (xhr) -> + setResponse(state, xhr) + + if log = logs[xhr.id] + log.snapshot("canceled").set({ + ended: true, + state: "failed" + }) + onAnyAbort: (route, xhr) => if route and _.isFunction(route.onAbort) route.onAbort.call(cy, xhr) @@ -217,6 +233,13 @@ defaults = { module.exports = (Commands, Cypress, cy, state, config) -> reset() + ## if our page is going away due to + ## a form submit / anchor click then + ## we need to cancel all pending + ## XHR's so the command log displays + ## correctly + Cypress.on("window:unload", cancelPendingXhrs) + Cypress.on "test:before:run", -> ## reset the existing server reset() @@ -303,7 +326,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> when args.length is 2 ## if our url actually matches an http method ## then we know the user doesn't want to stub this route - if _.isString(args[0]) and validHttpMethodsRe.test(args[0]) + if _.isString(args[0]) and $utils.isValidHttpMethod(args[0]) o.method = args[0] o.url = args[1] @@ -313,7 +336,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> o.response = args[1] when args.length is 3 - if validHttpMethodsRe.test(args[0]) or isUrlLikeArgs(args[1], args[2]) + if $utils.isValidHttpMethod(args[0]) or isUrlLikeArgs(args[1], args[2]) o.method = args[0] o.url = args[1] o.response = args[2] @@ -341,7 +364,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> if not (_.isString(options.url) or _.isRegExp(options.url)) $utils.throwErrByPath "route.url_invalid" - if not validHttpMethodsRe.test(options.method) + if not $utils.isValidHttpMethod(options.method) $utils.throwErrByPath "route.method_invalid", { args: { method: o.method } } diff --git a/packages/driver/src/cy/retries.coffee b/packages/driver/src/cy/retries.coffee index f7740911947f..33f6fe346f29 100644 --- a/packages/driver/src/cy/retries.coffee +++ b/packages/driver/src/cy/retries.coffee @@ -49,12 +49,8 @@ create = (Cypress, state, timeout, clearTimeout, whenStable, finishAssertions) - finishAssertions(assertions) getErrMessage = (err) -> - switch - when err and err.displayMessage - err.displayMessage - when err and err.message - err.message - else + _.get(err, 'displayMessage') or + _.get(err, 'message') or err $utils.throwErrByPath "miscellaneous.retry_timed_out", { diff --git a/packages/driver/src/cy/snapshots.coffee b/packages/driver/src/cy/snapshots.coffee index f06bb8971866..36ae0f4943fc 100644 --- a/packages/driver/src/cy/snapshots.coffee +++ b/packages/driver/src/cy/snapshots.coffee @@ -53,7 +53,7 @@ getStylesFor = (doc, $$, stylesheets, location) -> makePathsAbsoluteToDoc(doc, styleRules) -getDocumentStylesheets = (document) -> +getDocumentStylesheets = (document = {}) -> _.reduce document.styleSheets, (memo, stylesheet) -> memo[stylesheet.href] = stylesheet return memo @@ -208,6 +208,8 @@ create = ($$, state) -> ## careful renaming or removing this method, the runner depends on it getStyles + + getDocumentStylesheets } module.exports = { diff --git a/packages/driver/src/cypress/error_messages.coffee b/packages/driver/src/cypress/error_messages.coffee index bac9ff5f11c5..d7594354817c 100644 --- a/packages/driver/src/cypress/error_messages.coffee +++ b/packages/driver/src/cypress/error_messages.coffee @@ -277,28 +277,101 @@ module.exports = { hover: not_implemented: """ - #{cmd('hover')} is not currently implemented.\n - However it is usually easy to workaround.\n - Read the following document for a detailed explanation.\n + #{cmd('hover')} is not currently implemented. + + However it is usually easy to workaround. + + Read the following document for a detailed explanation. + https://on.cypress.io/hover """ invoke: - invalid_type: "Cannot call #{cmd('invoke')} because '{{prop}}' is not a function. You probably want to use #{cmd('its', '\'{{prop}}\'')}." + prop_not_a_function: + """ + #{cmd('invoke')} errored because the property: '{{prop}}' returned a '{{type}}' value instead of a function. #{cmd('invoke')} can only be used on properties that return callable functions. + + #{cmd('invoke')} waited for the specified property '{{prop}}' to return a function, but it never did. + + If you want to assert on the property's value, then switch to use #{cmd('its')} and add an assertion such as: + + cy.wrap({ foo: 'bar' }).its('foo').should('eq', 'bar') + """ + subject_null_or_undefined: + """ + #{cmd('invoke')} errored because your subject is: '{{value}}'. You cannot invoke any functions such as '{{prop}}' on a '{{value}}' value. + + If you expect your subject to be '{{value}}', then add an assertion such as: + + cy.wrap({{value}}).should('be.{{value}}') + """ + null_or_undefined_prop_value: + """ + #{cmd('invoke')} errored because the property: '{{prop}}' is not a function, and instead returned a '{{value}}' value. + + #{cmd('invoke')} waited for the specified property '{{prop}}' to become a callable function, but it never did. + + If you expect the property '{{prop}}' to be '{{value}}', then switch to use #{cmd('its')} and add an assertion such as: + + cy.wrap({ foo: {{value}} }).its('foo').should('be.{{value}}') + """ + + its: + subject_null_or_undefined: + """ + #{cmd('its')} errored because your subject is: '{{value}}'. You cannot access any properties such as '{{prop}}' on a '{{value}}' value. + + If you expect your subject to be '{{value}}', then add an assertion such as: + + cy.wrap({{value}}).should('be.{{value}}') + """ + null_or_undefined_prop_value: + """ + #{cmd('its')} errored because the property: '{{prop}}' returned a '{{value}}' value. + + #{cmd('its')} waited for the specified property '{{prop}}' to become accessible, but it never did. + + If you expect the property '{{prop}}' to be '{{value}}', then add an assertion such as: + + cy.wrap({ foo: {{value}} }).its('foo').should('be.{{value}}') + """ invoke_its: - current_prop_nonexistent: "#{cmd('{{cmd}}')} errored because your subject is currently: '{{value}}'. You cannot call any properties such as '{{prop}}' on a '{{value}}' value." + nonexistent_prop: + """ + #{cmd('{{cmd}}')} errored because the property: '{{prop}}' does not exist on your subject. + + #{cmd('{{cmd}}')} waited for the specified property '{{prop}}' to exist, but it never did. + + If you do not expect the property '{{prop}}' to exist, then add an assertion such as: + + cy.wrap({ foo: 'bar' }).its('quux').should('not.exist') + """ + previous_prop_null_or_undefined: + """ + #{cmd('{{cmd}}')} errored because the property: '{{previousProp}}' returned a '{{value}}' value. The property: '{{prop}}' does not exist on a '{{value}}' value. + + #{cmd('{{cmd}}')} waited for the specified property '{{prop}}' to become accessible, but it never did. + + If you do not expect the property '{{prop}}' to exist, then add an assertion such as: + + cy.wrap({ foo: {{value}} }).its('foo.baz').should('not.exist') + """ invalid_1st_arg: "#{cmd('{{cmd}}')} only accepts a string as the first argument." - invalid_num_of_args: """ - #{cmd('{{cmd}}')} only accepts a single argument.\n + invalid_num_of_args: + """ + #{cmd('{{cmd}}')} only accepts a single argument. + If you want to invoke a function with arguments, use cy.invoke(). - """ - invalid_property: "#{cmd('{{cmd}}')} errored because the property: '{{prop}}' does not exist on your subject." - previous_prop_nonexistent: "#{cmd('{{cmd}}')} errored because the property: '{{previousProp}}' returned a '{{value}}' value. You cannot access any properties such as '{{currentProp}}' on a '{{value}}' value." - timed_out: """ - #{cmd('{{cmd}}')} timed out after waiting '{{timeout}}ms'.\n - Your callback function returned a promise which never resolved.\n - The callback function was:\n + """ + timed_out: + """ + #{cmd('{{cmd}}')} timed out after waiting '{{timeout}}ms'. + + Your callback function returned a promise which never resolved. + + The callback function was: + {{func}} """ @@ -519,7 +592,7 @@ module.exports = { auth_invalid: "#{cmd('request')} must be passed an object literal for the 'auth' option." gzip_invalid: "#{cmd('request')} requires the 'gzip' option to be a boolean." headers_invalid: "#{cmd('request')} requires the 'headers' option to be an object literal." - invalid_method: "#{cmd('request')} was called with an invalid method: '{{method}}'. Method can only be: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS" + invalid_method: "#{cmd('request')} was called with an invalid method: '{{method}}'. Method can be: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, or any other method supported by Node's HTTP parser." form_invalid: """ #{cmd('request')} requires the 'form' option to be a boolean. @@ -616,7 +689,7 @@ module.exports = { route: failed_prerequisites: "#{cmd('route')} cannot be invoked before starting the #{cmd('server')}" invalid_arguments: "#{cmd('route')} was not provided any arguments. You must provide valid arguments." - method_invalid: "#{cmd('route')} was called with an invalid method: '{{method}}'. Method can only be: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS" + method_invalid: "#{cmd('route')} was called with an invalid method: '{{method}}'. Method can be: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, or any other method supported by Node's HTTP parser." response_invalid: "#{cmd('route')} cannot accept an undefined or null response. It must be set to something, even an empty string will work." url_invalid: "#{cmd('route')} was called with an invalid url. Url must be either a string or regular expression." url_missing: "#{cmd('route')} must be called with a url. It can be a string or regular expression." diff --git a/packages/driver/src/cypress/keyboard.coffee b/packages/driver/src/cypress/keyboard.coffee index ccf1140942da..8de28b8bc8c9 100644 --- a/packages/driver/src/cypress/keyboard.coffee +++ b/packages/driver/src/cypress/keyboard.coffee @@ -13,6 +13,7 @@ charsBetweenCurlyBracesRe = /({.+?})/ keyStandardMap = { # Cypress keyboard key : Standard value "{backspace}": "Backspace", + "{insert}": "Insert", "{del}": "Delete", "{downarrow}": "ArrowDown", "{enter}": "Enter", @@ -20,10 +21,14 @@ keyStandardMap = { "{leftarrow}": "ArrowLeft", "{rightarrow}": "ArrowRight", "{uparrow}": "ArrowUp", + "{home}": "Home", + "{end}": "End", "{alt}": "Alt", "{ctrl}": "Control", "{meta}": "Meta", - "{shift}": "Shift" + "{shift}": "Shift", + "{pageup}": "PageUp", + "{pagedown}": "PageDown" } $Keyboard = { @@ -101,6 +106,19 @@ $Keyboard = { return + ## charCode = 45 + ## no keyPress + ## no textInput + ## no input + "{insert}": (el, options) -> + options.charCode = 45 + options.keypress = false + options.textInput = false + options.input = false + options.setKey = "{insert}" + @ensureKey el, null, options + + ## charCode = 8 ## no keyPress ## no textInput @@ -126,7 +144,7 @@ $Keyboard = { return - + ## charCode = 27 ## no keyPress ## no textInput @@ -211,6 +229,58 @@ $Keyboard = { options.setKey = "{downarrow}" @ensureKey el, null, options, -> $selection.moveCursorDown(el) + + ## charCode = 36 + ## no keyPress + ## no textInput + ## no input + "{home}": (el, options) -> + options.charCode = 36 + options.keypress = false + options.textInput = false + options.input = false + options.setKey = "{home}" + @ensureKey el, null, options, -> + $selection.moveCursorToLineStart(el) + + ## charCode = 35 + ## no keyPress + ## no textInput + ## no input + "{end}": (el, options) -> + options.charCode = 35 + options.keypress = false + options.textInput = false + options.input = false + options.setKey = "{end}" + @ensureKey el, null, options, -> + $selection.moveCursorToLineEnd(el) + + + ## charCode = 33 + ## no keyPress + ## no textInput + ## no input + "{pageup}": (el, options) -> + options.charCode = 33 + options.keypress = false + options.textInput = false + options.input = false + options.setKey = "{pageup}" + @ensureKey el, null, options + + + ## charCode = 34 + ## no keyPress + ## no textInput + ## no input + "{pagedown}": (el, options) -> + options.charCode = 34 + options.keypress = false + options.textInput = false + options.input = false + options.setKey = "{pagedown}" + @ensureKey el, null, options } modifierChars: { @@ -455,7 +525,7 @@ $Keyboard = { if $elements.isInput(el) or $elements.isTextarea(el) ml = el.maxLength - + ## maxlength is -1 by default when omitted ## but could also be null or undefined :-/ ## only cafe if we are trying to type a key @@ -472,7 +542,7 @@ $Keyboard = { @simulateKey(el, "keyup", key, options) isSpecialChar: (chars) -> - !!@specialChars[chars] + chars in _.keys(@specialChars) handleSpecialChars: (el, chars, options) -> options.key = chars @@ -486,7 +556,7 @@ $Keyboard = { } isModifier: (chars) -> - !!@modifierChars[chars] + chars in _.keys(@modifierChars) handleModifier: (el, chars, options) -> modifier = @modifierChars[chars] diff --git a/packages/driver/src/cypress/log.coffee b/packages/driver/src/cypress/log.coffee index 372fd1487777..c479a57789ca 100644 --- a/packages/driver/src/cypress/log.coffee +++ b/packages/driver/src/cypress/log.coffee @@ -147,7 +147,7 @@ defaults = (state, config, obj) -> instrument: "command" url: state("url") hookName: state("hookName") - testId: state("runnable").id + testId: state("runnable")?.id viewportWidth: state("viewportWidth") viewportHeight: state("viewportHeight") referencesAlias: undefined diff --git a/packages/driver/src/cypress/server.coffee b/packages/driver/src/cypress/server.coffee index 51f68fc9d13b..22dcf0761b56 100644 --- a/packages/driver/src/cypress/server.coffee +++ b/packages/driver/src/cypress/server.coffee @@ -31,14 +31,16 @@ responseTypeIsTextOrEmptyString = (responseType) -> ## when the browser naturally cancels/aborts ## an XHR because the window is unloading +## on chrome < 71 isAbortedThroughUnload = (xhr) -> - xhr.readyState is 4 and - xhr.status is 0 and - ## responseText may be undefined on some responseTypes - ## https://github.com/cypress-io/cypress/issues/3008 - ## TODO: How do we want to handle other responseTypes? - (responseTypeIsTextOrEmptyString(xhr.responseType)) and - xhr.responseText is "" + xhr.canceled isnt true and + xhr.readyState is 4 and + xhr.status is 0 and + ## responseText may be undefined on some responseTypes + ## https://github.com/cypress-io/cypress/issues/3008 + ## TODO: How do we want to handle other responseTypes? + (responseTypeIsTextOrEmptyString(xhr.responseType)) and + xhr.responseText is "" warnOnStubDeprecation = (obj, type) -> if _.has(obj, "stub") @@ -76,6 +78,7 @@ serverDefaults = { onOpen: -> onSend: -> onXhrAbort: -> + onXhrCancel: -> onError: -> onLoad: -> onFixtureError: -> @@ -280,16 +283,60 @@ create = (options = {}) -> getProxyFor: (xhr) -> proxies[xhr.id] - abort: -> - ## abort any outstanding xhr's - ## which aren't already aborted - _.chain(xhrs) - .filter (xhr) -> - xhr.aborted isnt true and xhr.readyState isnt 4 - .invokeMap("abort") - .value() + abortXhr: (xhr) -> + proxy = server.getProxyFor(xhr) + + ## if the XHR leaks into the next test + ## after we've reset our internal server + ## then this may be undefined + return if not proxy + + ## return if we're already aborted which + ## can happen if the browser already canceled + ## this xhr but we called abort later + return if xhr.aborted + + xhr.aborted = true + + abortStack = server.getStack() + + proxy.aborted = true + + options.onXhrAbort(proxy, abortStack) + + if _.isFunction(options.onAnyAbort) + route = server.getRouteForXhr(xhr) - return server + ## call the onAnyAbort function + ## after we've called options.onSend + options.onAnyAbort(route, proxy) + + cancelXhr: (xhr) -> + proxy = server.getProxyFor(xhr) + + ## if the XHR leaks into the next test + ## after we've reset our internal server + ## then this may be undefined + return if not proxy + + xhr.canceled = true + + proxy.canceled = true + + options.onXhrCancel(proxy) + + return xhr + + cancelPendingXhrs: -> + ## cancel any outstanding xhr's + ## which aren't already complete + ## or already canceled + return _ + .chain(xhrs) + .reject({ readyState: 4 }) + .reject({ canceled: true }) + .map(server.cancelXhr) + .value() set: (obj) -> warnOnStubDeprecation(obj, "server") @@ -310,34 +357,6 @@ create = (options = {}) -> abort = XHR.prototype.abort srh = XHR.prototype.setRequestHeader - abortXhr = (xhr) -> - proxy = server.getProxyFor(xhr) - - ## if the XHR leaks into the next test - ## after we've reset our internal server - ## then this may be undefined - return if not proxy - - ## return if we're already aborted which - ## can happen if the browser already canceled - ## this xhr but we called abort later - return if xhr.aborted - - xhr.aborted = true - - abortStack = server.getStack() - - proxy.aborted = true - - options.onXhrAbort(proxy, abortStack) - - if _.isFunction(options.onAnyAbort) - route = server.getRouteForXhr(xhr) - - ## call the onAnyAbort function - ## after we've called options.onSend - options.onAnyAbort(route, proxy) - restoreFn = -> ## restore the property back on the window _.each {send: send, open: open, abort: abort, setRequestHeader: srh}, (value, key) -> @@ -358,7 +377,7 @@ create = (options = {}) -> ## set the aborted property or call onXhrAbort ## to test this just use a regular XHR if @readyState isnt 4 - abortXhr(@) + server.abortXhr(@) abort.apply(@, arguments) @@ -439,7 +458,7 @@ create = (options = {}) -> ## by the onreadystatechange function try if isAbortedThroughUnload(xhr) - abortXhr(xhr) + server.abortXhr(xhr) if _.isFunction(orst = fns.onreadystatechange) orst.apply(xhr, arguments) diff --git a/packages/driver/src/cypress/utils.coffee b/packages/driver/src/cypress/utils.coffee index 9ad1c7307f2e..37146afeff57 100644 --- a/packages/driver/src/cypress/utils.coffee +++ b/packages/driver/src/cypress/utils.coffee @@ -1,5 +1,6 @@ $ = require("jquery") _ = require("lodash") +methods = require("methods") moment = require("moment") Promise = require("bluebird") @@ -22,6 +23,30 @@ defaultOptions = { animationDistanceThreshold: 5 } +USER_FRIENDLY_TYPE_DETECTORS = _.map([ + [_.isUndefined, "undefined"] + [_.isNull, "null"] + [_.isBoolean, "boolean"] + [_.isNumber, "number"] + [_.isString, "string"] + [_.isRegExp, "regexp"] + [_.isSymbol, "symbol"] + [_.isElement, "element"] + [_.isError, "error"] + [_.isSet, "set"] + [_.isWeakSet, "set"] + [_.isMap, "map"] + [_.isWeakMap, "map"] + [_.isFunction, "function"] + [_.isArrayLikeObject, "array"] + [_.isBuffer, "buffer"] + [_.isDate, "date"] + [_.isObject, "object"] + [_.stubTrue, "unknown"] +], ([ fn, type]) -> + return [fn, _.constant(type)] +) + module.exports = { warning: (msg) -> console.warn("Cypress Warning: " + msg) @@ -220,6 +245,9 @@ module.exports = { else "" + value + ## give us some user-friendly "types" + stringifyFriendlyTypeof: _.cond(USER_FRIENDLY_TYPE_DETECTORS) + stringify: (values) -> ## if we already have an array ## then nest it again so that @@ -289,6 +317,9 @@ module.exports = { args.length is 3 and _.every(args, _.isFunction) + isValidHttpMethod: (str) -> + _.isString(str) and _.includes(methods, str.toLowerCase()) + addTwentyYears: -> moment().add(20, "years").unix() diff --git a/packages/driver/src/dom/jquery.js b/packages/driver/src/dom/jquery.js index b1730fb5cc4c..13336cbd8a17 100644 --- a/packages/driver/src/dom/jquery.js +++ b/packages/driver/src/dom/jquery.js @@ -22,9 +22,10 @@ const unwrap = function (obj) { } const isJquery = (obj) => { - // does it have the jquery property and is the - // constructor a function? - return !!(obj && obj.jquery && _.isFunction(obj.constructor)) + // does it have the jquery property and does this + // instance have a constructor with a jquery property + // on it's prototype? + return !!(obj && obj.jquery && _.get(obj, 'constructor.prototype.jquery')) } // doing a little jiggle wiggle here diff --git a/packages/driver/src/dom/selection.js b/packages/driver/src/dom/selection.js index bd1e47b7af2a..c0edef14fb7d 100644 --- a/packages/driver/src/dom/selection.js +++ b/packages/driver/src/dom/selection.js @@ -362,6 +362,24 @@ const _moveCursorUpOrDown = function (el, up) { } } +const moveCursorToLineStart = (el) => { + return _moveCursorToLineStartOrEnd(el, true) +} + +const moveCursorToLineEnd = (el) => { + return _moveCursorToLineStartOrEnd(el, false) +} + +const _moveCursorToLineStartOrEnd = function (el, toStart) { + if ($elements.isContentEditable(el) || $elements.isInput(el) || $elements.isTextarea(el)) { + const selection = _getSelectionByEl(el) + + // the selection.modify API is non-standard, may work differently in other browsers, and is not in IE11. + // https://developer.mozilla.org/en-US/docs/Web/API/Selection/modify + return $elements.callNativeMethod(selection, 'modify', 'move', toStart ? 'backward' : 'forward', 'lineboundary') + } +} + const isCollapsed = function (el) { if ($elements.isTextarea(el) || $elements.isInput(el)) { const { start, end } = getSelectionBounds(el) @@ -579,6 +597,8 @@ module.exports = { moveCursorRight, moveCursorUp, moveCursorDown, + moveCursorToLineStart, + moveCursorToLineEnd, replaceSelectionContents, isCollapsed, interceptSelect, diff --git a/packages/driver/test/cypress/fixtures/jquery.html b/packages/driver/test/cypress/fixtures/jquery.html index e32486cba1c2..ce1ad70d7f66 100644 --- a/packages/driver/test/cypress/fixtures/jquery.html +++ b/packages/driver/test/cypress/fixtures/jquery.html @@ -1,7 +1,7 @@ - jQuery Fixture + jQuery 3.2.1 Fixture diff --git a/packages/driver/test/cypress/integration/commands/actions/type_spec.coffee b/packages/driver/test/cypress/integration/commands/actions/type_spec.coffee index 573c0aa05c59..dbb15ed896f4 100644 --- a/packages/driver/test/cypress/integration/commands/actions/type_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/actions/type_spec.coffee @@ -4,6 +4,12 @@ Keyboard = Cypress.Keyboard Promise = Cypress.Promise $selection = require("../../../../../src/dom/selection") +## trim new lines at the end of innerText +## due to changing browser versions implementing +## this differently +trimInnerText = ($el) -> + _.trimEnd($el.get(0).innerText, "\n") + describe "src/cy/commands/actions/type", -> before -> cy @@ -11,6 +17,31 @@ describe "src/cy/commands/actions/type", -> .then (win) -> @body = win.document.body.outerHTML + el = cy.$$('[contenteditable]:first').get(0) + + innerHtml = el.innerHTML + + ## by default... the last new line by itself + ## will only ever count as a single new line... + ## but new lines above it will count as 2 new lines... + ## so by adding "3" new lines, the last counts as 1 + ## and the first 2 count as 2... + el.innerHTML = '

    '.repeat(3) + + ## browsers changed their implementation + ## of the number of newlines that

    + ## create. newer versions of chrome set 2 new lines + ## per set - whereas older ones create only 1 new line. + ## so we grab the current sets for the assertion later + ## so this test is browser version agnostic + newLines = el.innerText + + ## disregard the last new line, and divide by 2... + ## this tells us how many multiples of new lines + ## the browser inserts for new lines other than + ## the last new line + @multiplierNumNewLines = (newLines.length - 1) / 2 + beforeEach -> doc = cy.state("document") @@ -1038,7 +1069,7 @@ describe "src/cy/commands/actions/type", -> cy.$$('[contenteditable]:first').get(0).innerHTML = '
    foo
    ' cy.get("[contenteditable]:first") .type("bar").then ($div) -> - expect($div.get(0).innerText).to.eql("foobar\n") + expect(trimInnerText($div)).to.eql("foobar") expect($div.get(0).textContent).to.eql("foobar") expect($div.get(0).innerHTML).to.eql("
    foobar
    ") @@ -1046,7 +1077,7 @@ describe "src/cy/commands/actions/type", -> cy.$$('[contenteditable]:first').get(0).innerHTML = '

    foo

    ' cy.get("[contenteditable]:first") .type("bar").then ($div) -> - expect($div.get(0).innerText).to.eql("foobar\n\n") + expect(trimInnerText($div)).to.eql("foobar") expect($div.get(0).textContent).to.eql("foobar") expect($div.get(0).innerHTML).to.eql("

    foobar

    ") @@ -1054,13 +1085,13 @@ describe "src/cy/commands/actions/type", -> cy.$$('[contenteditable]:first').get(0).innerHTML = '
    bar
    ' cy.get("[contenteditable]:first") .type("{selectall}{leftarrow}foo").then ($div) -> - expect($div.get(0).innerText).to.eql("foobar\n") + expect(trimInnerText($div)).to.eql("foobar") it "collapses selection to end on {rightarrow}", -> cy.$$('[contenteditable]:first').get(0).innerHTML = '
    bar
    ' cy.get("[contenteditable]:first") .type("{selectall}{leftarrow}foo{selectall}{rightarrow}baz").then ($div) -> - expect($div.get(0).innerText).to.eql("foobarbaz\n") + expect(trimInnerText($div)).to.eql("foobarbaz") it "can remove a placeholder
    ", -> cy.$$('[contenteditable]:first').get(0).innerHTML = '

    ' @@ -1403,6 +1434,106 @@ describe "src/cy/commands/actions/type", -> expect($input).to.have.value("fodo") done() + context "{home}", -> + it "sets which and keyCode to 36 and does not fire keypress events", (done) -> + cy.$$("#comments").on "keypress", -> + done("should not have received keypress") + + cy.$$("#comments").on "keydown", (e) -> + expect(e.which).to.eq 36 + expect(e.keyCode).to.eq 36 + expect(e.key).to.eq "Home" + done() + + cy.get("#comments").type("{home}").then ($input) -> + done() + + it "does not fire textInput event", (done) -> + cy.$$("#comments").on "textInput", (e) -> + done("textInput should not have fired") + + cy.get("#comments").type("{home}").then -> done() + + it "does not fire input event", (done) -> + cy.$$("#comments").on "input", (e) -> + done("input should not have fired") + + cy.get("#comments").type("{home}").then -> done() + + it "can move the cursor to input start", -> + cy.get(":text:first").invoke("val", "bar").type("{home}n").then ($input) -> + expect($input).to.have.value("nbar") + + it "does not move the cursor if already at bounds 0", -> + cy.get(":text:first").invoke("val", "bar").type("{selectall}{leftarrow}{home}n").then ($input) -> + expect($input).to.have.value("nbar") + + it "should move the cursor to the start of each line in textarea", -> + cy.$$('textarea:first').get(0).value = 'foo\nbar\nbaz' + + cy.get("textarea:first") + .type("{home}11{uparrow}{home}22{uparrow}{home}33").should('have.value', "33foo\n22bar\n11baz") + + it "should move cursor to the start of each line in contenteditable", -> + cy.$$('[contenteditable]:first').get(0).innerHTML = + '
    foo
    ' + + '
    bar
    ' + + '
    baz
    ' + + cy.get("[contenteditable]:first") + .type("{home}11{uparrow}{home}22{uparrow}{home}33").then ($div) -> + expect(trimInnerText($div)).to.eql("33foo\n22bar\n11baz") + + context "{end}", -> + it "sets which and keyCode to 35 and does not fire keypress events", (done) -> + cy.$$("#comments").on "keypress", -> + done("should not have received keypress") + + cy.$$("#comments").on "keydown", (e) -> + expect(e.which).to.eq 35 + expect(e.keyCode).to.eq 35 + expect(e.key).to.eq "End" + done() + + cy.get("#comments").type("{end}").then ($input) -> + done() + + it "does not fire textInput event", (done) -> + cy.$$("#comments").on "textInput", (e) -> + done("textInput should not have fired") + + cy.get("#comments").type("{end}").then -> done() + + it "does not fire input event", (done) -> + cy.$$("#comments").on "input", (e) -> + done("input should not have fired") + + cy.get("#comments").type("{end}").then -> done() + + it "can move the cursor to input end", -> + cy.get(":text:first").invoke("val", "bar").type("{selectall}{leftarrow}{end}n").then ($input) -> + expect($input).to.have.value("barn") + + it "does not move the cursor if already at end of bounds", -> + cy.get(":text:first").invoke("val", "bar").type("{selectall}{rightarrow}{end}n").then ($input) -> + expect($input).to.have.value("barn") + + it "should move the cursor to the end of each line in textarea", -> + cy.$$('textarea:first').get(0).value = 'foo\nbar\nbaz' + + cy.get("textarea:first") + .type("{end}11{uparrow}{end}22{uparrow}{end}33").should('have.value', "foo33\nbar22\nbaz11") + + it "should move cursor to the end of each line in contenteditable", -> + cy.$$('[contenteditable]:first').get(0).innerHTML = + '
    foo
    ' + + '
    bar
    ' + + '
    baz
    ' + + cy.get("[contenteditable]:first") + .type("{end}11{uparrow}{end}22{uparrow}{end}33").then ($div) -> + expect(trimInnerText($div)).to.eql("foo33\nbar22\nbaz11") + context "{uparrow}", -> beforeEach -> cy.$$("#comments").val("foo\nbar\nbaz") @@ -1440,7 +1571,7 @@ describe "src/cy/commands/actions/type", -> cy.get("[contenteditable]:first") .type("{leftarrow}{leftarrow}{uparrow}11{uparrow}22{downarrow}{downarrow}33").then ($div) -> - expect($div.get(0).innerText).to.eql("foo22\nb11ar\nbaz33\n") + expect(trimInnerText($div)).to.eql("foo22\nb11ar\nbaz33") it "uparrow ignores current selection", -> ce = cy.$$('[contenteditable]:first').get(0) @@ -1456,7 +1587,7 @@ describe "src/cy/commands/actions/type", -> cy.get("[contenteditable]:first") .type("{uparrow}11").then ($div) -> - expect($div.get(0).innerText).to.eql("11foo\nbar\nbaz\n") + expect(trimInnerText($div)).to.eql("11foo\nbar\nbaz") it "up and down arrow on textarea", -> cy.$$('textarea:first').get(0).value = 'foo\nbar\nbaz' @@ -1470,7 +1601,6 @@ describe "src/cy/commands/actions/type", -> .type('{uparrow}{uparrow}') .should('have.value', '14') - context "{downarrow}", -> beforeEach -> cy.$$("#comments").val("foo\nbar\nbaz") @@ -1526,7 +1656,7 @@ describe "src/cy/commands/actions/type", -> cy.get("[contenteditable]:first") .type("{downarrow}22").then ($div) -> - expect($div.get(0).innerText).to.eql("foo\n22bar\nbaz\n") + expect(trimInnerText($div)).to.eql("foo\n22bar\nbaz") context "{selectall}{del}", -> it "can select all the text and delete", -> @@ -1588,17 +1718,120 @@ describe "src/cy/commands/actions/type", -> it "inserts new line into [contenteditable] ", -> cy.get("#input-types [contenteditable]:first").invoke("text", "foo") .type("bar{enter}baz{enter}{enter}{enter}quux").then ($div) -> - expect($div.get(0).innerText).to.eql("foobar\nbaz\n\n\nquux\n") + conditionalNewLines = "\n\n".repeat(@multiplierNumNewLines) + + expect(trimInnerText($div)).to.eql("foobar\nbaz#{conditionalNewLines}\nquux") expect($div.get(0).textContent).to.eql("foobarbazquux") expect($div.get(0).innerHTML).to.eql("foobar
    baz


    quux
    ") it "inserts new line into [contenteditable] from midline", -> cy.get("#input-types [contenteditable]:first").invoke("text", "foo") .type("bar{leftarrow}{enter}baz{leftarrow}{enter}quux").then ($div) -> - expect($div.get(0).innerText).to.eql("fooba\nba\nquuxzr\n") + expect(trimInnerText($div)).to.eql("fooba\nba\nquuxzr") expect($div.get(0).textContent).to.eql("foobabaquuxzr") expect($div.get(0).innerHTML).to.eql("fooba
    ba
    quuxzr
    ") + context "{insert}", -> + it "sets which and keyCode to 45 and does not fire keypress events", (done) -> + cy.$$(":text:first").on "keypress", -> + done("should not have received keypress") + + cy.$$(":text:first").on "keydown", (e) -> + expect(e.which).to.eq 45 + expect(e.keyCode).to.eq 45 + expect(e.key).to.eq "Insert" + done() + + cy.get(":text:first").invoke("val", "ab").type("{insert}") + + it "does not fire textInput event", (done) -> + cy.$$(":text:first").on "textInput", (e) -> + done("textInput should not have fired") + + cy.get(":text:first").invoke("val", "ab").type("{insert}").then -> done() + + it "does not fire input event", (done) -> + cy.$$(":text:first").on "input", (e) -> + done("input should not have fired") + + cy.get(":text:first").invoke("val", "ab").type("{insert}").then -> done() + + it "can prevent default insert movement", (done) -> + cy.$$(":text:first").on "keydown", (e) -> + if e.keyCode is 45 + e.preventDefault() + + cy.get(":text:first").invoke("val", "foo").type("d{insert}").then ($input) -> + expect($input).to.have.value("food") + done() + + context "{pageup}", -> + it "sets which and keyCode to 33 and does not fire keypress events", (done) -> + cy.$$(":text:first").on "keypress", -> + done("should not have received keypress") + + cy.$$(":text:first").on "keydown", (e) -> + expect(e.which).to.eq 33 + expect(e.keyCode).to.eq 33 + expect(e.key).to.eq "PageUp" + done() + + cy.get(":text:first").invoke("val", "ab").type("{pageup}") + + it "does not fire textInput event", (done) -> + cy.$$(":text:first").on "textInput", (e) -> + done("textInput should not have fired") + + cy.get(":text:first").invoke("val", "ab").type("{pageup}").then -> done() + + it "does not fire input event", (done) -> + cy.$$(":text:first").on "input", (e) -> + done("input should not have fired") + + cy.get(":text:first").invoke("val", "ab").type("{pageup}").then -> done() + + it "can prevent default pageup movement", (done) -> + cy.$$(":text:first").on "keydown", (e) -> + if e.keyCode is 33 + e.preventDefault() + + cy.get(":text:first").invoke("val", "foo").type("d{pageup}").then ($input) -> + expect($input).to.have.value("food") + done() + + context "{pagedown}", -> + it "sets which and keyCode to 34 and does not fire keypress events", (done) -> + cy.$$(":text:first").on "keypress", -> + done("should not have received keypress") + + cy.$$(":text:first").on "keydown", (e) -> + expect(e.which).to.eq 34 + expect(e.keyCode).to.eq 34 + expect(e.key).to.eq "PageDown" + done() + + cy.get(":text:first").invoke("val", "ab").type("{pagedown}") + + it "does not fire textInput event", (done) -> + cy.$$(":text:first").on "textInput", (e) -> + done("textInput should not have fired") + + cy.get(":text:first").invoke("val", "ab").type("{pagedown}").then -> done() + + it "does not fire input event", (done) -> + cy.$$(":text:first").on "input", (e) -> + done("input should not have fired") + + cy.get(":text:first").invoke("val", "ab").type("{pagedown}").then -> done() + + it "can prevent default pagedown movement", (done) -> + cy.$$(":text:first").on "keydown", (e) -> + if e.keyCode is 34 + e.preventDefault() + + cy.get(":text:first").invoke("val", "foo").type("d{pagedown}").then ($input) -> + expect($input).to.have.value("food") + done() describe "modifiers", -> @@ -2111,7 +2344,6 @@ describe "src/cy/commands/actions/type", -> .then -> expect(changed).to.eql 0 - describe "caret position", -> it "respects being formatted by input event handlers" @@ -2179,32 +2411,35 @@ describe "src/cy/commands/actions/type", -> el.innerHTML = 'start'+ '
    middle
    '+ '
    end
    ' + cy.get('[contenteditable]:first') ## move cursor to beginning of div .type('{selectall}{leftarrow}') - .type('{rightarrow}'.repeat(14)+'[_I_]').then -> - expect(cy.$$('[contenteditable]:first').get(0).innerText).to.eql('start\nmiddle\ne[_I_]nd\n') + .type('{rightarrow}'.repeat(14)+'[_I_]').then ($el) -> + expect(trimInnerText($el)).to.eql('start\nmiddle\ne[_I_]nd') it "can wrap cursor to prev line in [contenteditable] with {leftarrow}", -> $el = cy.$$('[contenteditable]:first') el = $el.get(0) + el.innerHTML = 'start'+ '
    middle
    '+ '
    end
    ' - cy.get('[contenteditable]:first').type('{leftarrow}'.repeat(12)+'[_I_]').then -> - expect(cy.$$('[contenteditable]:first').get(0).innerText).to.eql('star[_I_]t\nmiddle\nend\n') + cy.get('[contenteditable]:first').type('{leftarrow}'.repeat(12)+'[_I_]').then ($el) -> + expect(trimInnerText($el)).to.eql('star[_I_]t\nmiddle\nend') it "can wrap cursor to next line in [contenteditable] with {rightarrow} and empty lines", -> $el = cy.$$('[contenteditable]:first') el = $el.get(0) - el.innerHTML = '

    '.repeat(4)+ - '
    end
    ' + el.innerHTML = '

    '.repeat(4) + '
    end
    ' + + newLines = "\n\n\n".repeat(@multiplierNumNewLines) cy.get('[contenteditable]:first') .type('{selectall}{leftarrow}') - # .type('foobar'+'{rightarrow}'.repeat(6)+'[_I_]').then -> - # expect(cy.$$('[contenteditable]:first').get(0).innerText).to.eql('foobar\n\n\n\nen[_I_]d\n') + .type('foobar'+'{rightarrow}'.repeat(6)+'[_I_]').then -> + expect(trimInnerText($el)).to.eql("foobar#{newLines}\nen[_I_]d") it "can use {rightarrow} and nested elements", -> $el = cy.$$('[contenteditable]:first') @@ -2214,19 +2449,19 @@ describe "src/cy/commands/actions/type", -> cy.get('[contenteditable]:first') .type('{selectall}{leftarrow}') .type('{rightarrow}'.repeat(3)+'[_I_]').then -> - expect(cy.$$('[contenteditable]:first').get(0).innerText).to.eql('sta[_I_]rt\n') + expect(trimInnerText($el)).to.eql('sta[_I_]rt') it "enter and \\n should act the same for [contenteditable]", -> - cleanseText = (text) -> - text.replace(/ /g, ' ') + ## non breaking white space + text.split('\u00a0').join(' ') expectMatchInnerText = ($el , innerText) -> - expect(cleanseText($el.get(0).innerText)).to.eql(innerText) + expect(cleanseText(trimInnerText($el))).to.eql(innerText) ## NOTE: this may only pass in Chrome since the whitespace may be different in other browsers ## even if actual and expected appear the same. - expected = "{\n foo: 1\n bar: 2\n baz: 3\n}\n" + expected = "{\n foo: 1\n bar: 2\n baz: 3\n}" cy.get('[contenteditable]:first') .invoke('html', '

    ') .type('{{}{enter} foo: 1{enter} bar: 2{enter} baz: 3{enter}}') @@ -2237,7 +2472,6 @@ describe "src/cy/commands/actions/type", -> .should ($el) -> expectMatchInnerText($el, expected) - it "enter and \\n should act the same for textarea", -> expected = "{\n foo: 1\n bar: 2\n baz: 3\n}" cy.get('textarea:first') @@ -2248,8 +2482,6 @@ describe "src/cy/commands/actions/type", -> .type('{{}\n foo: 1\n bar: 2\n baz: 3\n}') .should('have.prop', 'value', expected) - - describe "{enter}", -> beforeEach -> @$forms = cy.$$("#form-submits") @@ -2780,6 +3012,31 @@ describe "src/cy/commands/actions/type", -> .get(":text:first").type(" ") .should("have.value", " ") + it "allows typing special characters", -> + cy + .get(":text:first").type("{esc}") + .should("have.value", "") + + _.each ["toString", "toLocaleString", "hasOwnProperty", "valueOf" + "undefined", "null", "true", "false", "True", "False"], (val) => + it "allows typing reserved Javscript word (#{val})", -> + cy + .get(":text:first").type(val) + .should("have.value", val) + + _.each ["Ω≈ç√∫˜µ≤≥÷", "2.2250738585072011e-308", "田中さんにあげて下さい", + "", "⁰⁴⁵₀₁₂", "🐵 🙈 🙉 🙊", + "", "$USER"], (val) => + it "allows typing some naughtly strings (#{val})", -> + cy + .get(":text:first").type(val) + .should("have.value", val) + + it "allows typing special characters", -> + cy + .get(":text:first").type("{esc}") + .should("have.value", "") + it "can type into input with invalid type attribute", -> cy.get(':text:first') .invoke('attr', 'type', 'asdf') diff --git a/packages/driver/test/cypress/integration/commands/connectors_spec.coffee b/packages/driver/test/cypress/integration/commands/connectors_spec.coffee index c646987ce2f0..c1547b34c33d 100644 --- a/packages/driver/test/cypress/integration/commands/connectors_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/connectors_spec.coffee @@ -50,7 +50,7 @@ describe "src/cy/commands/connectors", -> logs = [] cy.on "log:added", (attrs, log) => - logs.push(log) + logs?.push(log) cy.on "fail", (err) => expect(logs.length).to.eq(1) @@ -174,7 +174,7 @@ describe "src/cy/commands/connectors", -> cy.on "log:added", (attrs, log) => @lastLog = log - @logs.push(log) + @logs?.push(log) return null @@ -249,13 +249,13 @@ describe "src/cy/commands/connectors", -> beforeEach -> delete @remoteWindow.$.fn.foo - Cypress.config("defaultCommandTimeout", 50) + Cypress.config("defaultCommandTimeout", 100) @logs = [] cy.on "log:added", (attrs, log) => @lastLog = log - @logs.push(log) + @logs?.push(log) return null @@ -341,7 +341,7 @@ describe "src/cy/commands/connectors", -> cy.noop(@obj).invoke("bar", 1, 2).then (num) -> expect(num).to.eq 3 - it "changes subject to undefined", -> + obj = { bar: -> undefined } @@ -366,6 +366,56 @@ describe "src/cy/commands/connectors", -> cy.noop(num).invoke("valueOf").then (num) -> expect(num).to.eq 10 + it "retries until function exists on the subject", -> + obj = {} + + cy.on "command:retry", _.after 3, -> + obj.foo = -> "bar" + + cy.wrap(obj).invoke("foo").then (val) -> + expect(val).to.eq("bar") + + it "retries until property is a function", -> + obj = { + foo: "" + } + + cy.on "command:retry", _.after 3, -> + obj.foo = -> "bar" + + cy.wrap(obj).invoke("foo").then (val) -> + expect(val).to.eq("bar") + + it "retries until property is a function when initially undefined", -> + obj = { + foo: undefined + } + + cy.on "command:retry", _.after 3, -> + obj.foo = -> "bar" + + cy.wrap(obj).invoke("foo").then (val) -> + expect(val).to.eq("bar") + + it "retries until value matches assertions", -> + obj = { + foo: -> "foo" + } + + cy.on "command:retry", _.after 3, -> + obj.foo = -> "bar" + + cy.wrap(obj).invoke("foo").should("eq", "bar") + + [null, undefined].forEach (val) -> + it "changes subject to '#{val}' without throwing default assertion existence", -> + obj = { + foo: -> val + } + + cy.wrap(obj).invoke("foo").then (val2) -> + expect(val2).to.eq(val) + describe "errors", -> beforeEach -> Cypress.config("defaultCommandTimeout", 50) @@ -379,11 +429,18 @@ describe "src/cy/commands/connectors", -> it "throws when prop is not a function", (done) -> obj = { - foo: "foo" + foo: /re/ } cy.on "fail", (err) -> - expect(err.message).to.include("Cannot call cy.invoke() because 'foo' is not a function. You probably want to use cy.its('foo')") + expect(err.message).to.include("Timed out retrying: cy.invoke() errored because the property: 'foo' returned a 'regexp' value instead of a function. cy.invoke() can only be used on properties that return callable functions.") + + expect(err.message).to.include("cy.invoke() waited for the specified property 'foo' to return a function, but it never did.") + + expect(err.message).to.include("If you want to assert on the property's value, then switch to use cy.its() and add an assertion such as:") + + expect(err.message).to.include("cy.wrap({ foo: 'bar' }).its('foo').should('eq', 'bar')") + done() cy.wrap(obj).invoke("foo") @@ -396,7 +453,14 @@ describe "src/cy/commands/connectors", -> } cy.on "fail", (err) -> - expect(err.message).to.include("Cannot call cy.invoke() because 'foo.bar' is not a function. You probably want to use cy.its('foo.bar')") + expect(err.message).to.include("Timed out retrying: cy.invoke() errored because the property: 'bar' returned a 'string' value instead of a function. cy.invoke() can only be used on properties that return callable functions.") + + expect(err.message).to.include("cy.invoke() waited for the specified property 'bar' to return a function, but it never did.") + + expect(err.message).to.include("If you want to assert on the property's value, then switch to use cy.its() and add an assertion such as:") + + expect(err.message).to.include("cy.wrap({ foo: 'bar' }).its('foo').should('eq', 'bar')") + done() cy.wrap(obj).invoke("foo.bar") @@ -425,7 +489,7 @@ describe "src/cy/commands/connectors", -> cy.on "log:added", (attrs, log) => @lastLog = log - @logs.push(log) + @logs?.push(log) return null @@ -459,7 +523,7 @@ describe "src/cy/commands/connectors", -> Command: "invoke" Function: ".attr(numbers, [1, 2, 3])" "With Arguments": ["numbers", [1,2,3]] - On: @obj + Subject: @obj Yielded: {numbers: [1,2,3]} } @@ -468,7 +532,7 @@ describe "src/cy/commands/connectors", -> expect(@lastLog.invoke("consoleProps")).to.deep.eq { Command: "invoke" Function: ".bar()" - On: @obj + Subject: @obj Yielded: "bar" } @@ -478,7 +542,7 @@ describe "src/cy/commands/connectors", -> Command: "invoke" Function: ".sum(1, 2, 3)" "With Arguments": [1,2,3] - On: @obj + Subject: @obj Yielded: 6 } @@ -488,7 +552,7 @@ describe "src/cy/commands/connectors", -> Command: "invoke" Function: ".math.sum(1, 2, 3)" "With Arguments": [1,2,3] - On: @obj + Subject: @obj Yielded: 6 } @@ -498,7 +562,7 @@ describe "src/cy/commands/connectors", -> expect(consoleProps).to.deep.eq { Command: "invoke" Function: ".hide()" - On: $btn.get(0) + Subject: $btn.get(0) Yielded: $btn.get(0) } @@ -510,7 +574,7 @@ describe "src/cy/commands/connectors", -> cy.on "log:added", (attrs, log) => @lastLog = log - @logs.push(log) + @logs?.push(log) return null @@ -518,11 +582,19 @@ describe "src/cy/commands/connectors", -> cy.on "fail", (err) => lastLog = @lastLog - expect(err.message).to.include "cy.invoke() errored because the property: 'foo' does not exist on your subject." + expect(err.message).to.include "Timed out retrying: cy.invoke() errored because the property: 'foo' does not exist on your subject." + + expect(err.message).to.include "cy.invoke() waited for the specified property 'foo' to exist, but it never did." + + expect(err.message).to.include "If you do not expect the property 'foo' to exist, then add an assertion such as:" + + expect(err.message).to.include "cy.wrap({ foo: 'bar' }).its('quux').should('not.exist')" + expect(lastLog.get("error").message).to.include(err.message) + done() - cy.noop({}).invoke("foo") + cy.wrap({}).invoke("foo") it "throws without a subject", (done) -> cy.on "fail", (err) -> @@ -540,7 +612,7 @@ describe "src/cy/commands/connectors", -> expect(lastLog.get("error")).to.eq err done() - cy.noop({}).invoke({}) + cy.wrap({}).invoke({}) it "logs once when not dom subject", (done) -> cy.on "fail", (err) => @@ -552,23 +624,71 @@ describe "src/cy/commands/connectors", -> cy.invoke({}) - it "ensures subject", (done) -> - cy.on "fail", (err) -> - expect(err.message).to.include "cy.its() errored because your subject is currently: 'undefined'" + it "throws when failing assertions", (done) -> + obj = { + foo: -> "foo" + } + + cy.on "fail", (err) => + lastLog = @lastLog + + expect(err.message).to.eq("Timed out retrying: expected 'foo' to equal 'bar'") + + expect(lastLog.get("error").message).to.eq("expected 'foo' to equal 'bar'") + done() - cy.noop(undefined).its("attr") + cy.wrap(obj).invoke("foo").should("eq", "bar") - it "consoleProps subject", (done) -> + it "throws when initial subject is undefined", (done) -> cy.on "fail", (err) => - expect(@lastLog.invoke("consoleProps")).to.deep.eq { - Command: "its" - Error: "CypressError: Timed out retrying: cy.its() errored because the property: 'baz' does not exist on your subject." - Subject: {foo: "bar"} - } + lastLog = @lastLog + + expect(err.message).to.include("Timed out retrying: cy.invoke() errored because your subject is: 'undefined'. You cannot invoke any functions such as 'foo' on a 'undefined' value.") + + expect(err.message).to.include("If you expect your subject to be 'undefined', then add an assertion such as:") + + expect(err.message).to.include("cy.wrap(undefined).should('be.undefined')") + + expect(lastLog.get("error").message).to.include(err.message) + done() - cy.noop({foo: "bar"}).its("baz") + cy.wrap(undefined).invoke("foo") + + it "throws when property value is undefined", (done) -> + cy.on "fail", (err) => + lastLog = @lastLog + + expect(err.message).to.include "Timed out retrying: cy.invoke() errored because the property: 'foo' is not a function, and instead returned a 'undefined' value." + + expect(err.message).to.include "cy.invoke() waited for the specified property 'foo' to become a callable function, but it never did." + + expect(err.message).to.include "If you expect the property 'foo' to be 'undefined', then switch to use cy.its() and add an assertion such as:" + + expect(err.message).to.include "cy.wrap({ foo: undefined }).its('foo').should('be.undefined')" + + expect(lastLog.get("error").message).to.include(err.message) + + done() + + cy.wrap({ foo: undefined }).invoke("foo") + + it "throws when nested property value is undefined", (done) -> + cy.on "fail", (err) => + lastLog = @lastLog + + expect(err.message).to.include("Timed out retrying: cy.invoke() errored because the property: 'baz' does not exist on your subject.") + expect(lastLog.get("error").message).to.include(err.message) + done() + + obj = { + foo: { + bar: {} + } + } + + cy.wrap(obj).invoke("foo.bar.baz.fizz") context "#its", -> beforeEach -> @@ -613,9 +733,14 @@ describe "src/cy/commands/connectors", -> cy.wrap(obj).its("foo.bar.baz").should("eq", "baz") - it "returns undefined", -> - cy.noop({foo: undefined}).its("foo").then (val) -> - expect(val).to.be.undefined + it "does not invoke a function and can assert it throws", -> + err = new Error("nope cant access me") + + obj = { + foo: -> throw err + } + + cy.wrap(obj).its("foo").should("throw", err) it "returns property", -> cy.noop({baz: "baz"}).its("baz").then (num) -> @@ -640,6 +765,100 @@ describe "src/cy/commands/connectors", -> cy.wrap(num).its("toFixed").should("eq", toFixed) + it "retries by default until property exists without an assertion", -> + obj = {} + + cy.on "command:retry", _.after 3, -> + obj.foo = "bar" + + cy.wrap(obj).its("foo").then (val) -> + expect(val).to.eq("bar") + + it "retries until property is not undefined without an assertion", -> + obj = { + foo: undefined + } + + cy.on "command:retry", _.after 3, -> + obj.foo = "bar" + + cy.wrap(obj).its("foo").then (val) -> + expect(val).to.eq("bar") + + it "retries until property is not null without an assertion", -> + obj = { + foo: null + } + + cy.on "command:retry", _.after 3, -> + obj.foo = "bar" + + cy.wrap(obj).its("foo").then (val) -> + expect(val).to.eq("bar") + + it "retries when yielded undefined value and using assertion", -> + obj = { foo: '' } + + cy.stub(obj, 'foo').get( + cy.stub() + .onCall(0).returns(undefined) + .onCall(1).returns(undefined) + .onCall(2).returns(true) + ) + cy.wrap(obj).its('foo').should('eq', true) + + it "retries until property does NOT exist with an assertion", -> + obj = { + foo: "" + } + + cy.on "command:retry", _.after 3, -> + delete obj.foo + + cy.wrap(obj).its("foo").should("not.exist").then (val) -> + expect(val).to.be.undefined + + it "passes when property does not exist on the subject with assertions", -> + cy.wrap({}).its("foo").should("not.exist") + cy.wrap({}).its("foo").should("be.undefined") + cy.wrap({}).its("foo").should("not.be.ok") + + ## TODO: should these really pass here? + ## isn't this the same situation as: cy.should('not.have.class', '...') + ## + ## when we use the 'eq' and 'not.eq' chainer aren't we effectively + ## saying that it must *have* a value as opposed to the property not + ## existing at all? + ## + ## does a tree falling in the forest really make a sound? + cy.wrap({}).its("foo").should("eq", undefined) + cy.wrap({}).its("foo").should("not.eq", "bar") + + it "passes when nested property does not exist on the subject with assertions", -> + obj = { + foo: {} + } + + cy.wrap(obj).its("foo").should("not.have.property", "bar") + cy.wrap(obj).its("foo.bar").should("not.exist") + cy.wrap(obj).its("foo.bar.baz").should("not.exist") + + it "passes when property value is null with assertions", -> + obj = { + foo: null + } + + cy.wrap(obj).its("foo").should("be.null") + cy.wrap(obj).its("foo").should("eq", null) + + it "passes when property value is undefined with assertions", -> + obj = { + foo: undefined + } + + cy.wrap(obj).its("foo").should("be.undefined") + cy.wrap(obj).its("foo").should("eq", undefined) + describe ".log", -> beforeEach -> @obj = { @@ -664,7 +883,7 @@ describe "src/cy/commands/connectors", -> cy.on "log:added", (attrs, log) => @lastLog = log - @logs.push(log) + @logs?.push(log) return null @@ -708,7 +927,7 @@ describe "src/cy/commands/connectors", -> expect(@lastLog.invoke("consoleProps")).to.deep.eq { Command: "its" Property: ".num" - On: @obj + Subject: @obj Yielded: 123 } @@ -722,7 +941,7 @@ describe "src/cy/commands/connectors", -> if attrs.name is "its" @lastLog = log - @logs.push(log) + @logs?.push(log) return null @@ -734,6 +953,96 @@ describe "src/cy/commands/connectors", -> cy.its("wat") + it "throws when property does not exist", (done) -> + cy.on "fail", (err) => + lastLog = @lastLog + + expect(err.message).to.include "Timed out retrying: cy.its() errored because the property: 'foo' does not exist on your subject." + + expect(err.message).to.include "cy.its() waited for the specified property 'foo' to exist, but it never did." + + expect(err.message).to.include "If you do not expect the property 'foo' to exist, then add an assertion such as:" + + expect(err.message).to.include "cy.wrap({ foo: 'bar' }).its('quux').should('not.exist')" + + expect(lastLog.get("error").message).to.include(err.message) + + done() + + cy.wrap({}).its("foo") + + it "throws when property is undefined", (done) -> + cy.on "fail", (err) => + lastLog = @lastLog + + expect(err.message).to.include "Timed out retrying: cy.its() errored because the property: 'foo' returned a 'undefined' value." + + expect(err.message).to.include "cy.its() waited for the specified property 'foo' to become accessible, but it never did." + + expect(err.message).to.include "If you expect the property 'foo' to be 'undefined', then add an assertion such as:" + + expect(err.message).to.include "cy.wrap({ foo: undefined }).its('foo').should('be.undefined')" + + expect(lastLog.get("error").message).to.include(err.message) + + done() + + cy.wrap({ foo: undefined }).its("foo") + + it "throws when property is null", (done) -> + cy.on "fail", (err) => + lastLog = @lastLog + + expect(err.message).to.include "Timed out retrying: cy.its() errored because the property: 'foo' returned a 'null' value." + + expect(err.message).to.include "cy.its() waited for the specified property 'foo' to become accessible, but it never did." + + expect(err.message).to.include "If you expect the property 'foo' to be 'null', then add an assertion such as:" + + expect(err.message).to.include "cy.wrap({ foo: null }).its('foo').should('be.null')" + + expect(lastLog.get("error").message).to.include(err.message) + + done() + + cy.wrap({ foo: null }).its("foo") + + it "throws the traversalErr as precedence when property does not exist even if the additional assertions fail", (done) -> + cy.on "fail", (err) => + lastLog = @lastLog + + expect(err.message).to.include "Timed out retrying: cy.its() errored because the property: 'b' does not exist on your subject." + + expect(err.message).to.include "cy.its() waited for the specified property 'b' to exist, but it never did." + + expect(err.message).to.include "If you do not expect the property 'b' to exist, then add an assertion such as:" + + expect(err.message).to.include "cy.wrap({ foo: 'bar' }).its('quux').should('not.exist')" + + expect(lastLog.get("error").message).to.include(err.message) + + done() + + cy.wrap({ a: "a" }).its("b").should("be.true") + + it "throws the traversalErr as precedence when property value is undefined even if the additional assertions fail", (done) -> + cy.on "fail", (err) => + lastLog = @lastLog + + expect(err.message).to.include "Timed out retrying: cy.its() errored because the property: 'a' returned a 'undefined' value." + + expect(err.message).to.include "cy.its() waited for the specified property 'a' to become accessible, but it never did." + + expect(err.message).to.include "If you expect the property 'a' to be 'undefined', then add an assertion such as:" + + expect(err.message).to.include "cy.wrap({ foo: undefined }).its('foo').should('be.undefined')" + + expect(lastLog.get("error").message).to.include(err.message) + + done() + + cy.wrap({ a: undefined }).its("a").should("be.true") + it "does not display parenthesis on command", (done) -> obj = { foo: { @@ -752,21 +1061,27 @@ describe "src/cy/commands/connectors", -> cy.wrap(obj).its("foo.bar.baz").should("eq", "baz") - it "throws when property does not exist on the subject", (done) -> - cy.on "fail", (err) => - lastLog = @lastLog - - expect(err.message).to.include "cy.its() errored because the property: 'foo' does not exist on your subject." - expect(lastLog.get("error").message).to.include(err.message) + it "can handle getter that throws", (done) -> + spy = cy.spy((err)=> + expect(err.message).to.eq('Timed out retrying: some getter error') done() + ).as('onFail') + + cy.on 'fail', spy - cy.noop({}).its("foo") + obj = {} + + Object.defineProperty obj, 'foo', { + get: -> throw new Error('some getter error') + } + + cy.wrap(obj).its('foo') it "throws when reduced property does not exist on the subject", (done) -> cy.on "fail", (err) => lastLog = @lastLog - expect(err.message).to.include "cy.its() errored because the property: 'baz' does not exist on your subject." + expect(err.message).to.include("Timed out retrying: cy.its() errored because the property: 'baz' does not exist on your subject.") expect(lastLog.get("error").message).to.include(err.message) expect(lastLog.get("error").message).to.include(err.message) done() @@ -777,15 +1092,34 @@ describe "src/cy/commands/connectors", -> } } - cy.noop(obj).its("foo.bar.baz") + cy.wrap(obj).its("foo.bar.baz.fizz") [null, undefined].forEach (val) -> - it "throws on reduced #{val} subject", (done) -> + it "throws on traversed '#{val}' subject", (done) -> cy.on "fail", (err) -> - expect(err.message).to.include("cy.its() errored because the property: 'foo' returned a '#{val}' value. You cannot access any properties such as 'toString' on a '#{val}' value.") + expect(err.message).to.include("Timed out retrying: cy.its() errored because the property: 'a' returned a '#{val}' value. The property: 'b' does not exist on a '#{val}' value.") + + expect(err.message).to.include("cy.its() waited for the specified property 'b' to become accessible, but it never did.") + + expect(err.message).to.include("If you do not expect the property 'b' to exist, then add an assertion such as:") + + expect(err.message).to.include("cy.wrap({ foo: #{val} }).its('foo.baz').should('not.exist')") + done() - cy.wrap({foo: val}).its("foo.toString") + cy.wrap({ a: val }).its("a.b.c") + + it "throws on initial '#{val}' subject", (done) -> + cy.on "fail", (err) -> + expect(err.message).to.include("Timed out retrying: cy.its() errored because your subject is: '#{val}'. You cannot access any properties such as 'foo' on a '#{val}' value.") + + expect(err.message).to.include("If you expect your subject to be '#{val}', then add an assertion such as:") + + expect(err.message).to.include("cy.wrap(#{val}).should('be.#{val}')") + + done() + + cy.wrap(val).its("foo") it "throws two args were passed as subject", (done) -> cy.on "fail", (err) => @@ -801,14 +1135,45 @@ describe "src/cy/commands/connectors", -> cy.wrap(fn).its("bar", "baz").should("eq", "baz") - ## TODO: currently this doesn't work because - ## null subjects immediately throw - # it "throws on initial #{val} subject", -> - # cy.on "fail", (err) -> - # expect(err.message).to.include("cy.its() errored because the property: 'foo' returned a '#{val}' value. You cannot call any properties such as 'toString' on a '#{val}' value.") - # done() + it "resets traversalErr and throws the right assertion", (done) -> + cy.timeout(200) + + obj = {} + + cy.on "fail", (err) => + lastLog = @lastLog + + expect(err.message).to.include("Timed out retrying: expected 'bar' to equal 'baz'") + expect(lastLog.get("error").message).to.include(err.message) + done() + + cy.on "command:retry", _.after 3, => + obj.foo = { + bar: "bar" + } + + cy.noop(obj).its("foo.bar").should("eq", "baz") + + it "consoleProps subject", (done) -> + cy.on "fail", (err) => + expect(@lastLog.invoke("consoleProps")).to.deep.eq { + Command: "its" + Property: ".fizz.buzz" + Error: """ + CypressError: Timed out retrying: cy.its() errored because the property: 'fizz' does not exist on your subject. + + cy.its() waited for the specified property 'fizz' to exist, but it never did. + + If you do not expect the property 'fizz' to exist, then add an assertion such as: + + cy.wrap({ foo: 'bar' }).its('quux').should('not.exist') + """ + Subject: {foo: "bar"} + Yielded: undefined + } + done() - # cy.wrap(val).its("toString") + cy.noop({foo: "bar"}).its("fizz.buzz") describe "without jquery", -> before -> @@ -925,7 +1290,7 @@ describe "src/cy/commands/connectors", -> cy.on "log:added", (attrs, log) => @lastLog = log - @logs.push(log) + @logs?.push(log) return null @@ -943,7 +1308,7 @@ describe "src/cy/commands/connectors", -> logs = [] cy.on "log:added", (attrs, log) -> - logs.push(log) + logs?.push(log) cy.on "fail", (err) => ## get + each diff --git a/packages/driver/test/cypress/integration/commands/misc_spec.coffee b/packages/driver/test/cypress/integration/commands/misc_spec.coffee index fc5d157938f6..4b1e65864369 100644 --- a/packages/driver/test/cypress/integration/commands/misc_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/misc_spec.coffee @@ -69,6 +69,18 @@ describe "src/cy/commands/misc", -> cy.wrap({}).then (subject) -> expect(subject).to.deep.eq {} + ## https://github.com/cypress-io/cypress/issues/3241 + it "cy.wrap(undefined) should retry", () -> + stub = cy.stub() + + cy.wrap().should -> + stub() + expect(stub).to.be.calledTwice + + cy.wrap(undefined).should -> + stub() + expect(stub.callCount).to.eq(4) + it "can wrap jquery objects and continue to chain", -> @remoteWindow.$.fn.foo = "foo" @@ -125,6 +137,16 @@ describe "src/cy/commands/misc", -> expect(Array.isArray(arr)).to.be.true expect(arr[0]).to.eq(doc) + ## https://github.com/cypress-io/cypress/issues/2927 + it "can properly handle objects with 'jquery' functions as properties", -> + ## the root issue here has to do with the fact that window.jquery points + ## to the jquery constructor, but not an actual jquery instance and + ## we need to account for that... + cy.window().then (win) -> + win.jquery = -> + + return win + describe "errors", -> it "throws when wrapping an array of windows", (done) -> cy.on "fail", (err) => diff --git a/packages/driver/test/cypress/integration/commands/navigation_spec.coffee b/packages/driver/test/cypress/integration/commands/navigation_spec.coffee index 2caa1850f14e..4a5fa333708d 100644 --- a/packages/driver/test/cypress/integration/commands/navigation_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/navigation_spec.coffee @@ -243,6 +243,7 @@ describe "src/cy/commands/navigation", -> it "removes listeners", -> cy + .visit("/fixtures/generic.html") .visit("/fixtures/jquery.html") .then -> winLoadListeners = cy.listeners("window:load") @@ -266,6 +267,7 @@ describe "src/cy/commands/navigation", -> stub3 = cy.stub() cy + .visit("/fixtures/generic.html") .visit("/fixtures/jquery.html") .then -> cy.on("stability:changed", stub1) @@ -279,6 +281,7 @@ describe "src/cy/commands/navigation", -> it "removes listeners from window", -> cy + .visit("/fixtures/generic.html") .visit("/fixtures/jquery.html") .then (win) -> rel = cy.stub(win, "removeEventListener") @@ -369,6 +372,7 @@ describe "src/cy/commands/navigation", -> it "logs go", -> cy + .visit("/fixtures/generic.html") .visit("/fixtures/jquery.html") .go("back").then -> lastLog = @lastLog @@ -378,12 +382,14 @@ describe "src/cy/commands/navigation", -> it "can turn off logging", -> cy + .visit("/fixtures/generic.html") .visit("/fixtures/jquery.html") .go("back", {log: false}).then -> expect(@lastLog).to.be.undefined it "does not log 'Page Load' events", -> cy + .visit("/fixtures/generic.html") .visit("/fixtures/jquery.html") .go("back").then -> @logs.slice(0).forEach (log) -> @@ -393,6 +399,7 @@ describe "src/cy/commands/navigation", -> beforeunload = false cy + .visit("/fixtures/generic.html") .visit("/fixtures/jquery.html") .window().then (win) -> cy.on "window:before:unload", => @@ -906,6 +913,27 @@ describe "src/cy/commands/navigation", -> "Note": "Because this visit was to the same hash, the page did not reload and the onBeforeLoad and onLoad callbacks did not fire." }) + it "logs options if they are supplied", -> + cy.visit({ + url: "http://localhost:3500/fixtures/generic.html" + headers: { + "foo": "bar" + }, + notReal: "baz" + }) + .then -> + expect(@lastLog.invoke("consoleProps")["Options"]).to.deep.eq({ + url: "http://localhost:3500/fixtures/generic.html" + headers: { + "foo": "bar" + } + }) + + it "does not log options if they are not supplied", -> + cy.visit("http://localhost:3500/fixtures/generic.html") + .then -> + expect(@lastLog.invoke("consoleProps")["Options"]).to.be.undefined + describe "errors", -> beforeEach -> Cypress.config("defaultCommandTimeout", 50) diff --git a/packages/driver/test/cypress/integration/commands/querying_spec.coffee b/packages/driver/test/cypress/integration/commands/querying_spec.coffee index b9e56ef33a60..d3b6ce0278c6 100644 --- a/packages/driver/test/cypress/integration/commands/querying_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/querying_spec.coffee @@ -739,7 +739,7 @@ describe "src/cy/commands/querying", -> .get("@getUsers").then -> expect(@lastLog.pick("message", "referencesAlias", "aliasType")).to.deep.eq { message: "@getUsers" - referencesAlias: "getUsers" + referencesAlias: {name: "getUsers"} aliasType: "route" } @@ -747,7 +747,7 @@ describe "src/cy/commands/querying", -> cy.on "log:added", (attrs, log) -> if attrs.name is "get" expect(log.pick("$el", "numRetries", "referencesAlias", "aliasType")).to.deep.eq { - referencesAlias: "f" + referencesAlias: {name: "f"} aliasType: "primitive" } done() diff --git a/packages/driver/test/cypress/integration/commands/request_spec.coffee b/packages/driver/test/cypress/integration/commands/request_spec.coffee index 84a632bcd998..5d7f70713775 100644 --- a/packages/driver/test/cypress/integration/commands/request_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/request_spec.coffee @@ -1,10 +1,12 @@ _ = Cypress._ Promise = Cypress.Promise +RESPONSE_TIMEOUT = 22222 describe "src/cy/commands/request", -> context "#request", -> beforeEach -> cy.stub(Cypress, "backend").callThrough() + Cypress.config("responseTimeout", RESPONSE_TIMEOUT) describe "argument signature", -> beforeEach -> @@ -27,6 +29,7 @@ describe "src/cy/commands/request", -> method: "GET" gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) it "accepts object with url, method, headers, body", -> @@ -48,6 +51,17 @@ describe "src/cy/commands/request", -> headers: { "x-token": "abc123" } + timeout: RESPONSE_TIMEOUT + }) + + it "accepts object with url + timeout", -> + cy.request({url: "http://localhost:8000/foo", timeout: 23456}).then -> + @expectOptionsToBe({ + url: "http://localhost:8000/foo" + method: "GET" + gzip: true + followRedirect: true + timeout: 23456 }) it "accepts string url", -> @@ -57,6 +71,7 @@ describe "src/cy/commands/request", -> method: "GET" gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) it "accepts method + url", -> @@ -66,6 +81,7 @@ describe "src/cy/commands/request", -> method: "DELETE" gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) it "accepts method + url + body", -> @@ -77,6 +93,7 @@ describe "src/cy/commands/request", -> json: true gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) it "accepts url + body", -> @@ -88,6 +105,7 @@ describe "src/cy/commands/request", -> json: true gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) it "accepts url + string body", -> @@ -98,6 +116,7 @@ describe "src/cy/commands/request", -> body: "foo" gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) context "method normalization", -> @@ -108,6 +127,7 @@ describe "src/cy/commands/request", -> method: "POST" gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) context "url normalization", -> @@ -120,6 +140,7 @@ describe "src/cy/commands/request", -> method: "GET" gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) it "uses localhost urls", -> @@ -129,15 +150,17 @@ describe "src/cy/commands/request", -> method: "GET" gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) - it "uses wwww urls", -> + it "uses www urls", -> cy.request("www.foo.com").then -> @expectOptionsToBe({ url: "http://www.foo.com/" method: "GET" gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) it "prefixes with baseUrl when origin is empty", -> @@ -150,6 +173,7 @@ describe "src/cy/commands/request", -> method: "GET" gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) it "prefixes with baseUrl over current origin", -> @@ -162,6 +186,7 @@ describe "src/cy/commands/request", -> method: "GET" gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) context "gzip", -> @@ -175,6 +200,7 @@ describe "src/cy/commands/request", -> method: "GET" gzip: false followRedirect: true + timeout: RESPONSE_TIMEOUT }) context "auth", -> @@ -195,6 +221,7 @@ describe "src/cy/commands/request", -> user: "brian" pass: "password" } + timeout: RESPONSE_TIMEOUT }) context "followRedirect", -> @@ -206,6 +233,7 @@ describe "src/cy/commands/request", -> method: "GET" gzip: true followRedirect: true + timeout: RESPONSE_TIMEOUT }) it "can be set to false", -> @@ -219,6 +247,7 @@ describe "src/cy/commands/request", -> method: "GET" gzip: true followRedirect: false + timeout: RESPONSE_TIMEOUT }) it "normalizes followRedirects -> followRedirect", -> @@ -232,6 +261,7 @@ describe "src/cy/commands/request", -> method: "GET" gzip: true followRedirect: false + timeout: RESPONSE_TIMEOUT }) context "qs", -> @@ -249,6 +279,7 @@ describe "src/cy/commands/request", -> gzip: true followRedirect: true qs: {foo: "bar"} + timeout: RESPONSE_TIMEOUT }) context "form", -> @@ -268,6 +299,7 @@ describe "src/cy/commands/request", -> form: true followRedirect: true body: {foo: "bar"} + timeout: RESPONSE_TIMEOUT }) it "accepts a string for body", -> @@ -284,6 +316,31 @@ describe "src/cy/commands/request", -> form: true followRedirect: true body: "foo=bar&baz=quux" + timeout: RESPONSE_TIMEOUT + }) + + ## https://github.com/cypress-io/cypress/issues/2923 + it "application/x-www-form-urlencoded w/ an object body uses form: true", -> + cy.request({ + url: "http://localhost:8888" + headers: { + "a": "b" + "Content-type": "application/x-www-form-urlencoded" + } + body: { foo: "bar" } + }).then -> + @expectOptionsToBe({ + url: "http://localhost:8888/" + method: "GET" + gzip: true + form: true + followRedirect: true + headers: { + "a": "b" + "Content-type": "application/x-www-form-urlencoded" + } + body: { foo: "bar" } + timeout: RESPONSE_TIMEOUT }) describe "failOnStatus", -> @@ -318,6 +375,14 @@ describe "src/cy/commands/request", -> ## make sure it really was 500! expect(resp.status).to.eq(401) + describe "method", -> + it "can use M-SEARCH method", -> + cy.request({ + url: 'http://localhost:3500/dump-method', + method: 'm-Search' + }).then (res) => + expect(res.body).to.contain('M-SEARCH') + describe "subjects", -> it "resolves with response obj", -> resp = { @@ -628,7 +693,7 @@ describe "src/cy/commands/request", -> expect(@logs.length).to.eq(1) expect(lastLog.get("error")).to.eq(err) expect(lastLog.get("state")).to.eq("failed") - expect(err.message).to.eq("cy.request() was called with an invalid method: 'FOO'. Method can only be: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS") + expect(err.message).to.eq("cy.request() was called with an invalid method: 'FOO'. Method can be: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, or any other method supported by Node's HTTP parser.") done() cy.request({ diff --git a/packages/driver/test/cypress/integration/commands/waiting_spec.coffee b/packages/driver/test/cypress/integration/commands/waiting_spec.coffee index 95d3288bfd35..7f14aaad37e1 100644 --- a/packages/driver/test/cypress/integration/commands/waiting_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/waiting_spec.coffee @@ -770,7 +770,7 @@ describe "src/cy/commands/waiting", -> cy.on "fail", (err) => obj = { name: "wait" - referencesAlias: ["getFoo"] + referencesAlias: [{name: 'getFoo', cardinal: 1, ordinal: "1st"}] aliasType: "route" type: "parent" error: err @@ -847,11 +847,28 @@ describe "src/cy/commands/waiting", -> .window().then (win) -> xhrGet(win, "/foo") xhrGet(win, "/bar") + xhrGet(win, "/foo") null - .wait(["@getFoo", "@getBar"]).then (xhrs) -> + .wait(["@getFoo", "@getBar", "@getFoo"]).then (xhrs) -> lastLog = @lastLog - expect(lastLog.get("referencesAlias")).to.deep.eq ["getFoo", "getBar"] + expect(lastLog.get("referencesAlias")).to.deep.eq [ + { + name: "getFoo", + cardinal: 1, + ordinal: '1st' + }, + { + name: "getBar", + cardinal: 1, + ordinal: '1st' + }, + { + name: "getFoo", + cardinal: 2, + ordinal: '2nd' + } + ] it "#consoleProps waiting on 1 alias", -> cy diff --git a/packages/driver/test/cypress/integration/commands/window_spec.coffee b/packages/driver/test/cypress/integration/commands/window_spec.coffee index b202de819f7c..11eb83c140c0 100644 --- a/packages/driver/test/cypress/integration/commands/window_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/window_spec.coffee @@ -123,7 +123,7 @@ describe "src/cy/commands/window", -> expect(@logs[0].get("aliasType")).to.eq("primitive") expect(@logs[2].get("aliasType")).to.eq("primitive") - expect(@logs[2].get("referencesAlias")).to.eq("win") + expect(@logs[2].get("referencesAlias").name).to.eq("win") it "logs obj", -> cy.window().then -> @@ -270,7 +270,7 @@ describe "src/cy/commands/window", -> expect(logs[0].get("aliasType")).to.eq("primitive") expect(logs[2].get("aliasType")).to.eq("primitive") - expect(logs[2].get("referencesAlias")).to.eq("doc") + expect(logs[2].get("referencesAlias").name).to.eq("doc") it "logs obj", -> cy.document().then -> diff --git a/packages/driver/test/cypress/integration/commands/xhr_spec.coffee b/packages/driver/test/cypress/integration/commands/xhr_spec.coffee index 144c6d634b42..87501f75bdc1 100644 --- a/packages/driver/test/cypress/integration/commands/xhr_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/xhr_spec.coffee @@ -1315,6 +1315,18 @@ describe "src/cy/commands/xhr", -> .wait("@getFoo").then (xhr) -> expect(xhr.responseBody).to.eq "foo bar baz" + it "can stub requests with uncommon HTTP methods", -> + cy + .route("PROPFIND", "/foo", "foo bar baz").as("getFoo") + .window().then (win) -> + win.$.ajax({ + url: "/foo" + method: "PROPFIND" + }) + null + .wait("@getFoo").then (xhr) -> + expect(xhr.responseBody).to.eq "foo bar baz" + it.skip "does not error when response is null but respond is false", -> cy.route url: /foo/ @@ -1442,9 +1454,9 @@ describe "src/cy/commands/xhr", -> cy.route(getUrl) - it "url must be one of get, put, post, delete, patch, head, options", (done) -> + it "fails when method is invalid", (done) -> cy.on "fail", (err) -> - expect(err.message).to.include "cy.route() was called with an invalid method: 'POSTS'. Method can only be: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS" + expect(err.message).to.include "cy.route() was called with an invalid method: 'POSTS'." done() cy.route("posts", "/foo", {}) @@ -1830,6 +1842,9 @@ describe "src/cy/commands/xhr", -> context "abort", -> xhrs = [] + beforeEach -> + cy.visit("/fixtures/jquery.html") + it "does not abort xhr's between tests", -> cy.window().then (win) -> _.times 2, -> @@ -1871,9 +1886,13 @@ describe "src/cy/commands/xhr", -> cy.wrap(null).should -> expect(log.get("state")).to.eq("failed") + expect(log.invoke("renderProps")).to.deep.eq({ + message: "GET (aborted) /timeout?ms=999", + indicator: 'aborted', + }) expect(xhr.aborted).to.be.true - ## https://github.com/cypress-io/cypress/issues/3008 + ## https://github.com/cypress-io/cypress/issues/3008 it "aborts xhrs even when responseType not '' or 'text'", -> log = null @@ -1881,7 +1900,7 @@ describe "src/cy/commands/xhr", -> if attrs.name is "xhr" if not log log = l - + cy .window() .then (win) -> @@ -1927,19 +1946,22 @@ describe "src/cy/commands/xhr", -> expect(log.get("state")).to.eq("passed") context "Cypress.on(window:unload)", -> - it "aborts all open XHR's", -> + it "cancels all open XHR's", -> xhrs = [] - cy.window().then (win) -> + cy + .window() + .then (win) -> _.times 2, -> xhr = new win.XMLHttpRequest - xhr.open("GET", "/timeout?ms=100") + xhr.open("GET", "/timeout?ms=200") xhr.send() xhrs.push(xhr) - .reload().then -> + .reload() + .then -> _.each xhrs, (xhr) -> - expect(xhr.aborted).to.be.true + expect(xhr.canceled).to.be.true context "Cypress.on(window:before:load)", -> it "reapplies server + route automatically before window:load", -> diff --git a/packages/driver/test/cypress/integration/cy/snapshot_spec.coffee b/packages/driver/test/cypress/integration/cy/snapshot_spec.coffee index 196738386766..20151e9d8638 100644 --- a/packages/driver/test/cypress/integration/cy/snapshot_spec.coffee +++ b/packages/driver/test/cypress/integration/cy/snapshot_spec.coffee @@ -1,16 +1,17 @@ $ = Cypress.$.bind(Cypress) +$Snapshots = require("../../../../src/cy/snapshots") normalizeStyles = (styles) -> styles .replace(/\s+/gm, "") .replace(/['"]/gm, "'") -describe "driver/src/cy/snapshot", -> +describe "driver/src/cy/snapshots", -> context "invalid snapshot html", -> beforeEach -> cy.visit("/fixtures/invalid_html.html") - it "can spapshot html with invalid attributes", -> + it "can snapshot html with invalid attributes", -> { htmlAttrs } = cy.createSnapshot() expect(htmlAttrs).to.eql({ @@ -264,3 +265,11 @@ describe "driver/src/cy/snapshot", -> { body } = cy.createSnapshot(@$el) expect(body.find("iframe").css("height")).to.equal("70px") + + context ".getDocumentStylesheets", -> + it "returns empty obj when no document", -> + fn = -> + + snapshot = $Snapshots.create(fn, fn) + + expect(snapshot.getDocumentStylesheets(null)).to.deep.eq({}) diff --git a/packages/driver/test/cypress/integration/dom/jquery_spec.js b/packages/driver/test/cypress/integration/dom/jquery_spec.js new file mode 100644 index 000000000000..ce18f6cb7fe5 --- /dev/null +++ b/packages/driver/test/cypress/integration/dom/jquery_spec.js @@ -0,0 +1,15 @@ +const { $ } = Cypress + +describe('src/dom/jquery', () => { + context('.isJquery', () => { + it('does not get confused when window contains jquery function', () => { + window.jquery = () => {} + + expect(Cypress.dom.isJquery(window)).to.be.false + }) + + it('is true for actual jquery instances', () => { + expect(Cypress.dom.isJquery($(':first'))).to.be.true + }) + }) +}) diff --git a/packages/driver/test/cypress/integration/e2e/return_value_spec.coffee b/packages/driver/test/cypress/integration/e2e/return_value_spec.coffee index 9a10b99e8776..0eb158f95fad 100644 --- a/packages/driver/test/cypress/integration/e2e/return_value_spec.coffee +++ b/packages/driver/test/cypress/integration/e2e/return_value_spec.coffee @@ -29,7 +29,7 @@ describe "return values", -> it "stringifies function bodies", (done) -> cy.on "fail", (err) -> - expect(err.message).to.include("> function () {") + expect(err.message).to.include("> function") expect(err.message).to.include("return \"foo\";") expect(err.message).to.include("Cypress detected that you invoked one or more cy commands but returned a different value.") @@ -78,7 +78,7 @@ describe "return values", -> expect(lastLog.get("name")).to.eq("foo") expect(lastLog.get("error")).to.eq(err) expect(err.message).to.include("> cy.foo()") - expect(err.message).to.include("> function () {") + expect(err.message).to.include("> function") expect(err.message).to.include("return \"bar\";") expect(err.message).to.include("Cypress detected that you invoked one or more cy commands in a custom command but returned a different value.") diff --git a/packages/driver/test/cypress/integration/issues/761_2968_3973_spec.js b/packages/driver/test/cypress/integration/issues/761_2968_3973_spec.js new file mode 100644 index 000000000000..7bcd49067fe5 --- /dev/null +++ b/packages/driver/test/cypress/integration/issues/761_2968_3973_spec.js @@ -0,0 +1,138 @@ +// https://github.com/cypress-io/cypress/issues/761 +describe('issue #761 - aborted XHRs from previous tests', () => { + context('aborted when complete', () => { + it('test 1 dispatches xhr, but completes in test 2', () => { + cy.window().then((win) => { + const xhr = new win.XMLHttpRequest() + + xhr.open('GET', '/timeout?ms=1000') + xhr.onload = () => { + // we are in test 2 at this point + // and should not throw + xhr.abort() + } + xhr.send() + }) + }) + + it('test 2 aborts the completed XHR', () => { + cy.wait(2000) + }) + }) + + context('aborted before complete', () => { + let xhr = null + + // TODO: we lose a reference here to the xhr in test 2 + // so it shows up as "pending" forever because we reset + // the proxied XHR's as references when the next test starts + it('test 1 dispatches xhr, but completes in test 2', () => { + cy.window().then((win) => { + xhr = new win.XMLHttpRequest() + + xhr.open('GET', '/timeout?ms=1000') + xhr.send() + }) + }) + + it('test 2 aborts the incomplete XHR which is currently in flight', () => { + // we are in test 2 at this point + // and should not throw when we + // abort the incomplete xhr + expect(xhr.aborted).not.to.be.true + + xhr.abort() + }) + }) +}) + +// as of chrome 71, chrome no longer fires +// readyState or abort events synchronously +// when the document unloads. instead we must +// assume they are aborting the request and +// simply check to ensure the XHR has been +// canceled internally by Cypress +// https://github.com/cypress-io/cypress/issues/3973 +describe('issue #3973 - unloaded xhrs do not fire readystatechange event in chrome >= 71', () => { + it('cancels pending requests that are incomplete', () => { + const logs = [] + + const xhrs = [] + const stub = cy.stub() + + cy.on('log:added', (attrs, log) => { + if (attrs.name === 'xhr') { + logs.push(log) + } + }) + + cy + .server() + .route('GET', /timeout/).as('getTimeout') + .visit('http://localhost:3500/fixtures/generic.html') + .window() + .then((win) => { + const xhr = new win.XMLHttpRequest() + + xhrs.push(xhr) + + xhr.open('GET', '/timeout?ms=100') + xhr.send() + }) + .wait('@getTimeout') + .window() + .then((win) => { + return new Promise((resolve) => { + cy.on('window:unload', resolve) + + const xhr = new win.XMLHttpRequest() + + xhrs.push(xhr) + + xhr.open('GET', '/timeout?ms=2000') + + xhr.abort = stub // this should not get called + xhr.onerror = stub // this should not fire + xhr.onload = stub // this should not fire + + xhr.send() + + win.location.reload() + }) + }) + .wait('@getTimeout') + .then((xhrProxy) => { + // after we unload we should cancel the + // pending XHR's and receive it here + // after waiting on it + expect(xhrProxy.canceled).to.be.true + + const [firstXhr, secondXhr] = xhrs + const [firstLog, secondLog] = logs + + // should be the same XHR here as the proxy's XHR + expect(secondXhr === xhrProxy.xhr).to.be.true + + expect(firstXhr.canceled).not.to.be.true + expect(firstXhr.aborted).not.to.be.true + expect(firstXhr.readyState).to.eq(4) + expect(firstLog.get('state')).to.eq('passed') + + // since we've canceled the underlying XHR + // ensure that our abort code did not run + // and that the underlying XHR was never + // completed with a status or response + expect(secondXhr.canceled).to.be.true + expect(secondXhr.aborted).not.to.be.true + expect(secondXhr.status).to.eq(0) + expect(secondXhr.responseText).to.eq('') + + expect(stub).not.to.be.called + expect(secondLog.get('state')).to.eq('failed') + expect(secondLog.invoke('renderProps')).to.deep.eq({ + message: 'GET (canceled) /timeout?ms=2000', + indicator: 'aborted', + }) + }) + }) +}) diff --git a/packages/driver/test/cypress/integration/issues/761_2968_spec.js b/packages/driver/test/cypress/integration/issues/761_2968_spec.js deleted file mode 100644 index c04ab38da183..000000000000 --- a/packages/driver/test/cypress/integration/issues/761_2968_spec.js +++ /dev/null @@ -1,111 +0,0 @@ -// https://github.com/cypress-io/cypress/issues/761 -describe('issue #761 - aborted XHRs from previous tests', () => { - context('aborted when complete', () => { - it('test 1 dispatches xhr, but completes in test 2', () => { - cy.window().then((win) => { - const xhr = new win.XMLHttpRequest() - - xhr.open('GET', '/timeout?ms=1000') - xhr.onload = () => { - // we are in test 2 at this point - // and should not throw - xhr.abort() - } - xhr.send() - }) - }) - - it('test 2 aborts the completed XHR', () => { - cy.wait(2000) - }) - }) - - context('aborted before complete', () => { - let xhr = null - - // TODO: we lose a reference here to the xhr in test 2 - // so it shows up as "pending" forever because we reset - // the proxied XHR's as references when the next test starts - it('test 1 dispatches xhr, but completes in test 2', () => { - cy.window().then((win) => { - xhr = new win.XMLHttpRequest() - - xhr.open('GET', '/timeout?ms=1000') - xhr.send() - }) - }) - - it('test 2 aborts the incomplete XHR which is currently in flight', () => { - // we are in test 2 at this point - // and should not throw when we - // abort the incomplete xhr - expect(xhr.aborted).not.to.be.true - - xhr.abort() - }) - }) -}) - -// this tests that XHR references are blown away -// and no longer invoked when unloading the window -// and that its unnecessary to abort them -// https://github.com/cypress-io/cypress/issues/2968 -describe('issue #2968 - unloaded xhrs do not need to be aborted', () => { - it('let the browser naturally abort requests without manual intervention on unload', () => { - let xhr - let log - - const stub = cy.stub() - - cy.on('log:changed', (attrs, l) => { - if (attrs.name === 'xhr') { - log = l - } - }) - - cy - .visit('http://localhost:3500/fixtures/generic.html') - .window() - .then((win) => { - return new Promise((resolve, reject) => { - xhr = new win.XMLHttpRequest() - - win.XMLHttpRequest.prototype.abort = stub - - xhr.open('GET', '/timeout?ms=1000') - xhr.abort = stub // this should not get called - xhr.onerror = stub // this should not fire - xhr.onload = stub // this should not fire - xhr.onreadystatechange = () => { - if (xhr.readyState === 4) { - try { - // the browser should naturally - // abort / cancel this request when - // the unload event is called which - // should cause this xhr to have - // these properties and be displayed - // correctly in the Cypress Command Log - expect(xhr.aborted).to.be.true - expect(xhr.readyState).to.eq(4) - expect(xhr.status).to.eq(0) - expect(xhr.responseText).to.eq('') - } catch (err) { - reject(err) - } - - resolve() - } - } - - xhr.send() - - win.location.href = 'about:blank' - }) - }) - .wrap(null) - .should(() => { - expect(stub).not.to.be.called - expect(log.get('state')).to.eq('failed') - }) - }) -}) diff --git a/packages/driver/test/support/server.coffee b/packages/driver/test/support/server.coffee index f16ec81f5be4..a71def2745b1 100644 --- a/packages/driver/test/support/server.coffee +++ b/packages/driver/test/support/server.coffee @@ -72,6 +72,9 @@ niv.install("react-dom@15.6.1") res.setHeader('Content-Type', 'text/html; charset=utf-8,text/html') res.end("Test
    Hello
    ") + app.all '/dump-method', (req, res) -> + res.send("request method: #{req.method}") + app.post '/post-only', (req, res) -> res.send("it worked!
    request body:
    #{JSON.stringify(req.body)}") diff --git a/packages/electron/lib/electron.coffee b/packages/electron/lib/electron.coffee index 0b93e1081b82..535c4f5c61f3 100644 --- a/packages/electron/lib/electron.coffee +++ b/packages/electron/lib/electron.coffee @@ -59,8 +59,6 @@ module.exports = { .then -> execPath = paths.getPathToExec() - debug("spawning %s", execPath) - ## we have an active debugger session if inspector.url() dp = process.debugPort + 1 @@ -72,6 +70,8 @@ module.exports = { if opts.inspectBrk argv.unshift("--inspect-brk=5566") + debug("spawning %s with args", execPath, argv) + cp.spawn(execPath, argv, {stdio: "inherit"}) .on "close", (code) -> debug("electron closing with code", code) diff --git a/packages/electron/lib/install.coffee b/packages/electron/lib/install.coffee index 9de42e6d37b6..7e0a26b81f57 100644 --- a/packages/electron/lib/install.coffee +++ b/packages/electron/lib/install.coffee @@ -64,7 +64,7 @@ module.exports = { out: "tmp" name: "Cypress" platform: os.platform() - arch: "x64" + arch: os.arch() asar: false prune: true overwrite: true diff --git a/packages/electron/package.json b/packages/electron/package.json index 230e2dfe4a28..4d8ff4844614 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,7 +1,7 @@ { "name": "@packages/electron", "version": "0.0.0", - "electronVersion": "1.8.2", + "electronVersion": "2.0.18", "private": true, "main": "index.js", "scripts": { diff --git a/packages/example/cypress/integration/examples/assertions.spec.js b/packages/example/cypress/integration/examples/assertions.spec.js index 5d7b4ceb714b..791383b66518 100644 --- a/packages/example/cypress/integration/examples/assertions.spec.js +++ b/packages/example/cypress/integration/examples/assertions.spec.js @@ -53,6 +53,7 @@ context('Assertions', () => { // We can use Chai's BDD style assertions expect(true).to.be.true const o = { foo: 'bar' } + expect(o).to.equal(o) expect(o).to.deep.equal({ foo: 'bar' }) // matching text using regular expression @@ -150,6 +151,7 @@ context('Assertions', () => { .should(($div) => { // we can massage text before comparing const secondText = normalizeText($div.text()) + expect(secondText, 'second text').to.equal(text) }) }) @@ -159,6 +161,7 @@ context('Assertions', () => { name: 'Joe', age: 20, } + assert.isObject(person, 'value is object') }) }) diff --git a/packages/example/cypress/integration/examples/network_requests.spec.js b/packages/example/cypress/integration/examples/network_requests.spec.js index 276615b95f58..259e9eea570a 100644 --- a/packages/example/cypress/integration/examples/network_requests.spec.js +++ b/packages/example/cypress/integration/examples/network_requests.spec.js @@ -91,6 +91,7 @@ context('Network Requests', () => { // https://on.cypress.io/route let message = 'whoa, this comment does not exist' + cy.server() // Listen to GET to comments/1 diff --git a/packages/example/cypress/integration/examples/utilities.spec.js b/packages/example/cypress/integration/examples/utilities.spec.js index 0f5c9452c8bd..c3e2076dc01e 100644 --- a/packages/example/cypress/integration/examples/utilities.spec.js +++ b/packages/example/cypress/integration/examples/utilities.spec.js @@ -34,6 +34,7 @@ context('Utilities', () => { .then((dataUrl) => { // create an element and set its src to the dataUrl let img = Cypress.$('', { src: dataUrl }) + // need to explicitly return cy here since we are initially returning // the Cypress.Blob.imgSrcToDataURL promise to our test // append the image @@ -49,6 +50,7 @@ context('Utilities', () => { let matching = Cypress.minimatch('/users/1/comments', '/users/*/comments', { matchBase: true, }) + expect(matching, 'matching wildcard').to.be.true matching = Cypress.minimatch('/users/1/comments/2', '/users/*/comments', { @@ -74,6 +76,7 @@ context('Utilities', () => { it('Cypress.moment() - format or parse dates using a moment method', () => { // https://on.cypress.io/moment const time = Cypress.moment().utc('2014-04-25T19:38:53.196Z').format('h:mm A') + expect(time).to.be.a('string') cy.get('.utility-moment').contains('3:38 PM') diff --git a/packages/example/gulpfile.js b/packages/example/gulpfile.js index a602adb89d0f..02b676bde882 100644 --- a/packages/example/gulpfile.js +++ b/packages/example/gulpfile.js @@ -5,15 +5,15 @@ let RevAll = require('gulp-rev-all') let runSequence = require('run-sequence') gulp.task('assets', function () { - let revAll = new RevAll({ + let revAllOpts = { dontGlobal: ['.ico', 'fira.css', 'javascript-logo.png'], dontRenameFile: ['.ico', '.html', /fonts/], dontSearchFile: ['.js'], debug: false, - }) + } return gulp.src('./app/**/*') - .pipe(revAll.revision()) + .pipe(RevAll.revision(revAllOpts)) .pipe(gulp.dest('build')) }) diff --git a/packages/example/package.json b/packages/example/package.json index b7bdc3488f13..537c2a7a881a 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -26,16 +26,16 @@ "glob": "7.1.3" }, "devDependencies": { - "bin-up": "1.1.0", + "bin-up": "1.2.0", "chai": "3.5.0", "cross-env": "5.2.0", "cypress-example-kitchensink": "1.5.1", "gulp": "3.9.1", "gulp-clean": "0.4.0", "gulp-gh-pages-will": "0.5.5", - "gulp-rev-all": "0.8.24", + "gulp-rev-all": "0.9.8", "mocha": "2.5.3", "run-sequence": "1.2.2", - "shelljs": "0.7.8" + "shelljs": "0.8.3" } } diff --git a/packages/extension/app/manifest.json b/packages/extension/app/manifest.json index 60b74238b449..4248dddbc603 100644 --- a/packages/extension/app/manifest.json +++ b/packages/extension/app/manifest.json @@ -8,6 +8,7 @@ "https://*/*", "" ], + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAugoxpSqfoblTYUGvyXZpmBgjYQUY9k2Hx3PaDwquyaTH6GBxitwVMSu5sZuDYgPHpGYoF4ol6A4PZHhd6JvfuUDS9ZrxTW0XzP+dSS9AwmJo3uLuP88zBs4mhpje1+WE5NGM0pTzyCXYTPoyzyPRmToALWD96cahSGuhG8bSmaBw3py+16qNKm8SOlANbUvHtEaTpmrSWBUIq7YV8SIPLtR8G47vjqPTE1yEsBQ3GAgllhi0cJolwk/629fRLr3KVckICmU6spXD/jVhIgAeyHhFuFGYNuubzbel8trBVw5Q/HE5F6j66sBvEvW64tH4lPxnM5JPv0qie5wouPiT0wIDAQAB", "icons": { "16": "icons/icon_16x16.png", "48": "icons/icon_48x48.png", diff --git a/packages/extension/app/newtab.html b/packages/extension/app/newtab.html index a46f607cbd5c..d1c282681b9b 100644 --- a/packages/extension/app/newtab.html +++ b/packages/extension/app/newtab.html @@ -14,10 +14,10 @@

    Cypress is currently automating this browser.

    Please note:

    • Any opened tabs will be closed when Cypress is stopped.
    • -
    • Tests currently running will fail while another tab has focus.
    • +
    • Tests currently running may fail while another tab has focus.
    • Cookies and session from other sites will be cleared.
    -
    Read more about browser management + Read more about browser management
    \ No newline at end of file diff --git a/packages/extension/app/popup.html b/packages/extension/app/popup.html index 87fc2104e7d8..a529468d18ef 100644 --- a/packages/extension/app/popup.html +++ b/packages/extension/app/popup.html @@ -13,7 +13,7 @@ Docs
  • - Chat + Chat
  • diff --git a/packages/extension/package.json b/packages/extension/package.json index 79dfddfa5f7c..e12a4ba5c879 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -28,7 +28,7 @@ ], "devDependencies": { "@cypress/icons": "0.7.0", - "bin-up": "1.1.0", + "bin-up": "1.2.0", "browserify": "13.3.0", "chai": "3.5.0", "coffeeify": "2.1.0", diff --git a/packages/extension/test/unit/extension_spec.coffee b/packages/extension/test/unit/extension_spec.coffee index e15b5dd5866f..2054e0ff1d17 100644 --- a/packages/extension/test/unit/extension_spec.coffee +++ b/packages/extension/test/unit/extension_spec.coffee @@ -1,5 +1,6 @@ require("../spec_helper") +exec = require("child_process").exec fs = require("fs-extra") path = require("path") Promise = require("bluebird") @@ -8,6 +9,7 @@ extension = require("../../index") cwd = process.cwd() fs = Promise.promisifyAll(fs) +exec = Promise.promisify(exec) describe "Extension", -> context ".getCookieUrl", -> @@ -87,3 +89,13 @@ describe "Extension", -> fs.readFileAsync(@src, "utf8") .then (str2) -> expect(str).to.eq(str2) + + context "manifest", -> + it "has a key that resolves to the static extension ID", -> + fs.readJsonAsync(path.join(cwd, "app/manifest.json")) + .then (manifest) -> + cmd = "echo \"#{manifest.key}\" | openssl base64 -d -A | shasum -a 256 | head -c32 | tr 0-9a-f a-p" + exec(cmd) + .then (stdout) -> + expect(stdout).to.eq("caljajdfkjjjdehjdoimjkkakekklcck") + diff --git a/packages/https-proxy/index.js b/packages/https-proxy/index.js index 65bd49e8f74f..4c553f7e2cc3 100644 --- a/packages/https-proxy/index.js +++ b/packages/https-proxy/index.js @@ -1,3 +1,6 @@ require('@packages/coffee/register') +require('@packages/ts/register') module.exports = require('./lib/proxy') + +module.exports.CA = require('./lib/ca') diff --git a/packages/https-proxy/lib/ca.coffee b/packages/https-proxy/lib/ca.coffee index de7720ba2b9d..aa4dcf8f41b2 100644 --- a/packages/https-proxy/lib/ca.coffee +++ b/packages/https-proxy/lib/ca.coffee @@ -1,5 +1,6 @@ _ = require("lodash") fs = require("fs-extra") +os = require("os") path = require("path") Forge = require("node-forge") Promise = require("bluebird") @@ -217,6 +218,9 @@ class CA @create = (caFolder) -> ca = new CA + if not caFolder + caFolder = path.join(os.tmpdir(), 'cy-ca') + ca.baseCAFolder = caFolder ca.certsFolder = path.join(ca.baseCAFolder, "certs") ca.keysFolder = path.join(ca.baseCAFolder, "keys") @@ -233,4 +237,4 @@ class CA .catch(ca.generateCA) .return(ca) -module.exports = CA \ No newline at end of file +module.exports = CA diff --git a/packages/https-proxy/lib/server.coffee b/packages/https-proxy/lib/server.coffee index 8d804a540160..e69ca5747bfa 100644 --- a/packages/https-proxy/lib/server.coffee +++ b/packages/https-proxy/lib/server.coffee @@ -1,26 +1,38 @@ _ = require("lodash") +agent = require("@packages/network").agent +allowDestroy = require("server-destroy-vvo") +debug = require("debug")("cypress:https-proxy") fs = require("fs-extra") -net = require("net") -url = require("url") +getProxyForUrl = require("proxy-from-env").getProxyForUrl https = require("https") +net = require("net") +parse = require("./util/parse") Promise = require("bluebird") semaphore = require("semaphore") -allowDestroy = require("server-destroy-vvo") -log = require("debug")("cypress:https-proxy") -parse = require("./util/parse") +url = require("url") fs = Promise.promisifyAll(fs) sslServers = {} sslSemaphores = {} +## https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_record +SSL_RECORD_TYPES = [ + 22 ## Handshake + 128, 0 ## TODO: what do these unknown types mean? +] + class Server constructor: (@_ca, @_port) -> @_onError = null connect: (req, socket, head, options = {}) -> + ## don't buffer writes - thanks a lot, Nagle + ## https://github.com/cypress-io/cypress/issues/3192 + socket.setNoDelay(true) + if not head or head.length is 0 - log("Writing socket connection headers for URL:", req.url) + debug("Writing socket connection headers for URL:", req.url) socket.once "data", (data) => @connect(req, socket, data, options) @@ -38,10 +50,9 @@ class Server ## if onDirectConnection return true ## then dont proxy, just pass this through if odc.call(@, req, socket, head) is true - log("Making direct connection to #{req.url}") return @_makeDirectConnection(req, socket, head) else - log("Not making direct connection to #{req.url}") + debug("Not making direct connection to #{req.url}") socket.pause() @@ -69,42 +80,84 @@ class Server res.end() .pipe(res) + _upstreamProxyForHostPort: (hostname, port) -> + getProxyForUrl("https://#{hostname}:#{port}") + _makeDirectConnection: (req, socket, head) -> { port, hostname } = url.parse("http://#{req.url}") + if upstreamProxy = @_upstreamProxyForHostPort(hostname, port) + return @_makeUpstreamProxyConnection(upstreamProxy, socket, head, port, hostname) + + debug("Making direct connection to #{hostname}:#{port}") @_makeConnection(socket, head, port, hostname) _makeConnection: (socket, head, port, hostname) -> - cb = -> + onConnect = -> socket.pipe(conn) conn.pipe(socket) - socket.emit("data", head) + conn.write(head) socket.resume() - ## compact out hostname when undefined - args = _.compact([port, hostname, cb]) - - conn = net.connect.apply(net, args) + conn = new net.Socket() + conn.setNoDelay(true) conn.on "error", (err) => if @_onError @_onError(err, socket, head, port) + ## compact out hostname when undefined + args = _.compact([port, hostname, onConnect]) + conn.connect.apply(conn, args) + + # todo: as soon as all requests are intercepted, this can go away since this is just for pass-through + _makeUpstreamProxyConnection: (upstreamProxy, socket, head, toPort, toHostname) -> + debug("making proxied connection to #{toHostname}:#{toPort} with upstream #{upstreamProxy}") + + onUpstreamSock = (err, upstreamSock) -> + if @_onError + if err + return @_onError(err, socket, head, port) + upstreamSock.on "error", (err) => + @_onError(err, socket, head, port) + + if not upstreamSock + ## couldn't establish a proxy connection, fail gracefully + socket.resume() + return socket.destroy() + + upstreamSock.setNoDelay(true) + upstreamSock.pipe(socket) + socket.pipe(upstreamSock) + upstreamSock.write(head) + + socket.resume() + + agent.httpsAgent.createProxiedConnection { + proxy: upstreamProxy + href: "https://#{toHostname}:#{toPort}" + uri: { + port: toPort + hostname: toHostname + } + }, onUpstreamSock.bind(@) + _onServerConnectData: (req, socket, head) -> firstBytes = head[0] makeConnection = (port) => - log("Making intercepted connection to %s", port) + debug("Making intercepted connection to %s", port) @_makeConnection(socket, head, port) - if firstBytes is 0x16 or firstBytes is 0x80 or firstBytes is 0x00 + if firstBytes in SSL_RECORD_TYPES {hostname} = url.parse("http://#{req.url}") if sslServer = sslServers[hostname] return makeConnection(sslServer.port) + ## only be creating one SSL server per hostname at once if not sem = sslSemaphores[hostname] sem = sslSemaphores[hostname] = semaphore(1) @@ -164,11 +217,11 @@ class Server @_sniServer.on "upgrade", @_onUpgrade.bind(@, options.onUpgrade) @_sniServer.on "request", @_onRequest.bind(@, options.onRequest) - @_sniServer.listen => + @_sniServer.listen 0, '127.0.0.1', => ## store the port of our current sniServer @_sniPort = @_sniServer.address().port - log("Created SNI HTTPS Proxy on port %s", @_sniPort) + debug("Created SNI HTTPS Proxy on port %s", @_sniPort) resolve() diff --git a/packages/https-proxy/package.json b/packages/https-proxy/package.json index f97d5141da40..1d7a0cd52ff5 100644 --- a/packages/https-proxy/package.json +++ b/packages/https-proxy/package.json @@ -11,6 +11,7 @@ "clean-deps": "rm -rf node_modules", "pretest": "npm run check-deps-pre", "test": "cross-env NODE_ENV=test bin-up mocha --reporter mocha-multi-reporters --reporter-options configFile=../../mocha-reporter-config.json", + "test-debug": "cross-env NODE_ENV=test bin-up mocha --inspect-brk --reporter mocha-multi-reporters --reporter-options configFile=../../mocha-reporter-config.json", "pretest-watch": "npm run check-deps-pre", "test-watch": "cross-env NODE_ENV=test bin-up mocha --watch", "https": "node https.js" @@ -19,10 +20,10 @@ "lib" ], "devDependencies": { - "bin-up": "1.1.0", + "bin-up": "1.2.0", "chai": "3.5.0", "cross-env": "5.2.0", - "http-mitm-proxy": "0.5.3", + "@cypress/debugging-proxy": "1.6.0", "request": "2.88.0", "request-promise": "4.2.4", "sinon": "1.17.7", @@ -37,6 +38,7 @@ "fs-extra": "0.30.0", "lodash": "4.17.11", "node-forge": "0.6.49", + "proxy-from-env": "1.0.0", "semaphore": "1.1.0", "server-destroy-vvo": "1.0.1", "ssl-root-cas": "1.3.1" diff --git a/packages/https-proxy/test/helpers/mitm.coffee b/packages/https-proxy/test/helpers/mitm.coffee deleted file mode 100644 index f66ebcc659ae..000000000000 --- a/packages/https-proxy/test/helpers/mitm.coffee +++ /dev/null @@ -1,17 +0,0 @@ -Promise = require("bluebird") - -Proxy = require("http-mitm-proxy") -proxy = Proxy() - -proxy.onRequest (ctx, cb) -> - cb() - -module.exports = { - start: -> - new Promise (resolve) -> - proxy.listen({port: 8081, forceSNI: true}, resolve) - - stop: -> - proxy.close() - -} \ No newline at end of file diff --git a/packages/https-proxy/test/helpers/proxy_bak.coffee b/packages/https-proxy/test/helpers/proxy_bak.coffee deleted file mode 100644 index 6a3d387cb039..000000000000 --- a/packages/https-proxy/test/helpers/proxy_bak.coffee +++ /dev/null @@ -1,284 +0,0 @@ -fs = require("fs-extra") -net = require("net") -url = require("url") -path = require("path") -http = require("http") -https = require("https") -request = require("request") -Promise = require("bluebird") -sempahore = require("semaphore") -CA = require("../../lib/ca") - -Promise.promisifyAll(fs) - -ca = null -httpsSrv = null -httpsPort = null - -sslServers = {} -sslSemaphores = {} - -onClientError = (err) -> - console.log "CLIENT ERROR", err - -onError = (err) -> - console.log "ERROR", err - -onRequest = (req, res) -> - console.log "onRequest!!!!!!!!!", req.url, req.headers, req.method - - hostPort = parseHostAndPort(req) - - # req.pause() - - opts = { - url: req.url - baseUrl: "https://" + hostPort.host + ":" + hostPort.port - method: req.method - headers: req.headers - } - - req.pipe(request(opts)) - .on "error", -> - console.log "**ERROR", req.url - res.statusCode = 500 - res.end() - .pipe(res) - -parseHostAndPort = (req, defaultPort) -> - host = req.headers.host - - return null if not host - - hostPort = parseHost(host, defaultPort) - - ## this handles paths which include the full url. This could happen if it's a proxy - if m = req.url.match(/^http:\/\/([^\/]*)\/?(.*)$/) - parsedUrl = url.parse(req.url) - hostPort.host = parsedUrl.hostname - hostPort.port = parsedUrl.port - req.url = parsedUrl.path - - hostPort - -parseHost = (hostString, defaultPort) -> - if m = hostString.match(/^http:\/\/(.*)/) - parsedUrl = url.parse(hostString) - - return { - host: parsedUrl.hostname - port: parsedUrl.port - } - - hostPort = hostString.split(':') - host = hostPort[0] - port = if hostPort.length is 2 then +hostPort[1] else defaultPort - - return { - host: host - port: port - } - -onConnect = (req, socket, head) -> - console.log "ON CONNECT!!!!!!!!!!!!!!!" - ## tell the client that the connection is established - # socket.write('HTTP/' + req.httpVersion + ' 200 OK\r\n\r\n', 'UTF-8', function() { - # // creating pipes in both ends - # conn.pipe(socket); - # socket.pipe(conn); - # }); - - console.log "URL", req.url - console.log "HEADERS", req.headers - console.log "HEAD IS", head - console.log "HEAD LENGTH", head.length - - # srvUrl = url.parse("http://#{req.url}") - - # conn = null - - # cb = -> - # socket.write('HTTP/1.1 200 Connection Established\r\n' + - # 'Proxy-agent: Cypress\r\n' + - # '\r\n') - # conn.write(head) - # conn.pipe(socket) - # socket.pipe(conn) - - # conn = net.connect(srvUrl.port, srvUrl.hostname, cb) - - # conn.on "error", (err) -> - # ## TODO: attach error handling here - # console.log "*******ERROR CONNECTING", err, err.stack - - # # conn.on "close", -> - # # console.log "CONNECTION CLOSED", arguments - - # return - - # URL www.cypress.io:443 - # HEADERS { host: 'www.cypress.io:443', - # 'proxy-connection': 'keep-alive', - # 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2609.0 Safari/537.36' } - # HEAD IS - # HEAD LENGTH 0 - - getHttpsServer = (hostname) -> - onCertificateRequired(hostname) - .then (certPaths) -> - Promise.props({ - keyFileExists: fs.statAsync(certPaths.keyFile) - certFileExists: fs.statAsync(certPaths.certFile) - }) - .catch (err) -> - onCertificateMissing(certPaths) - .then (data = {}) -> - return { - key: data.keyFileData - cert: data.certFileData - hosts: data.hosts - } - .then (data = {}) -> - hosts = [hostname] - delete data.hosts - - hosts.forEach (host) -> - console.log "ADD CONTEXT", host, data - httpsSrv.addContext(host, data) - # sslServers[host] = { port: httpsPort } - - # return cb(null, self.httpsPort) - - return httpsPort - - onCertificateMissing = (certPaths) -> - hosts = certPaths.hosts #or [ctx.hostname] - - ca.generateServerCertificateKeys(hosts) - .spread (certPEM, privateKeyPEM) -> - return { - hosts: hosts - keyFileData: privateKeyPEM - certFileData: certPEM - } - - onCertificateRequired = (hostname) -> - Promise.resolve({ - keyFile: "" - certFile: "" - hosts: [hostname] - }) - - makeConnection = (port) -> - console.log "makeConnection", port - conn = net.connect port, -> - console.log "connected to", port#, socket, conn, head - socket.pipe(conn) - conn.pipe(socket) - socket.emit("data", head) - - return socket.resume() - - conn.on "error", onError - - onServerConnectData = (head) -> - firstBytes = head[0] - - if firstBytes is 0x16 or firstBytes is 0x80 or firstBytes is 0x00 - {hostname} = url.parse("http://#{req.url}") - - if sslServer = sslServers[hostname] - return makeConnection(sslServer.port) - - wildcardhost = hostname.replace(/[^\.]+\./, "*.") - - sem = sslSemaphores[wildcardhost] - - if not sem - sem = sslSemaphores[wildcardhost] = sempahore(1) - - sem.take -> - leave = -> - process.nextTick -> - console.log "leaving sem" - sem.leave() - - if sslServer = sslServers[hostname] - leave() - return makeConnection(sslServer.port) - - if sslServer = sslServers[wildcardhost] - leave() - sslServers[hostname] = { - port: sslServer - } - - return makeConnection(sslServers[hostname].port) - - getHttpsServer(hostname) - .then (port) -> - leave() - - makeConnection(port) - - else - throw new Error("@httpPort") - makeConnection(@httpPort) - - if not head or head.length is 0 - socket.once "data", onConnect.bind(@, req, socket) - - socket.write "HTTP/1.1 200 OK\r\n" - - if req.headers["proxy-connection"] is "keep-alive" - socket.write("Proxy-Connection: keep-alive\r\n") - socket.write("Connection: keep-alive\r\n") - - return socket.write("\r\n") - - else - socket.pause() - - onServerConnectData(head) - -prx = http.createServer() - -prx.on("connect", onConnect) -prx.on("request", onRequest) -prx.on("clientError", onClientError) -prx.on("error", onError) - -module.exports = { - prx: prx - - startHttpsSrv: -> - new Promise (resolve) -> - httpsSrv = https.createServer({}) - # httpsSrv.timeout = 0 - httpsSrv.on("connect", onConnect) - httpsSrv.on("request", onRequest) - httpsSrv.on("clientError", onClientError) - httpsSrv.on("error", onError) - httpsSrv.listen -> - resolve([httpsSrv.address().port, httpsSrv]) - - start: -> - dir = path.join(process.cwd(), "ca") - - CA.create(dir) - .then (c) => - ca = c - - @startHttpsSrv() - .spread (port, httpsSrv) -> - httpsPort = port - - new Promise (resolve) -> - prx.listen 3333, -> - console.log "server listening on port: 3333" - resolve(prx) - - stop: -> - new Promise (resolve) -> - prx.close(resolve) -} \ No newline at end of file diff --git a/packages/https-proxy/test/integration/proxy_spec.coffee b/packages/https-proxy/test/integration/proxy_spec.coffee index b49ef466f34e..3965e90a961f 100644 --- a/packages/https-proxy/test/integration/proxy_spec.coffee +++ b/packages/https-proxy/test/integration/proxy_spec.coffee @@ -2,10 +2,12 @@ require("../spec_helper") process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" +_ = require("lodash") +DebugProxy = require("@cypress/debugging-proxy") +net = require("net") path = require("path") Promise = require("bluebird") proxy = require("../helpers/proxy") -mitmProxy = require("../helpers/mitm") httpServer = require("../helpers/http_server") httpsServer = require("../helpers/https_server") @@ -14,8 +16,6 @@ describe "Proxy", -> Promise.join( httpServer.start() - # mitmProxy.start() - httpsServer.start(8443) httpsServer.start(8444) @@ -91,6 +91,25 @@ describe "Proxy", -> .then (html) -> expect(html).to.include("https server") + it "closes outgoing connections when client disconnects", -> + @sandbox.spy(net.Socket.prototype, 'connect') + + request({ + strictSSL: false + url: "https://localhost:8444/replace" + proxy: "http://localhost:3333" + resolveWithFullResponse: true + }) + .then (res) => + ## ensure client has disconnected + expect(res.socket.destroyed).to.be.true + ## ensure the outgoing socket created for this connection was destroyed + socket = net.Socket.prototype.connect.getCalls() + .find (call) => + _.isEqual(call.args.slice(0,2), ["8444", "localhost"]) + .thisValue + expect(socket.destroyed).to.be.true + it "can boot the httpServer", -> request({ strictSSL: false @@ -139,3 +158,78 @@ describe "Proxy", -> url: "https://localhost:8443/" proxy: "http://localhost:3333" }) + + context "with an upstream proxy", -> + beforeEach -> + @oldEnv = Object.assign({}, process.env) + process.env.NO_PROXY = "" + process.env.HTTP_PROXY = process.env.HTTPS_PROXY = "http://localhost:9001" + + @upstream = new DebugProxy({ + keepRequests: true + }) + + @upstream.start(9001) + + it "passes a request to an https server through the upstream", -> + request({ + strictSSL: false + url: "https://localhost:8444/" + proxy: "http://localhost:3333" + }).then (res) => + expect(@upstream.getRequests()[0]).to.include({ + url: 'localhost:8444' + https: true + }) + expect(res).to.contain("https server") + + it "uses HTTP basic auth when provided", -> + @upstream.setAuth({ + username: 'foo' + password: 'bar' + }) + + process.env.HTTP_PROXY = process.env.HTTPS_PROXY = "http://foo:bar@localhost:9001" + + request({ + strictSSL: false + url: "https://localhost:8444/" + proxy: "http://localhost:3333" + }).then (res) => + expect(@upstream.getRequests()[0]).to.include({ + url: 'localhost:8444' + https: true + }) + expect(res).to.contain("https server") + + it "closes outgoing connections when client disconnects", -> + @sandbox.spy(net.Socket.prototype, 'connect') + + request({ + strictSSL: false + url: "https://localhost:8444/replace" + proxy: "http://localhost:3333" + resolveWithFullResponse: true + forever: false + }) + .then (res) => + ## ensure client has disconnected + expect(res.socket.destroyed).to.be.true + + ## ensure the outgoing socket created for this connection was destroyed + socket = net.Socket.prototype.connect.getCalls() + .find (call) => + _.isEqual(call.args[0][0], { + host: 'localhost' + port: 9001 + }) + .thisValue + + new Promise (resolve) -> + socket.on 'close', => + expect(socket.destroyed).to.be.true + resolve() + + afterEach -> + @upstream.stop() + Object.assign(process.env, @oldEnv) diff --git a/packages/https-proxy/test/mocha.opts b/packages/https-proxy/test/mocha.opts index 6cf813fc8a6f..c88a378ae8cf 100644 --- a/packages/https-proxy/test/mocha.opts +++ b/packages/https-proxy/test/mocha.opts @@ -1,5 +1,5 @@ test/unit test/integration --reporter spec ---compilers coffee:@packages/coffee/register +--compilers ts:@packages/ts/register,coffee:@packages/coffee/register --recursive diff --git a/packages/https-proxy/test/unit/server_spec.coffee b/packages/https-proxy/test/unit/server_spec.coffee index b9dd77bbc9ad..49b9b896806f 100644 --- a/packages/https-proxy/test/unit/server_spec.coffee +++ b/packages/https-proxy/test/unit/server_spec.coffee @@ -1,5 +1,6 @@ require("../spec_helper") +EE = require("events") Promise = require("bluebird") proxy = require("../helpers/proxy") Server = require("../../lib/server") @@ -35,7 +36,7 @@ describe "lib/server", -> it "calls options.onError with err and port", (done) -> onError = @sandbox.stub() - socket = {} + socket = new EE() head = {} @setup({onError: onError}) @@ -46,7 +47,9 @@ describe "lib/server", -> err = onError.getCall(0).args[0] expect(err.message).to.eq("connect ECONNREFUSED 127.0.0.1:8444") - expect(onError).to.be.calledWithMatch(err, socket, head, 8444) + expect(onError.getCall(0).args[1]).to.eq(socket) + expect(onError.getCall(0).args[2]).to.eq(head) + expect(onError.getCall(0).args[3]).to.eq("8444") done() return diff --git a/packages/launcher/lib/darwin/util.ts b/packages/launcher/lib/darwin/util.ts index a0650876cca7..2dc3bdee0bc5 100644 --- a/packages/launcher/lib/darwin/util.ts +++ b/packages/launcher/lib/darwin/util.ts @@ -1,7 +1,7 @@ import { log } from '../log' import { notInstalledErr } from '../errors' import { prop, tap } from 'ramda' -import * as execa from 'execa' +import execa from 'execa' import * as fs from 'fs-extra' import * as path from 'path' import * as plist from 'plist' diff --git a/packages/launcher/lib/detect.ts b/packages/launcher/lib/detect.ts index 65e78b496352..1a46c51a7bf9 100644 --- a/packages/launcher/lib/detect.ts +++ b/packages/launcher/lib/detect.ts @@ -1,19 +1,19 @@ -import * as Bluebird from 'bluebird' -import { extend, compact, find } from 'lodash' +import Bluebird from 'bluebird' +import { compact, extend, find } from 'lodash' import * as os from 'os' -import { merge, pick, props, tap, uniqBy, flatten } from 'ramda' +import { flatten, merge, pick, props, tap, uniqBy } from 'ramda' import { browsers } from './browsers' import * as darwinHelper from './darwin' +import { notDetectedAtPathErr } from './errors' import * as linuxHelper from './linux' import { log } from './log' import { - FoundBrowser, Browser, - NotInstalledError, - NotDetectedAtPathError + FoundBrowser, + NotDetectedAtPathError, + NotInstalledError } from './types' import * as windowsHelper from './windows' -import { notDetectedAtPathErr } from './errors' const setMajorVersion = (browser: FoundBrowser) => { if (browser.version) { @@ -64,7 +64,7 @@ function lookup( * one for each binary. If Windows is detected, only one `checkOneBrowser` will be called, because * we don't use the `binary` field on Windows. */ -function checkBrowser(browser: Browser): Promise<(boolean | FoundBrowser)[]> { +function checkBrowser(browser: Browser): Bluebird<(boolean | FoundBrowser)[]> { if (Array.isArray(browser.binary) && os.platform() !== 'win32') { return Bluebird.map(browser.binary, (binary: string) => { return checkOneBrowser(extend({}, browser, { binary })) @@ -128,7 +128,7 @@ export const detect = (goalBrowsers?: Browser[]): Bluebird => { export const detectByPath = ( path: string, goalBrowsers?: Browser[] -): Bluebird => { +): Promise => { if (!goalBrowsers) { goalBrowsers = browsers } @@ -164,5 +164,5 @@ export const detectByPath = ( throw err } throw notDetectedAtPathErr(err.message) - }) as Bluebird + }) } diff --git a/packages/launcher/lib/launcher.ts b/packages/launcher/lib/launcher.ts index 07f733c6f951..d39cd8c5d1cb 100644 --- a/packages/launcher/lib/launcher.ts +++ b/packages/launcher/lib/launcher.ts @@ -1,9 +1,6 @@ -import { LauncherApi } from './types' import { launch } from './browsers' import { detect, detectByPath } from './detect' -module.exports = { - detect, - detectByPath, - launch -} as LauncherApi +export { detect } +export { detectByPath } +export { launch } diff --git a/packages/launcher/lib/linux/index.ts b/packages/launcher/lib/linux/index.ts index 8529366445b4..d92023cca87d 100644 --- a/packages/launcher/lib/linux/index.ts +++ b/packages/launcher/lib/linux/index.ts @@ -2,7 +2,7 @@ import { log } from '../log' import { trim, tap } from 'ramda' import { FoundBrowser, Browser } from '../types' import { notInstalledErr } from '../errors' -import * as execa from 'execa' +import execa from 'execa' function getLinuxBrowser( name: string, diff --git a/packages/launcher/lib/log.ts b/packages/launcher/lib/log.ts index ccf6842068af..659585811b05 100644 --- a/packages/launcher/lib/log.ts +++ b/packages/launcher/lib/log.ts @@ -1,3 +1,3 @@ -import * as debug from 'debug' +import debug from 'debug' export const log = debug('cypress:launcher') diff --git a/packages/launcher/lib/windows/index.ts b/packages/launcher/lib/windows/index.ts index 35a22ec1706e..c0818fef55c8 100644 --- a/packages/launcher/lib/windows/index.ts +++ b/packages/launcher/lib/windows/index.ts @@ -1,11 +1,11 @@ -import { log } from '../log' -import { FoundBrowser, Browser } from '../types' -import { notInstalledErr } from '../errors' -import * as execa from 'execa' -import { normalize, join } from 'path' -import { trim, tap } from 'ramda' +import execa from 'execa' import { pathExists } from 'fs-extra' import { homedir } from 'os' +import { join, normalize } from 'path' +import { tap, trim } from 'ramda' +import { notInstalledErr } from '../errors' +import { log } from '../log' +import { Browser, FoundBrowser } from '../types' function formFullAppPath(name: string) { const prefix = 'C:/Program Files (x86)/Google/Chrome/Application' diff --git a/packages/launcher/package.json b/packages/launcher/package.json index 7f294d88ad9f..7aebc45cd2f6 100644 --- a/packages/launcher/package.json +++ b/packages/launcher/package.json @@ -4,6 +4,9 @@ "private": true, "main": "index.js", "types": "../ts/index.d.ts", + "files": [ + "lib" + ], "scripts": { "pretest": "npm run check-deps-pre && npm run lint", "test": "npm run unit", @@ -18,36 +21,24 @@ "lint-js": "bin-up eslint --fix *.js", "lint-ts": "tslint --project . --fix --format stylish lib/*.ts lib/**/*.ts", "format-ts": "prettier --no-semi --single-quote --write lib/*.ts lib/**/*.ts", - "build-js": "tsc", + "build": "bin-up tsc --project .", + "build-js": "bin-up tsc --project .", "size": "t=\"$(npm pack .)\"; wc -c \"${t}\"; tar tvf \"${t}\"; rm \"${t}\";" }, - "files": [ - "lib" - ], "devDependencies": { - "@types/bluebird": "3.5.21", - "@types/chai": "3.5.2", - "@types/debug": "0.0.31", - "@types/execa": "0.7.2", - "@types/fs-extra": "3.0.0", - "@types/lodash": "4.14.122", - "@types/mocha": "2.2.48", - "@types/node": "7.10.3", - "@types/ramda": "0.25.47", - "bin-up": "1.1.0", + "bin-up": "1.2.0", "chai": "3.5.0", - "prettier": "1.16.4", - "shelljs": "0.7.8", + "prettier": "1.17.0", + "shelljs": "0.8.3", "sinon": "2.4.1", "sinon-chai": "3.3.0", - "tslint": "5.12.1", - "tslint-config-standard": "7.1.0", - "typescript": "2.9.2" + "tslint": "5.16.0", + "tslint-config-standard": "7.1.0" }, "dependencies": { "bluebird": "3.5.3", "debug": "2.6.9", - "execa": "0.6.3", + "execa": "1.0.0", "fs-extra": "3.0.1", "lodash": "4.17.11", "plist": "2.1.0", diff --git a/packages/launcher/test/unit/linux/spec.ts b/packages/launcher/test/unit/linux/spec.ts index 691ecbdaa80c..5207ba803584 100644 --- a/packages/launcher/test/unit/linux/spec.ts +++ b/packages/launcher/test/unit/linux/spec.ts @@ -1,8 +1,8 @@ import * as linuxHelper from '../../../lib/linux' import { log } from '../../log' import { detect, detectByPath } from '../../../lib/detect' -const execa = require('execa') -const sinon = require('sinon') +import execa from 'execa' +import sinon from 'sinon' const goalBrowsers = [ { diff --git a/packages/network/.gitignore b/packages/network/.gitignore new file mode 100644 index 000000000000..1054c1e01537 --- /dev/null +++ b/packages/network/.gitignore @@ -0,0 +1,2 @@ +lib/*.js +index.js diff --git a/packages/network/index.ts b/packages/network/index.ts new file mode 100644 index 000000000000..e1bbf0b21833 --- /dev/null +++ b/packages/network/index.ts @@ -0,0 +1,9 @@ +if (process.env.CYPRESS_ENV !== 'production') { + require('@packages/ts/register') +} + +import agent from './lib/agent' +import * as connect from './lib/connect' + +export { agent } +export { connect } diff --git a/packages/network/lib/agent.ts b/packages/network/lib/agent.ts new file mode 100644 index 000000000000..5c96ef3f90a6 --- /dev/null +++ b/packages/network/lib/agent.ts @@ -0,0 +1,319 @@ +import debugModule from 'debug' +import http from 'http' +import https from 'https' +import _ from 'lodash' +import net from 'net' +import { getProxyForUrl } from 'proxy-from-env' +import tls from 'tls' +import url from 'url' +import { getAddress } from './connect' + +const debug = debugModule('cypress:network:agent') +const CRLF = '\r\n' +const statusCodeRe = /^HTTP\/1.[01] (\d*)/ + +interface RequestOptionsWithProxy extends http.RequestOptions { + proxy: string +} + +type FamilyCache = { + [host: string] : 4 | 6 +} + +export function buildConnectReqHead(hostname: string, port: string, proxy: url.Url) { + const connectReq = [`CONNECT ${hostname}:${port} HTTP/1.1`] + + connectReq.push(`Host: ${hostname}:${port}`) + + if (proxy.auth) { + connectReq.push(`Proxy-Authorization: basic ${Buffer.from(proxy.auth).toString('base64')}`) + } + + return connectReq.join(CRLF) + _.repeat(CRLF, 2) +} + +export const createProxySock = (proxy: url.Url) => { + if (proxy.protocol === 'http:') { + return net.connect(Number(proxy.port || 80), proxy.hostname) + } + + if (proxy.protocol === 'https:') { + // if the upstream is https, we need to wrap the socket with tls + return tls.connect(Number(proxy.port || 443), proxy.hostname) + } + + // socksv5, etc... + throw new Error(`Unsupported proxy protocol: ${proxy.protocol}`) +} + +export const isRequestHttps = (options: http.RequestOptions) => { + // WSS connections will not have an href, but you can tell protocol from the defaultAgent + return _.get(options, '_defaultAgent.protocol') === 'https:' || (options.href || '').slice(0, 6) === 'https' +} + +export const isResponseStatusCode200 = (head: string) => { + // read status code from proxy's response + const matches = head.match(statusCodeRe) + return _.get(matches, 1) === '200' +} + +export const regenerateRequestHead = (req: http.ClientRequest) => { + delete req._header + req._implicitHeader() + if (req.output && req.output.length > 0) { + // the _header has already been queued to be written to the socket + const first = req.output[0] + const endOfHeaders = first.indexOf(_.repeat(CRLF, 2)) + 4 + req.output[0] = req._header + first.substring(endOfHeaders) + } +} + +const getFirstWorkingFamily = ( + { port, host }: http.RequestOptions, + familyCache: FamilyCache, + cb: Function +) => { + // this is a workaround for localhost (and potentially others) having invalid + // A records but valid AAAA records. here, we just cache the family of the first + // returned A/AAAA record for a host that we can establish a connection to. + // https://github.com/cypress-io/cypress/issues/112 + + const isIP = net.isIP(host) + if (isIP) { + // isIP conveniently returns the family of the address + return cb(isIP) + } + + if (process.env.HTTP_PROXY) { + // can't make direct connections through the proxy, this won't work + return cb() + } + + if (familyCache[host]) { + return cb(familyCache[host]) + } + + return getAddress(port, host) + .then((firstWorkingAddress: net.Address) => { + familyCache[host] = firstWorkingAddress.family + return cb(firstWorkingAddress.family) + }) + .catch(() => { + return cb() + }) +} + +const addRequest = http.Agent.prototype.addRequest + +http.Agent.prototype.addRequest = function (req, options) { + // get all the TCP handles for the free sockets + const hasNullHandle = _ + .chain(this.freeSockets) + .values() + .flatten() + .find((socket) => { + return !socket._handle + }) + .value() + + // if any of our freeSockets have a null handle + // then immediately return on nextTick to prevent + // a node 8.2.1 bug where socket._handle is null + // https://github.com/nodejs/node/blob/v8.2.1/lib/_http_agent.js#L171 + // https://github.com/nodejs/node/blame/a3cf96c76f92e39c8bf8121525275ed07063fda9/lib/_http_agent.js#L167 + if (hasNullHandle) { + return process.nextTick(() => { + this.addRequest(req, options) + }) + } + + return addRequest.call(this, req, options) +} + +export class CombinedAgent { + httpAgent: HttpAgent + httpsAgent: HttpsAgent + familyCache: FamilyCache = {} + + constructor(httpOpts: http.AgentOptions = {}, httpsOpts: https.AgentOptions = {}) { + this.httpAgent = new HttpAgent(httpOpts) + this.httpsAgent = new HttpsAgent(httpsOpts) + } + + // called by Node.js whenever a new request is made internally + addRequest(req: http.ClientRequest, options: http.RequestOptions) { + const isHttps = isRequestHttps(options) + + if (!options.href) { + // options.path can contain query parameters, which url.format will not-so-kindly urlencode for us... + // so just append it to the resultant URL string + options.href = url.format({ + protocol: isHttps ? 'https:' : 'http:', + slashes: true, + hostname: options.host, + port: options.port, + }) + options.path + + if (!options.uri) { + options.uri = url.parse(options.href) + } + } + + debug(`addRequest called for ${options.href}`) + + return getFirstWorkingFamily(options, this.familyCache, (family: net.family) => { + options.family = family + + if (isHttps) { + return this.httpsAgent.addRequest(req, options) + } + + this.httpAgent.addRequest(req, options) + }) + } +} + +class HttpAgent extends http.Agent { + httpsAgent: https.Agent + + constructor (opts: http.AgentOptions = {}) { + opts.keepAlive = true + super(opts) + // we will need this if they wish to make http requests over an https proxy + this.httpsAgent = new https.Agent({ keepAlive: true }) + } + + createSocket (req: http.ClientRequest, options: http.RequestOptions, cb: http.SocketCallback) { + if (process.env.HTTP_PROXY) { + const proxy = getProxyForUrl(options.href) + + if (proxy) { + options.proxy = proxy + + return this._createProxiedSocket(req, options, cb) + } + } + + super.createSocket(req, options, cb) + } + + _createProxiedSocket (req: http.ClientRequest, options: RequestOptionsWithProxy, cb: http.SocketCallback) { + debug(`Creating proxied socket for ${options.href} through ${options.proxy}`) + + const proxy = url.parse(options.proxy) + + // set req.path to the full path so the proxy can resolve it + // @ts-ignore: Cannot assign to 'path' because it is a constant or a read-only property. + req.path = options.href + + delete req._header // so we can set headers again + + req.setHeader('host', `${options.host}:${options.port}`) + if (proxy.auth) { + req.setHeader('proxy-authorization', `basic ${Buffer.from(proxy.auth).toString('base64')}`) + } + + // node has queued an HTTP message to be sent already, so we need to regenerate the + // queued message with the new path and headers + // https://github.com/TooTallNate/node-http-proxy-agent/blob/master/index.js#L93 + regenerateRequestHead(req) + + options.port = Number(proxy.port || 80) + options.host = proxy.hostname || 'localhost' + delete options.path // so the underlying net.connect doesn't default to IPC + + if (proxy.protocol === 'https:') { + // gonna have to use the https module to reach the proxy, even though this is an http req + req.agent = this.httpsAgent + + return this.httpsAgent.addRequest(req, options) + } + + super.createSocket(req, options, cb) + } +} + +class HttpsAgent extends https.Agent { + constructor (opts: https.AgentOptions = {}) { + opts.keepAlive = true + super(opts) + } + + createConnection (options: http.RequestOptions, cb: http.SocketCallback) { + if (process.env.HTTPS_PROXY) { + const proxy = getProxyForUrl(options.href) + + if (typeof proxy === "string") { + options.proxy = proxy + + return this.createProxiedConnection(options, cb) + } + } + + // @ts-ignore + cb(null, super.createConnection(options)) + } + + createProxiedConnection (options: RequestOptionsWithProxy, cb: http.SocketCallback) { + // heavily inspired by + // https://github.com/mknj/node-keepalive-proxy-agent/blob/master/index.js + debug(`Creating proxied socket for ${options.href} through ${options.proxy}`) + + const proxy = url.parse(options.proxy) + const port = options.uri.port || '443' + const hostname = options.uri.hostname || 'localhost' + + const proxySocket = createProxySock(proxy) + + const onClose = () => { + onError(new Error("Connection closed while sending request to upstream proxy")) + } + + const onError = (err: Error) => { + proxySocket.destroy() + cb(err, undefined) + } + + let buffer = '' + + const onData = (data: Buffer) => { + debug(`Proxy socket for ${options.href} established`) + + buffer += data.toString() + + if (!_.includes(buffer, _.repeat(CRLF, 2))) { + // haven't received end of headers yet, keep buffering + proxySocket.once('data', onData) + return + } + + proxySocket.removeListener('error', onError) + proxySocket.removeListener('close', onClose) + + if (!isResponseStatusCode200(buffer)) { + return onError(new Error(`Error establishing proxy connection. Response from server was: ${buffer}`)) + } + + if (options._agentKey) { + // https.Agent will upgrade and reuse this socket now + options.socket = proxySocket + options.servername = hostname + return cb(undefined, super.createConnection(options, undefined)) + } + + cb(undefined, proxySocket) + } + + proxySocket.once('error', onError) + proxySocket.once('close', onClose) + proxySocket.once('data', onData) + + const connectReq = buildConnectReqHead(hostname, port, proxy) + + proxySocket.write(connectReq) + } +} + +const agent = new CombinedAgent() + +export default agent diff --git a/packages/network/lib/connect.ts b/packages/network/lib/connect.ts new file mode 100644 index 000000000000..5f3a1542d48f --- /dev/null +++ b/packages/network/lib/connect.ts @@ -0,0 +1,37 @@ +import Bluebird from 'bluebird' +import dns from 'dns' +import net from 'net' + +export function byPortAndAddress (port: number, address: net.Address) { + // https://nodejs.org/api/net.html#net_net_connect_port_host_connectlistener + return new Bluebird((resolve, reject) => { + const onConnect = () => { + client.end() + resolve(address) + } + + const client = net.connect(port, address.address, onConnect) + + client.on('error', reject) + }) +} + +export function getAddress (port: number, hostname: string) { + const fn = byPortAndAddress.bind({}, port) + + // promisify at the very last second which enables us to + // modify dns lookup function (via hosts overrides) + const lookupAsync = Bluebird.promisify(dns.lookup, { context: dns }) + + // this does not go out to the network to figure + // out the addresess. in fact it respects the /etc/hosts file + // https://github.com/nodejs/node/blob/dbdbdd4998e163deecefbb1d34cda84f749844a4/lib/dns.js#L108 + // https://nodejs.org/api/dns.html#dns_dns_lookup_hostname_options_callback + // @ts-ignore + return lookupAsync(hostname, { all: true }) + .then((addresses: net.Address[]) => { + // convert to an array if string + return Array.prototype.concat.call(addresses).map(fn) + }) + .any() +} diff --git a/packages/network/package.json b/packages/network/package.json new file mode 100644 index 000000000000..2c895011a90b --- /dev/null +++ b/packages/network/package.json @@ -0,0 +1,29 @@ +{ + "name": "@packages/network", + "version": "0.0.0", + "private": true, + "main": "index.js", + "files": [ + "lib" + ], + "scripts": { + "build": "bin-up tsc --project .", + "build-js": "bin-up tsc --project .", + "test": "bin-up mocha --reporter mocha-multi-reporters --reporter-options configFile=../../mocha-reporter-config.json" + }, + "dependencies": { + "bluebird": "3.5.3", + "debug": "4.1.1", + "lodash": "4.17.11", + "proxy-from-env": "1.0.0" + }, + "devDependencies": { + "bin-up": "1.2.0", + "@cypress/debugging-proxy": "1.6.0", + "express": "4.16.4", + "request": "2.88.0", + "request-promise": "4.2.4", + "sinon": "7.3.1", + "sinon-chai": "3.3.0" + } +} diff --git a/packages/network/test/mocha.opts b/packages/network/test/mocha.opts new file mode 100644 index 000000000000..3622a281707e --- /dev/null +++ b/packages/network/test/mocha.opts @@ -0,0 +1,4 @@ +test/unit +--compilers ts:@packages/ts/register +--timeout 10000 +--recursive diff --git a/packages/network/test/support/servers.ts b/packages/network/test/support/servers.ts new file mode 100644 index 000000000000..de12fdba407d --- /dev/null +++ b/packages/network/test/support/servers.ts @@ -0,0 +1,101 @@ +import express from 'express' +import { CA } from '@packages/https-proxy' +import http from 'http' +import https from 'https' +import Io from '@packages/socket' +import Promise from 'bluebird' + +export interface AsyncServer { + closeAsync: () => Promise + destroyAsync: () => Promise + listenAsync: (port) => Promise +} + +function addDestroy(server: http.Server | https.Server) { + let connections = [] + + server.on('connection', function(conn) { + connections.push(conn) + + conn.on('close', () => { + connections = connections.filter(connection => connection !== conn) + }) + }) + + // @ts-ignore Property 'destroy' does not exist on type 'Server'. + server.destroy = function(cb) { + server.close(cb) + connections.map(connection => connection.destroy()) + } + + return server +} + +function createExpressApp() { + const app: express.Application = express() + + app.get('/get', (req, res) => { + res.send('It worked!') + }) + + app.get('/empty-response', (req, res) => { + // ERR_EMPTY_RESPONSE in Chrome + setTimeout(() => res.connection.destroy(), 100) + }) + + return app +} + +function getLocalhostCertKeys() { + return CA.create() + .then(ca => ca.generateServerCertificateKeys('localhost')) +} + +function onWsConnection(socket) { + socket.send('It worked!') +} + +export class Servers { + https: { cert: string, key: string } + httpServer: http.Server & AsyncServer + httpsServer: https.Server & AsyncServer + wsServer: any + wssServer: any + + start(httpPort: number, httpsPort: number) { + return Promise.join( + createExpressApp(), + getLocalhostCertKeys(), + ) + .spread((app: Express.Application, [cert, key]: string[]) => { + this.httpServer = Promise.promisifyAll( + addDestroy(http.createServer(app)) + ) as http.Server & AsyncServer + this.wsServer = Io.server(this.httpServer) + + this.https = { cert, key } + this.httpsServer = Promise.promisifyAll( + addDestroy(https.createServer(this.https, app)) + ) as https.Server & AsyncServer + this.wssServer = Io.server(this.httpsServer) + + ;[this.wsServer, this.wssServer].map(ws => { + ws.on('connection', onWsConnection) + }) + + // @ts-skip + return Promise.join( + this.httpServer.listenAsync(httpPort), + this.httpsServer.listenAsync(httpsPort) + ) + .return() + }) + } + + stop() { + return Promise.join( + this.httpServer.destroyAsync(), + this.httpsServer.destroyAsync() + ) + } +} diff --git a/packages/network/test/unit/agent_spec.ts b/packages/network/test/unit/agent_spec.ts new file mode 100644 index 000000000000..81b63fa8b9d5 --- /dev/null +++ b/packages/network/test/unit/agent_spec.ts @@ -0,0 +1,416 @@ +import Bluebird from 'bluebird' +import chai from 'chai' +import http from 'http' +import https from 'https' +import net from 'net' +import request from 'request-promise' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import tls from 'tls' +import url from 'url' +import DebuggingProxy from '@cypress/debugging-proxy' +import Io from '@packages/socket' +import { + buildConnectReqHead, createProxySock, isRequestHttps, isResponseStatusCode200, + regenerateRequestHead, CombinedAgent +} from '../../lib/agent' +import { AsyncServer, Servers } from '../support/servers' + +const expect = chai.expect +chai.use(sinonChai) + +const PROXY_PORT = 31000 +const HTTP_PORT = 31080 +const HTTPS_PORT = 31443 + +describe('lib/agent', function() { + beforeEach(function() { + this.oldEnv = Object.assign({}, process.env) + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + }) + + afterEach(function() { + process.env = this.oldEnv + sinon.restore() + }) + + context('CombinedAgent', function() { + before(function() { + this.servers = new Servers() + return this.servers.start(HTTP_PORT, HTTPS_PORT) + }) + + after(function() { + return this.servers.stop() + }) + + ;[ + { + name: 'with no upstream', + }, + { + name: 'with an HTTP upstream', + proxyUrl: `http://localhost:${PROXY_PORT}`, + }, + { + name: 'with an HTTPS upstream', + proxyUrl: `https://localhost:${PROXY_PORT}`, + httpsProxy: true, + }, + { + name: 'with an HTTP upstream requiring auth', + proxyUrl: `http://foo:bar@localhost:${PROXY_PORT}`, + proxyAuth: true, + }, + { + name: 'with an HTTPS upstream requiring auth', + proxyUrl: `https://foo:bar@localhost:${PROXY_PORT}`, + httpsProxy: true, + proxyAuth: true + } + ].slice().map((testCase) => { + context(testCase.name, function() { + beforeEach(function() { + if (testCase.proxyUrl) { + process.env.HTTP_PROXY = process.env.HTTPS_PROXY = testCase.proxyUrl + process.env.NO_PROXY = '' + } + + this.agent = new CombinedAgent() + + this.request = request.defaults({ + proxy: null, + agent: this.agent + }) + + if (testCase.proxyUrl) { + let options: any = { + keepRequests: true, + https: false, + auth: false + } + + if (testCase.httpsProxy) { + options.https = this.servers.https + } + + if (testCase.proxyAuth) { + options.auth = { + username: 'foo', + password: 'bar' + } + } + + this.debugProxy = new DebuggingProxy(options) + return this.debugProxy.start(PROXY_PORT) + } + }) + + afterEach(function() { + if (testCase.proxyUrl) { + this.debugProxy.stop() + } + }) + + it('HTTP pages can be loaded', function() { + return this.request({ + url: `http://localhost:${HTTP_PORT}/get`, + }).then(body => { + expect(body).to.eq('It worked!') + if (this.debugProxy) { + expect(this.debugProxy.requests[0]).to.include({ + url: `http://localhost:${HTTP_PORT}/get` + }) + } + }) + }) + + it('HTTPS pages can be loaded', function() { + return this.request({ + url: `https://localhost:${HTTPS_PORT}/get` + }).then(body => { + expect(body).to.eq('It worked!') + if (this.debugProxy) { + expect(this.debugProxy.requests[0]).to.include({ + https: true, + url: `localhost:${HTTPS_PORT}` + }) + } + }) + }) + + it('HTTP errors are catchable', function() { + return this.request({ + url: `http://localhost:${HTTP_PORT}/empty-response`, + }) + .then(() => { + throw new Error("Shouldn't reach this") + }) + .catch(err => { + if (this.debugProxy) { + expect(this.debugProxy.requests[0]).to.include({ + url: `http://localhost:${HTTP_PORT}/empty-response` + }) + expect(err.statusCode).to.eq(502) + } else { + expect(err.message).to.eq('Error: socket hang up') + } + }) + }) + + it('HTTPS errors are catchable', function() { + return this.request({ + url: `https://localhost:${HTTPS_PORT}/empty-response`, + }) + .then(() => { + throw new Error("Shouldn't reach this") + }) + .catch(err => { + expect(err.message).to.eq('Error: socket hang up') + }) + }) + + it('HTTP websocket connections can be established and used', function() { + return new Bluebird((resolve) => { + Io.client(`http://localhost:${HTTP_PORT}`, { + agent: this.agent, + transports: ['websocket'] + }).on('message', resolve) + }) + .then(msg => { + expect(msg).to.eq('It worked!') + if (this.debugProxy) { + expect(this.debugProxy.requests[0].ws).to.be.true + expect(this.debugProxy.requests[0].url).to.include('http://localhost:31080') + } + }) + }) + + it('HTTPS websocket connections can be established and used', function() { + return new Bluebird((resolve) => { + Io.client(`https://localhost:${HTTPS_PORT}`, { + agent: this.agent, + transports: ['websocket'] + }).on('message', resolve) + }) + .then(msg => { + expect(msg).to.eq('It worked!') + if (this.debugProxy) { + expect(this.debugProxy.requests[0]).to.include({ + url: 'localhost:31443' + }) + } + }) + }) + }) + }) + + context('HttpsAgent', function() { + it("#createProxiedConnection calls to super for caching, TLS-ifying", function() { + const combinedAgent = new CombinedAgent() + const spy = sinon.spy(https.Agent.prototype, 'createConnection') + + const proxy = new DebuggingProxy() + const proxyPort = PROXY_PORT + 1 + + process.env.HTTP_PROXY = process.env.HTTPS_PROXY = `http://localhost:${proxyPort}` + process.env.NO_PROXY = '' + + return proxy.start(proxyPort) + .then(() => { + return request({ + url: `https://localhost:${HTTPS_PORT}/get`, + agent: combinedAgent, + proxy: null + }) + }) + .then(() => { + const options = spy.getCall(0).args[0] + const session = combinedAgent.httpsAgent._sessionCache.map[options._agentKey] + expect(spy).to.be.calledOnce + expect(combinedAgent.httpsAgent._sessionCache.list).to.have.length(1) + expect(session).to.not.be.undefined + + return proxy.stop() + }) + }) + + it("#createProxiedConnection throws when connection is accepted then closed", function() { + const combinedAgent = new CombinedAgent() + + const proxy = Bluebird.promisifyAll( + net.createServer((socket) => { + socket.end() + }) + ) as net.Server & AsyncServer + + const proxyPort = PROXY_PORT + 2 + + process.env.HTTP_PROXY = process.env.HTTPS_PROXY = `http://localhost:${proxyPort}` + process.env.NO_PROXY = '' + + return proxy.listenAsync(proxyPort) + .then(() => { + return request({ + url: `https://localhost:${HTTPS_PORT}/get`, + agent: combinedAgent, + proxy: null + }) + }) + .then(() => { + throw new Error('should not succeed') + }) + .catch((e) => { + expect(e.message).to.eq('Error: Connection closed while sending request to upstream proxy') + + return proxy.closeAsync() + }) + }) + }) + }) + + context(".buildConnectReqHead", function() { + it('builds the correct request', function() { + const head = buildConnectReqHead('foo.bar', '1234', {}) + expect(head).to.eq([ + 'CONNECT foo.bar:1234 HTTP/1.1', + 'Host: foo.bar:1234', + '', '' + ].join('\r\n')) + }) + + it('can do Proxy-Authorization', function() { + const head = buildConnectReqHead('foo.bar', '1234', { + auth: 'baz:quux' + }) + expect(head).to.eq([ + 'CONNECT foo.bar:1234 HTTP/1.1', + 'Host: foo.bar:1234', + 'Proxy-Authorization: basic YmF6OnF1dXg=', + '', '' + ].join('\r\n')) + }) + }) + + context(".createProxySock", function() { + it("creates a `net` socket for an http url", function() { + sinon.stub(net, 'connect') + const proxy = url.parse('http://foo.bar:1234') + createProxySock(proxy) + expect(net.connect).to.be.calledWith(1234, 'foo.bar') + }) + + it("creates a `tls` socket for an https url", function() { + sinon.stub(tls, 'connect') + const proxy = url.parse('https://foo.bar:1234') + createProxySock(proxy) + expect(tls.connect).to.be.calledWith(1234, 'foo.bar') + }) + + it("throws on unsupported proxy protocol", function() { + const proxy = url.parse('socksv5://foo.bar:1234') + try { + createProxySock(proxy) + throw new Error("Shouldn't be reached") + } catch (e) { + expect(e.message).to.eq("Unsupported proxy protocol: socksv5:") + } + }) + }) + + context(".isRequestHttps", function() { + [ + { + protocol: 'http', + agent: http.globalAgent, + expect: false + }, + { + protocol: 'https', + agent: https.globalAgent, + expect: true + } + ].map((testCase) => { + it(`detects correctly from ${testCase.protocol} requests`, () => { + const spy = sinon.spy(testCase.agent, 'addRequest') + + return request({ + url: `${testCase.protocol}://foo.bar.baz.invalid`, + agent: testCase.agent + }) + .then(() => { + throw new Error('Shouldn\'t succeed') + }) + .catch((e) => { + const requestOptions = spy.getCall(0).args[1] + expect(isRequestHttps(requestOptions)).to.equal(testCase.expect) + }) + }) + + it(`detects correctly from ${testCase.protocol} websocket requests`, () => { + const spy = sinon.spy(testCase.agent, 'addRequest') + + return new Bluebird((resolve, reject) => { + Io.client(`${testCase.protocol}://foo.bar.baz.invalid`, { + agent: testCase.agent, + transports: ['websocket'], + timeout: 1 + }) + .on('message', reject) + .on('connect_error', resolve) + }) + .then(() => { + const requestOptions = spy.getCall(0).args[1] + expect(isRequestHttps(requestOptions)).to.equal(testCase.expect) + }) + }) + }) + }) + + context(".isResponseStatusCode200", function() { + it("matches a 200 OK response correctly", function() { + const result = isResponseStatusCode200("HTTP/1.1 200 Connection established") + expect(result).to.be.true + }) + + it("matches a 500 error response correctly", function() { + const result = isResponseStatusCode200("HTTP/1.1 500 Internal Server Error") + expect(result).to.be.false + }) + }) + + context(".regenerateRequestHead", function() { + it("regenerates changed request head", () => { + const spy = sinon.spy(http.globalAgent, 'createSocket') + return request({ + url: 'http://foo.bar.baz.invalid', + agent: http.globalAgent + }) + .then(() => { + throw new Error('this should fail') + }) + .catch(() => { + const req = spy.getCall(0).args[0] + expect(req._header).to.equal([ + 'GET / HTTP/1.1', + 'host: foo.bar.baz.invalid', + 'Connection: close', + '', '' + ].join('\r\n')) + // now change some stuff, regen, and expect it to work + delete req._header + req.path = 'http://quuz.quux.invalid/abc?def=123' + req.setHeader('Host', 'foo.fleem.invalid') + req.setHeader('bing', 'bang') + regenerateRequestHead(req) + expect(req._header).to.equal([ + 'GET http://quuz.quux.invalid/abc?def=123 HTTP/1.1', + 'Host: foo.fleem.invalid', + 'bing: bang', + 'Connection: close', + '', '' + ].join('\r\n')) + }) + }) + }) +}) diff --git a/packages/network/tsconfig.json b/packages/network/tsconfig.json new file mode 100644 index 000000000000..6e4f8367c920 --- /dev/null +++ b/packages/network/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./../ts/tsconfig.json", + "include": [ + "*.ts", + "lib/*.ts", + "lib/**/*.ts" + ], + "files": [ + "./../ts/index.d.ts" + ] +} diff --git a/packages/reporter/README.md b/packages/reporter/README.md index 8f5800a51e59..835960641a11 100644 --- a/packages/reporter/README.md +++ b/packages/reporter/README.md @@ -62,8 +62,23 @@ npm run watch ## Testing +### Cypress + +Run Cypress tests found in `cypress/integration`. + +```bash +npm run cypress:open +``` + +You'll want to run `npm run watch` in the `packages/reporter` to iterate on the reporter under test while testing. + +You'll want to run `npm run watch` in the `packages/runner` to get changes to the main Cypress reporter while testing. + +### Enzyme + Run enzyme component tests found in `*.spec` files in `src`: ```bash npm test -``` \ No newline at end of file +``` + diff --git a/packages/reporter/cypress.json b/packages/reporter/cypress.json index b16929a9f710..40950ebabc22 100644 --- a/packages/reporter/cypress.json +++ b/packages/reporter/cypress.json @@ -1,4 +1,5 @@ { + "projectId": "ypt4pf", "viewportWidth": 400, "viewportHeight": 450, "supportFile": false, diff --git a/packages/reporter/cypress/.eslintrc b/packages/reporter/cypress/.eslintrc new file mode 100644 index 000000000000..0d714a44ea96 --- /dev/null +++ b/packages/reporter/cypress/.eslintrc @@ -0,0 +1,8 @@ +{ + "plugins": [ + "cypress" + ], + "env": { + "cypress/globals": true + } +} \ No newline at end of file diff --git a/packages/reporter/cypress/fixtures/aliases_runnables.json b/packages/reporter/cypress/fixtures/aliases_runnables.json new file mode 100644 index 000000000000..6626864d36a9 --- /dev/null +++ b/packages/reporter/cypress/fixtures/aliases_runnables.json @@ -0,0 +1,13 @@ +{ + "id": "r1", + "title": "", + "root": true, + "suites": [], + "tests": [ + { + "id": "r3", + "title": "test 1", + "state": "passed" + } + ] +} diff --git a/packages/reporter/cypress/fixtures/errors_runnables.json b/packages/reporter/cypress/fixtures/errors_runnables.json new file mode 100644 index 000000000000..0f79cc2d6baf --- /dev/null +++ b/packages/reporter/cypress/fixtures/errors_runnables.json @@ -0,0 +1,37 @@ +{ + "id": "r1", + "title": "", + "root": true, + "tests": [], + "suites": [ + { + "id": "r2", + "title": "suite 1", + "root": false, + "tests": [ + { + "id": "r3", + "title": "test 1", + "state": "failed", + "err": { + "name": "CommandError", + "message": "failed to visit", + "stack": "failed to visit\n\ncould not visit http: //localhost:3000" + }, + "commands": [ + { + "hookName": "test", + "id": "c1", + "instrument": "command", + "message": "http://localhost:3000", + "name": "visit", + "state": "failed", + "testId": "r3", + "type": "parent" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/reporter/cypress/integration/aliases_spec.coffee b/packages/reporter/cypress/integration/aliases_spec.coffee new file mode 100644 index 000000000000..435d6d408388 --- /dev/null +++ b/packages/reporter/cypress/integration/aliases_spec.coffee @@ -0,0 +1,222 @@ +{ EventEmitter } = require("events") +_ = Cypress._ + +addLog = (runner, log) -> + defaultLog = { + event: false + hookName: "test" + id: _.uniqueId('l') + instrument: "command" + renderProps: {} + state: "passed" + testId: "r3" + type: "parent" + url: "http://example.com" + } + + runner.emit("reporter:log:add", _.extend(defaultLog, log)) + +describe "aliases", -> + beforeEach -> + cy.fixture("aliases_runnables").as("runnables") + + @runner = new EventEmitter() + + cy.visit("cypress/support/index.html").then (win) => + win.render({ + runner: @runner + specPath: "/foo/bar" + }) + + cy.get(".reporter").then => + @runner.emit("runnables:ready", @runnables) + @runner.emit("reporter:start", {}) + + + describe "without duplicates", -> + beforeEach -> + addLog(@runner, { + alias: "getUsers" + aliasType: "route" + displayName: "xhr stub" + event: true + name: "xhr" + renderProps: {message: "GET --- /users", indicator: "passed"} + }) + addLog(@runner, { + aliasType: "route" + message: "@getUsers, function(){}" + name: "wait" + referencesAlias: [{ + cardinal: 1 + name: "getUsers" + ordinal: "1st" + }], + }) + + it "render without a count", -> + cy.contains('.command-number', '1') + .parent() + .within -> + cy.get('.command-alias-count').should('not.exist') + cy.contains('.command-alias', '@getUsers') + .trigger("mouseover") + + cy.get(".tooltip span").should ($tooltip) -> + expect($tooltip).to.contain("Found an alias for: 'getUsers'") + + describe "with consecutive duplicates", -> + beforeEach -> + addLog(@runner, { + alias: "getPosts" + aliasType: "route" + displayName: "xhr stub" + event: true + name: "xhr" + renderProps: {message: "GET --- /posts", indicator: "passed"} + }) + addLog(@runner, { + alias: "getPosts" + aliasType: "route" + displayName: "xhr stub" + event: true + name: "xhr" + renderProps: {message: "GET --- /posts", indicator: "passed"} + }) + addLog(@runner, { + aliasType: "route" + message: "@getPosts, function(){}" + name: "wait" + referencesAlias: [{ + cardinal: 1 + name: "getPosts" + ordinal: "1st" + }], + }) + addLog(@runner, { + aliasType: "route" + message: "@getPosts, function(){}" + name: "wait" + referencesAlias: [{ + cardinal: 2 + name: "getPosts" + ordinal: "2nd" + }], + }) + + it "render with counts in non-event commands", -> + cy.contains('.command-number', '1') + .parent() + .within -> + cy.contains('.command-alias-count', '1') + cy.contains('.command-alias', '@getPosts') + .trigger("mouseover") + + cy.get(".tooltip span").should ($tooltip) -> + expect($tooltip).to.contain("Found 1st alias for: 'getPosts'") + + cy.contains('.command-number', '2') + .parent() + .within -> + cy.contains('.command-alias-count', '2') + cy.contains('.command-alias', '@getPosts') + .trigger("mouseover") + + cy.get(".tooltip span").should ($tooltip) -> + expect($tooltip).to.contain("Found 2nd alias for: 'getPosts'") + + it "render with counts in event commands when collapsed", -> + cy.get(".command-wrapper") + .first() + .within -> + cy.contains('.num-duplicates', '2') + cy.contains('.command-alias', 'getPosts') + + it "render without counts in event commands when expanded", -> + cy.get(".command-expander") + .first() + .click() + + cy.get(".command-wrapper") + .first() + .within ($commandWrapper) -> + cy.get('.num-duplicates').should('not.be.visible') + cy.contains('.command-alias', 'getPosts') + + describe "with non-consecutive duplicates", -> + beforeEach -> + addLog(@runner, { + alias: "getPosts" + aliasType: "route" + displayName: "xhr stub" + event: true + name: "xhr" + renderProps: {message: "GET --- /posts", indicator: "passed"} + }) + addLog(@runner, { + alias: "getUsers" + aliasType: "route" + displayName: "xhr stub" + event: true + name: "xhr" + renderProps: {message: "GET --- /users", indicator: "passed"} + }) + addLog(@runner, { + alias: "getPosts" + aliasType: "route" + displayName: "xhr stub" + event: true + name: "xhr" + renderProps: {message: "GET --- /posts", indicator: "passed"} + }) + addLog(@runner, { + aliasType: "route" + message: "@getPosts, function(){}" + name: "wait" + referencesAlias: [{ + cardinal: 1 + name: "getPosts" + ordinal: "1st" + }], + }) + addLog(@runner, { + aliasType: "route" + message: "@getUsers, function(){}" + name: "wait" + referencesAlias: [{ + cardinal: 1 + name: "getUsers" + ordinal: "1st" + }], + }) + addLog(@runner, { + aliasType: "route" + message: "@getPosts, function(){}" + name: "wait" + referencesAlias: [{ + cardinal: 2 + name: "getPosts" + ordinal: "2nd" + }], + }) + + it "render with counts", -> + cy.contains('.command-number', '1') + .parent() + .within -> + cy.contains('.command-alias-count', '1') + cy.contains('.command-alias', '@getPosts') + .trigger("mouseover") + + cy.get(".tooltip span").should ($tooltip) -> + expect($tooltip).to.contain("Found 1st alias for: 'getPosts'") + + cy.contains('.command-number', '3') + .parent() + .within -> + cy.contains('.command-alias-count', '2') + cy.contains('.command-alias', '@getPosts') + .trigger("mouseover") + + cy.get(".tooltip span").should ($tooltip) -> + expect($tooltip).to.contain("Found 2nd alias for: 'getPosts'") diff --git a/packages/reporter/cypress/integration/controls_spec.coffee b/packages/reporter/cypress/integration/controls_spec.coffee deleted file mode 100644 index a30e73226b48..000000000000 --- a/packages/reporter/cypress/integration/controls_spec.coffee +++ /dev/null @@ -1,29 +0,0 @@ -{ EventEmitter } = require("events") - -describe "controls", -> - beforeEach -> - cy.fixture("runnables").as("runnables") - - @runner = new EventEmitter() - - cy.visit("cypress/support/index.html").then (win) => - win.render({ - runner: @runner - specPath: "/foo/bar" - }) - - cy.get(".reporter").then => - @runner.emit("runnables:ready", @runnables) - @runner.emit("reporter:start", {}) - - describe "responsive design", -> - describe ">= 400px wide", -> - it "shows 'Tests'", -> - cy.get(".focus-tests span").should("be.visible") - - describe "< 400px wide", -> - beforeEach -> - cy.viewport(399, 450) - - it "hides 'Tests'", -> - cy.get(".focus-tests span").should("not.be.visible") diff --git a/packages/reporter/cypress/integration/controls_spec.js b/packages/reporter/cypress/integration/controls_spec.js new file mode 100644 index 000000000000..5bdd593ec543 --- /dev/null +++ b/packages/reporter/cypress/integration/controls_spec.js @@ -0,0 +1,41 @@ +const { EventEmitter } = require('events') + +describe('controls', function () { + beforeEach(function () { + cy.fixture('runnables').as('runnables') + + this.runner = new EventEmitter() + + cy.visit('cypress/support/index.html').then((win) => { + win.render({ + runner: this.runner, + specPath: '/foo/bar', + }) + }) + + cy.get('.reporter').then(() => { + this.runner.emit('runnables:ready', this.runnables) + + this.runner.emit('reporter:start', {}) + }) + }) + + describe('responsive design', function () { + describe('>= 400px wide', () => { + it('shows \'Tests\'', () => { + cy.get('.focus-tests span').should('be.visible') + }) + } + ) + + describe('< 400px wide', function () { + beforeEach(() => { + cy.viewport(399, 450) + }) + + it('hides \'Tests\'', () => { + cy.get('.focus-tests span').should('not.be.visible') + }) + }) + }) +}) diff --git a/packages/reporter/cypress/integration/errors_spec.js b/packages/reporter/cypress/integration/errors_spec.js new file mode 100644 index 000000000000..a94b7f92f707 --- /dev/null +++ b/packages/reporter/cypress/integration/errors_spec.js @@ -0,0 +1,46 @@ +const { EventEmitter } = require('events') + +describe('test errors', function () { + beforeEach(function () { + cy.fixture('errors_runnables').as('runnablesErr') + + // SET ERROR INFO + this.setError = function (err) { + this.runnablesErr.suites[0].tests[0].err = err + + cy.get('.reporter').then(() => { + this.runner.emit('runnables:ready', this.runnablesErr) + + this.runner.emit('reporter:start', {}) + }) + } + + this.runner = new EventEmitter() + + cy.visit('cypress/support/index.html').then((win) => { + win.render({ + runner: this.runner, + specPath: '/foo/bar', + }) + }) + }) + + describe('command error', function () { + beforeEach(function () { + this.commandErr = { + name: 'CypressError', + message: 'cy.click() failed because this element:\n\n\n\nis being covered by another element:\n\n\n\nFix this problem, or use {force: true} to disable error checking.\n\nhttps://on.cypress.io/element-cannot-be-interacted-with', + } + + this.setError(this.commandErr) + }) + + it('renders message and escapes html', () => { + cy.get('.test-error') + .should('contain', 'cy.click()') + .and('contain', '') + .and('contain', '') + .and('contain', 'https://on.cypress.io/element-cannot-be-interacted-with') + }) + }) +}) diff --git a/packages/reporter/cypress/integration/suites_spec.js b/packages/reporter/cypress/integration/suites_spec.js new file mode 100644 index 000000000000..c5411b573677 --- /dev/null +++ b/packages/reporter/cypress/integration/suites_spec.js @@ -0,0 +1,96 @@ +const { EventEmitter } = require('events') + +describe('controls', function () { + beforeEach(function () { + cy.fixture('runnables').as('runnables') + + this.runner = new EventEmitter() + + cy.visit('cypress/support/index.html').then((win) => { + win.render({ + runner: this.runner, + specPath: '/foo/bar', + }) + }) + + cy.get('.reporter').then(() => { + this.runner.emit('runnables:ready', this.runnables) + + this.runner.emit('reporter:start', {}) + }) + }) + + describe('suites', function () { + beforeEach(function () { + this.suiteTitle = this.runnables.suites[0].title + }) + describe('expand and collapse', function () { + it('is expanded by default', function () { + cy.contains(this.suiteTitle) + .parents('.collapsible').as('suiteWrapper') + .should('have.class', 'is-open') + .find('.collapsible-content').eq(0) + .should('be.visible') + }) + + describe('expand/collapse suite manually', function () { + beforeEach(function () { + cy.contains(this.suiteTitle) + .parents('.collapsible').as('suiteWrapper') + .should('have.class', 'is-open') + .find('.collapsible-content') + .should('be.visible') + }) + + it('expands/collapses on click', function () { + cy.contains(this.suiteTitle) + .click() + cy.get('@suiteWrapper') + .should('not.have.class', 'is-open') + .find('.collapsible-content').eq(0) + .should('not.be.visible') + cy.contains(this.suiteTitle) + .click() + cy.get('@suiteWrapper') + .should('have.class', 'is-open') + .find('.collapsible-content').eq(0) + .should('be.visible') + }) + + it('expands/collapses on enter', function () { + cy.contains(this.suiteTitle) + .parents('.collapsible-header') + .focus().type('{enter}') + cy.get('@suiteWrapper') + .should('not.have.class', 'is-open') + .find('.collapsible-content').eq(0) + .should('not.be.visible') + cy.contains(this.suiteTitle) + .parents('.collapsible-header') + .focus().type('{enter}') + cy.get('@suiteWrapper') + .should('have.class', 'is-open') + .find('.collapsible-content').eq(0) + .should('be.visible') + }) + + it('expands/collapses on space', function () { + cy.contains(this.suiteTitle) + .parents('.collapsible-header') + .focus().type(' ') + cy.get('@suiteWrapper') + .should('not.have.class', 'is-open') + .find('.collapsible-content').eq(0) + .should('not.be.visible') + cy.contains(this.suiteTitle) + .parents('.collapsible-header') + .focus().type(' ') + cy.get('@suiteWrapper') + .should('have.class', 'is-open') + .find('.collapsible-content').eq(0) + .should('be.visible') + }) + }) + }) + }) +}) diff --git a/packages/reporter/cypress/integration/tests_spec.js b/packages/reporter/cypress/integration/tests_spec.js new file mode 100644 index 000000000000..de8e6b0e4cff --- /dev/null +++ b/packages/reporter/cypress/integration/tests_spec.js @@ -0,0 +1,97 @@ +const { EventEmitter } = require('events') + +describe('controls', function () { + beforeEach(function () { + cy.fixture('runnables').as('runnables') + + this.runner = new EventEmitter() + + cy.visit('cypress/support/index.html').then((win) => { + win.render({ + runner: this.runner, + specPath: '/foo/bar', + }) + }) + + cy.get('.reporter').then(() => { + this.runner.emit('runnables:ready', this.runnables) + + this.runner.emit('reporter:start', {}) + }) + }) + + describe('tests', function () { + beforeEach(function () { + this.passingTestTitle = this.runnables.suites[0].tests[0].title + this.failingTestTitle = this.runnables.suites[0].tests[1].title + }) + describe('expand and collapse', function () { + it('is collapsed by default', function () { + cy.contains(this.passingTestTitle) + .parents('.runnable-wrapper').as('testWrapper') + .should('not.have.class', 'is-open') + .find('.collapsible-content') + .should('not.be.visible') + }) + + describe('expand/collapse test manually', function () { + beforeEach(function () { + cy.contains(this.passingTestTitle) + .parents('.runnable-wrapper').as('testWrapper') + .should('not.have.class', 'is-open') + .find('.collapsible-content') + .should('not.be.visible') + }) + + it('expands/collapses on click', function () { + cy.contains(this.passingTestTitle) + .click() + cy.get('@testWrapper') + .should('have.class', 'is-open') + .find('.collapsible-content').should('be.visible') + cy.contains(this.passingTestTitle) + .click() + cy.get('@testWrapper') + .should('not.have.class', 'is-open') + .find('.collapsible-content').should('not.be.visible') + }) + + it('expands/collapses on enter', function () { + cy.contains(this.passingTestTitle) + .focus().type('{enter}') + cy.get('@testWrapper') + .should('have.class', 'is-open') + .find('.collapsible-content').should('be.visible') + cy.contains(this.passingTestTitle) + .focus().type('{enter}') + cy.get('@testWrapper') + .should('not.have.class', 'is-open') + .find('.collapsible-content').should('not.be.visible') + }) + + it('expands/collapses on space', function () { + cy.contains(this.passingTestTitle) + .focus().type(' ') + cy.get('@testWrapper') + .should('have.class', 'is-open') + .find('.collapsible-content').should('be.visible') + cy.contains(this.passingTestTitle) + .focus().type(' ') + cy.get('@testWrapper') + .should('not.have.class', 'is-open') + .find('.collapsible-content').should('not.be.visible') + }) + }) + }) + + describe('failed tests', function () { + it('expands automatically', function () { + cy.contains(this.failingTestTitle) + .parents('.runnable-wrapper').as('testWrapper') + .should('have.class', 'is-open') + .find('.collapsible-content') + .should('be.visible') + }) + }) + }) +}) diff --git a/packages/reporter/cypress/plugins/index.js b/packages/reporter/cypress/plugins/index.js new file mode 100644 index 000000000000..0c0c42d5b58c --- /dev/null +++ b/packages/reporter/cypress/plugins/index.js @@ -0,0 +1 @@ +module.exports = () => {} diff --git a/packages/reporter/package.json b/packages/reporter/package.json index 9098eb0c9de6..8288e68335e0 100644 --- a/packages/reporter/package.json +++ b/packages/reporter/package.json @@ -19,21 +19,23 @@ "clean-deps": "rm -rf node_modules", "pretest": "npm run check-deps-pre", "test": "node ./scripts/test.js", - "lint": "bin-up eslint --fix lib/*.js scripts/*.js src/*.js* src/**/*.js*" + "lint": "bin-up eslint --fix lib/*.js scripts/*.js src/*.js* src/**/*.js*", + "cypress:open": "node ../../scripts/cypress open --project .", + "cypress:run": "node ../../scripts/cypress run --project ." }, "files": [ "dist" ], "devDependencies": { - "@babel/plugin-proposal-object-rest-spread": "7.3.4", + "@babel/plugin-proposal-object-rest-spread": "7.4.4", "@cypress/react-tooltip": "0.4.0", - "bin-up": "1.1.0", + "bin-up": "1.2.0", "chai": "3.5.0", "chai-enzyme": "1.0.0-beta.1", "classnames": "2.2.6", - "css-element-queries": "0.4.0", + "css-element-queries": "1.1.1", "enzyme": "3.9.0", - "enzyme-adapter-react-16": "1.10.0", + "enzyme-adapter-react-16": "1.12.1", "font-awesome": "4.7.0", "jsdom": "13.2.0", "lodash": "4.17.11", @@ -46,6 +48,6 @@ "rebuild-node-sass": "1.1.0", "sinon": "7.0.0", "sinon-chai": "3.3.0", - "zunder": "6.3.2" + "zunder": "6.4.1" } } diff --git a/packages/reporter/src/collapsible/collapsible.jsx b/packages/reporter/src/collapsible/collapsible.jsx index 2a69ecf19e6a..fff8fa5c44e6 100644 --- a/packages/reporter/src/collapsible/collapsible.jsx +++ b/packages/reporter/src/collapsible/collapsible.jsx @@ -1,6 +1,8 @@ import cs from 'classnames' import React, { Component } from 'react' +import { onEnterOrSpace } from '../lib/util' + class Collapsible extends Component { static defaultProps = { isOpen: false, @@ -24,7 +26,15 @@ class Collapsible extends Component { render () { return (
    -
    +
    {this.props.header} diff --git a/packages/reporter/src/commands/command.jsx b/packages/reporter/src/commands/command.jsx index 98c7f7f92943..10de4f1cf68f 100644 --- a/packages/reporter/src/commands/command.jsx +++ b/packages/reporter/src/commands/command.jsx @@ -24,24 +24,47 @@ const visibleMessage = (model) => { 'This element is not visible.' } -const AliasesReferences = observer(({ model }) => ( - - {_.map([].concat(model.referencesAlias), (alias) => ( - - @{alias} +const shouldShowCount = (aliasesWithDuplicates, aliasName) => { + return _.includes(aliasesWithDuplicates, aliasName) +} + +const AliasReference = observer(({ model, aliasObj, aliasesWithDuplicates }) => { + if (shouldShowCount(aliasesWithDuplicates, aliasObj.name)) { + return ( + + + @{aliasObj.name} + {aliasObj.cardinal} + + ) + } + + return ( + + @{aliasObj.name} + + ) +}) + +const AliasesReferences = observer(({ model, aliasesWithDuplicates }) => ( + + {_.map([].concat(model.referencesAlias), (aliasObj) => ( + + + ))} )) -const Aliases = observer(({ model }) => { +const Aliases = observer(({ model, aliasesWithDuplicates }) => { if (!model.alias) return null return ( {_.map([].concat(model.alias), (alias) => ( - {alias} + 1) ? shouldShowCount(aliasesWithDuplicates, alias) : false })}>{alias} ))} @@ -69,7 +92,7 @@ class Command extends Component { } render () { - const { model } = this.props + const { model, aliasesWithDuplicates } = this.props const message = model.displayMessage return ( @@ -117,19 +140,21 @@ class Command extends Component { {model.event ? `(${displayName(model)})` : displayName(model)} - {model.referencesAlias ? : } + {model.referencesAlias ? : } - {model.numElements} - - {model.numDuplicates} - + + + + {model.numDuplicates} + +
    diff --git a/packages/reporter/src/commands/command.spec.jsx b/packages/reporter/src/commands/command.spec.jsx index 15c502c533b4..705e8f42e8f0 100644 --- a/packages/reporter/src/commands/command.spec.jsx +++ b/packages/reporter/src/commands/command.spec.jsx @@ -1,4 +1,4 @@ -import { shallow } from 'enzyme' +import { shallow, mount } from 'enzyme' import _ from 'lodash' import React from 'react' import sinon from 'sinon' @@ -180,11 +180,11 @@ describe('', () => { let aliases beforeEach(() => { - aliases = shallow().find(AliasesReferences).shallow() + aliases = mount().find(AliasesReferences) }) it('renders the aliases for each one it references', () => { - expect(aliases.find('.command-alias').length).to.equal(2) + expect(aliases.find('.command-alias').length).to.equal(3) }) it('renders the aliases with the right class', () => { @@ -197,14 +197,21 @@ describe('', () => { }) it('renders tooltip for each alias it references', () => { - expect(aliases.find('Tooltip').length).to.equal(2) + expect(aliases.find('Tooltip').length).to.equal(3) }) it('renders the right tooltip title for each alias it references', () => { const tooltips = aliases.find('Tooltip') expect(tooltips.first()).to.have.prop('title', 'Found an alias for: \'barAlias\'') - expect(tooltips.last()).to.have.prop('title', 'Found an alias for: \'bazAlias\'') + expect(tooltips.last()).to.have.prop('title', 'Found 2nd alias for: \'bazAlias\'') + }) + + it('only renders the count for aliases with duplicates', () => { + const commandAliasContainers = aliases.find('.command-alias-container') + + expect(commandAliasContainers.first().find('.command-alias-count')).to.not.exist + expect(commandAliasContainers.last().find('.command-alias-count')).to.have.text('2') }) }) @@ -489,6 +496,18 @@ describe('', () => { expect(component).not.to.have.className('command-is-duplicate') }) + it('num duplicates renders with has-alias class if command is an alias', () => { + const component = shallow() + + expect(component.find('.num-duplicates')).to.have.className('has-alias') + }) + + it('num duplicates renders without has-alias class if command is not an alias', () => { + const component = shallow() + + expect(component.find('.num-duplicates')).not.to.have.className('has-alias') + }) + it('displays number of duplicates', () => { const component = shallow() diff --git a/packages/reporter/src/commands/commands.scss b/packages/reporter/src/commands/commands.scss index 87cb9aa8756d..0fcb1e578bb4 100644 --- a/packages/reporter/src/commands/commands.scss +++ b/packages/reporter/src/commands/commands.scss @@ -22,11 +22,20 @@ color: #959595; display: inline-block; font-size: 11px; + cursor: pointer; + + &:focus { + outline: 1px dotted #6c6c6c; + } &:hover { border-bottom: 1px dotted #959595; color: #333; - cursor: pointer; + } + + &:hover:focus { + border-bottom: 1px dotted #959595; + color: #333; } .hook-failed-message { @@ -122,6 +131,46 @@ &.primitive { background-color: darken(#FFE0DE, 3%); } + + &.show-count { + border-radius: 10px 0 0 10px; + } + } + + // ensures alias & number of duplicates don't break if reporter + // width is narrow + .alias-container { + white-space: nowrap; + + > { + display: inline-block; + } + } + + .num-duplicates, + .command-alias-count { + border-radius: 5px; + color: #777; + font-size: 90%; + font-style: normal; + line-height: 1; + margin-left: 0; + } + + .num-duplicates.has-alias, + .command-alias-count { + border-radius: 0 10px 10px 0; + padding: 0px 6px 1px 4px; + } + + .num-duplicates, + .command-alias-count { + background-color: lighten(#ffdf9c, 8%); + } + + .command-alias-count { + display: inline; + padding: 2px 6px 2px 4px; } } @@ -205,6 +254,7 @@ .command-message-text { display: block; + flex-grow: 2; overflow: hidden; text-overflow: ellipsis; } @@ -439,6 +489,10 @@ .num-duplicates { display: none; } + + .command-alias { + border-radius: 10px !important; + } } .command-is-pinned, diff --git a/packages/reporter/src/errors/test-error.jsx b/packages/reporter/src/errors/test-error.jsx new file mode 100644 index 000000000000..0526abd8b772 --- /dev/null +++ b/packages/reporter/src/errors/test-error.jsx @@ -0,0 +1,23 @@ +import React from 'react' +import FlashOnClick from '../lib/flash-on-click' + +function TestError (props) { + function _onErrorClick (e) { + e.stopPropagation() + + props.events.emit('show:error', props.model.id) + } + + const { displayMessage } = props.model.err + + return ( + +
    {displayMessage}
    +
    + ) +} + +export default TestError diff --git a/packages/reporter/src/errors/test-error.spec.jsx b/packages/reporter/src/errors/test-error.spec.jsx new file mode 100644 index 000000000000..32f1157e0708 --- /dev/null +++ b/packages/reporter/src/errors/test-error.spec.jsx @@ -0,0 +1,40 @@ +import _ from 'lodash' +import React from 'react' +import { shallow } from 'enzyme' +import sinon from 'sinon' + +import TestError from './test-error' + +const eventsStub = () => ({ + emit: sinon.spy(), +}) + +const model = (props) => { + return _.extend({ + commands: [], + err: {}, + id: 't1', + isActive: true, + level: 1, + state: 'passed', + type: 'test', + shouldRender: true, + title: 'some title', + }, props) +} + +describe('', () => { + context('errors', () => { + it('emits show:error event and stops propagation when error is clicked', () => { + const events = eventsStub() + const component = shallow() + const e = { + stopPropagation: sinon.spy(), + } + + component.find('FlashOnClick').simulate('click', e) + expect(events.emit).to.have.been.calledWith('show:error', 't1') + expect(e.stopPropagation).to.have.been.called + }) + }) +}) diff --git a/packages/reporter/src/header/controls.jsx b/packages/reporter/src/header/controls.jsx index 7392fd33d149..449cb9668672 100644 --- a/packages/reporter/src/header/controls.jsx +++ b/packages/reporter/src/header/controls.jsx @@ -26,7 +26,7 @@ const Controls = observer(({ events, appState }) => { ))} {ifThen(appState.isPaused, ( - @@ -34,6 +34,7 @@ const Controls = observer(({ events, appState }) => { {ifThen(!appState.isPaused, ( ))} {ifThen(!appState.isRunning, ( - ))} {ifThen(!!appState.nextCommandName, ( - diff --git a/packages/reporter/src/header/stats.jsx b/packages/reporter/src/header/stats.jsx index 32153e387840..290094dbf81e 100644 --- a/packages/reporter/src/header/stats.jsx +++ b/packages/reporter/src/header/stats.jsx @@ -5,17 +5,20 @@ const count = (num) => num > 0 ? num : '--' const formatDuration = (duration) => duration > 0 ? (duration / 1000).toFixed(2) : 0 const Stats = observer(({ stats }) => ( -
      +
      • - + + Passed: {count(stats.numPassed)}
      • - + + Failed: {count(stats.numFailed)}
      • - + + Pending: {count(stats.numPending)}
      • diff --git a/packages/reporter/src/hooks/hook-model.js b/packages/reporter/src/hooks/hook-model.js index a7c97a28466a..c49f51257c5d 100644 --- a/packages/reporter/src/hooks/hook-model.js +++ b/packages/reporter/src/hooks/hook-model.js @@ -1,5 +1,5 @@ import _ from 'lodash' -import { observable } from 'mobx' +import { observable, computed } from 'mobx' export default class Hook { @observable id @@ -13,6 +13,28 @@ export default class Hook { this.name = props.name } + @computed get aliasesWithDuplicates () { + // Consecutive duplicates only appear once in command array, but hasDuplicates is true + // Non-consecutive duplicates appear multiple times in command array, but hasDuplicates is false + // This returns aliases that have consecutive or non-consecutive duplicates + let consecutiveDuplicateAliases = [] + const aliases = this.commands.map((command) => { + if (command.alias) { + if (command.hasDuplicates) { + consecutiveDuplicateAliases.push(command.alias) + } + + return command.alias + } + }) + + const nonConsecutiveDuplicateAliases = aliases.filter((alias, i) => { + return aliases.indexOf(alias) === i && aliases.lastIndexOf(alias) !== i + }) + + return consecutiveDuplicateAliases.concat(nonConsecutiveDuplicateAliases) + } + addCommand (command) { if (!command.event) { command.number = this._currentNumber diff --git a/packages/reporter/src/hooks/hook-model.spec.js b/packages/reporter/src/hooks/hook-model.spec.js index a74e8c81d68b..9925c4f2ca6b 100644 --- a/packages/reporter/src/hooks/hook-model.spec.js +++ b/packages/reporter/src/hooks/hook-model.spec.js @@ -107,4 +107,25 @@ describe('Hook model', () => { expect(hook.commandMatchingErr({ displayMessage: 'matching error message' })).to.be.undefined }) }) + + context('#aliasesWithDuplicates', () => { + it('returns duplicates marked with hasDuplicates and those that appear mulitple times in the commands array', () => { + hook.addCommand({ isMatchingEvent: () => { + return false + }, alias: 'foo' }) + hook.addCommand({ isMatchingEvent: () => { + return false + }, alias: 'bar' }) + hook.addCommand({ isMatchingEvent: () => { + return false + }, alias: 'foo' }) + hook.addCommand({ isMatchingEvent: () => { + return false + }, alias: 'baz', hasDuplicates: true }) + + expect(hook.aliasesWithDuplicates).to.include('foo') + expect(hook.aliasesWithDuplicates).to.include('baz') + expect(hook.aliasesWithDuplicates).to.not.include('bar') + }) + }) }) diff --git a/packages/reporter/src/hooks/hooks.jsx b/packages/reporter/src/hooks/hooks.jsx index 1ff0e879ecc5..2a9c96eb95bc 100644 --- a/packages/reporter/src/hooks/hooks.jsx +++ b/packages/reporter/src/hooks/hooks.jsx @@ -19,7 +19,7 @@ const Hook = observer(({ model }) => ( isOpen={true} >
          - {_.map(model.commands, (command) => )} + {_.map(model.commands, (command) => )}
      • diff --git a/packages/reporter/src/lib/events.js b/packages/reporter/src/lib/events.js index 34d646e3e1f4..380174852460 100644 --- a/packages/reporter/src/lib/events.js +++ b/packages/reporter/src/lib/events.js @@ -143,6 +143,10 @@ export default { autoScrollingEnabled: appState.autoScrollingEnabled, }) }) + + localBus.on('external:open', (url) => { + runner.emit('external:open', url) + }) }, emit (event, ...args) { diff --git a/packages/reporter/src/lib/util.js b/packages/reporter/src/lib/util.js index c81ce1fb0b9b..00f8f82bb836 100644 --- a/packages/reporter/src/lib/util.js +++ b/packages/reporter/src/lib/util.js @@ -5,6 +5,16 @@ function indent (level) { return INDENT_BASE + level * INDENT_AMOUNT } +// Returns a keyboard handler that invokes the provided function when either enter or space is pressed +const onEnterOrSpace = (f) => { + return (e) => { + if (e.key === ' ' || e.key === 'Enter') { + f() + } + } +} + export { indent, + onEnterOrSpace, } diff --git a/packages/reporter/src/main-runner.scss b/packages/reporter/src/main-runner.scss index 78e5330ff309..79331ea842d8 100644 --- a/packages/reporter/src/main-runner.scss +++ b/packages/reporter/src/main-runner.scss @@ -4,3 +4,15 @@ @import 'lib/base'; @import 'lib/tooltip'; @import './!(lib)*/**/*'; + +/* Used to provide additional context for screen readers */ +.visually-hidden { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} \ No newline at end of file diff --git a/packages/reporter/src/main.scss b/packages/reporter/src/main.scss index 2c5b02c2c5a0..769165813b55 100644 --- a/packages/reporter/src/main.scss +++ b/packages/reporter/src/main.scss @@ -5,3 +5,15 @@ @import 'lib/base'; @import 'lib/tooltip'; @import '!(lib)*/**/*'; + +/* Used to provide additional context for screen readers */ +.visually-hidden { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} \ No newline at end of file diff --git a/packages/reporter/src/runnables/runnables.scss b/packages/reporter/src/runnables/runnables.scss index 9ad1158d4109..68adc846f297 100644 --- a/packages/reporter/src/runnables/runnables.scss +++ b/packages/reporter/src/runnables/runnables.scss @@ -24,7 +24,7 @@ overflow: auto; line-height: 18px; padding-left: 0; - + pre { background-color: darken($yellow-lightest, 5%); border-left: 5px solid $fail; @@ -39,6 +39,10 @@ word-break: break-word; user-select: initial; + a { + text-decoration: underline; + } + &:empty { display: none; } @@ -56,9 +60,13 @@ > .runnable-wrapper .runnable-controls i.fa-repeat { visibility: visible !important; } - } + &:focus { + outline: 1px dotted #6c6c6c; + outline-offset: -1px; + } + &.runnable-active { .runnable-state { @extend .#{$fa-css-prefix}-refresh; @@ -149,6 +157,14 @@ } } + &.suite > div > .runnable-wrapper:focus { + outline: 0; + + .runnable-title { + outline: 1px dotted; + } + } + &.runnable-passed > .runnable-wrapper { .runnable-state { @extend .#{$fa-css-prefix}-check; @@ -180,11 +196,20 @@ .runnable-title { font-size: 12.5px; + + &:focus { + outline: 1px dotted #6c6c6c; + } } .runnable-content-region { overflow: auto; position: relative; + + &:focus { + outline: 1px dotted #6c6c6c; + outline-offset: 3px; + } } .suite > div > .runnable-wrapper, diff --git a/packages/reporter/src/test/test.jsx b/packages/reporter/src/test/test.jsx index 9faf3301f9cf..fda538aadd78 100644 --- a/packages/reporter/src/test/test.jsx +++ b/packages/reporter/src/test/test.jsx @@ -6,14 +6,14 @@ import Tooltip from '@cypress/react-tooltip' import appState from '../lib/app-state' import events from '../lib/events' -import { indent } from '../lib/util' +import { indent, onEnterOrSpace } from '../lib/util' import runnablesStore from '../runnables/runnables-store' import scroller from '../lib/scroller' import Hooks from '../hooks/hooks' import Agents from '../agents/agents' import Routes from '../routes/routes' -import FlashOnClick from '../lib/flash-on-click' +import TestError from '../errors/test-error' const NoCommands = observer(() => (
          @@ -58,7 +58,7 @@ class Test extends Component { } render () { - const { model } = this.props + const { events, model } = this.props if (!model.shouldRender) return null @@ -70,8 +70,17 @@ class Test extends Component { style={{ paddingLeft: indent(model.level) }} >
          - - {model.title} + + + {model.title} + {model.state} +
          @@ -79,12 +88,7 @@ class Test extends Component {
          {this._contents()} - -
          {model.err.displayMessage}
          -
          +
    ) } @@ -131,10 +135,6 @@ class Test extends Component { } } - _onErrorClick = (e) => { - e.stopPropagation() - this.props.events.emit('show:error', this.props.model.id) - } } export { NoCommands } diff --git a/packages/reporter/src/test/test.spec.jsx b/packages/reporter/src/test/test.spec.jsx index 17721d14bcaf..655ec8447767 100644 --- a/packages/reporter/src/test/test.spec.jsx +++ b/packages/reporter/src/test/test.spec.jsx @@ -14,10 +14,6 @@ const appStateStub = (props) => { }, props) } -const eventsStub = () => ({ - emit: sinon.spy(), -}) - const model = (props) => { return _.extend({ commands: [], @@ -43,16 +39,6 @@ describe('', () => { expect(component).to.be.empty }) - it('emits show:error event and stops propagation when error is clicked', () => { - const events = eventsStub() - const component = shallow() - const e = { stopPropagation: sinon.spy() } - - component.find('FlashOnClick').simulate('click', e) - expect(events.emit).to.have.been.calledWith('show:error', 't1') - expect(e.stopPropagation).to.have.been.called - }) - context('open/closed', () => { it('renders without is-open class by default', () => { const component = shallow() diff --git a/packages/runner/package.json b/packages/runner/package.json index 71edb9e2d760..317bbca09e92 100644 --- a/packages/runner/package.json +++ b/packages/runner/package.json @@ -24,15 +24,15 @@ "lib" ], "devDependencies": { - "@babel/plugin-proposal-object-rest-spread": "7.3.4", + "@babel/plugin-proposal-object-rest-spread": "7.4.4", "@cypress/react-tooltip": "0.4.0", - "bin-up": "1.1.0", + "bin-up": "1.2.0", "bluebird": "3.5.0", "chai": "4.2.0", "chai-enzyme": "1.0.0-beta.1", "classnames": "2.2.6", "enzyme": "3.9.0", - "enzyme-adapter-react-16": "1.10.0", + "enzyme-adapter-react-16": "1.12.1", "font-awesome": "4.7.0", "jsdom": "13.2.0", "lodash": "4.17.11", @@ -45,6 +45,6 @@ "rebuild-node-sass": "1.1.0", "sinon": "7.0.0", "sinon-chai": "3.3.0", - "zunder": "6.3.2" + "zunder": "6.4.1" } } diff --git a/packages/runner/scripts/set-zunder-config.js b/packages/runner/scripts/set-zunder-config.js index 16e4a5021198..326ca914a1c5 100644 --- a/packages/runner/scripts/set-zunder-config.js +++ b/packages/runner/scripts/set-zunder-config.js @@ -9,6 +9,8 @@ module.exports = function setZunderConfig (zunder) { coffeeCompiler: require('@packages/coffee'), }, ]) + browserifyOptions.extensions.push('.ts') + browserifyOptions.plugin.push(zunder.defaults.browserify.pluginTsify.module) // ensure no duplicates of common dependencies between runner, reporter, & driver browserifyOptions.transform.push([ zunder.defaults.browserify.transformAliasify.module, diff --git a/packages/runner/src/errors/automation-disconnected.jsx b/packages/runner/src/errors/automation-disconnected.jsx index a444977fc5a0..0467742198e0 100644 --- a/packages/runner/src/errors/automation-disconnected.jsx +++ b/packages/runner/src/errors/automation-disconnected.jsx @@ -9,7 +9,7 @@ export default ({ onReload }) => ( Reload the Browser
    - + Why am I seeing this message? diff --git a/packages/runner/src/errors/no-automation.jsx b/packages/runner/src/errors/no-automation.jsx index 34449e0cc24f..ee05d43f40be 100644 --- a/packages/runner/src/errors/no-automation.jsx +++ b/packages/runner/src/errors/no-automation.jsx @@ -23,7 +23,7 @@ const noBrowsers = () => ( We couldn't find any supported browsers capable of running Cypress on your machine.

    - + Download Chrome @@ -63,7 +63,7 @@ export default ({ browsers, onLaunchBrowser }) => (

    Whoops, we can't run your tests.

    {browsers.length ? browserPicker(browsers, onLaunchBrowser) : noBrowsers()} diff --git a/packages/runner/src/header/header.jsx b/packages/runner/src/header/header.jsx index 943c566ffb8c..2f1669d0b35b 100644 --- a/packages/runner/src/header/header.jsx +++ b/packages/runner/src/header/header.jsx @@ -29,11 +29,12 @@ export default class Header extends Component { wrapperClassName='selector-playground-toggle-tooltip-wrapper' >