Skip to content

Commit

Permalink
Implement user.clear() (#58)
Browse files Browse the repository at this point in the history
  • Loading branch information
calebeby committed May 7, 2021
1 parent 93bf33d commit 4e0335c
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 29 deletions.
7 changes: 7 additions & 0 deletions .changeset/afraid-pigs-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'test-mule': minor
---

Implement `user.clear()`

Additionally, the default delay between keypresses in `user.type` has been decreased to 1ms.
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,12 +444,14 @@ test(
);
```

#### `TestMuleUser.type(element: ElementHandle, text: string, options?: { force?: boolean }): Promise<void>`
#### `TestMuleUser.type(element: ElementHandle, text: string, options?: { force?: boolean, delay?: number }): 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**.

The `delay` option controls the amount of time (ms) between keypresses (defaults to 1ms).

**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)
Expand Down Expand Up @@ -481,6 +483,24 @@ test(
);
```

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

Clears a text input's value, if the element is visible. The element must be an `<input>` or `<textarea>`.

**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 }`.

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

test(
'clear example',
withBrowser(async ({ utils, user, screen }) => {
await utils.injectHTML('<input value="text"/>');
const button = await user.clear(button);
}),
);
```

### 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
67 changes: 62 additions & 5 deletions src/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,17 @@ export interface TestMuleUser {
element: ElementHandle | null,
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]`. */
type(
element: ElementHandle | null,
text: string,
options?: { delay?: number; force?: boolean },
): Promise<void>;
/** Clears a text input's value, if the element is visible. The element must be an `<input>` or `<textarea>`. */
clear(
element: ElementHandle | null,
options?: { force?: boolean },
): Promise<void>;
}

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

const forgotAwaitError = removeFuncFromStackTrace(
Expand Down Expand Up @@ -124,18 +130,23 @@ ${coveringEl}`;
return error;
}

if (document.activeElement === el) {
// No need to focus it, it is already focused
// We won't move the cursor to the end either because that could be unexpected
} else if (
if (
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement
) {
// No need to focus it if it is already focused
// We won't move the cursor to the end either because that could be unexpected
if (document.activeElement === el) return;

el.focus();
// Move cursor to the end
const end = el.value.length;
el.setSelectionRange(end, end);
} else if (el instanceof HTMLElement && el.isContentEditable) {
// No need to focus it if it is already focused
// We won't move the cursor to the end either because that could be unexpected
if (document.activeElement === el) return;

el.focus();
const range = el.ownerDocument.createRange();
range.selectNodeContents(el);
Expand Down Expand Up @@ -182,6 +193,52 @@ Element must be an <input> or <textarea> or an element with the contenteditable
}
}
},
async clear(el, { force = false } = {}) {
assertElementHandle(el, user.clear);

const forgotAwaitError = removeFuncFromStackTrace(
new Error(forgotAwaitMsg),
user.clear,
);
const handleForgotAwait = (error: Error) => {
throw state.isTestFinished && /target closed/i.test(error.message)
? forgotAwaitError
: error;
};

await el
.evaluateHandle(
runWithUtils((utils, el, force: boolean) => {
try {
utils.assertAttached(el);
if (!force) utils.assertVisible(el);
} catch (error) {
return error;
}
}),
force,
)
.then(throwBrowserError(user.clear))
.catch(handleForgotAwait);

await el
.evaluateHandle(
runWithUtils((utils, el) => {
if (
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement
) {
el.select();
} else {
return utils.error`user.clear command is only available for <input> and textarea elements, received: ${el}`;
}
}),
)
.then(throwBrowserError(user.clear))
.catch(handleForgotAwait);

await page.keyboard.press('Backspace');
},
};
return user;
};
Expand Down
48 changes: 48 additions & 0 deletions tests/user/clear.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { withBrowser } from 'test-mule';

test(
'clears input element',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<input />`);
const input = await screen.getByRole('textbox');
await user.type(input, 'hiiiiiiii');
await expect(input).toHaveValue('hiiiiiiii');
await user.clear(input);
await expect(input).toHaveValue('');
}),
);

test(
'clears textarea element',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<textarea>some text</textarea>`);
const input = await screen.getByRole('textbox');
await expect(input).toHaveValue('some text');
await user.type(input, ' asdf{enter}hi');
await expect(input).toHaveValue('some text asdf\nhi');
await user.clear(input);
await expect(input).toHaveValue('');
}),
);

test(
'throws for contenteditable elements',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<div contenteditable>text</div>`);
const div = await screen.getByText(/text/);
await expect(user.clear(div)).rejects.toThrowErrorMatchingInlineSnapshot(
`"user.clear command is only available for <input> and textarea elements, received: <div contenteditable=\\"\\">text</div>"`,
);
}),
);

