From 72f27163d86e26e16ec1991d145b0ad9bc038f12 Mon Sep 17 00:00:00 2001 From: Tony Spataro Date: Fri, 6 Jan 2023 14:20:12 -0800 Subject: [PATCH] Support Cypress 12 queries (#96) * Mitigate console spam during component tests * Add hook to switch between query and command form of cy.component * Implement cy.component as a query * Refactor command declarations and options into central locations. --- cypress/component/commands/component.cy.jsx | 24 ++--- src/commands/{ => actions}/clear.ts | 4 +- src/commands/{ => actions}/component.ts | 98 +++------------------ src/commands/{ => actions}/fill.ts | 33 +------ src/commands/{ => actions}/select.ts | 4 +- src/commands/index.ts | 78 ++++++++++++++-- src/commands/internals/component.ts | 40 +++++++++ src/commands/queries/component.ts | 65 ++++++++++++++ src/interfaces.ts | 21 ++++- 9 files changed, 230 insertions(+), 137 deletions(-) rename src/commands/{ => actions}/clear.ts (93%) rename src/commands/{ => actions}/component.ts (57%) rename src/commands/{ => actions}/fill.ts (78%) rename src/commands/{ => actions}/select.ts (95%) create mode 100644 src/commands/internals/component.ts create mode 100644 src/commands/queries/component.ts diff --git a/cypress/component/commands/component.cy.jsx b/cypress/component/commands/component.cy.jsx index ddec3ec..ce0834d 100644 --- a/cypress/component/commands/component.cy.jsx +++ b/cypress/component/commands/component.cy.jsx @@ -12,12 +12,16 @@ import { } from '@appfolio/react-gears'; import * as comp from '../../../src/components'; -import { component as rawComponent } from '../../../src/commands/component'; +import { component as rawCommand } from '../../../src/commands/actions/component'; // Hide/show something after dt has elapsed. function Timed({ children, init = false, dt = 2000 }) { const [isVisible, setIsVisible] = React.useState(init); - if (dt) setTimeout(() => setIsVisible(!isVisible), dt); + React.useEffect(() => { + let t; + if (dt) t = setTimeout(() => setIsVisible(!isVisible), dt); + return () => t && clearTimeout(t); + }, []) return isVisible ? children : null; } @@ -149,7 +153,7 @@ describe('cy.component', () => { - + @@ -166,7 +170,7 @@ describe('cy.component', () => { - + @@ -180,17 +184,17 @@ describe('cy.component', () => { context('invalid parameters', () => { it('no Component', () => { - expect(() => rawComponent(undefined, undefined, 'Label')).to.throw( + expect(() => rawCommand(undefined, undefined, 'Label')).to.throw( 'invalid component spec' ); }); it('React component', () => { - expect(() => rawComponent(undefined, Button, 'Label')).to.throw( + expect(() => rawCommand(undefined, Button, 'Label')).to.throw( 'React component' ); }); it('extraneous text', () => { - expect(() => rawComponent(undefined, comp.Nav, 'Hi')).to.throw( + expect(() => rawCommand(undefined, comp.Nav, 'Hi')).to.throw( 'does not implement ComponentWithText' ); }); @@ -203,14 +207,14 @@ describe('cy.component', () => { A - + B - + @@ -258,7 +262,7 @@ describe('cy.component', () => { }); }); - context('given timeout:value', () => { + context('given a timeout greater than defaultCommandTimeout', () => { beforeEach(() => { cy.mount( diff --git a/src/commands/clear.ts b/src/commands/actions/clear.ts similarity index 93% rename from src/commands/clear.ts rename to src/commands/actions/clear.ts index 89612dd..8d47929 100644 --- a/src/commands/clear.ts +++ b/src/commands/actions/clear.ts @@ -1,5 +1,5 @@ -import { QUIET, FORCE_QUIET } from './internals/constants'; -import { blurIfNecessary, dismissAriaPopup } from './internals/interaction'; +import { QUIET, FORCE_QUIET } from '../internals/constants'; +import { blurIfNecessary, dismissAriaPopup } from '../internals/interaction'; /** * Clear a vanilla HTML input or a fancy gears component e.g. Select. diff --git a/src/commands/component.ts b/src/commands/actions/component.ts similarity index 57% rename from src/commands/component.ts rename to src/commands/actions/component.ts index 1571068..1292d8d 100644 --- a/src/commands/component.ts +++ b/src/commands/actions/component.ts @@ -2,96 +2,20 @@ import { Component, - Text, + ComponentOptions, isComponent, isComponentWithText, isReact, - isText, -} from '../interfaces'; -import { getFirstDeepestElement } from './internals/driver'; -import { findAllByText, orderByInnerText } from './internals/text'; - -/** - * Options for the cy.component command. - */ -export interface ComponentOptions { - all: boolean; - log: boolean; - timeout?: number; -} - -declare global { - namespace Cypress { - interface Chainable { - /** - * Find the DOM representation of a react-gears component, as identified - * by its label, header or other characteristic text. - * - * @example verify that field initially has text; clear it; save the form - * import { Button, Input } from '@appfolio/react-gears-cypress' - * cy.component(Input, 'First Name, { log: false }).should('not.be.empty') - * cy.component(Input, 'First Name').clear() - * cy.component(Button, /Create|Save/).click(); - */ - component( - component: Component, - text: Text, - options?: Partial - ): Chainable; - /** - * Find DOM representation(s) of a react-gears component regardless of - * label, header or other characteristic text. - * - * @example verify there are three Select fields - * import { Select } from '@appfolio/react-gears-cypress' - * cy.component(Select, { all: true }).count().should('eq', 3) - */ - component( - component: Component, - options?: Partial - ): Chainable; - } - } -} - -function describePseudoSelector(component: Component, text?: Text) { - if (!text) return component.query; - else if (text instanceof RegExp) - return `${component.query}:component-text(${text})`; - else return `${component.query}:component-text('${text}')`; -} - -// Extract the options passed to the command, if any. -function getOptions(rest: any[]) { - switch (rest.length) { - case 1: - if (rest[0] && !isText(rest[0])) return rest[0]; - break; - default: - return rest[1]; - } -} - -// Extract the text paramter passed to the command, if any. -const getText = (rest: any[]) => (isText(rest[0]) ? rest[0] : undefined); - -// Return a full hash of options w/ default values for anything not overrridden. -function normalizeOptions(rest: any[]): ComponentOptions { - // Deliberate copy of defaults every time; Cypress destructively modifies it. - const defl = { - all: false, - log: true, - timeout: Cypress.config().defaultCommandTimeout, - }; - return getOptions(rest) || defl; -} - -function mapAll($collection: JQuery, callback: ($el: JQuery) => JQuery) { - return $collection.map(function (this: HTMLElement) { - const $element = Cypress.$(this); - return callback($element).get()[0]; - }); -} +} from '../../interfaces'; +import { + describePseudoSelector, + getOptions, + getText, + mapAll, + normalizeOptions, +} from '../internals/component'; +import { getFirstDeepestElement } from '../internals/driver'; +import { findAllByText, orderByInnerText } from '../internals/text'; export function component( prevSubject: JQuery | void, diff --git a/src/commands/fill.ts b/src/commands/actions/fill.ts similarity index 78% rename from src/commands/fill.ts rename to src/commands/actions/fill.ts index fe9f2d3..0e1b3bd 100644 --- a/src/commands/fill.ts +++ b/src/commands/actions/fill.ts @@ -1,34 +1,9 @@ /// -import * as match from '../match'; -import { FORCE_QUIET, FORCE_QUICK_QUIET, QUIET } from './internals/constants'; -import { blurIfNecessary, dismissAriaPopup } from './internals/interaction'; - -/** - * Options for the cy.fill command. - */ -export interface FillOptions { - log: boolean; -} - -declare global { - namespace Cypress { - interface Chainable { - /** - * Replace the contents of a form field by clearing it, then typing or - * selecting. Handles react-gears Select and DateInput components, as - * well as other text inputs that have an aria popup associated with them. - * - * @see https://github.com/appfolio/react-gears-cypress/blob/master/README.md - * - * @example - * cy.get('input').fill('Hello, World') - * cy.get('input["type=select"]').fill('Option 1') - */ - fill(text: string, options?: Partial): Chainable; - } - } -} +import { FillOptions } from '../../interfaces'; +import { FORCE_QUIET, FORCE_QUICK_QUIET, QUIET } from '../internals/constants'; +import { blurIfNecessary, dismissAriaPopup } from '../internals/interaction'; +import * as match from '../../match'; /** * Replace a form component's existing value. Works on diff --git a/src/commands/select.ts b/src/commands/actions/select.ts similarity index 95% rename from src/commands/select.ts rename to src/commands/actions/select.ts index d224a93..349d4b5 100644 --- a/src/commands/select.ts +++ b/src/commands/actions/select.ts @@ -1,6 +1,6 @@ -import * as match from '../match'; -import { FORCE_QUIET, QUIET } from './internals/constants'; +import * as match from '../../match'; +import { FORCE_QUIET, QUIET } from '../internals/constants'; /** * Choose a value from a select (either vanilla HTML or gears). */ diff --git a/src/commands/index.ts b/src/commands/index.ts index b120d6a..3412ac9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,12 +1,65 @@ -import { clear } from './clear'; -import { component } from './component'; -import { fill } from './fill'; -import { select } from './select'; +import { Component, ComponentOptions, FillOptions, Text } from '../interfaces'; +import { clear } from './actions/clear'; +import { component as componentCommand } from './actions/component'; +import { fill } from './actions/fill'; +import { select } from './actions/select'; +import { component as componentQuery } from './queries/component'; +export const supportsQueries = !!(Cypress.Commands as any).addQuery; +const component = supportsQueries ? componentQuery : componentCommand; + +// Convenience export of our command functions, in case someone needs to wrap/augment them. +// TODO: stop exporting these in v6 export { clear, component, fill, select }; type CommandName = 'clear' | 'fill' | 'component' | 'select'; +declare global { + namespace Cypress { + interface Chainable { + /** + * Find the DOM representation of a react-gears component, as identified + * by its label, header or other characteristic text. + * + * @example verify that field initially has text; clear it; save the form + * import { Button, Input } from '@appfolio/react-gears-cypress' + * cy.component(Input, 'First Name, { log: false }).should('not.be.empty') + * cy.component(Input, 'First Name').clear() + * cy.component(Button, /Create|Save/).click(); + */ + component( + component: Component, + text: Text, + options?: Partial + ): Chainable; + /** + * Find DOM representation(s) of a react-gears component regardless of + * label, header or other characteristic text. + * + * @example verify there are three Select fields + * import { Select } from '@appfolio/react-gears-cypress' + * cy.component(Select, { all: true }).count().should('eq', 3) + */ + component( + component: Component, + options?: Partial + ): Chainable; + /** + * Replace the contents of a form field by clearing it, then typing or + * selecting. Handles react-gears Select and DateInput components, as + * well as other text inputs that have an aria popup associated with them. + * + * @see https://github.com/appfolio/react-gears-cypress/blob/master/README.md + * + * @example + * cy.get('input').fill('Hello, World') + * cy.get('input["type=select"]').fill('Option 1') + */ + fill(text: string, options?: Partial): Chainable; + } + } +} + /** * Register Cypress commands provided by this package. Some commands are new and * some are overridden. @@ -16,6 +69,9 @@ type CommandName = 'clear' | 'fill' | 'component' | 'select'; * * @example install just a couple commands * commands.add('fill', 'gears); + * + * TODO: stop allowing the user to pick and choose commands in v6; just install everything + * TODO: install upon require, vs. making the user call our add function? */ export function add(...names: CommandName[]) { const all = !names.length; @@ -23,8 +79,18 @@ export function add(...names: CommandName[]) { Cypress.Commands.overwrite('clear', clear); if (all || names.includes('fill')) Cypress.Commands.add('fill', { prevSubject: ['element'] }, fill); - if (all || names.includes('component')) - Cypress.Commands.add('component', { prevSubject: ['optional'] }, component); + if (all || names.includes('component')) { + if (supportsQueries) { + // TODO: author and install the query version of cy.component + Cypress.Commands.addQuery('component', componentQuery); + } else { + Cypress.Commands.add( + 'component', + { prevSubject: ['optional'] }, + componentCommand + ); + } + } if (all || names.includes('select')) Cypress.Commands.overwrite('select', select); } diff --git a/src/commands/internals/component.ts b/src/commands/internals/component.ts new file mode 100644 index 0000000..e56fb0c --- /dev/null +++ b/src/commands/internals/component.ts @@ -0,0 +1,40 @@ +import { Component, ComponentOptions, Text, isText } from '../../interfaces'; + +export function describePseudoSelector(component: Component, text?: Text) { + if (!text) return component.query; + else if (text instanceof RegExp) + return `${component.query}:component-text(${text})`; + else return `${component.query}:component-text('${text}')`; +} + +// Extract the options passed to the command, if any. +export function getOptions(rest: any[]) { + switch (rest.length) { + case 1: + if (rest[0] && !isText(rest[0])) return rest[0]; + break; + default: + return rest[1]; + } +} + +// Extract the text paramter passed to the command, if any. +export const getText = (rest: any[]) => (isText(rest[0]) ? rest[0] : undefined); + +// Return a full hash of options w/ default values for anything not overrridden. +export function normalizeOptions(rest: any[]): ComponentOptions { + // Deliberate copy of defaults every time; Cypress destructively modifies it. + const defl = { + all: false, + log: true, + timeout: Cypress.config().defaultCommandTimeout, + }; + return getOptions(rest) || defl; +} + +export function mapAll($collection: JQuery, callback: ($el: JQuery) => JQuery) { + return $collection.map(function (this: HTMLElement) { + const $element = Cypress.$(this); + return callback($element).get()[0]; + }); +} diff --git a/src/commands/queries/component.ts b/src/commands/queries/component.ts new file mode 100644 index 0000000..f582809 --- /dev/null +++ b/src/commands/queries/component.ts @@ -0,0 +1,65 @@ +/// + +import { + Component, + isComponent, + isComponentWithText, + isReact, +} from '../../interfaces'; +import { + describePseudoSelector, + getText, + mapAll, + normalizeOptions, +} from '../internals/component'; +import { getFirstDeepestElement } from '../internals/driver'; +import { findAllByText, orderByInnerText } from '../internals/text'; + +export function component( + this: Cypress.Command, + defn: Component, + ...rest: any[] +) { + const options = normalizeOptions(rest); + const text = getText(rest); + + if (!isComponent(defn)) { + throw new Error( + isReact(defn) + ? `react-gears-cypress: cannot use a React component as a specification: ${defn}` + : `react-gears-cypress: invalid component specification ${defn}` + ); + } else if (text && !isComponentWithText(defn)) { + throw new Error( + `react-gears-cypress: trying to find by text, but ${defn.name} does not implement ComponentWithText` + ); + } + + if (options.timeout) { + // @ts-expect-error cypress(2345) undocumented queued command attribute (our tests prove that it works) + this.set('timeout', options.timeout); + } + + return (prevSubject: JQuery | void) => { + const $subject = prevSubject || cy.$$('body'); + let $el: JQuery; + + if (text && isComponentWithText(defn)) { + $el = findAllByText($subject, defn.textQuery, text); + if ($el && $el.length && !options.all) + $el = getFirstDeepestElement(orderByInnerText($el)); + if ($el.length && defn.traverseViaText) + $el = mapAll($el, defn.traverseViaText); + } else { + $el = $subject.find(defn.query); + if ($el.length > 1 && !options.all) $el = getFirstDeepestElement($el); + if ($el.length && defn.traverse) $el = mapAll($el, defn.traverse); + } + + // Make command log more readable by emulating Cypress internal state. + // @ts-expect-error cypress(2551) undocumented extension to JQuery interface? + if (!$el.selector) $el.selector = describePseudoSelector(defn, text); + + return $el; + }; +} diff --git a/src/interfaces.ts b/src/interfaces.ts index 06ef171..612115a 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -13,7 +13,7 @@ export type Color = | 'danger'; /** - * Methods for interacting with a specific react-gears component. + * Methods for finding the top-level DOM element of a specific react-gears component. */ export interface Component { /** @@ -34,6 +34,18 @@ export interface Component { traverse?: ($el: JQuery) => JQuery; } +/** + * Options for the cy.component command. + */ +export interface ComponentOptions { + all: boolean; + log: boolean; + timeout: number; +} + +/** + * Methods for finding a specific react-gears component by its textual label. + */ export interface ComponentWithText extends Component { /** * CSS selector used to find the component's label, title or other characteristic @@ -49,6 +61,13 @@ export interface ComponentWithText extends Component { traverseViaText?: ($el: JQuery) => JQuery; } +/** + * Options for the cy.fill command. + */ +export interface FillOptions { + log: boolean; +} + /** * Label/title parameter used to find components. */