Skip to content

Commit 064e5b4

Browse files
authored
Add user.type and refactor user API (#48)
1 parent 737d782 commit 064e5b4

14 files changed

Lines changed: 864 additions & 73 deletions

.changeset/forty-mugs-laugh.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'test-mule': minor
3+
---
4+
5+
- Add user.type method
6+
- Add actionability checks: visible and attached

README.md

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Test Mule is driven by these goals:
1919
- [Making Assertions](#making-assertions)
2020
- [Performing Actions](#performing-actions)
2121
- [Troubleshooting/Debugging a Failing Test](#troubleshootingdebugging-a-failing-test)
22+
- [Actionability](#actionability)
2223
- [Full Example](#full-example)
2324
- [API](#api)
2425
- [`withBrowser`](#withbrowser)
@@ -261,6 +262,29 @@ test(
261262
);
262263
```
263264

265+
### Actionability
266+
267+
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.
268+
269+
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.
270+
271+
We are working on adding more actionability checks.
272+
273+
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.
274+
275+
#### Attached
276+
277+
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.
278+
279+
#### Visible
280+
281+
Ensures that the element is visible to a user. Currently, the following checks are performed (more will likely be added):
282+
283+
- Element is [Attached](#attached) to the DOM
284+
- Element does not have `display: none` or `visibility: hidden`
285+
- 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)
286+
- Element's opacity is greater than 0.05 (opacity of parent elements are considered)
287+
264288
## Full Example
265289

266290
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
399423

400424
> **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.
401425
402-
#### `TestMuleUser.click(element: ElementHandle): Promise<void>`
426+
#### `TestMuleUser.click(element: ElementHandle, options?: { force?: boolean }): Promise<void>`
427+
428+
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!
403429

404-
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!
430+
**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 }`.
431+
432+
Additionally, it refuses to click an element if there is another element covering it. `{ force: true }` overrides this behavior.
405433

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

409437
test(
410438
'click example',
411-
withBrowser(async ({ user, screen }) => {
439+
withBrowser(async ({ utils, user, screen }) => {
440+
await utils.injectHTML('<button>button text</button>');
412441
const button = await screen.getByRole('button', { name: /button text/i });
413442
await user.click(button);
414443
}),
415444
);
416445
```
417446

447+
#### `TestMuleUser.type(element: ElementHandle, text: string, options?: { force?: boolean }): Promise<void>`
448+
449+
Types text into an element, if the element is visible. The element must be an `<input>` or `<textarea>` or have `[contenteditable]`.
450+
451+
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**.
452+
453+
**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 }`.
454+
455+
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)
456+
457+
| Text string | Key | Notes |
458+
| -------------- | ---------- | --------------------------------------------------------------------------------------- |
459+
| `{enter}` | Enter | |
460+
| `{tab}` | Tab | |
461+
| `{backspace}` | Backspace | |
462+
| `{del}` | Delete | |
463+
| `{selectall}` | N/A | Selects all the text of the element. Does not work for elements using `contenteditable` |
464+
| `{arrowleft}` | ArrowLeft | |
465+
| `{arrowright}` | ArrowRight | |
466+
| `{arrowup}` | ArrowUp | |
467+
| `{arrowdown}` | ArrowDown | |
468+
469+
```js
470+
import { withBrowser } from 'test-mule';
471+
472+
test(
473+
'type example',
474+
withBrowser(async ({ utils, user, screen }) => {
475+
await utils.injectHTML('<input />');
476+
const button = await user.type(
477+
button,
478+
'this is some text..{backspace}{arrowleft} asdf',
479+
);
480+
}),
481+
);
482+
```
483+
418484
### Utilities API: `TestMuleUtils`
419485

420486
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)

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module.exports = {
44
'test-mule': '<rootDir>/dist/cjs/index.cjs',
55
},
66
testRunner: 'jest-circus/runner',
7+
watchPathIgnorePatterns: ['<rootDir>/src/'],
78
transform: {
89
'^.+\\.tsx?$': ['esbuild-jest', { sourcemap: true }],
910
},

rollup.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import jestDomConfig from './src/jest-dom/rollup.config';
22
import pptrTestingLibraryConfig from './src/pptr-testing-library-client/rollup.config';
3+
import userUtilsConfig from './src/user-util/rollup.config';
34

45
import dts from 'rollup-plugin-dts';
56
import babel from '@rollup/plugin-babel';
@@ -40,6 +41,7 @@ const typesConfig = {
4041

4142
export default [
4243
mainConfig,
44+
userUtilsConfig,
4345
jestDomConfig,
4446
pptrTestingLibraryConfig,
4547
typesConfig,

src/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,11 +404,18 @@ const createTab = async ({
404404
const within: TestMuleContext['within'] = (
405405
element: puppeteer.ElementHandle | null,
406406
) => {
407-
assertElementHandle(element, within, 'within(el)', 'el');
407+
assertElementHandle(element, within);
408408
return getQueriesForElement(page, state, element);
409409
};
410410

411-
return { screen, utils, page, within, user: testMuleUser(state), state };
411+
return {
412+
screen,
413+
utils,
414+
page,
415+
within,
416+
user: testMuleUser(page, state),
417+
state,
418+
};
412419
};
413420

414421
afterAll(async () => {
@@ -419,3 +426,4 @@ afterAll(async () => {
419426
});
420427

421428
export const devices = puppeteer.devices;
429+
export { port };

src/user-util/index.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
export { printElement } from '../serialize';
2+
3+
export const assertAttached = (el: Element) => {
4+
if (!el.isConnected) {
5+
throw error`Cannot perform action on element that is not attached to the DOM:
6+
${el}`;
7+
}
8+
};
9+
10+
// Element is visible if all:
11+
// - it is attached to the DOM
12+
// - it has a rendered size (its rendered width and height are not zero)
13+
// - Computed opacity (product of opacity of ancestors) is non-zero
14+
// - is not display: none or visibility: hidden
15+
export const assertVisible = (el: Element) => {
16+
assertAttached(el);
17+
18+
// getComputedStyle allows inherited properties to be seen correctly
19+
const style = getComputedStyle(el);
20+
21+
if (style.visibility === 'hidden') {
22+
throw error`Cannot perform action on element that is not visible (it has visibility:hidden):
23+
${el}`;
24+
}
25+
26+
// The opacity of a parent element affects the rendering of a child element,
27+
// but the opacity property is not inherited, so this computes the rendered opacity
28+
// by walking up the tree and multiplying the opacities.
29+
let opacity = Number(style.opacity);
30+
let opacityEl: Element | null = el;
31+
while (opacity && (opacityEl = opacityEl.parentElement)) {
32+
opacity *= (getComputedStyle(opacityEl).opacity as any) as number;
33+
}
34+
35+
if (opacity < 0.05) {
36+
throw error`Cannot perform action on element that is not visible (it is near zero opacity):
37+
${el}`;
38+
}
39+
40+
const rect = el.getBoundingClientRect();
41+
// handles: rendered width is zero or rendered height is zero or display:none
42+
if (rect.width * rect.height === 0) {
43+
throw error`Cannot perform action on element that is not visible (it was not rendered or has a size of zero):
44+
${el}`;
45+
}
46+
};
47+
48+
// this is used to generate the arrays that are used
49+
// to produce messages with live elements in the browser,
50+
// and stringified elements in node
51+
// example usage:
52+
// error`something bad happened: ${el}`
53+
// returns { error: ['something bad happened', el]}
54+
export const error = (
55+
literals: TemplateStringsArray,
56+
...placeholders: Element[]
57+
) => {
58+
return {
59+
error: literals.reduce((acc, val, i) => {
60+
if (i !== 0) acc.push(placeholders[i - 1]);
61+
if (val !== '') acc.push(val);
62+
return acc;
63+
}, [] as (string | Element)[]),
64+
};
65+
};

src/user-util/rollup.config.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import babel from '@rollup/plugin-babel';
2+
import nodeResolve from '@rollup/plugin-node-resolve';
3+
import { terser } from 'rollup-plugin-terser';
4+
5+
const extensions = ['.js', '.jsx', '.es6', '.es', '.mjs', '.ts', '.tsx'];
6+
7+
/** @type {import('rollup').RollupOptions} */
8+
const config = {
9+
input: ['src/user-util/index.ts'],
10+
plugins: [
11+
babel({ babelHelpers: 'bundled', extensions }),
12+
nodeResolve({ extensions }),
13+
terser({ ecma: 2019 }),
14+
],
15+
output: { file: 'dist/user-util.js' },
16+
};
17+
18+
export default config;

0 commit comments

Comments
 (0)