test(
'throws for non-input elements',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<div>text</div>`);
const div = await screen.getByText(/text/);
await expect(user.clear(div)).rejects.toThrowErrorMatchingInlineSnapshot(
`"user.clear command is only available for <input> and textarea elements, received: <div>text</div>"`,
);
}),
);
40 changes: 17 additions & 23 deletions tests/user/type.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
import type { ElementHandle } from 'puppeteer';
import { withBrowser } from 'test-mule';

type InputHandle = ElementHandle<HTMLInputElement>;
type FormHandle = ElementHandle<HTMLFormElement>;

test(
'element text changes, and separate input events are fired',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(
`<input oninput="document.querySelector('h1').innerHTML++"/>
<h1>0</h1>`,
);
const input: InputHandle = await screen.getByRole('textbox');
const input = await screen.getByRole('textbox');
await user.type(input, 'hiiiiiiii');
const heading = await screen.getByRole('heading');
// 9 input events should have fired
expect(await heading.evaluate((h) => Number(h.innerHTML))).toEqual(9);
await expect(heading).toHaveTextContent('9');
await expect(input).toHaveValue('hiiiiiiii');
}),
);
Expand All @@ -24,7 +20,7 @@ test(
'appends to existing text (<input />)',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<input value="1234" />`);
const input: InputHandle = await screen.getByRole('textbox');
const input = await screen.getByRole('textbox');
await user.type(input, '5678');
await expect(input).toHaveValue('12345678');
}),
Expand All @@ -34,7 +30,7 @@ test(
'appends to existing text (<textarea />)',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<textarea>1234</textarea>`);
const textarea: InputHandle = await screen.getByRole('textbox');
const textarea = await screen.getByRole('textbox');
await user.type(textarea, '5678');
await expect(textarea).toHaveValue('12345678');
}),
Expand All @@ -45,9 +41,9 @@ test(
withBrowser(async ({ user, utils, screen }) => {
// Directly on the contenteditable element
await utils.injectHTML(`<div contenteditable role="textbox">1234</div>`);
const div: InputHandle = await screen.getByRole('textbox');
const div = await screen.getByRole('textbox');
await user.type(div, '5678');
expect(await div.evaluate((div) => div.textContent)).toEqual('12345678');
await expect(div).toHaveTextContent('12345678');

// Ancestor element is contenteditable
await utils.injectHTML(`<div contenteditable><a href="hi">1234</a></div>`);
Expand All @@ -66,8 +62,8 @@ describe('special character sequences', () => {
await utils.injectHTML(
`<form name="searchForm" onsubmit="event.preventDefault(); this.remove()"><input value="1234" /></form>`,
);
const input: InputHandle = await screen.getByRole('textbox');
const form: FormHandle = await screen.getByRole('form');
const input = await screen.getByRole('textbox');
const form = 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}');
Expand All @@ -79,7 +75,7 @@ describe('special character sequences', () => {
'{enter} in <textarea> adds newline',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<textarea>1234</textarea>`);
const input: InputHandle = await screen.getByRole('textbox');
const input = 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');
Expand All @@ -89,7 +85,7 @@ describe('special character sequences', () => {
'arrow keys',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<textarea>1234</textarea>`);
const input: InputHandle = await screen.getByRole('textbox');
const input = await screen.getByRole('textbox');
await user.type(input, '56{arrowleft}insert');
await expect(input).toHaveValue('12345insert6');
}),
Expand All @@ -107,8 +103,8 @@ describe('special character sequences', () => {
<textarea></textarea>
</label
`);
const nameBox: InputHandle = await screen.getByLabelText(/name/i);
const descriptionBox: InputHandle = await screen.getByLabelText(/desc/i);
const nameBox = await screen.getByLabelText(/name/i);
const descriptionBox = await screen.getByLabelText(/desc/i);
await user.type(nameBox, '1234{tab}5678');
await expect(nameBox).toHaveValue('1234');
await expect(descriptionBox).toHaveValue('5678');
Expand All @@ -118,7 +114,7 @@ describe('special character sequences', () => {
'{backspace} and {del}',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<textarea>1234</textarea>`);
const input: InputHandle = await screen.getByRole('textbox');
const input = await screen.getByRole('textbox');
await user.type(input, '56{arrowleft}{backspace}');
await expect(input).toHaveValue('12346');
await user.type(input, '{arrowleft}{arrowleft}{del}');
Expand All @@ -129,7 +125,7 @@ describe('special character sequences', () => {
'{selectall}',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<textarea>1234</textarea>`);
const input: InputHandle = await screen.getByRole('textbox');
const input = await screen.getByRole('textbox');
await user.type(input, '56{selectall}{backspace}abc');
await expect(input).toHaveValue('abc');
}),
Expand All @@ -138,9 +134,7 @@ describe('special character sequences', () => {
'{selectall} throws if used on contenteditable',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<div contenteditable>hello</div>`);
const div: ElementHandle<HTMLDivElement> = await screen.getByText(
'hello',
);
const div = await screen.getByText('hello');
await expect(
user.type(div, '{selectall}'),
).rejects.toThrowErrorMatchingInlineSnapshot(
Expand All @@ -154,7 +148,7 @@ test(
'delay',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<textarea>1234</textarea>`);
const input: InputHandle = await screen.getByRole('textbox');
const input = await screen.getByRole('textbox');
let startTime = Date.now();
await user.type(input, '123');
expect(Date.now() - startTime).toBeLessThan(100);
Expand Down Expand Up @@ -189,7 +183,7 @@ describe('actionability checks', () => {
'refuses to type in element that is not visible',
withBrowser(async ({ user, utils, screen }) => {
await utils.injectHTML(`<input style="opacity: 0" />`);
const input: InputHandle = await screen.getByRole('textbox');
const input = 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):
Expand Down

0 comments on commit 4e0335c

Please sign in to comment.