Skip to content

Commit

Permalink
Add waitFor (#369)
Browse files Browse the repository at this point in the history
Co-authored-by: Paul Hebert <paul@cloudfour.com>
  • Loading branch information
calebeby and Paul-Hebert committed Jan 14, 2022
1 parent c5b48aa commit c0a8a0a
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .changeset/silly-cougars-trade.md
@@ -0,0 +1,5 @@
---
'pleasantest': minor
---

Add `waitFor` feature
30 changes: 30 additions & 0 deletions README.md
Expand Up @@ -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<T>(callback: () => T | Promise<T>, options?: WaitForOptions) => Promise<T>`

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.
Expand Down
16 changes: 14 additions & 2 deletions 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';
Expand Down Expand Up @@ -59,6 +62,7 @@ export interface PleasantestContext {
within(element: puppeteer.ElementHandle | null): BoundQueries;
page: puppeteer.Page;
user: PleasantestUser;
waitFor: <T>(cb: () => T | Promise<T>, opts?: WaitForOptions) => Promise<T>;
}

export interface WithBrowserOpts {
Expand Down Expand Up @@ -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(),
Expand All @@ -451,4 +461,6 @@ afterAll(async () => {
await cleanupClientRuntimeServer();
});

export type { WaitForOptions };

export { getAccessibilityTree } from './accessibility';
2 changes: 2 additions & 0 deletions src/pptr-testing-library-client/index.ts
Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion src/pptr-testing-library-client/rollup.config.js
Expand Up @@ -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' },
Expand Down
94 changes: 93 additions & 1 deletion src/pptr-testing-library.ts
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
<T>(
page: Page,
asyncHookTracker: AsyncHookTracker,
cb: () => T | Promise<T>,
{ onTimeout, container, ...opts }: WaitForOptions,
wrappedFunction: (...args: any) => any,
): Promise<T>;
}

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);
21 changes: 21 additions & 0 deletions tests/forgot-await.test.ts
Expand Up @@ -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 }) => {
^"
`);
});
83 changes: 83 additions & 0 deletions 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('<h1></h1>');
await utils.runJS(`
setTimeout(() => {
document.write('<h2>Hi</h2>')
}, 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<never>(() => {}),
{ 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<never>(() => {}), {
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<never>(() => {}), {
^
-------------------------------------------------------
dist/cjs/index.cjs"
`);
}),
);

0 comments on commit c0a8a0a

Please sign in to comment.