Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement user.clear() #58

Merged
merged 3 commits into from
May 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
---
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is quite a file name!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

'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