diff --git a/.changeset/stale-bats-applaud.md b/.changeset/stale-bats-applaud.md new file mode 100644 index 00000000..389498d8 --- /dev/null +++ b/.changeset/stale-bats-applaud.md @@ -0,0 +1,5 @@ +--- +'test-mule': minor +--- + +Implement `user.selectOptions()` diff --git a/README.md b/README.md index 8b21762d..e6530914 100644 --- a/README.md +++ b/README.md @@ -501,6 +501,32 @@ test( ); ``` +#### `TestMuleUser.selectOptions(element: ElementHandle, values: ElementHandle | ElementHandle[] | string[] | string, options?: { force?: boolean }): Promise` + +Selects the specified option(s) of a `` 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(` + , + `); + 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) diff --git a/src/user-util/index.ts b/src/user-util/index.ts index 9df50698..30e4eb02 100644 --- a/src/user-util/index.ts +++ b/src/user-util/index.ts @@ -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) => { diff --git a/src/user.ts b/src/user.ts index c1039813..06e4e583 100644 --- a/src/user.ts +++ b/src/user.ts @@ -23,43 +23,58 @@ export interface TestMuleUser { element: ElementHandle | null, options?: { force?: boolean }, ): Promise; + /** Selects the specified option(s) of a 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; } 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), @@ -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: @@ -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}"] @@ -123,13 +126,8 @@ ${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 @@ -137,7 +135,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(); // Move cursor to the end const end = el.value.length; @@ -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); @@ -165,13 +161,11 @@ Element must be an or