Skip to content

Commit

Permalink
Implement user.selectOptions() (#60)
Browse files Browse the repository at this point in the history
* Refactor/simplify user.clear() a little bit

* Refactor user method forgot-await logic

* Refactor so that try/catch is not needed for each user method

* Implement `user.selectOptions()`
  • Loading branch information
calebeby committed May 13, 2021
1 parent 4e0335c commit f5c2fab
Show file tree
Hide file tree
Showing 8 changed files with 319 additions and 85 deletions.
5 changes: 5 additions & 0 deletions .changeset/stale-bats-applaud.md
@@ -0,0 +1,5 @@
---
'test-mule': minor
---

Implement `user.selectOptions()`
26 changes: 26 additions & 0 deletions README.md
Expand Up @@ -501,6 +501,32 @@ test(
);
```

#### `TestMuleUser.selectOptions(element: ElementHandle, values: ElementHandle | ElementHandle[] | string[] | string, options?: { force?: boolean }): Promise<void>`

Selects the specified option(s) of a `<select>` or a `<select multiple>` element. Values can be passed as either strings (option values) or as [`ElementHandle`](https://pptr.dev/#?product=Puppeteer&version=v7.1.0&show=api-class-elementhandle) references to elements.

**Actionability checks**: It refuses to select in 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(
'select example',
withBrowser(async ({ utils, user, screen }) => {
await utils.injectHTML(`
<select>
<option value="1">A</option>
<option value="2">B</option>
<option value="3">C</option>
</select>,
`);
const selectEl = await screen.getByRole('combobox');
await user.selectOptions(selectEl, '2');
await expect(selectEl).toHaveValue('2');
}),
);
```

### 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
2 changes: 1 addition & 1 deletion src/user-util/index.ts
Expand Up @@ -53,7 +53,7 @@ ${el}`;
// returns { error: ['something bad happened', el]}
export const error = (
literals: TemplateStringsArray,
...placeholders: Element[]
...placeholders: (Element | string)[]
) => {
return {
error: literals.reduce((acc, val, i) => {
Expand Down
200 changes: 121 additions & 79 deletions src/user.ts
Expand Up @@ -23,43 +23,58 @@ export interface TestMuleUser {
element: ElementHandle | null,
options?: { force?: boolean },
): Promise<void>;
/** Selects the specified option(s) of a <select> or a <select multiple> element. Values can be passed as either strings (option values) or as ElementHandle references to elements. */
selectOptions(
element: ElementHandle | null,
values: ElementHandle | ElementHandle[] | string[] | string,
options?: { force?: boolean },
): Promise<void>;
}

const forgotAwaitMsg =
'Cannot interact with browser after test finishes. Did you forget to await?';

export const testMuleUser = (
page: Page,
/** Wraps each user method to catch errors that happen when user forgets to await */
const wrapWithForgotAwait = (
user: TestMuleUser,
state: { isTestFinished: boolean },
) => {
const user: TestMuleUser = {
async click(el, { force = false } = {}) {
assertElementHandle(el, user.click);

for (const key of Object.keys(user) as (keyof TestMuleUser)[]) {
const original = user[key];
// eslint-disable-next-line @cloudfour/unicorn/consistent-function-scoping
const wrapper = async (...args: any[]) => {
const forgotAwaitError = removeFuncFromStackTrace(
new Error(forgotAwaitMsg),
user.click,
wrapper,
);

const handleForgotAwait = (error: Error) => {
try {
return await (original as any)(...args);
} catch (error) {
throw state.isTestFinished && /target closed/i.test(error.message)
? forgotAwaitError
: error;
};
}
};

user[key] = wrapper;
}
};

export const testMuleUser = (
page: Page,
state: { isTestFinished: boolean },
) => {
const user: TestMuleUser = {
async click(el, { force = false } = {}) {
assertElementHandle(el, user.click);
await el
.evaluateHandle(
runWithUtils((utils, clickEl, force: boolean) => {
try {
utils.assertAttached(clickEl);
if (!force) utils.assertVisible(clickEl);
} catch (error) {
return error;
}

utils.assertAttached(clickEl);
if (!force) {
utils.assertVisible(clickEl);
const clickElRect = clickEl.getBoundingClientRect();

// See if there is an element covering the center of the click target element
const coveringEl = document.elementFromPoint(
Math.floor(clickElRect.x + clickElRect.width / 2),
Expand All @@ -78,10 +93,8 @@ ${coveringEl}`;
}),
force,
)
.then(throwBrowserError(user.click))
.catch(handleForgotAwait);

await el.click().catch(handleForgotAwait);
.then(throwBrowserError(user.click));
await el.click();
},

