Skip to content

Commit

Permalink
Add user.type and refactor user API (#48)
Browse files Browse the repository at this point in the history
  • Loading branch information
calebeby committed Mar 25, 2021
1 parent 737d782 commit 064e5b4
Show file tree
Hide file tree
Showing 14 changed files with 864 additions and 73 deletions.
6 changes: 6 additions & 0 deletions .changeset/forty-mugs-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'test-mule': minor
---

- Add user.type method
- Add actionability checks: visible and attached
72 changes: 69 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<void>`
#### `TestMuleUser.click(element: ElementHandle, options?: { force?: boolean }): Promise<void>`

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('<button>button text</button>');
const button = await screen.getByRole('button', { name: /button text/i });
await user.click(button);
}),
);
```

#### `TestMuleUser.type(element: ElementHandle, text: string, options?: { force?: boolean }): Promise<void>`

Types text into an element, if the element is visible. The element must be an `<input>` or `<textarea>` or have `[contenteditable]`.

If the element already has text in it, the additional text is appended to the existing text. **This is different from Puppeteer and Playwright's default .type behavior**.

**Actionability checks**: It refuses to type into elements that are not [**attached**](#attached) or not [**visible**](#visible). You can override the visibility check by passing `{ force: true }`.

In the text, you can pass special commands using curly brackets to trigger special keypresses, similar to [user-event](https://github.com/testing-library/user-event#special-characters) and [Cypress](https://docs.cypress.io/api/commands/type.html#Arguments). Open an issue if you want more commands available here! Note: If you want to simulate individual keypresses independent from a text field, you can use Puppeteer's [page.keyboard API](https://pptr.dev/#?product=Puppeteer&version=v7.1.0&show=api-pagekeyboard)

| Text string | Key | Notes |
| -------------- | ---------- | --------------------------------------------------------------------------------------- |
| `{enter}` | Enter | |
| `{tab}` | Tab | |
| `{backspace}` | Backspace | |
| `{del}` | Delete | |
| `{selectall}` | N/A | Selects all the text of the element. Does not work for elements using `contenteditable` |
| `{arrowleft}` | ArrowLeft | |
| `{arrowright}` | ArrowRight | |
| `{arrowup}` | ArrowUp | |
| `{arrowdown}` | ArrowDown | |

```js
import { withBrowser } from 'test-mule';

test(
'type example',
withBrowser(async ({ utils, user, screen }) => {
await utils.injectHTML('<input />');
const button = await user.type(
button,
'this is some text..{backspace}{arrowleft} asdf',
);
}),
);
```

### Utilities API: `TestMuleUtils`

The utilities API provides shortcuts for loading and running code in the browser. The methods are wrappers around behavior that can be performed more verbosely with the [Puppeteer `Page` object](#testmulecontextpage). This API is exposed via the [`utils` property in `TestMuleContext`](#testmulecontextutils-testmuleutils)
Expand Down
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module.exports = {
'test-mule': '<rootDir>/dist/cjs/index.cjs',
},
testRunner: 'jest-circus/runner',
watchPathIgnorePatterns: ['<rootDir>/src/'],
transform: {
'^.+\\.tsx?$': ['esbuild-jest', { sourcemap: true }],
},
Expand Down
2 changes: 2 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import jestDomConfig from './src/jest-dom/rollup.config';
import pptrTestingLibraryConfig from './src/pptr-testing-library-client/rollup.config';
import userUtilsConfig from './src/user-util/rollup.config';

import dts from 'rollup-plugin-dts';
import babel from '@rollup/plugin-babel';
Expand Down Expand Up @@ -40,6 +41,7 @@ const typesConfig = {

export default [
mainConfig,
userUtilsConfig,
jestDomConfig,
pptrTestingLibraryConfig,
typesConfig,
Expand Down
12 changes: 10 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,11 +404,18 @@ const createTab = async ({
const within: TestMuleContext['within'] = (
element: puppeteer.ElementHandle | null,
) => {
assertElementHandle(element, within, 'within(el)', 'el');
assertElementHandle(element, within);
return getQueriesForElement(page, state, element);
};

return { screen, utils, page, within, user: testMuleUser(state), state };
return {
screen,
utils,
page,
within,
user: testMuleUser(page, state),
state,
};
};

afterAll(async () => {
Expand All @@ -419,3 +426,4 @@ afterAll(async () => {
});

export const devices = puppeteer.devices;
export { port };
65 changes: 65 additions & 0 deletions src/user-util/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export { printElement } from '../serialize';

export const assertAttached = (el: Element) => {
if (!el.isConnected) {
throw error`Cannot perform action on element that is not attached to the DOM:
${el}`;
}
};

// Element is visible if all:
// - it is attached to the DOM
// - it has a rendered size (its rendered width and height are not zero)
// - Computed opacity (product of opacity of ancestors) is non-zero
// - is not display: none or visibility: hidden
export const assertVisible = (el: Element) => {
assertAttached(el);

// getComputedStyle allows inherited properties to be seen correctly
const style = getComputedStyle(el);

if (style.visibility === 'hidden') {
throw error`Cannot perform action on element that is not visible (it has visibility:hidden):
${el}`;
}

// The opacity of a parent element affects the rendering of a child element,
// but the opacity property is not inherited, so this computes the rendered opacity
// by walking up the tree and multiplying the opacities.
let opacity = Number(style.opacity);
let opacityEl: Element | null = el;
while (opacity && (opacityEl = opacityEl.parentElement)) {
opacity *= (getComputedStyle(opacityEl).opacity as any) as number;
}

if (opacity < 0.05) {
throw error`Cannot perform action on element that is not visible (it is near zero opacity):
${el}`;
}

const rect = el.getBoundingClientRect();
// handles: rendered width is zero or rendered height is zero or display:none
if (rect.width * rect.height === 0) {
throw error`Cannot perform action on element that is not visible (it was not rendered or has a size of zero):
${el}`;
}
};

// this is used to generate the arrays that are used
// to produce messages with live elements in the browser,
// and stringified elements in node
// example usage:
// error`something bad happened: ${el}`
// returns { error: ['something bad happened', el]}
export const error = (
literals: TemplateStringsArray,
...placeholders: Element[]
) => {
return {
error: literals.reduce((acc, val, i) => {
if (i !== 0) acc.push(placeholders[i - 1]);
if (val !== '') acc.push(val);
return acc;
}, [] as (string | Element)[]),
};
};
18 changes: 18 additions & 0 deletions src/user-util/rollup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import babel from '@rollup/plugin-babel';
import nodeResolve from '@rollup/plugin-node-resolve';
import { terser } from 'rollup-plugin-terser';

const extensions = ['.js', '.jsx', '.es6', '.es', '.mjs', '.ts', '.tsx'];

/** @type {import('rollup').RollupOptions} */
const config = {
input: ['src/user-util/index.ts'],
plugins: [
babel({ babelHelpers: 'bundled', extensions }),
nodeResolve({ extensions }),
terser({ ecma: 2019 }),
],
output: { file: 'dist/user-util.js' },
};

export default config;

0 comments on commit 064e5b4

Please sign in to comment.