Skip to content

Commit c0a8a0a

Browse files
Add waitFor (#369)
Co-authored-by: Paul Hebert <paul@cloudfour.com>
1 parent c5b48aa commit c0a8a0a

8 files changed

Lines changed: 259 additions & 4 deletions

File tree

.changeset/silly-cougars-trade.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'pleasantest': minor
3+
---
4+
5+
Add `waitFor` feature

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,36 @@ The `devices` import from `pleasantest` is re-exported from Puppeteer, [here is
411411

412412
### `PleasantestContext` Object (passed into test function wrapped by `withBrowser`)
413413

414+
#### `PleasantestContext.waitFor<T>(callback: () => T | Promise<T>, options?: WaitForOptions) => Promise<T>`
415+
416+
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).
417+
418+
The return value of the callback function is returned by `waitFor`.
419+
420+
`WaitForOptions`: (all properties are optional):
421+
422+
- `container`: `ElementHandle`, default `document.documentElement` (root element): The element watched by the MutationObserver which,
423+
when it or its descendants change, causes the callback to run again (regardless of the interval).
424+
- `timeout`: `number`, default: 1000ms The amount of time (milliseconds) that will pass before waitFor "gives up" and throws whatever the callback threw.
425+
- `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.
426+
- `onTimeout`: `(error: Error) => Error`: Manipulate the error thrown when the timeout triggers.
427+
- `mutationObserverOptions`: [`MutationObserverInit`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#parameters) Options to pass to initialize the `MutationObserver`,
428+
429+
```js
430+
import { withBrowser } from 'pleasantest';
431+
432+
test(
433+
'test name',
434+
withBrowser(async ({ waitFor, page }) => {
435+
// ^^^^^^^
436+
// Wait until the url changes to ...
437+
await waitFor(async () => {
438+
expect(page.url).toBe('https://something.com/something');
439+
});
440+
}),
441+
);
442+
```
443+
414444
#### `PleasantestContext.screen`
415445

416446
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.

src/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import * as puppeteer from 'puppeteer';
22
import { relative, join, isAbsolute, dirname } from 'path';
3-
import type { BoundQueries } from './pptr-testing-library';
4-
import { getQueriesForElement } from './pptr-testing-library';
3+
import type { BoundQueries, WaitForOptions } from './pptr-testing-library';
4+
import {
5+
getQueriesForElement,
6+
waitFor as innerWaitFor,
7+
} from './pptr-testing-library';
58
import { connectToBrowser } from './connect-to-browser';
69
import { parseStackTrace } from 'errorstacks';
710
import './extend-expect';
@@ -59,6 +62,7 @@ export interface PleasantestContext {
5962
within(element: puppeteer.ElementHandle | null): BoundQueries;
6063
page: puppeteer.Page;
6164
user: PleasantestUser;
65+
waitFor: <T>(cb: () => T | Promise<T>, opts?: WaitForOptions) => Promise<T>;
6266
}
6367

6468
export interface WithBrowserOpts {
@@ -428,11 +432,17 @@ const createTab = async ({
428432
return getQueriesForElement(page, asyncHookTracker, element);
429433
};
430434

435+
const waitFor: PleasantestContext['waitFor'] = (
436+
cb,
437+
opts: WaitForOptions = {},
438+
) => innerWaitFor(page, asyncHookTracker, cb, opts, waitFor);
439+
431440
return {
432441
screen,
433442
utils,
434443
page,
435444
within,
445+
waitFor,
436446
user: await pleasantestUser(page, asyncHookTracker),
437447
asyncHookTracker,
438448
cleanupServer: () => closeServer(),
@@ -451,4 +461,6 @@ afterAll(async () => {
451461
await cleanupClientRuntimeServer();
452462
});
453463

464+
export type { WaitForOptions };
465+
454466
export { getAccessibilityTree } from './accessibility';

src/pptr-testing-library-client/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { configure } from '@testing-library/dom/dist/config';
33
import { addToElementCache } from '../serialize';
44
// @ts-expect-error types are not defined for this internal import
55
export * from '@testing-library/dom/dist/queries';
6+
// @ts-expect-error types are not defined for this internal import
7+
export { waitFor } from '@testing-library/dom/dist/wait-for';
68

79
export {
810
reviveElementsInString,

src/pptr-testing-library-client/rollup.config.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,17 @@ const config = {
6060
babel({ babelHelpers: 'bundled', extensions }),
6161
nodeResolve({ extensions }),
6262
removeCloneNodePlugin,
63-
terser({ ecma: 2019 }),
63+
terser({
64+
ecma: 2019,
65+
module: true,
66+
compress: {
67+
passes: 3,
68+
global_defs: {
69+
jest: false,
70+
'globalVar.process': undefined,
71+
},
72+
},
73+
}),
6474
],
6575
external: [],
6676
treeshake: { moduleSideEffects: 'no-external' },

src/pptr-testing-library.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
printColorsInErrorMessages,
55
removeFuncFromStackTrace,
66
} from './utils';
7-
import type { ElementHandle, JSHandle } from 'puppeteer';
7+
import type { ElementHandle, JSHandle, Page } from 'puppeteer';
88
import { createClientRuntimeServer } from './module-server/client-runtime-server';
99
import type { AsyncHookTracker } from './async-hooks';
1010

@@ -216,3 +216,95 @@ export const getQueriesForElement = (
216216

217217
return queries;
218218
};
219+
220+
let waitForCounter = 0;
221+
222+
export interface WaitForOptions {
223+
/**
224+
* The element watched by the MutationObserver which,
225+
* when it or its descendants change,
226+
* causes the callback to run again (regardless of the interval).
227+
* Default: `document.documentElement` (root element)
228+
*/
229+
container?: ElementHandle;
230+
/**
231+
* The amount of time (milliseconds) that will pass before waitFor "gives up" and throws whatever the callback threw.
232+
* Default: 1000ms
233+
*/
234+
timeout?: number;
235+
/**
236+
* The maximum amount of time (milliseconds) that will pass between each run of the callback.
237+
* If the MutationObserver notices a DOM change before this interval triggers,
238+
* the callback will run again immediately.
239+
* Default: 50ms
240+
*/
241+
interval?: number;
242+
/** Manipulate the error thrown when the timeout triggers. */
243+
onTimeout?: (error: Error) => Error;
244+
/** Options to pass to initialize the MutationObserver. */
245+
mutationObserverOptions?: MutationObserverInit;
246+
}
247+
248+
interface WaitFor {
249+
<T>(
250+
page: Page,
251+
asyncHookTracker: AsyncHookTracker,
252+
cb: () => T | Promise<T>,
253+
{ onTimeout, container, ...opts }: WaitForOptions,
254+
wrappedFunction: (...args: any) => any,
255+
): Promise<T>;
256+
}
257+
258+
export const waitFor: WaitFor = async (
259+
page,
260+
asyncHookTracker,
261+
cb,
262+
{ onTimeout, container, ...opts },
263+
wrappedFunction,
264+
) =>
265+
asyncHookTracker.addHook(async () => {
266+
const { port } = await createClientRuntimeServer();
267+
268+
waitForCounter++;
269+
// Functions exposed via page.exposeFunction can't be removed,
270+
// So we need a unique name for each variable
271+
const browserFuncName = `pleasantest_waitFor_${waitForCounter}`;
272+
273+
await page.exposeFunction(browserFuncName, cb);
274+
275+
const evalResult = await page.evaluateHandle(
276+
// Using new Function to avoid babel transpiling the import
277+
// @ts-expect-error pptr's types don't like new Function
278+
new Function(
279+
'opts',
280+
'container',
281+
`return import("http://localhost:${port}/@pleasantest/dom-testing-library")
282+
.then(async ({ waitFor }) => {
283+
try {
284+
const result = await waitFor(${browserFuncName}, { ...opts, container })
285+
return { success: true, result }
286+
} catch (error) {
287+
if (/timed out in waitFor/i.test(error.message)) {
288+
// Leave out stack trace so the stack trace is given from Node
289+
return { success: false, result: { message: error.message } }
290+
}
291+
return { success: false, result: error }
292+
}
293+
})`,
294+
),
295+
opts,
296+
// Container has to be passed separately because puppeteer won't unwrap nested JSHandles
297+
container,
298+
);
299+
const wasSuccessful = await evalResult.evaluate((r) => r.success);
300+
const result = await evalResult.evaluate((r) =>
301+
r.success
302+
? r.result
303+
: { message: r.result.message, stack: r.result.stack },
304+
);
305+
if (wasSuccessful) return result;
306+
const err = new Error(result.message);
307+
if (result.stack) err.stack = result.stack;
308+
else removeFuncFromStackTrace(err, asyncHookTracker.addHook);
309+
throw onTimeout ? onTimeout(err) : err;
310+
}, wrappedFunction);

tests/forgot-await.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,24 @@ test('forgot await in getAccessibilityTree', async () => {
179179
^"
180180
`);
181181
});
182+
183+
test('forgot await in waitFor', async () => {
184+
const error = await withBrowser(async ({ waitFor }) => {
185+
waitFor(() => {});
186+
})().catch((error) => error);
187+
expect(await printErrorFrames(error)).toMatchInlineSnapshot(`
188+
"Error: Cannot interact with browser after test finishes. Did you forget to await?
189+
-------------------------------------------------------
190+
tests/forgot-await.test.ts
191+
192+
waitFor(() => {});
193+
^
194+
-------------------------------------------------------
195+
dist/cjs/index.cjs
196+
-------------------------------------------------------
197+
tests/forgot-await.test.ts
198+
199+
const error = await withBrowser(async ({ waitFor }) => {
200+
^"
201+
`);
202+
});