// Implementation notes:
Expand All @@ -94,16 +107,6 @@ ${coveringEl}`;
async type(el, text, { delay = 1, force = false } = {}) {
assertElementHandle(el, user.type);

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

// Splits input into chunks
// i.e. "something{backspace}something{enter} "
// => ["something", "{backspace}", "something", "{enter}"]
Expand All @@ -123,21 +126,15 @@ ${coveringEl}`;
await el
.evaluateHandle(
runWithUtils((utils, el, force: boolean) => {
try {
utils.assertAttached(el);
if (!force) utils.assertVisible(el);
} catch (error) {
return error;
}

utils.assertAttached(el);
if (!force) utils.assertVisible(el);
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;
Expand All @@ -146,7 +143,6 @@ ${coveringEl}`;
// 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 All @@ -165,13 +161,11 @@ Element must be an <input> or <textarea> or an element with the contenteditable
}),
force,
)
.then(throwBrowserError(user.type))
.catch(handleForgotAwait);

.then(throwBrowserError(user.type));
for (const chunk of chunks) {
const key = typeCommandsMap[chunk];
if (key) {
await page.keyboard.press(key, { delay }).catch(handleForgotAwait);
await page.keyboard.press(key, { delay });
} else if (chunk === '{selectall}') {
await el
.evaluateHandle(
Expand All @@ -182,64 +176,105 @@ Element must be an <input> or <textarea> or an element with the contenteditable
) {
el.select();
} else {
return utils.error`{selectall} command is only available for <input> and textarea elements, received: ${el}`;
return utils.error`{selectall} command is only available for <input> and <textarea> elements, received: ${el}`;
}
}),
)
.then(throwBrowserError(user.type))
.catch(handleForgotAwait);
.then(throwBrowserError(user.type));
} else {
await page.keyboard.type(chunk, { delay }).catch(handleForgotAwait);
await page.keyboard.type(chunk, { delay });
}
}
},
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) => {
utils.assertAttached(el);
if (!force) utils.assertVisible(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}`;
return utils.error`user.clear is only available for <input> and <textarea> elements, received: ${el}`;
}
}),
force,
)
.then(throwBrowserError(user.clear))
.catch(handleForgotAwait);

.then(throwBrowserError(user.clear));
await page.keyboard.press('Backspace');
},
async selectOptions(el, values, { force = false } = {}) {
assertElementHandle(el, user.selectOptions);
const valuesArray = Array.isArray(values) ? values : [values];
for (const value of valuesArray) {
// Make sure all values are strings or ElementHandles
if (typeof value !== 'string') {
assertElementHandle(
value,
user.selectOptions,
'values must be a string or ElementHandle or array of either of those.',
);
}
}

const valuesArrayHandle = await el
.evaluateHandle(
runWithUtils(
(
utils,
el,
force: boolean,
...valuesArray: (string | ElementHandle)[]
) => {
utils.assertAttached(el);
if (!force) utils.assertVisible(el);
if (!(el instanceof HTMLSelectElement))
return utils.error`user.selectOptions is only available for <select> elements, received: ${el}`;
if (valuesArray.length > 1 && !el.multiple)
return utils.error`Cannot select multiple options on a <select> element without the \`multiple\` attribute:\n\n${el}`;

const validOptions = new Set(
[...el.options].map((el) => el.value),
);

return valuesArray.map((value) => {
if (value instanceof HTMLOptionElement) {
if (
!validOptions.has(value.value) ||
![...el.options].includes(value)
) {
throw utils.error`Could not select an option ${value}, it is not one of the valid options in the <select>. Valid options are: ${JSON.stringify(
[...validOptions],
)}`;
}

return value.value;
}

if (!validOptions.has(value as string))
throw utils.error`Could not select an option ${JSON.stringify(
value as string,
)}, it is not one of the valid options in the <select>. Valid options are: ${JSON.stringify(
[...validOptions],
)}`;

return value;
});
},
),
force,
...(valuesArray as any),
)
.then(throwBrowserError(user.selectOptions));

await el.select(...((await valuesArrayHandle.jsonValue()) as any));
},
};
wrapWithForgotAwait(user, state);
return user;
};

Expand All @@ -263,7 +298,14 @@ const runWithUtils = <Args extends any[], Return extends unknown>(
return new Function(
'...args',
`return import("http://localhost:${port}/@test-mule/user-util")
.then((utils) => [utils, (0, ${fn.toString()})(utils, ...args)])
.then((utils) => {
try {
return [utils, (0, ${fn.toString()})(utils, ...args)]
} catch (error) {
if (error.error) error = error.error
return [utils, { error }]
}
})
.then(([utils, result]) => {
if (result && typeof result === 'object' && result.error) {
const msgWithLiveEls = result.error
Expand Down
8 changes: 6 additions & 2 deletions src/utils.ts
Expand Up @@ -18,15 +18,19 @@ export const jsHandleToArray = async (arrayHandle: JSHandle) => {
export const assertElementHandle: (
input: unknown,
fn: (...params: any[]) => any,
) => asserts input is ElementHandle = (input, fn) => {
messageStart?: string,
) => asserts input is ElementHandle = (
input,
fn,
messageStart = `element must be an ElementHandle\n\n`,
) => {
const type =
input === null
? 'null'
: typeof input === 'object' && Promise.resolve(input) === input // https://stackoverflow.com/questions/27746304/how-do-i-tell-if-an-object-is-a-promise/38339199#38339199
? 'Promise'
: typeof input;

const messageStart = `element must be an ElementHandle\n\n`;
if (type === 'Promise') {
throw removeFuncFromStackTrace(
new Error(`${messageStart}Received Promise. Did you forget await?`),
Expand Down
4 changes: 2 additions & 2 deletions tests/user/clear.test.ts
Expand Up @@ -31,7 +31,7 @@ test(
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>"`,
`"user.clear is only available for <input> and <textarea> elements, received: <div contenteditable=\\"\\">text</div>"`,
);
}),
);
Expand All @@ -42,7 +42,7 @@ test(
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>"`,
`"user.clear is only available for <input> and <textarea> elements, received: <div>text</div>"`,
);
}),
);

0 comments on commit f5c2fab

Please sign in to comment.