diff --git a/README.md b/README.md index 5985110..29a6db9 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ If you are using typescript, also add the following to `cypress/tsconfig.json` ## API -The idea of the commands – they should be as similar as possible to cypress default commands (like `cy.type`), but starts with `real` – `cy.realType`. +The idea of the commands – they should be as similar as possible to cypress default commands (like `cy.type`), but starts with `real` – `cy.realType`. Here is an overview of the available **real** event commands: - [cy.realClick](#cyrealclick) @@ -90,7 +90,7 @@ cy.get("button").realClick(); cy.get("button").realClick(options); ``` -Example: +Example: ```js cy.get("button").realClick({ position: "topLeft" }) // click on the top left corner of button @@ -101,11 +101,11 @@ Options: - `Optional` **button**: \"none\" \| \"left\" \| \"right\" \| \"middle\" \| \"back\" \| \"forward\" - `Optional` **pointer**: \"mouse\" \| \"pen\" -- `Optional` x coordinate to click **x**: number +- `Optional` x coordinate to click **x**: number - `Optional` y coordinate to click **y**: number - `Optional` **position**: "topLeft" | "top" | "topRight" | "left" | "center" | "right" | "bottomLeft" | "bottom" | "bottomRight" -> Make sure that `x` and `y` has a bigger priority than `position`. +> Make sure that `x` and `y` has a bigger priority than `position`. ## cy.realHover @@ -124,7 +124,7 @@ Options: ## cy.realPress Fires native press event. It can fire one key event or the "shortcut" like Shift+Control+M. -Make sure that event is global, it means that it is required to **firstly** focus any control before firing this event. +Make sure that event is global, it means that it is required to **firstly** focus any control before firing this event. ```jsx cy.realPress("Tab"); // switch the focus for a11y testing @@ -154,7 +154,7 @@ cy.get("button").realTouch(); cy.get("button").realTouch(options); ``` -##### Usage: +##### Usage: ```js cy.get("button").realTouch({ position: "topLeft" }) // touches the top left corner of button @@ -166,6 +166,9 @@ Options: - `Optional` **x**: undefined \| number **`default`** 30 - `Optional` **y**: undefined \| false \| true **`default`** true - `Optional` **position**: "topLeft" | "top" | "topRight" | "left" | "center" | "right" | "bottomLeft" | "bottom" | "bottomRight" +- `Optional` **radius**: undefined \| number **`default`** 1 +- `Optional` **radiusX**: undefined \| number **`default`** 1 +- `Optional` **radiusY**: undefined \| number **`default`** 1 ### cy.realType @@ -204,7 +207,7 @@ Options: Runs a native swipe events. It means that **touch events** will be fired. Actually a sequence of `touchStart` -> `touchMove` -> `touchEnd`. It can perfectly swipe drawers and other tools [like this one](https://csb-dhe0i-qj8xxmx8y.vercel.app/). -> Make sure to enable mobile viewport :) +> Make sure to enable mobile viewport :) ```js @@ -229,7 +232,7 @@ cy.realType(direction, options); Options: - `Optional` **length**: undefined \| number **`default`** 10 -- `Optional` x coordinate to touch **x**: number +- `Optional` x coordinate to touch **x**: number - `Optional` y coordinate to touch **y**: number - `Optional` **touchPosition**: "topLeft" | "top" | "topRight" | "left" | "center" | "right" | "bottomLeft" | "bottom" | "bottomRight" diff --git a/cypress/fixtures/frame-one.html b/cypress/fixtures/frame-one.html new file mode 100644 index 0000000..e8ddb2f --- /dev/null +++ b/cypress/fixtures/frame-one.html @@ -0,0 +1,15 @@ + + + +
+ +
+ + + + + diff --git a/cypress/fixtures/frame-two.html b/cypress/fixtures/frame-two.html new file mode 100644 index 0000000..ec56d21 --- /dev/null +++ b/cypress/fixtures/frame-two.html @@ -0,0 +1,22 @@ + + + +
+
+
+ + + + + + + diff --git a/cypress/fixtures/iframe-page.html b/cypress/fixtures/iframe-page.html new file mode 100644 index 0000000..e0e93fc --- /dev/null +++ b/cypress/fixtures/iframe-page.html @@ -0,0 +1,14 @@ + + + +
+ +
+ + + + diff --git a/cypress/integration/click.spec.ts b/cypress/integration/click.spec.ts index 7f14b67..a5851a5 100644 --- a/cypress/integration/click.spec.ts +++ b/cypress/integration/click.spec.ts @@ -33,10 +33,146 @@ describe("cy.realClick", () => { .realClick({ x: 100, y: 185 }) .realClick({ x: 125, y: 190 }) .realClick({ x: 150, y: 185 }) - .realClick({ x: 170, y: 165 } ) + .realClick({ x: 170, y: 165 }); }); it("opens system native event on right click", () => { cy.get(".action-btn").realClick({ button: "right" }); }); + + describe("scroll behavior", () => { + function getScreenEdges() { + const cypressAppWindow = window.parent.document.querySelector("iframe") + .contentWindow; + const windowTopEdge = cypressAppWindow.document.documentElement.scrollTop; + const windowBottomEdge = windowTopEdge + cypressAppWindow.innerHeight; + const windowCenter = windowTopEdge + cypressAppWindow.innerHeight / 2; + + return { + top: windowTopEdge, + bottom: windowBottomEdge, + center: windowCenter, + }; + } + + function getElementEdges($el: JQuery) { + const $elTop = $el.offset().top; + + return { + top: $elTop, + bottom: $elTop + $el.outerHeight(), + }; + } + + beforeEach(() => { + cy.window().scrollTo("top"); + }); + + it("defaults to scrolling the element to the top of the viewport", () => { + cy.get("#action-canvas") + .realClick() + .then(($canvas: JQuery) => { + const { top: $elTop } = getElementEdges($canvas); + const { top: screenTop } = getScreenEdges(); + + expect($elTop).to.equal(screenTop); + }); + }); + + it("scrolls the element to center of viewport", () => { + cy.get("#action-canvas") + .realClick({ scrollBehavior: "center" }) + .then(($canvas: JQuery) => { + const { top: $elTop, bottom: $elBottom } = getElementEdges($canvas); + const { top: screenTop, bottom: screenBottom } = getScreenEdges(); + + const screenCenter = screenTop + (screenBottom - screenTop) / 2; + + expect($elTop).to.equal(screenCenter - $canvas.outerHeight() / 2); + expect($elBottom).to.equal(screenCenter + $canvas.outerHeight() / 2); + }); + }); + + it("scrolls the element to the top of the viewport", () => { + cy.get("#action-canvas") + .realClick({ scrollBehavior: "top" }) + .then(($canvas: JQuery) => { + const { top: $elTop } = getElementEdges($canvas); + const { top: screenTop } = getScreenEdges(); + + expect($elTop).to.equal(screenTop); + }); + }); + + it("scrolls the element to the bottom of the viewport", () => { + cy.get("#action-canvas") + .realClick({ scrollBehavior: "bottom" }) + .then(($canvas: JQuery) => { + const { bottom: $elBottom } = getElementEdges($canvas); + const { bottom: screenBottom } = getScreenEdges(); + + expect($elBottom).to.equal(screenBottom); + }); + }); + + it("scrolls the element to the nearest edge of the viewport", () => { + cy.window().scrollTo("bottom"); + + cy.get("#action-canvas") + .realClick({ scrollBehavior: "nearest" }) + .then(($canvas: JQuery) => { + const { top: $elTop } = getElementEdges($canvas); + const { top: screenTop } = getScreenEdges(); + + expect($elTop).to.equal(screenTop); + }); + + cy.window().scrollTo("top"); + + cy.get("#action-canvas") + .realClick({ scrollBehavior: "nearest" }) + .then(($canvas: JQuery) => { + const { bottom: $elBottom } = getElementEdges($canvas); + const { bottom: screenBottom } = getScreenEdges(); + + expect($elBottom).to.equal(screenBottom); + }); + }); + }); +}); + +describe("iframe behavior", () => { + beforeEach(() => { + cy.visit("./cypress/fixtures/iframe-page.html"); + }); + + it("clicks elements inside iframes", () => { + cy.get("iframe") + .then(($firstIframe) => { + return cy.wrap($firstIframe.contents().find("iframe")); + }) + .then(($secondIframe) => { + return cy.wrap($secondIframe.contents().find("body")); + }) + .within(() => { + cy.get("#target").contains("clicked").should("not.exist"); + cy.get("#target").realClick().contains("clicked").should("exist"); + }); + }); + + it("clicks elements inside transformed iframes", () => { + cy.get("iframe") + .then(($firstIframe) => { + $firstIframe.css("transform", "scale(.5)"); + return cy.wrap($firstIframe.contents().find("iframe")); + }) + .then(($secondIframe) => { + $secondIframe.css("transform", "scale(.75)"); + return cy.wrap($secondIframe.contents().find("body")); + }) + .within(() => { + cy.get("#target").contains("clicked").should("not.exist"); + cy.get("#target").realClick().contains("clicked").should("exist"); + }); + }); }); diff --git a/cypress/integration/hover.spec.ts b/cypress/integration/hover.spec.ts index 43b9a0b..2848f25 100644 --- a/cypress/integration/hover.spec.ts +++ b/cypress/integration/hover.spec.ts @@ -9,4 +9,131 @@ describe("cy.realHover", () => { .realHover() .should("have.css", "background-color", "rgb(201, 48, 44)"); }); + + describe('scroll behavior', () => { + function getScreenEdges() { + const cypressAppWindow = window.parent.document.querySelector("iframe").contentWindow; + const windowTopEdge = cypressAppWindow.document.documentElement.scrollTop; + const windowBottomEdge = windowTopEdge + cypressAppWindow.innerHeight; + const windowCenter = windowTopEdge + (cypressAppWindow.innerHeight / 2); + + return { + screenTop: windowTopEdge, + screenBottom: windowBottomEdge, + screenCenter: windowCenter, + }; + } + + function getElementEdges($el: JQuery) { + const $elTop = $el.offset().top; + + return { + $elTop, + $elBottom: $elTop + $el.outerHeight() + } + } + + beforeEach(() => { + cy.window().scrollTo('top'); + }); + + it('defaults to scrolling the element to the top of the viewport', () => { + cy.get('#action-canvas').realHover().then(($canvas: JQuery) => { + const { $elTop } = getElementEdges($canvas); + const { screenTop } = getScreenEdges(); + + expect($elTop).to.equal(screenTop); + }); + }); + + it('scrolls the element to center of viewport', () => { + cy.get('#action-canvas').realHover({ scrollBehavior: 'center' }).then(($canvas: JQuery) => { + const { $elTop, $elBottom } = getElementEdges($canvas); + const { screenTop, screenBottom } = getScreenEdges(); + + const screenCenter = screenTop + (screenBottom - screenTop) / 2; + + expect($elTop).to.equal(screenCenter - ($canvas.outerHeight() / 2)); + expect($elBottom).to.equal(screenCenter + ($canvas.outerHeight() / 2)); + }); + }); + + it('scrolls the element to the top of the viewport', () => { + cy.get('#action-canvas').realHover({ scrollBehavior: 'top' }).then(($canvas: JQuery) => { + const { $elTop } = getElementEdges($canvas); + const { screenTop } = getScreenEdges(); + + expect($elTop).to.equal(screenTop); + }); + }); + + it('scrolls the element to the bottom of the viewport', () => { + cy.get('#action-canvas').realHover({ scrollBehavior: 'bottom' }).then(($canvas: JQuery) => { + const { $elBottom } = getElementEdges($canvas); + const { screenBottom } = getScreenEdges(); + + expect($elBottom).to.equal(screenBottom); + }); + }); + + it('scrolls the element to the nearest edge of the viewport', () => { + cy.window().scrollTo('bottom'); + + cy.get('#action-canvas').realHover({ scrollBehavior: 'nearest' }).then(($canvas: JQuery) => { + const { $elTop } = getElementEdges($canvas); + const { screenTop } = getScreenEdges(); + + expect($elTop).to.equal(screenTop); + }); + + cy.window().scrollTo('top'); + + cy.get('#action-canvas').realHover({ scrollBehavior: 'nearest' }).then(($canvas: JQuery) => { + const { $elBottom } = getElementEdges($canvas); + const { screenBottom } = getScreenEdges(); + + expect($elBottom).to.equal(screenBottom); + }); + }); + }); +}); + +describe('iframe behavior', () => { + beforeEach(() => { + cy.visit('./cypress/fixtures/iframe-page.html'); + }); + + it('hovers elements inside iframes', () => { + cy.get('iframe').then(($firstIframe) => { + return cy.wrap($firstIframe.contents().find('iframe')); + }).then(($secondIframe) => { + return cy.wrap($secondIframe.contents().find('body')); + }).within(() => { + cy.get('#target').then(($target) => { + expect($target.css('background-color')).to.equal('rgb(0, 128, 0)'); + }); + + cy.get('#target').realHover().then(($target) => { + expect($target.css('background-color')).to.equal('rgb(255, 192, 203)'); + }); + }); + }); + + it('hovers elements inside transformed iframes', () => { + cy.get('iframe').then(($firstIframe) => { + $firstIframe.css('transform', 'scale(.5)'); + return cy.wrap($firstIframe.contents().find('iframe')); + }).then(($secondIframe) => { + $secondIframe.css('transform', 'scale(.75)'); + return cy.wrap($secondIframe.contents().find('body')); + }).within(() => { + cy.get('#target').then(($target) => { + expect($target.css('background-color')).to.equal('rgb(0, 128, 0)'); + }); + + cy.get('#target').realHover().then(($target) => { + expect($target.css('background-color')).to.equal('rgb(255, 192, 203)'); + }); + }); + }); }); diff --git a/cypress/integration/swipe.spec.ts b/cypress/integration/swipe.spec.ts index c1418a4..3558dd7 100644 --- a/cypress/integration/swipe.spec.ts +++ b/cypress/integration/swipe.spec.ts @@ -30,7 +30,7 @@ describe("cy.realSwipe", () => { touchPosition: "top", }, ] as const).forEach(({ button, swipe, length, touchPosition }) => { - it(`swipes ${button} drawer ${swipe}`, () => { + it(`swipes ${button} drawer ${swipe}`, { retries: 4 }, () => { cy.contains("button", button).click(); cy.get(".MuiDrawer-paper").realSwipe(swipe, { length, step: 10, touchPosition }); @@ -38,7 +38,7 @@ describe("cy.realSwipe", () => { }); }); - it("opens drawer with swipe", () => { + it("opens drawer with swipe", { retries: 4 }, () => { cy.get('.jss3.jss4').realSwipe("toRight", { length: 150, step: 10, touchPosition: "center" }); cy.get('.MuiDrawer-paper').realSwipe("toLeft", { length: 150, step: 10, touchPosition: "center" }); }); diff --git a/cypress/integration/touch.spec.ts b/cypress/integration/touch.spec.ts index f79308a..3513060 100644 --- a/cypress/integration/touch.spec.ts +++ b/cypress/integration/touch.spec.ts @@ -35,4 +35,53 @@ describe("cy.realTouch", () => { .realTouch({ x: 150, y: 185 }) .realTouch({ x: 170, y: 165 } ) }); -}); \ No newline at end of file + + it("touches with a default radius of 1", (done) => { + cy.get(".action-btn") + .then(($button) => { + $button.get(0).addEventListener("pointerdown", (event) => { + expect(event.width).to.equal(2); + expect(event.height).to.equal(2); + done(); + }); + }) + .realTouch(); + }); + + it("touches with a custom radius", (done) => { + cy.get(".action-btn") + .then(($button) => { + $button.get(0).addEventListener("pointerdown", (event) => { + expect(event.width).to.equal(20); + expect(event.height).to.equal(20); + done(); + }); + }) + .realTouch({ radius: 10 }); + }); + + it("touches with a custom radius for each axis", (done) => { + cy.get(".action-btn") + .then(($button) => { + $button.get(0).addEventListener("pointerdown", (event) => { + expect(event.width).to.equal(10); + expect(event.height).to.equal(14); + done(); + }); + }) + .realTouch({ radiusX: 5, radiusY: 7 }); + }); + + it("touches using provided 0 for one of the axis", (done) => { + cy.get(".action-btn") + .then(($button) => { + $button.get(0).addEventListener("pointerdown", (event) => { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() + expect(event.clientX).to.be.closeTo(rect.left - 5, 5); + expect(event.clientY).to.be.closeTo(rect.top, 5); + done(); + }); + }) + .realTouch({ x: -5, y: 0, radius: 10 }); + }); +}); diff --git a/package.json b/package.json index b8ed1c6..48121fd 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "devDependencies": { "@typescript-eslint/eslint-plugin": "^4.8.2", "@typescript-eslint/parser": "^4.8.2", - "cypress": "^5.6.0", + "cypress": "^6.1.0", "eslint": "^7.14.0", "eslint-plugin-cypress": "^2.11.2", "eslint-plugin-no-only-tests": "^2.4.0", diff --git a/src/commands/realClick.ts b/src/commands/realClick.ts index 9fbbf5b..b75a1ae 100644 --- a/src/commands/realClick.ts +++ b/src/commands/realClick.ts @@ -1,6 +1,7 @@ import { fireCdpCommand } from "../fireCdpCommand"; import { getCypressElementCoordinates, + ScrollBehaviorOptions, Position, } from "../getCypressElementCoordinates"; @@ -26,6 +27,11 @@ export interface RealClickOptions { * cy.get("body").realClick({ x: 11, y: 12 }) // global click by coordinates */ y?: number; + /** + * Controls how the page is scrolled to bring the subject into view, if needed. + * @example cy.realHover({ scrollBehavior: "top" }); + */ + scrollBehavior?: ScrollBehaviorOptions; } /** @ignore this, update documentation for this function at index.d.ts */ @@ -38,7 +44,7 @@ export async function realClick( ? { x: options.x, y: options.y } : options.position; - const { x, y } = getCypressElementCoordinates(subject, position); + const { x, y } = getCypressElementCoordinates(subject, position, options.scrollBehavior); const log = Cypress.log({ $el: subject, diff --git a/src/commands/realHover.ts b/src/commands/realHover.ts index 8b5a93a..64e443e 100644 --- a/src/commands/realHover.ts +++ b/src/commands/realHover.ts @@ -1,6 +1,7 @@ import { fireCdpCommand } from "../fireCdpCommand"; import { Position, + ScrollBehaviorOptions, getCypressElementCoordinates, } from "../getCypressElementCoordinates"; @@ -14,6 +15,11 @@ export interface RealHoverOptions { * @example cy.realHover({ position: "topLeft" }) */ position?: Position; + /** + * Controls how the page is scrolled to bring the subject into view, if needed. + * @example cy.realHover({ scrollBehavior: "top" }); + */ + scrollBehavior?: ScrollBehaviorOptions; } /** @ignore this, update documentation for this function at index.d.ts */ @@ -21,7 +27,7 @@ export async function realHover( subject: JQuery, options: RealHoverOptions = {} ) { - const { x, y } = getCypressElementCoordinates(subject, options.position); + const { x, y } = getCypressElementCoordinates(subject, options.position, options.scrollBehavior); const log = Cypress.log({ $el: subject, diff --git a/src/commands/realTouch.ts b/src/commands/realTouch.ts index 4d33daf..387663f 100644 --- a/src/commands/realTouch.ts +++ b/src/commands/realTouch.ts @@ -19,40 +19,70 @@ export interface RealTouchOptions { * cy.get("body").realTouch({ x: 11, y: 12 }) // global touch by coordinates */ y?: number; + /** radius of the touch area. + * @example + * cy.get("canvas").realTouch({ x: 100, y: 115, radius: 10 }) + * cy.get("body").realTouch({ x: 11, y: 12, radius: 10 }) // global touch by coordinates + */ + radius?: number; + /** specific radius of the X axis of the touch area + * @example + * cy.get("canvas").realTouch({ x: 100, y: 115, radiusX: 10, radiusY: 20 }) + * cy.get("body").realTouch({ x: 11, y: 12, radiusX: 10, radiusY: 20 }) // global touch by coordinates + */ + radiusX?: number; + /** specific radius of the Y axis of the touch area + * @example + * cy.get("canvas").realTouch({ x: 100, y: 115, radiusX: 10, radiusY: 20 }) + * cy.get("body").realTouch({ x: 11, y: 12, radiusX: 10, radiusY: 20 }) // global touch by coordinates + */ + radiusY?: number; } export async function realTouch( subject: JQuery, options: RealTouchOptions = {} ) { - const position = options.x && options.y - ? { x: options.x, y: options.y } + const position = typeof options.x === 'number' || typeof options.y === 'number' + ? { x: options.x || 0, y: options.y || 0 } : options.position; + const radiusX = options.radiusX || options.radius || 1 + const radiusY = options.radiusY || options.radius || 1 - const elementPoints = getCypressElementCoordinates(subject, position); + const elementPoint = getCypressElementCoordinates(subject, position); const log = Cypress.log({ $el: subject, name: "realTouch", consoleProps: () => ({ "Applied To": subject.get(0), - "Absolute Coordinates": [elementPoints], + "Absolute Coordinates": [elementPoint], + "Touched Area (Radius)": { + x: radiusX, + y: radiusY, + } }) }) log.snapshot("before"); + const touchPoint = { + ...elementPoint, + radiusX, + radiusY + } + await fireCdpCommand("Input.dispatchTouchEvent", { type: "touchStart", - touchPoints: [elementPoints], + touchPoints: [touchPoint], }); await fireCdpCommand("Input.dispatchTouchEvent", { type: "touchEnd", - touchPoints: [elementPoints], + touchPoints: [touchPoint], }) log.snapshot("after").end(); return subject; -} \ No newline at end of file +} diff --git a/src/getCypressElementCoordinates.ts b/src/getCypressElementCoordinates.ts index 0640346..1ae5804 100644 --- a/src/getCypressElementCoordinates.ts +++ b/src/getCypressElementCoordinates.ts @@ -10,7 +10,9 @@ export type Position = | "bottomRight" | { x: number; y: number }; - function getPositionedCoordinates( +export type ScrollBehaviorOptions = "center" | "top" | "bottom" | "nearest"; + +function getPositionedCoordinates( x0: number, y0: number, width: number, @@ -44,6 +46,88 @@ export type Position = return [x0 + width / 2, y0 + height / 2]; } } +/** + * Scrolls the given htmlElement into view on the page. + * The position the element is scrolled to can be customized with scrollBehavior. + */ +function scrollIntoView( + htmlElement: HTMLElement, + scrollBehavior: ScrollBehaviorOptions = "center" +) { + let block: ScrollLogicalPosition; + + if (scrollBehavior === "top") { + block = "start"; + } else if (scrollBehavior === "bottom") { + block = "end"; + } else { + block = scrollBehavior; + } + + htmlElement.scrollIntoView({ block }); +} + +function getIframesPositionShift(element: HTMLElement) { + let currentWindow: Window | null = element.ownerDocument.defaultView; + const noPositionShift = { + frameScale: 1, + frameX: 0, + frameY: 0, + }; + + if (!currentWindow) { + return noPositionShift; + } + + // eslint-disable-next-line prefer-const + const iframes = [] + + while ( + currentWindow && + currentWindow !== window.top + ) { + iframes.push( + // for cross origin domains .frameElement returns null so query using parentWindow + // but when running using --disable-web-security it will return the frame element + (currentWindow.frameElement as HTMLElement) ?? + currentWindow.parent.document.querySelector("iframe") + ); + + currentWindow = currentWindow.parent; + } + + return iframes.reduceRight(({ frameX, frameY, frameScale }, frame) => { + const { x, y, width } = frame.getBoundingClientRect(); + + return { + frameX: frameX + x * frameScale, + frameY: frameY + y * frameScale, + frameScale: frameScale * (width / frame.offsetWidth), + } + }, noPositionShift) +} + +/** + * Returns the coordinates and size of a given Element, relative to the Cypress app