diff --git a/.changeset/silly-cougars-trade.md b/.changeset/silly-cougars-trade.md new file mode 100644 index 00000000..4e0a5da9 --- /dev/null +++ b/.changeset/silly-cougars-trade.md @@ -0,0 +1,5 @@ +--- +'pleasantest': minor +--- + +Add `waitFor` feature diff --git a/README.md b/README.md index ece28133..4469ad62 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,36 @@ The `devices` import from `pleasantest` is re-exported from Puppeteer, [here is ### `PleasantestContext` Object (passed into test function wrapped by `withBrowser`) +#### `PleasantestContext.waitFor(callback: () => T | Promise, options?: WaitForOptions) => Promise` + +The `waitFor` method in the `PleasantestContext` object repeatedly executes the callback passed into it until the callback stops throwing or rejecting, or after a configurable timeout. [This utility comes from Testing Library](https://testing-library.com/docs/dom-testing-library/api-async/#waitfor). + +The return value of the callback function is returned by `waitFor`. + +`WaitForOptions`: (all properties are optional): + +- `container`: `ElementHandle`, default `document.documentElement` (root element): The element watched by the MutationObserver which, + when it or its descendants change, causes the callback to run again (regardless of the interval). +- `timeout`: `number`, default: 1000ms The amount of time (milliseconds) that will pass before waitFor "gives up" and throws whatever the callback threw. +- `interval`: `number`, default: 50ms: The maximum amount of time (milliseconds) that will pass between each run of the callback. If the MutationObserver notices a DOM change before this interval triggers, the callback will run again immediately. +- `onTimeout`: `(error: Error) => Error`: Manipulate the error thrown when the timeout triggers. +- `mutationObserverOptions`: [`MutationObserverInit`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#parameters) Options to pass to initialize the `MutationObserver`, + +```js +import { withBrowser } from 'pleasantest'; + +test( + 'test name', + withBrowser(async ({ waitFor, page }) => { + // ^^^^^^^ + // Wait until the url changes to ... + await waitFor(async () => { + expect(page.url).toBe('https://something.com/something'); + }); + }), +); +``` + #### `PleasantestContext.screen` The `PleasantestContext` object exposes the [`screen`](https://testing-library.com/docs/queries/about/#screen) property, which is an [object with Testing Library queries pre-bound to the document](https://testing-library.com/docs/queries/about/#screen). All of the [Testing Library queries](https://testing-library.com/docs/queries/about#overview) are available. These are used to find elements in the DOM for use in your tests. There is one difference in how you use the queries in Pleasantest compared to Testing Library: in Pleasantest, all queries must be `await`ed to handle the time it takes to communicate with the browser. In addition, since your tests are running in Node, the queries return Promises that resolve to [`ElementHandle`](https://pptr.dev/#?product=Puppeteer&version=v13.0.0&show=api-class-elementhandle)'s from Puppeteer. diff --git a/src/index.ts b/src/index.ts index cb158672..e32a7b76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,10 @@ import * as puppeteer from 'puppeteer'; import { relative, join, isAbsolute, dirname } from 'path'; -import type { BoundQueries } from './pptr-testing-library'; -import { getQueriesForElement } from './pptr-testing-library'; +import type { BoundQueries, WaitForOptions } from './pptr-testing-library'; +import { + getQueriesForElement, + waitFor as innerWaitFor, +} from './pptr-testing-library'; import { connectToBrowser } from './connect-to-browser'; import { parseStackTrace } from 'errorstacks'; import './extend-expect'; @@ -59,6 +62,7 @@ export interface PleasantestContext { within(element: puppeteer.ElementHandle | null): BoundQueries; page: puppeteer.Page; user: PleasantestUser; + waitFor: (cb: () => T | Promise, opts?: WaitForOptions) => Promise; } export interface WithBrowserOpts { @@ -428,11 +432,17 @@ const createTab = async ({ return getQueriesForElement(page, asyncHookTracker, element); }; + const waitFor: PleasantestContext['waitFor'] = ( + cb, + opts: WaitForOptions = {}, + ) => innerWaitFor(page, asyncHookTracker, cb, opts, waitFor); + return { screen, utils, page, within, + waitFor, user: await pleasantestUser(page, asyncHookTracker), asyncHookTracker, cleanupServer: () => closeServer(), @@ -451,4 +461,6 @@ afterAll(async () => { await cleanupClientRuntimeServer(); }); +export type { WaitForOptions }; + export { getAccessibilityTree } from './accessibility'; diff --git a/src/pptr-testing-library-client/index.ts b/src/pptr-testing-library-client/index.ts index 388ea5a6..bd265e0f 100644 --- a/src/pptr-testing-library-client/index.ts +++ b/src/pptr-testing-library-client/index.ts @@ -3,6 +3,8 @@ import { configure } from '@testing-library/dom/dist/config'; import { addToElementCache } from '../serialize'; // @ts-expect-error types are not defined for this internal import export * from '@testing-library/dom/dist/queries'; +// @ts-expect-error types are not defined for this internal import +export { waitFor } from '@testing-library/dom/dist/wait-for'; export { reviveElementsInString, diff --git a/src/pptr-testing-library-client/rollup.config.js b/src/pptr-testing-library-client/rollup.config.js index fe23c469..6654982b 100644 --- a/src/pptr-testing-library-client/rollup.config.js +++ b/src/pptr-testing-library-client/rollup.config.js @@ -60,7 +60,17 @@ const config = { babel({ babelHelpers: 'bundled', extensions }), nodeResolve({ extensions }), removeCloneNodePlugin, - terser({ ecma: 2019 }), + terser({ + ecma: 2019, + module: true, + compress: { + passes: 3, + global_defs: { + jest: false, + 'globalVar.process': undefined, + }, + }, + }), ], external: [], treeshake: { moduleSideEffects: 'no-external' }, diff --git a/src/pptr-testing-library.ts b/src/pptr-testing-library.ts index dd8033a2..a9e860a1 100644 --- a/src/pptr-testing-library.ts +++ b/src/pptr-testing-library.ts @@ -4,7 +4,7 @@ import { printColorsInErrorMessages, removeFuncFromStackTrace, } from './utils'; -import type { ElementHandle, JSHandle } from 'puppeteer'; +import type { ElementHandle, JSHandle, Page } from 'puppeteer'; import { createClientRuntimeServer } from './module-server/client-runtime-server'; import type { AsyncHookTracker } from './async-hooks'; @@ -216,3 +216,95 @@ export const getQueriesForElement = ( return queries; }; + +let waitForCounter = 0; + +export interface WaitForOptions { + /** + * The element watched by the MutationObserver which, + * when it or its descendants change, + * causes the callback to run again (regardless of the interval). + * Default: `document.documentElement` (root element) + */ + container?: ElementHandle; + /** + * The amount of time (milliseconds) that will pass before waitFor "gives up" and throws whatever the callback threw. + * Default: 1000ms + */ + timeout?: number; + /** + * The maximum amount of time (milliseconds) that will pass between each run of the callback. + * If the MutationObserver notices a DOM change before this interval triggers, + * the callback will run again immediately. + * Default: 50ms + */ + interval?: number; + /** Manipulate the error thrown when the timeout triggers. */ + onTimeout?: (error: Error) => Error; + /** Options to pass to initialize the MutationObserver. */ + mutationObserverOptions?: MutationObserverInit; +} + +interface WaitFor { + ( + page: Page, + asyncHookTracker: AsyncHookTracker, + cb: () => T | Promise, + { onTimeout, container, ...opts }: WaitForOptions, + wrappedFunction: (...args: any) => any, + ): Promise; +} + +export const waitFor: WaitFor = async ( + page, + asyncHookTracker, + cb, + { onTimeout, container, ...opts }, + wrappedFunction, +) => + asyncHookTracker.addHook(async () => { + const { port } = await createClientRuntimeServer(); + + waitForCounter++; + // Functions exposed via page.exposeFunction can't be removed, + // So we need a unique name for each variable + const browserFuncName = `pleasantest_waitFor_${waitForCounter}`; + + await page.exposeFunction(browserFuncName, cb); + + const evalResult = await page.evaluateHandle( + // Using new Function to avoid babel transpiling the import + // @ts-expect-error pptr's types don't like new Function + new Function( + 'opts', + 'container', + `return import("http://localhost:${port}/@pleasantest/dom-testing-library") + .then(async ({ waitFor }) => { + try { + const result = await waitFor(${browserFuncName}, { ...opts, container }) + return { success: true, result } + } catch (error) { + if (/timed out in waitFor/i.test(error.message)) { + // Leave out stack trace so the stack trace is given from Node + return { success: false, result: { message: error.message } } + } + return { success: false, result: error } + } + })`, + ), + opts, + // Container has to be passed separately because puppeteer won't unwrap nested JSHandles + container, + ); + const wasSuccessful = await evalResult.evaluate((r) => r.success); + const result = await evalResult.evaluate((r) => + r.success + ? r.result + : { message: r.result.message, stack: r.result.stack }, + ); + if (wasSuccessful) return result; + const err = new Error(result.message); + if (result.stack) err.stack = result.stack; + else removeFuncFromStackTrace(err, asyncHookTracker.addHook); + throw onTimeout ? onTimeout(err) : err; + }, wrappedFunction); diff --git a/tests/forgot-await.test.ts b/tests/forgot-await.test.ts index 90940c9e..32d52676 100644 --- a/tests/forgot-await.test.ts +++ b/tests/forgot-await.test.ts @@ -179,3 +179,24 @@ test('forgot await in getAccessibilityTree', async () => { ^" `); }); + +test('forgot await in waitFor', async () => { + const error = await withBrowser(async ({ waitFor }) => { + waitFor(() => {}); + })().catch((error) => error); + expect(await printErrorFrames(error)).toMatchInlineSnapshot(` + "Error: Cannot interact with browser after test finishes. Did you forget to await? + ------------------------------------------------------- + tests/forgot-await.test.ts + + waitFor(() => {}); + ^ + ------------------------------------------------------- + dist/cjs/index.cjs + ------------------------------------------------------- + tests/forgot-await.test.ts + + const error = await withBrowser(async ({ waitFor }) => { + ^" + `); +}); diff --git a/tests/wait-for.test.ts b/tests/wait-for.test.ts new file mode 100644 index 00000000..913d5767 --- /dev/null +++ b/tests/wait-for.test.ts @@ -0,0 +1,83 @@ +import { withBrowser } from 'pleasantest'; +import { printErrorFrames } from './test-utils'; + +test( + 'Basic case', + withBrowser(async ({ utils, page, waitFor }) => { + await utils.injectHTML('

'); + await utils.runJS(` + setTimeout(() => { + document.write('

Hi

') + }, 100) + `); + // At first the element should not be there + // Because it waits 100ms to add it + expect(await page.$('h2')).toBeNull(); + const waitForCallback = jest.fn(async () => { + expect(await page.$('h2')).not.toBeNull(); + return 42; + }); + const returnedValue = await waitFor(waitForCallback); + expect(returnedValue).toBe(42); + expect(await page.$('h2')).not.toBeNull(); + expect(waitForCallback).toHaveBeenCalled(); + }), +); + +test( + 'Throws error with timeout', + withBrowser(async ({ waitFor }) => { + const error1 = await waitFor( + () => { + throw new Error('something bad happened'); + }, + { timeout: 100 }, + ).catch((error) => error); + expect(await printErrorFrames(error1)).toMatchInlineSnapshot(` + "Error: something bad happened + ------------------------------------------------------- + tests/wait-for.test.ts + + throw new Error('something bad happened'); + ^" + `); + + // If the callback function never resolves (or takes too long to resolve), + // The error message is different + const error2 = await waitFor( + // Function returns a promise that never resolves + () => new Promise(() => {}), + { timeout: 10 }, + ).catch((error) => error); + expect(await printErrorFrames(error2)).toMatchInlineSnapshot(` + "Error: Timed out in waitFor. + ------------------------------------------------------- + tests/wait-for.test.ts + + const error2 = await waitFor( + ^ + ------------------------------------------------------- + dist/cjs/index.cjs" + `); + + // Allows customizing error message using onTimeout + const error3 = await waitFor(() => new Promise(() => {}), { + timeout: 10, + onTimeout: (err) => { + err.message += '\nCaleb wuz here'; + return err; + }, + }).catch((error) => error); + expect(await printErrorFrames(error3)).toMatchInlineSnapshot(` + "Error: Timed out in waitFor. + Caleb wuz here + ------------------------------------------------------- + tests/wait-for.test.ts + + const error3 = await waitFor(() => new Promise(() => {}), { + ^ + ------------------------------------------------------- + dist/cjs/index.cjs" + `); + }), +);