tests/wait-for.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { withBrowser } from 'pleasantest';
2+
import { printErrorFrames } from './test-utils';
3+
4+
test(
5+
'Basic case',
6+
withBrowser(async ({ utils, page, waitFor }) => {
7+
await utils.injectHTML('<h1></h1>');
8+
await utils.runJS(`
9+
setTimeout(() => {
10+
document.write('<h2>Hi</h2>')
11+
}, 100)
12+
`);
13+
// At first the element should not be there
14+
// Because it waits 100ms to add it
15+
expect(await page.$('h2')).toBeNull();
16+
const waitForCallback = jest.fn(async () => {
17+
expect(await page.$('h2')).not.toBeNull();
18+
return 42;
19+
});
20+
const returnedValue = await waitFor(waitForCallback);
21+
expect(returnedValue).toBe(42);
22+
expect(await page.$('h2')).not.toBeNull();
23+
expect(waitForCallback).toHaveBeenCalled();
24+
}),
25+
);
26+
27+
test(
28+
'Throws error with timeout',
29+
withBrowser(async ({ waitFor }) => {
30+
const error1 = await waitFor(
31+
() => {
32+
throw new Error('something bad happened');
33+
},
34+
{ timeout: 100 },
35+
).catch((error) => error);
36+
expect(await printErrorFrames(error1)).toMatchInlineSnapshot(`
37+
"Error: something bad happened
38+
-------------------------------------------------------
39+
tests/wait-for.test.ts
40+
41+
throw new Error('something bad happened');
42+
^"
43+
`);
44+
45+
// If the callback function never resolves (or takes too long to resolve),
46+
// The error message is different
47+
const error2 = await waitFor(
48+
// Function returns a promise that never resolves
49+
() => new Promise<never>(() => {}),
50+
{ timeout: 10 },
51+
).catch((error) => error);
52+
expect(await printErrorFrames(error2)).toMatchInlineSnapshot(`
53+
"Error: Timed out in waitFor.
54+
-------------------------------------------------------
55+
tests/wait-for.test.ts
56+
57+
const error2 = await waitFor(
58+
^
59+
-------------------------------------------------------
60+
dist/cjs/index.cjs"
61+
`);
62+
63+
// Allows customizing error message using onTimeout
64+
const error3 = await waitFor(() => new Promise<never>(() => {}), {
65+
timeout: 10,
66+
onTimeout: (err) => {
67+
err.message += '\nCaleb wuz here';
68+
return err;
69+
},
70+
}).catch((error) => error);
71+
expect(await printErrorFrames(error3)).toMatchInlineSnapshot(`
72+
"Error: Timed out in waitFor.
73+
Caleb wuz here
74+
-------------------------------------------------------
75+
tests/wait-for.test.ts
76+
77+
const error3 = await waitFor(() => new Promise<never>(() => {}), {
78+
^
79+
-------------------------------------------------------
80+
dist/cjs/index.cjs"
81+
`);
82+
}),
83+
);

0 commit comments

Comments
 (0)