diff --git a/cypress/fixtures/test-app/index.html b/cypress/fixtures/test-app/index.html index 867a217..c95a725 100644 --- a/cypress/fixtures/test-app/index.html +++ b/cypress/fixtures/test-app/index.html @@ -23,47 +23,76 @@ click Run All Tests.
-

getByPlaceholderText

- +

*ByLabel and *ByPlaceholder

+ + + + +
-

getByText

- +

*ByText

+
-

getByText within

- +

ByText within

+
-

getByLabelText

- - +

*ByDisplayValue

+ + +
-

getByAltText

+

*ByAltText

Image Alt Text + Image Alt Text 2 +
+
+

*ByTitle

+ 1 + 2 +
+
+

*ByRole

+
dialog 1
+
dialog 2
-

getByTestId

+

*ByTestId

+
-

getAllByText

+

*AllByText

+
+

*ByText on another page

+ Next Page +
diff --git a/cypress/fixtures/test-app/next-page.html b/cypress/fixtures/test-app/next-page.html new file mode 100644 index 0000000..bc0de9b --- /dev/null +++ b/cypress/fixtures/test-app/next-page.html @@ -0,0 +1,29 @@ + + + + + + + cypress-testing-library + + + +
+ No auto-reload after changing this static HTML markup: + click Run All Tests. +
+
+

New Page Loaded

