Skip to content
This repository has been archived by the owner on Nov 6, 2023. It is now read-only.

Commit

Permalink
Support Cypress 12 queries (#96)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
xeger authored Jan 6, 2023
1 parent eb9de43 commit 72f2716
Show file tree
Hide file tree
Showing 9 changed files with 230 additions and 137 deletions.
24 changes: 14 additions & 10 deletions cypress/component/commands/component.cy.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -149,7 +153,7 @@ describe('cy.component', () => {
<BlockPanel title="outer subject">
<Timed>
<FormLabelGroup label="now you see me">
<Input value="some value" />
<Input defaultValue="some value" />
</FormLabelGroup>
</Timed>
</BlockPanel>
Expand All @@ -166,7 +170,7 @@ describe('cy.component', () => {
<BlockPanel title="outer subject">
<Timed init={true}>
<FormLabelGroup label="now you see me">
<Input value="some value" />
<Input defaultValue="some value" />
</FormLabelGroup>
</Timed>
</BlockPanel>
Expand All @@ -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'
);
});
Expand All @@ -203,14 +207,14 @@ describe('cy.component', () => {
<BlockPanel title="A">
<Alert>A</Alert>
<FormLabelGroup label="A">
<Input value="A" />
<Input defaultValue="A" />
</FormLabelGroup>
<Button color="primary">A</Button>
</BlockPanel>
<BlockPanel title="B">
<Alert>B</Alert>
<FormLabelGroup label="B">
<Input value="B" />
<Input defaultValue="B" />
</FormLabelGroup>
<Button color="secondary">B</Button>
</BlockPanel>
Expand Down Expand Up @@ -258,7 +262,7 @@ describe('cy.component', () => {
});
});

context('given timeout:value', () => {
context('given a timeout greater than defaultCommandTimeout', () => {
beforeEach(() => {
cy.mount(
<Timed dt={Cypress.config('defaultCommandTimeout') + 1000}>
Expand Down
4 changes: 2 additions & 2 deletions src/commands/clear.ts → src/commands/actions/clear.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
98 changes: 11 additions & 87 deletions src/commands/component.ts → src/commands/actions/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Subject> {
/**
* 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<ComponentOptions>
): Chainable<Subject>;
/**
* 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<ComponentOptions>
): Chainable<Subject>;
}
}
}

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,
Expand Down
33 changes: 4 additions & 29 deletions src/commands/fill.ts → src/commands/actions/fill.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,9 @@
/// <reference types="cypress" />

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<Subject> {
/**
* 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<FillOptions>): Chainable<Subject>;
}
}
}
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
Expand Down
4 changes: 2 additions & 2 deletions src/commands/select.ts → src/commands/actions/select.ts
Original file line number Diff line number Diff line change
@@ -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).
*/
Expand Down
78 changes: 72 additions & 6 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -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<Subject> {
/**
* 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<ComponentOptions>
): Chainable<Subject>;
/**
* 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<ComponentOptions>
): Chainable<Subject>;
/**
* 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<FillOptions>): Chainable<Subject>;
}
}
}

/**
* Register Cypress commands provided by this package. Some commands are new and
* some are overridden.
Expand All @@ -16,15 +69,28 @@ 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;
if (all || names.includes('clear'))
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);
}
40 changes: 40 additions & 0 deletions src/commands/internals/component.ts
Original file line number Diff line number Diff line change
@@ -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];
});
}
Loading

0 comments on commit 72f2716

Please sign in to comment.