From 064e5b4d4b6c08d54cb4dcf167a824fe115b23ce Mon Sep 17 00:00:00 2001 From: Caleb Eby Date: Wed, 24 Mar 2021 17:38:06 -0700 Subject: [PATCH] Add user.type and refactor user API (#48) --- .changeset/forty-mugs-laugh.md | 6 + README.md | 72 +++++++- jest.config.js | 1 + rollup.config.js | 2 + src/index.ts | 12 +- src/user-util/index.ts | 65 +++++++ src/user-util/rollup.config.js | 18 ++ src/user.ts | 286 +++++++++++++++++++++++++------ src/utils.ts | 10 +- src/vite-server.ts | 2 + tests/forgot-await.test.ts | 22 ++- tests/user/actionability.test.ts | 156 +++++++++++++++++ tests/user/click.test.ts | 69 +++++++- tests/user/type.test.ts | 216 +++++++++++++++++++++++ 14 files changed, 864 insertions(+), 73 deletions(-) create mode 100644 .changeset/forty-mugs-laugh.md create mode 100644 src/user-util/index.ts create mode 100644 src/user-util/rollup.config.js create mode 100644 tests/user/actionability.test.ts create mode 100644 tests/user/type.test.ts diff --git a/.changeset/forty-mugs-laugh.md b/.changeset/forty-mugs-laugh.md new file mode 100644 index 00000000..28857a26 --- /dev/null +++ b/.changeset/forty-mugs-laugh.md @@ -0,0 +1,6 @@ +--- +'test-mule': minor +--- + +- Add user.type method +- Add actionability checks: visible and attached diff --git a/README.md b/README.md index 0fd1d9ec..b5d03f65 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Test Mule is driven by these goals: - [Making Assertions](#making-assertions) - [Performing Actions](#performing-actions) - [Troubleshooting/Debugging a Failing Test](#troubleshootingdebugging-a-failing-test) + - [Actionability](#actionability) - [Full Example](#full-example) - [API](#api) - [`withBrowser`](#withbrowser) @@ -261,6 +262,29 @@ test( ); ``` +### Actionability + +Test Mule performs actionability checks when interacting with the page using the [User API](#user-api-testmuleuser). This concept is closely modeled after [Cypress](https://docs.cypress.io/guides/core-concepts/interacting-with-elements.html#Actionability) and [Playwright's](https://playwright.dev/docs/actionability) implementations of actionability. + +The core concept behind actionability is that if a real user would not be able to perform an action in your page, you should not be able to perform the actions in your test either. For example, since a user cannot click on an invisible element, your test should not allow you to click on invisible elements. + +We are working on adding more actionability checks. + +Here are the actionability checks that are currently implemented. Different methods in the User API perform different actionability checks based on what makes sense. In the API documentation for the [User API](#user-api-testmuleuser), the actionability checks that each method performs are listed. + +#### Attached + +Ensures that the element is attached to the DOM, using [`Node.isConnected`](https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected). For example, if you use `document.createElement()`, the created element is not attached to the DOM until you use `ParentNode.append()` or similar. + +#### Visible + +Ensures that the element is visible to a user. Currently, the following checks are performed (more will likely be added): + +- Element is [Attached](#attached) to the DOM +- Element does not have `display: none` or `visibility: hidden` +- Element has a size (its [bounding box](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) has a non-zero width and height) +- Element's opacity is greater than 0.05 (opacity of parent elements are considered) + ## Full Example There is a menu example in the [examples folder](./examples/menu/index.test.ts) @@ -399,22 +423,64 @@ The user API allows you to perform actions on behalf of the user. If you have us > **Warning**: The User API is in progress. It should be safe to use the existing methods, but keep in mind that more methods will be added in the future, and more checks will be performed for existing methods as well. -#### `TestMuleUser.click(element: ElementHandle): Promise` +#### `TestMuleUser.click(element: ElementHandle, options?: { force?: boolean }): Promise` + +Clicks an element, if the element is visible and the center of it is not covered by another element. If the center of the element is covered by another element, an error is thrown. This is a thin wrapper around Puppeteer's [`ElementHandle.click` method](https://pptr.dev/#?product=Puppeteer&version=v7.0.1&show=api-elementhandleclickoptions). The difference is that `TestMuleUser.click` checks that the target element is an element that actually can be clicked before clicking it! -Clicks an element, if the element is visible and the center of it is not covered by another element. If the center of the element is covered by another element, an error is thrown. This is a thin wrapper around Puppeteer's [`ElementHandle.click` method](https://pptr.dev/#?product=Puppeteer&version=v7.0.1&show=api-elementhandleclickoptions). The difference is that `TestMuleUser.click` checks that the target element is not covered before performing the click. Don't forget to `await`, since this returns a Promise! +**Actionability checks**: It refuses to click elements that are not [**attached**](#attached) or not [**visible**](#visible). You can override the visibility check by passing `{ force: true }`. + +Additionally, it refuses to click an element if there is another element covering it. `{ force: true }` overrides this behavior. ```js import { withBrowser } from 'test-mule'; test( 'click example', - withBrowser(async ({ user, screen }) => { + withBrowser(async ({ utils, user, screen }) => { + await utils.injectHTML(''); const button = await screen.getByRole('button', { name: /button text/i }); await user.click(button); }), ); ``` +#### `TestMuleUser.type(element: ElementHandle, text: string, options?: { force?: boolean }): Promise` + +Types text into an element, if the element is visible. The element must be an `` or ``); + const textarea: InputHandle = await screen.getByRole('textbox'); + await user.type(textarea, '5678'); + await expect(textarea).toHaveValue('12345678'); + }), +); + +test( + 'appends to existing text (
)', + withBrowser(async ({ user, utils, screen }) => { + // directly on the contenteditable element + await utils.injectHTML(`
1234
`); + const div: InputHandle = await screen.getByRole('textbox'); + await user.type(div, '5678'); + expect(await div.evaluate((div) => div.textContent)).toEqual('12345678'); + + // ancestor element is contenteditable + await utils.injectHTML(``); + const link = await screen.getByText('1234'); + await user.type(link, '5678'); + expect( + await link.evaluate((link) => link.parentElement!.textContent), + ).toEqual('12345678'); + }), +); + +describe('special character sequences', () => { + test( + '{enter} in submits form', + withBrowser(async ({ user, utils, screen }) => { + await utils.injectHTML( + `
`, + ); + const input: InputHandle = await screen.getByRole('textbox'); + const form: FormHandle = await screen.getByRole('form'); + await expect(form).toBeInTheDocument(); + // it shouldn't care about the capitalization in the command sequences + await user.type(input, 'hello{eNtEr}'); + await expect(input).toHaveValue('1234hello'); + await expect(form).not.toBeInTheDocument(); + }), + ); + test( + '{enter} in `); + const input: InputHandle = await screen.getByRole('textbox'); + // it shouldn't care about the capitalization in the command sequences + await user.type(input, 'hello{ENteR}hello2'); + await expect(input).toHaveValue('1234hello\nhello2'); + }), + ); + test( + 'arrow keys', + withBrowser(async ({ user, utils, screen }) => { + await utils.injectHTML(``); + const input: InputHandle = await screen.getByRole('textbox'); + await user.type(input, '56{arrowleft}insert'); + await expect(input).toHaveValue('12345insert6'); + }), + ); + test( + '{tab} moves the focus to the next field and continues typing', + withBrowser(async ({ user, utils, screen }) => { + await utils.injectHTML(` + + { + await utils.injectHTML(``); + const input: InputHandle = await screen.getByRole('textbox'); + await user.type(input, '56{arrowleft}{backspace}'); + await expect(input).toHaveValue('12346'); + await user.type(input, '{arrowleft}{arrowleft}{del}'); + await expect(input).toHaveValue('1246'); + }), + ); + test( + '{selectall}', + withBrowser(async ({ user, utils, screen }) => { + await utils.injectHTML(``); + const input: InputHandle = await screen.getByRole('textbox'); + await user.type(input, '56{selectall}{backspace}abc'); + await expect(input).toHaveValue('abc'); + }), + ); + test( + '{selectall} throws if used on contenteditable', + withBrowser(async ({ user, utils, screen }) => { + await utils.injectHTML(`
hello
`); + const div: ElementHandle = await screen.getByText( + 'hello', + ); + await expect( + user.type(div, '{selectall}'), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"{selectall} command is only available for and textarea elements, received:
hello
"`, + ); + }), + ); +}); + +test( + 'delay', + withBrowser(async ({ user, utils, screen }) => { + await utils.injectHTML(``); + const input: InputHandle = await screen.getByRole('textbox'); + let startTime = new Date().getTime(); + await user.type(input, '123'); + expect(new Date().getTime() - startTime).toBeLessThan(100); + startTime = new Date().getTime(); + await user.type(input, '123', { delay: 50 }); + expect(new Date().getTime() - startTime).toBeGreaterThan(150); + }), +); + +describe('actionability checks', () => { + test( + 'refuses to type in element that is not in the DOM', + withBrowser(async ({ screen, user, utils }) => { + await utils.injectHTML(''); + const input = await screen.getByRole('textbox'); + await input.evaluate((input) => input.remove()); + await expect(user.type(input, 'hello')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Cannot perform action on element that is not attached to the DOM: + " + `); + // Puppeteer's .type silently fails/refuses to type in an unattached element + // So our .type won't type in an unattached element even with { force: true } + await expect(user.type(input, 'hello', { force: true })).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Cannot perform action on element that is not attached to the DOM: + " + `); + }), + ); + test( + 'refuses to type in element that is not visible', + withBrowser(async ({ user, utils, screen }) => { + await utils.injectHTML(``); + const input: InputHandle = await screen.getByRole('textbox'); + await expect(user.type(input, 'some text')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Cannot perform action on element that is not visible (it is near zero opacity): + " + `); + // with { force: true } it should skip the visibility check + await user.type(input, 'some text', { force: true }); + await expect(input).toHaveValue('some text'); + }), + ); + test( + 'refuses to type in element that is not typeable', + withBrowser(async ({ user, utils, screen }) => { + await utils.injectHTML(`
Hi
`); + const div = await screen.getByText('Hi'); + await expect(user.type(div, '5678')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Cannot type in element that is not typeable: +
Hi
+ Element must be an or