+
+ + diff --git a/cypress/integration/commands.spec.js b/cypress/integration/commands.spec.js deleted file mode 100644 index 7b1c95a..0000000 --- a/cypress/integration/commands.spec.js +++ /dev/null @@ -1,69 +0,0 @@ -describe('dom-testing-library commands', () => { - beforeEach(() => { - cy.visit('/') - }) - it('getByPlaceholderText', () => { - cy.getByPlaceholderText('Placeholder Text') - .click() - .type('Hello Placeholder') - }) - - it('getByLabelText', () => { - cy.getByLabelText('Label For Input Labelled By Id') - .click() - .type('Hello Input Labelled By Id') - }) - - it('getByAltText', () => { - cy.getByAltText('Image Alt Text').click() - }) - - it('getByTestId', () => { - cy.getByTestId('image-with-random-alt-tag').click() - }) - - it('getAllByText', () => { - cy.getAllByText(/^Jackie Chan/).click({multiple: true}) - }) - - it('queryByText', () => { - cy.queryAllByText('Button Text').should('exist') - cy.queryByText('Non-existing Button Text', {timeout: 100}).should( - 'not.exist', - ) - }) - - it('getByText within', () => { - cy.get('#nested').within(() => { - cy.getByText('Button Text').click() - }) - }) - - it('getByText in container', () => { - return cy.get('#nested').then(subject => { - cy.getByText('Button Text', {container: subject}).click() - }) - }) - - it('getByTestId only throws the error message', () => { - const testId = 'Some random id' - const errorMessage = `Unable to find an element by: [data-testid="${testId}"]` - cy.on('fail', err => { - expect(err.message).to.eq(errorMessage) - }) - - cy.getByTestId(testId, {timeout: 100}).click() - }) - - it('getByText only throws the error message', () => { - const text = 'Some random text' - const errorMessage = `Unable to find an element with the text: ${text}. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.` - cy.on('fail', err => { - expect(err.message).to.eq(errorMessage) - }) - - cy.getByText('Some random text', {timeout: 100}).click() - }) -}) - -/* global cy */ diff --git a/cypress/integration/find.spec.js b/cypress/integration/find.spec.js new file mode 100644 index 0000000..3a43048 --- /dev/null +++ b/cypress/integration/find.spec.js @@ -0,0 +1,130 @@ +describe('find* dom-testing-library commands', () => { + beforeEach(() => { + cy.visit('/') + }) + + // Test each of the types of queries: LabelText, PlaceholderText, Text, DisplayValue, AltText, Title, Role, TestId + + it('findByLabelText', () => { + cy.findByLabelText('Label 1') + .click() + .type('Hello Input Labelled By Id') + }) + + it('findAllByLabelText', () => { + cy.findAllByLabelText(/^Label \d$/).should('have.length', 2) + }) + + it('findByPlaceholderText', () => { + cy.findByPlaceholderText('Input 1') + .click() + .type('Hello Placeholder') + }) + + it('findAllByPlaceholderText', () => { + cy.findAllByPlaceholderText(/^Input \d$/).should('have.length', 2) + }) + + it('findByText', () => { + cy.findByText('Button Text 1') + .click() + .should('contain', 'Button Clicked') + }) + + it('findAllByText', () => { + cy.findAllByText(/^Button Text \d$/) + .should('have.length', 2) + .click({ multiple: true }) + .should('contain', 'Button Clicked') + }) + + it('findByDisplayValue', () => { + cy.findByDisplayValue('Display Value 1') + .click() + .clear() + .type('Some new text') + }) + + it('findAllByDisplayValue', () => { + cy.findAllByDisplayValue(/^Display Value \d$/) + .should('have.length', 2) + }) + + it('findByAltText', () => { + cy.findByAltText('Image Alt Text 1').click() + }) + + it('findAllByAltText', () => { + cy.findAllByAltText(/^Image Alt Text \d$/).should('have.length', 2) + }) + + it('findByTitle', () => { + cy.findByTitle('Title 1').click() + }) + + it('findAllByTitle', () => { + cy.findAllByTitle(/^Title \d$/).should('have.length', 2) + }) + + it('findByRole', () => { + cy.findByRole('dialog').click() + }) + + it('findAllByRole', () => { + cy.findAllByRole(/^dialog/).should('have.length', 2) + }) + + it('findByTestId', () => { + cy.findByTestId('image-with-random-alt-tag-1').click() + }) + + it('findAllByTestId', () => { + cy.findAllByTestId(/^image-with-random-alt-tag-\d$/).should('have.length', 2) + }) + + /* Test the behaviour around these queries */ + + it('findByText with should(\'not.exist\')', () => { + cy.findAllByText(/^Button Text \d$/).should('exist') + cy.findByText('Non-existing Button Text', {timeout: 100}).should('not.exist') + }) + + it('findByText within', () => { + cy.get('#nested').within(() => { + cy.findByText('Button Text 2').click() + }) + }) + + it('findByText in container', () => { + return cy.get('#nested') + .then(subject => { + cy.findByText(/^Button Text/, {container: subject}).click() + }) + }) + + it('findByText works when another page loads', () => { + cy.findByText('Next Page').click() + cy.findByText('New Page Loaded').should('exist') + }) + + it('findByText should error if no elements are found', () => { + const regex = /Supercalifragilistic/ + const errorMessage = `Timed out retrying: Expected to find element: 'findByText(${regex})', but never found it.` + cy.on('fail', err => { + expect(err.message).to.eq(errorMessage) + }) + + cy.findByText(regex, {timeout: 100}) // Doesn't explicitly need .should('exist') if it's the last element? + }) + + it('findByText finding multiple items should error', () => { + const errorMessage = `Found multiple elements with the text: /^Button Text/i\n\n(If this is intentional, then use the \`*AllBy*\` variant of the query (like \`queryAllByText\`, \`getAllByText\`, or \`findAllByText\`)).` + cy.on('fail', err => { + expect(err.message).to.eq(errorMessage) + }) + + cy.findByText(/^Button Text/i) + }) +}) + +/* global cy */ diff --git a/cypress/integration/get.spec.js b/cypress/integration/get.spec.js new file mode 100644 index 0000000..122f76b --- /dev/null +++ b/cypress/integration/get.spec.js @@ -0,0 +1,26 @@ +describe('get* queries should error', () => { + beforeEach(() => { + cy.visit('/') + }) + + const queryPrefixes = ['By', 'AllBy'] + const queryTypes = ['LabelText', 'PlaceholderText', 'Text', 'DisplayValue', 'AltText', 'Title', 'Role', 'TestId'] + + queryPrefixes.forEach(queryPrefix => { + queryTypes.forEach(queryType => { + const obsoleteQueryName = `get${queryPrefix + queryType}`; + const preferredQueryName = `find${queryPrefix + queryType}`; + it(`${obsoleteQueryName} should error and suggest using ${preferredQueryName}`, () => { + + const errorMessage = `You used '${obsoleteQueryName}' which has been removed from Cypress Testing Library because it does not make sense in this context. Please use '${preferredQueryName}' instead.` + cy.on('fail', err => { + expect(err.message).to.eq(errorMessage) + }) + + cy[`${obsoleteQueryName}`]('Irrelevant') + }) + }) + }) +}) + +/* global cy */ \ No newline at end of file diff --git a/cypress/integration/query.spec.js b/cypress/integration/query.spec.js new file mode 100644 index 0000000..87eb41e --- /dev/null +++ b/cypress/integration/query.spec.js @@ -0,0 +1,152 @@ +describe('query* dom-testing-library commands', () => { + beforeEach(() => { + cy.visit('/') + }) + + // Test each of the types of queries: LabelText, PlaceholderText, Text, DisplayValue, AltText, Title, Role, TestId + + it('queryByLabelText', () => { + cy.queryByLabelText('Label 1') + .click() + .type('Hello Input Labelled By Id') + }) + + it('queryAllByLabelText', () => { + cy.queryAllByLabelText(/^Label \d$/).should('have.length', 2) + }) + + it('queryByPlaceholderText', () => { + cy.queryByPlaceholderText('Input 1') + .click() + .type('Hello Placeholder') + }) + + it('queryAllByPlaceholderText', () => { + cy.queryAllByPlaceholderText(/^Input \d$/).should('have.length', 2) + }) + + it('queryByText', () => { + cy.queryByText('Button Text 1') + .click() + .should('contain', 'Button Clicked') + }) + + it('queryAllByText', () => { + cy.queryAllByText(/^Button Text \d$/) + .should('have.length', 2) + .click({ multiple: true }) + .should('contain', 'Button Clicked') + }) + + it('queryByDisplayValue', () => { + cy.queryByDisplayValue('Display Value 1') + .click() + .clear() + .type('Some new text') + }) + + it('queryAllByDisplayValue', () => { + cy.queryAllByDisplayValue(/^Display Value \d$/) + .should('have.length', 2) + }) + + it('queryByAltText', () => { + cy.queryByAltText('Image Alt Text 1').click() + }) + + it('queryAllByAltText', () => { + cy.queryAllByAltText(/^Image Alt Text \d$/).should('have.length', 2) + }) + + it('queryByTitle', () => { + cy.queryByTitle('Title 1').click() + }) + + it('queryAllByTitle', () => { + cy.queryAllByTitle(/^Title \d$/).should('have.length', 2) + }) + + it('queryByRole', () => { + cy.queryByRole('dialog').click() + }) + + it('queryAllByRole', () => { + cy.queryAllByRole(/^dialog/).should('have.length', 2) + }) + + it('queryByTestId', () => { + cy.queryByTestId('image-with-random-alt-tag-1').click() + }) + + it('queryAllByTestId', () => { + cy.queryAllByTestId(/^image-with-random-alt-tag-\d$/).should('have.length', 2) + }) + + /* Test the behaviour around these queries */ + + it('queryByText with .should(\'not.exist\')', () => { + cy.queryAllByText(/^Button Text \d$/).should('exist') + cy.queryByText('Non-existing Button Text', {timeout: 100}).should('not.exist') + }) + + it('queryByText within', () => { + cy.get('#nested').within(() => { + cy.queryByText('Button Text 2').click() + }) + }) + + it('query* will return immediately, and never retry', () => { + cy.queryByText('Next Page').click() + + const errorMessage = `expected 'queryByText(\`New Page Loaded\`)' to exist in the DOM` + cy.on('fail', err => { + expect(err.message).to.eq(errorMessage) + }) + + cy.queryByText('New Page Loaded', { timeout: 300 }).should('exist') + }) + + it('query* in container', () => { + return cy.get('#nested') + .then(subject => { + cy.queryByText(/^Button Text/, {container: subject}).click() + }) + }) + + it('queryByText can return no result, and should not error', () => { + const text = 'Supercalifragilistic' + + cy.queryByText(text, {timeout: 100}) + .should('have.length', 0) + .and('not.exist') + }) + + it('queryAllByText can return no results message should not error', () => { + const text = 'Supercalifragilistic' + + cy.queryAllByText(text, {timeout: 100}) + .should('have.length', 0) + .and('not.exist') + }) + + it('queryAllByText with a should(\'exist\') must provide selector error message', () => { + const text = 'Supercalifragilistic' + const errorMessage = `expected 'queryAllByText(\`${text}\`)' to exist in the DOM` + cy.on('fail', err => { + expect(err.message).to.eq(errorMessage) + }) + + cy.queryAllByText(text, {timeout: 100}).should('exist') // NOT POSSIBLE WITH QUERYALL? + }) + + it('queryByText finding multiple items should error', () => { + const errorMessage = `Found multiple elements with the text: /^queryByText/i\n\n(If this is intentional, then use the \`*AllBy*\` variant of the query (like \`queryAllByText\`, \`getAllByText\`, or \`findAllByText\`)).` + cy.on('fail', err => { + expect(err.message).to.eq(errorMessage) + }) + + cy.queryByText(/^queryByText/i) + }) +}) + +/* global cy */ diff --git a/src/index.js b/src/index.js index 7289d7c..3fcfb8f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,4 @@ -import {configure, queries, waitForElement} from '@testing-library/dom' +import {configure, queries} from '@testing-library/dom' import {getContainer} from './utils' const getDefaultCommandOptions = () => { @@ -7,7 +7,41 @@ const getDefaultCommandOptions = () => { } } -const commands = Object.keys(queries).map(queryName => { +const queryNames = Object.keys(queries); + +const getRegex = /^get/; +const queryRegex = /^query/; +const findRegex = /^find/; + +const getQueryNames = queryNames.filter(q => getRegex.test(q)); +const queryQueryNames = queryNames.filter(q => queryRegex.test(q)); +const findQueryNames = queryNames.filter(q => findRegex.test(q)); + +const getCommands = getQueryNames.map(queryName => { + return { + name: queryName, + command: () => { + Cypress.log({ + name: queryName + }); + + throw new Error(`You used '${queryName}' which has been removed from Cypress Testing Library because it does not make sense in this context. Please use '${queryName.replace(getRegex, 'find')}' instead.`) + } + } +}) + +const queryCommands = queryQueryNames.map(queryName => { + return createCommand(queryName, queryName); +}) + +const findCommands = findQueryNames.map(queryName => { + // dom-testing-library find* queries use a promise to look for an element, but that doesn't work well with Cypress retryability + // Use the query* commands so that we can lean on Cypress to do the retry for us + // When it does return a null or empty array, Cypress will retry until the assertions are satisfied or the command times out + return createCommand(queryName, queryName.replace(findRegex, 'query')); +}) + +function createCommand(queryName, implementationName) { return { name: queryName, command: (...args) => { @@ -16,62 +50,108 @@ const commands = Object.keys(queries).map(queryName => { const waitOptions = typeof lastArg === 'object' ? {...defaults, ...lastArg} : defaults - const queryImpl = queries[queryName] + const queryImpl = queries[implementationName] const baseCommandImpl = doc => { const container = getContainer(waitOptions.container || doc) - return waitForElement(() => queryImpl(container, ...args), { - ...waitOptions, - container, - }) + return queryImpl(container, ...args) } - let commandImpl - if ( - queryName.startsWith('queryBy') || - queryName.startsWith('queryAllBy') - ) { - commandImpl = doc => - baseCommandImpl(doc).catch(_ => - doc.querySelector('.___cypressNotExistingSelector'), - ) - } else { - commandImpl = doc => baseCommandImpl(doc) + const commandImpl = doc => baseCommandImpl(doc) + + const inputArr = args.filter(filterInputs); + + const consoleProps = { + // TODO: Would be good to completely separate out the types of input into their own properties + input: inputArr } - const thenHandler = new Function( - 'commandImpl', - ` - return function Command__${queryName}(thenArgs) { - return commandImpl(thenArgs.document) - } - `, - )(commandImpl) + + Cypress.log({ + $el: inputArr, + name: queryName, + message: inputArr, + consoleProps: () => consoleProps + }); + return cy .window({log: false}) - .then({timeout: waitOptions.timeout + 100}, thenHandler) - .then(subject => { - Cypress.log({ - $el: subject, - name: queryName, - message: args.filter(value => { - if (Array.isArray(value) && value.length === 0) { - return false - } - if (value instanceof RegExp) { - return value.toString() - } - if ( - typeof value === 'object' && - Object.keys(value).length === 0 - ) { - return false + .then((thenArgs) => { + const getValue = () => { + const value = commandImpl(thenArgs.document); + const result = Cypress.$(value); + + // Overriding the selector of the jquery object because it's displayed in the long message of .should('exist') failure message + // Hopefully it makes it clearer, because I find the normal response of "Expected to find element '', but never found it" confusing + result.selector = `${queryName}(${queryArgument(args)})`; + + if (result.length > 0) { + consoleProps.yielded = result.toArray() + } + + return result; + } + + const resolveValue = () => { + // retry calling "getValue" until following assertions pass or this command times out + return Cypress.Promise.try(getValue).then(value => { + return cy.verifyUpcomingAssertions(value, waitOptions, { + onRetry: resolveValue, + }) + }) + } + + if (queryRegex.test(queryName)) { + // For get* queries, do not retry + return getValue(); + } + + return resolveValue() + .then(subject => { + + // Remove the error that occurred because it is irrelevant now + if (consoleProps.error) { + delete consoleProps.error; } - return Boolean(value) - }), - }) - return subject + + return subject; + }) }) }, } -}) +} + +function filterInputs(value) { + if (Array.isArray(value) && value.length === 0) { + return false + } + if (value instanceof RegExp) { + return value.toString() + } + if ( + typeof value === 'object' && + Object.keys(value).length === 0 + ) { + return false + } + return Boolean(value) +} + +function queryArgument(args) { + const input = args + .find(value => { + return (value instanceof RegExp) || (typeof value === 'string') + }); + + if (input && typeof input === 'string') { + return `\`${input}\``; + } + + return input; +} + +const commands = [ + ...getCommands, + ...findCommands, + ...queryCommands +]; export {commands, configure}