Skip to content

Commit 4e0335c

Browse files
authored
Implement user.clear() (#58)
1 parent 93bf33d commit 4e0335c

5 files changed

Lines changed: 155 additions & 29 deletions

File tree

.changeset/afraid-pigs-cry.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'test-mule': minor
3+
---
4+
5+
Implement `user.clear()`
6+
7+
Additionally, the default delay between keypresses in `user.type` has been decreased to 1ms.

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,12 +444,14 @@ test(
444444
);
445445
```
446446

447-
#### `TestMuleUser.type(element: ElementHandle, text: string, options?: { force?: boolean }): Promise<void>`
447+
#### `TestMuleUser.type(element: ElementHandle, text: string, options?: { force?: boolean, delay?: number }): Promise<void>`
448448

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

451451
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**.
452452

453+
The `delay` option controls the amount of time (ms) between keypresses (defaults to 1ms).
454+
453455
**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 }`.
454456

455457
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)
@@ -481,6 +483,24 @@ test(
481483
);
482484
```
483485

486+
#### `TestMuleUser.clear(element: ElementHandle, options?: { force?: boolean }): Promise<void>`
487+
488+
Clears a text input's value, if the element is visible. The element must be an `<input>` or `<textarea>`.
489+
490+
**Actionability checks**: It refuses to clear elements that are not [**attached**](#attached) or not [**visible**](#visible). You can override the visibility check by passing `{ force: true }`.
491+
492+
```js
493+
import { withBrowser } from 'test-mule';
494+
495+
test(
496+
'clear example',
497+
withBrowser(async ({ utils, user, screen }) => {
498+
await utils.injectHTML('<input value="text"/>');
499+
const button = await user.clear(button);
500+
}),
501+
);
502+
```
503+
484504
### Utilities API: `TestMuleUtils`
485505

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

src/user.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,17 @@ export interface TestMuleUser {
1212
element: ElementHandle | null,
1313
options?: { force?: boolean },
1414
): Promise<void>;
15+
/** Types text into an element, if the element is visible. The element must be an `<input>` or `<textarea>` or have `[contenteditable]`. */
1516
type(
1617
element: ElementHandle | null,
1718
text: string,
1819
options?: { delay?: number; force?: boolean },
1920
): Promise<void>;
21+
/** Clears a text input's value, if the element is visible. The element must be an `<input>` or `<textarea>`. */
22+
clear(
23+
element: ElementHandle | null,
24+
options?: { force?: boolean },
25+
): Promise<void>;
2026
}
2127

2228
const forgotAwaitMsg =
@@ -85,7 +91,7 @@ ${coveringEl}`;
8591
// - The names of the commands in curly brackets are mirroring the user-event command names
8692
// *NOT* the Cypress names.
8793
// i.e. Cypress uses {leftarrow} but user-event and test-mule use {arrowleft}
88-
async type(el, text, { delay = 10, force = false } = {}) {
94+
async type(el, text, { delay = 1, force = false } = {}) {
8995
assertElementHandle(el, user.type);
9096

9197
const forgotAwaitError = removeFuncFromStackTrace(
@@ -124,18 +130,23 @@ ${coveringEl}`;
124130
return error;
125131
}
126132

127-
if (document.activeElement === el) {
128-
// No need to focus it, it is already focused
129-
// We won't move the cursor to the end either because that could be unexpected
130-
} else if (
133+
if (
131134
el instanceof HTMLInputElement ||
132135
el instanceof HTMLTextAreaElement
133136
) {
137+
// No need to focus it if it is already focused
138+
// We won't move the cursor to the end either because that could be unexpected
139+
if (document.activeElement === el) return;
140+
134141
el.focus();
135142
// Move cursor to the end
136143
const end = el.value.length;
137144
el.setSelectionRange(end, end);
138145
} else if (el instanceof HTMLElement && el.isContentEditable) {
146+
// No need to focus it if it is already focused
147+
// We won't move the cursor to the end either because that could be unexpected
148+
if (document.activeElement === el) return;
149+
139150
el.focus();
140151
const range = el.ownerDocument.createRange();
141152
range.selectNodeContents(el);
@@ -182,6 +193,52 @@ Element must be an <input> or <textarea> or an element with the contenteditable
182193
}
183194
}
184195
},
196+
async clear(el, { force = false } = {}) {
197+
assertElementHandle(el, user.clear);
198+
199+
const forgotAwaitError = removeFuncFromStackTrace(
200+
new Error(forgotAwaitMsg),
201+
user.clear,
202+
);
203+
const handleForgotAwait = (error: Error) => {
204+
throw state.isTestFinished && /target closed/i.test(error.message)
205+
? forgotAwaitError
206+
: error;
207+
};
208+
209+
await el
210+
.evaluateHandle(
211+
runWithUtils((utils, el, force: boolean) => {
212+
try {
213+
utils.assertAttached(el);
214+
if (!force) utils.assertVisible(el);
215+
} catch (error) {
216+
return error;
217+
}
218+
}),
219+
force,
220+
)
221+
.then(throwBrowserError(user.clear))
222+
.catch(handleForgotAwait);
223+
224+
await el
225+
.evaluateHandle(
226+
runWithUtils((utils, el) => {
227+
if (
228+
el instanceof HTMLInputElement ||
229+
el instanceof HTMLTextAreaElement
230+
) {
231+
el.select();
232+
} else {
233+
return utils.error`user.clear command is only available for <input> and textarea elements, received: ${el}`;
234+
}
235+
}),
236+
)
237+
.then(throwBrowserError(user.clear))
238+
.catch(handleForgotAwait);
239+
240+
await page.keyboard.press('Backspace');
241+
},
185242
};
186243
return user;
187244
};

tests/user/clear.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { withBrowser } from 'test-mule';
2+
3+
test(
4+
'clears input element',
5+
withBrowser(async ({ user, utils, screen }) => {
6+
await utils.injectHTML(`<input />`);
7+
const input = await screen.getByRole('textbox');
8+
await user.type(input, 'hiiiiiiii');
9+
await expect(input).toHaveValue('hiiiiiiii');
10+
await user.clear(input);
11+
await expect(input).toHaveValue('');
12+
}),
13+
);
14+
15+
test(
16+
'clears textarea element',
17+
withBrowser(async ({ user, utils, screen }) => {
18+
await utils.injectHTML(`<textarea>some text</textarea>`);
19+
const input = await screen.getByRole('textbox');
20+
await expect(input).toHaveValue('some text');
21+
await user.type(input, ' asdf{enter}hi');
22+
await expect(input).toHaveValue('some text asdf\nhi');
23+
await user.clear(input);
24+
await expect(input).toHaveValue('');
25+
}),
26+
);
27+
28+
test(
29+
'throws for contenteditable elements',
30+
withBrowser(async ({ user, utils, screen }) => {
31+
await utils.injectHTML(`<div contenteditable>text</div>`);
32+
const div = await screen.getByText(/text/);
33+
await expect(user.clear(div)).rejects.toThrowErrorMatchingInlineSnapshot(
34+
`"user.clear command is only available for <input> and textarea elements, received: <div contenteditable=\\"\\">text</div>"`,
35+
);
36+
}),
37+
);
38+
39+
test(
40+
'throws for non-input elements',
41+
withBrowser(async ({ user, utils, screen }) => {
42+
await utils.injectHTML(`<div>text</div>`);
43+
const div = await screen.getByText(/text/);
44+
await expect(user.clear(div)).rejects.toThrowErrorMatchingInlineSnapshot(
45+
`"user.clear command is only available for <input> and textarea elements, received: <div>text</div>"`,
46+
);
47+
}),
48+
);

tests/user/type.test.ts

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,17 @@
1-
import type { ElementHandle } from 'puppeteer';
21
import { withBrowser } from 'test-mule';
32

4-
type InputHandle = ElementHandle<HTMLInputElement>;
5-
type FormHandle = ElementHandle<HTMLFormElement>;
6-
73
test(
84
'element text changes, and separate input events are fired',
95
withBrowser(async ({ user, utils, screen }) => {
106
await utils.injectHTML(
117
`<input oninput="document.querySelector('h1').innerHTML++"/>
128
<h1>0</h1>`,
139
);
14-
const input: InputHandle = await screen.getByRole('textbox');
10+
const input = await screen.getByRole('textbox');
1511
await user.type(input, 'hiiiiiiii');
1612
const heading = await screen.getByRole('heading');
1713
// 9 input events should have fired
18-
expect(await heading.evaluate((h) => Number(h.innerHTML))).toEqual(9);
14+
await expect(heading).toHaveTextContent('9');
1915
await expect(input).toHaveValue('hiiiiiiii');
2016
}),
2117
);
@@ -24,7 +20,7 @@ test(
2420
'appends to existing text (<input />)',
2521
withBrowser(async ({ user, utils, screen }) => {
2622
await utils.injectHTML(`<input value="1234" />`);
27-
const input: InputHandle = await screen.getByRole('textbox');
23+
const input = await screen.getByRole('textbox');
2824
await user.type(input, '5678');
2925
await expect(input).toHaveValue('12345678');
3026
}),
@@ -34,7 +30,7 @@ test(
3430
'appends to existing text (<textarea />)',
3531
withBrowser(async ({ user, utils, screen }) => {
3632
await utils.injectHTML(`<textarea>1234</textarea>`);
37-
const textarea: InputHandle = await screen.getByRole('textbox');
33+
const textarea = await screen.getByRole('textbox');
3834
await user.type(textarea, '5678');
3935
await expect(textarea).toHaveValue('12345678');
4036
}),
@@ -45,9 +41,9 @@ test(
4541
withBrowser(async ({ user, utils, screen }) => {
4642
// Directly on the contenteditable element
4743
await utils.injectHTML(`<div contenteditable role="textbox">1234</div>`);
48-
const div: InputHandle = await screen.getByRole('textbox');
44+
const div = await screen.getByRole('textbox');
4945
await user.type(div, '5678');
50-
expect(await div.evaluate((div) => div.textContent)).toEqual('12345678');
46+
await expect(div).toHaveTextContent('12345678');
5147

5248
// Ancestor element is contenteditable
5349
await utils.injectHTML(`<div contenteditable><a href="hi">1234</a></div>`);
@@ -66,8 +62,8 @@ describe('special character sequences', () => {
6662
await utils.injectHTML(
6763
`<form name="searchForm" onsubmit="event.preventDefault(); this.remove()"><input value="1234" /></form>`,
6864
);
69-
const input: InputHandle = await screen.getByRole('textbox');
70-
const form: FormHandle = await screen.getByRole('form');
65+
const input = await screen.getByRole('textbox');
66+
const form = await screen.getByRole('form');
7167
await expect(form).toBeInTheDocument();
7268
// It shouldn't care about the capitalization in the command sequences
7369
await user.type(input, 'hello{eNtEr}');
@@ -79,7 +75,7 @@ describe('special character sequences', () => {
7975
'{enter} in <textarea> adds newline',
8076
withBrowser(async ({ user, utils, screen }) => {
8177
await utils.injectHTML(`<textarea>1234</textarea>`);
82-
const input: InputHandle = await screen.getByRole('textbox');
78+
const input = await screen.getByRole('textbox');
8379
// It shouldn't care about the capitalization in the command sequences
8480
await user.type(input, 'hello{ENteR}hello2');
8581
await expect(input).toHaveValue('1234hello\nhello2');
@@ -89,7 +85,7 @@ describe('special character sequences', () => {
8985
'arrow keys',
9086
withBrowser(async ({ user, utils, screen }) => {
9187
await utils.injectHTML(`<textarea>1234</textarea>`);
92-
const input: InputHandle = await screen.getByRole('textbox');
88+
const input = await screen.getByRole('textbox');
9389
await user.type(input, '56{arrowleft}insert');
9490
await expect(input).toHaveValue('12345insert6');
9591
}),
@@ -107,8 +103,8 @@ describe('special character sequences', () => {
107103
<textarea></textarea>
108104
</label
109105
`);
110-
const nameBox: InputHandle = await screen.getByLabelText(/name/i);
111-
const descriptionBox: InputHandle = await screen.getByLabelText(/desc/i);
106+
const nameBox = await screen.getByLabelText(/name/i);
107+
const descriptionBox = await screen.getByLabelText(/desc/i);
112108
await user.type(nameBox, '1234{tab}5678');
113109
await expect(nameBox).toHaveValue('1234');
114110
await expect(descriptionBox).toHaveValue('5678');
@@ -118,7 +114,7 @@ describe('special character sequences', () => {
118114
'{backspace} and {del}',
119115
withBrowser(async ({ user, utils, screen }) => {
120116
await utils.injectHTML(`<textarea>1234</textarea>`);
121-
const input: InputHandle = await screen.getByRole('textbox');
117+
const input = await screen.getByRole('textbox');
122118
await user.type(input, '56{arrowleft}{backspace}');
123119
await expect(input).toHaveValue('12346');
124120
await user.type(input, '{arrowleft}{arrowleft}{del}');
@@ -129,7 +125,7 @@ describe('special character sequences', () => {
129125
'{selectall}',
130126
withBrowser(async ({ user, utils, screen }) => {
131127
await utils.injectHTML(`<textarea>1234</textarea>`);
132-
const input: InputHandle = await screen.getByRole('textbox');
128+
const input = await screen.getByRole('textbox');
133129
await user.type(input, '56{selectall}{backspace}abc');
134130
await expect(input).toHaveValue('abc');
135131
}),
@@ -138,9 +134,7 @@ describe('special character sequences', () => {
138134
'{selectall} throws if used on contenteditable',
139135
withBrowser(async ({ user, utils, screen }) => {
140136
await utils.injectHTML(`<div contenteditable>hello</div>`);
141-
const div: ElementHandle<HTMLDivElement> = await screen.getByText(
142-
'hello',
143-
);
137+
const div = await screen.getByText('hello');
144138
await expect(
145139
user.type(div, '{selectall}'),
146140
).rejects.toThrowErrorMatchingInlineSnapshot(
@@ -154,7 +148,7 @@ test(
154148
'delay',
155149
withBrowser(async ({ user, utils, screen }) => {
156150
await utils.injectHTML(`<textarea>1234</textarea>`);
157-
const input: InputHandle = await screen.getByRole('textbox');
151+
const input = await screen.getByRole('textbox');
158152
let startTime = Date.now();
159153
await user.type(input, '123');
160154
expect(Date.now() - startTime).toBeLessThan(100);
@@ -189,7 +183,7 @@ describe('actionability checks', () => {
189183
'refuses to type in element that is not visible',
190184
withBrowser(async ({ user, utils, screen }) => {
191185
await utils.injectHTML(`<input style="opacity: 0" />`);
192-
const input: InputHandle = await screen.getByRole('textbox');
186+
const input = await screen.getByRole('textbox');
193187
await expect(user.type(input, 'some text')).rejects
194188
.toThrowErrorMatchingInlineSnapshot(`
195189
"Cannot perform action on element that is not visible (it is near zero opacity):

0 commit comments

Comments
 (0)