diff --git a/README.md b/README.md index 4331dc7..c78df77 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,11 @@ it throws the error.

Returns the BrowserWindow object that corresponds to the given Playwright page (with retries).

This is basically a wrapper around [app.browserWindow(page)](https://playwright.dev/docs/api/class-electronapplication#electron-application-browser-window) that retries the operation.

+
retryUntilTruthy(fn, [timeoutMs], [intervalMs], [options])Promise.<T>
+

Retries a given function until it returns a truthy value or the timeout is reached.

+

This offers similar functionality to Playwright's page.waitForFunction() +method – but with more flexibility and control over the retry attempts. It also defaults to ignoring common errors due to +the way that Playwright handles browser contexts.

stubDialog(app, method, value)Promise.<void>

Stub a single dialog method. This is a convenience function that calls stubMultipleDialogs for a single method.

@@ -213,7 +218,24 @@ For example, wait for a MenuItem to be enabled... or be visible.. etc

addTimeout(functionName, timeoutMs, timeoutMessage, ...args)Promise.<T>

Add a timeout to any helper function from this library which returns a Promise.

retry(fn, [options])Promise.<T>
-

Retries a function until it returns without throwing an error containing a specific message.

+

Retries a function until it returns without throwing an error.

+

Starting with Electron 27, Playwright can get very flakey when running code in Electron's main or renderer processes. +It will often throw errors like "context or browser has been closed" or "Promise was collected" for no apparent reason. +This function retries a given function until it returns without throwing one of these errors, or until the timeout is reached.

+

You can simply wrap your Playwright calls in this function to make them more reliable:

+
test('my test', async () => {
+  // instead of this:
+  const oldWayRenderer = await page.evaluate(() => document.body.classList.contains('active'))
+  const oldWayMain = await electronApp.evaluate(({}) => document.body.classList.contains('active'))
+  // use this:
+  const newWay = await retry(() =>
+    page.evaluate(() => document.body.classList.contains('active'))
+  )
+  // note the `() =>` in front of the original function call
+  // and the `await` keyword in front of `retry`,
+  // but NOT in front of `page.evaluate`
+})
+
setRetryOptions(options)

Sets the default retry() options. These options will be used for all subsequent calls to retry() unless overridden. You can reset the defaults at any time by calling resetRetryOptions().

@@ -354,6 +376,32 @@ that retries the operation.

| options.retries |

The number of retry attempts. Defaults to 5.

| | options.intervalMs |

The interval between retries in milliseconds. Defaults to 200.

| + + +## retryUntilTruthy(fn, [timeoutMs], [intervalMs], [options]) ⇒ Promise.<T> +

Retries a given function until it returns a truthy value or the timeout is reached.

+

This offers similar functionality to Playwright's page.waitForFunction() +method – but with more flexibility and control over the retry attempts. It also defaults to ignoring common errors due to +the way that Playwright handles browser contexts.

+ +**Kind**: global function +**Returns**: Promise.<T> - +**Throws**: + +- Error + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| fn | function | |

The function to retry. It can return a promise or a value.

| +| [timeoutMs] | number | 5000 |

The maximum time in milliseconds to keep retrying the function. Defaults to 5000ms.

| +| [intervalMs] | number | 100 |

The delay between each retry attempt in milliseconds. Defaults to 100ms.

| +| [options] | RetryOptions | {} |

Optional options for each retry attempt.

| + ## ElectronAppInfo @@ -759,7 +807,24 @@ For example, wait for a MenuItem to be enabled... or be visible.. etc

## retry(fn, [options]) ⇒ Promise.<T> -

Retries a function until it returns without throwing an error containing a specific message.

+

Retries a function until it returns without throwing an error.

+

Starting with Electron 27, Playwright can get very flakey when running code in Electron's main or renderer processes. +It will often throw errors like "context or browser has been closed" or "Promise was collected" for no apparent reason. +This function retries a given function until it returns without throwing one of these errors, or until the timeout is reached.

+

You can simply wrap your Playwright calls in this function to make them more reliable:

+
test('my test', async () => {
+  // instead of this:
+  const oldWayRenderer = await page.evaluate(() => document.body.classList.contains('active'))
+  const oldWayMain = await electronApp.evaluate(({}) => document.body.classList.contains('active'))
+  // use this:
+  const newWay = await retry(() =>
+    page.evaluate(() => document.body.classList.contains('active'))
+  )
+  // note the `() =>` in front of the original function call
+  // and the `await` keyword in front of `retry`,
+  // but NOT in front of `page.evaluate`
+})
+
**Kind**: global function **Returns**: Promise.<T> -

A promise that resolves with the result of the function or rejects with an error or timeout message.

@@ -772,7 +837,7 @@ For example, wait for a MenuItem to be enabled... or be visible.. etc

| [options.retries] | number | 5 |

The number of retry attempts.

| | [options.intervalMs] | number | 200 |

The delay between each retry attempt in milliseconds.

| | [options.timeoutMs] | number | 5000 |

The maximum time to wait before giving up in milliseconds.

| -| [options.errorMatch] | string \| RegExp | "['context or browser has been closed', 'Promise was collected']" |

The error message or pattern to match against.

| +| [options.errorMatch] | string \| Array.<string> \| RegExp | "['context or browser has been closed', 'Promise was collected', 'Execution context was destroyed']" |

String(s) or regex to match against error message. If the error does not match, it will throw immediately. If it does match, it will retry.

| diff --git a/src/utilities.ts b/src/utilities.ts index b414e57..8fe9553 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -93,7 +93,28 @@ export type RetryOptions = { } /** - * Retries a function until it returns without throwing an error containing a specific message. + * Retries a function until it returns without throwing an error. + * + * Starting with Electron 27, Playwright can get very flakey when running code in Electron's main or renderer processes. + * It will often throw errors like "context or browser has been closed" or "Promise was collected" for no apparent reason. + * This function retries a given function until it returns without throwing one of these errors, or until the timeout is reached. + * + * You can simply wrap your Playwright calls in this function to make them more reliable: + * + * ```javascript + * test('my test', async () => { + * // instead of this: + * const oldWayRenderer = await page.evaluate(() => document.body.classList.contains('active')) + * const oldWayMain = await electronApp.evaluate(({}) => document.body.classList.contains('active')) + * // use this: + * const newWay = await retry(() => + * page.evaluate(() => document.body.classList.contains('active')) + * ) + * // note the `() =>` in front of the original function call + * // and the `await` keyword in front of `retry`, + * // but NOT in front of `page.evaluate` + * }) + * ``` * * @category Utilities * @@ -103,7 +124,7 @@ export type RetryOptions = { * @param {number} [options.retries=5] The number of retry attempts. * @param {number} [options.intervalMs=200] The delay between each retry attempt in milliseconds. * @param {number} [options.timeoutMs=5000] The maximum time to wait before giving up in milliseconds. - * @param {string|RegExp} [options.errorMatch=['context or browser has been closed', 'Promise was collected']] The error message or pattern to match against. + * @param {string|string[]|RegExp} [options.errorMatch=['context or browser has been closed', 'Promise was collected', 'Execution context was destroyed']] String(s) or regex to match against error message. If the error does not match, it will throw immediately. If it does match, it will retry. * @returns {Promise} A promise that resolves with the result of the function or rejects with an error or timeout message. */ export async function retry( @@ -169,7 +190,7 @@ const currentRetryOptions: RetryOptions = { ...retryDefaults } */ export function setRetryOptions(options: Partial): RetryOptions { Object.assign(currentRetryOptions, options) - return retryDefaults + return currentRetryOptions } /** @@ -198,6 +219,38 @@ export function resetRetryOptions(): void { Object.assign(currentRetryOptions, retryDefaults) } +/** + * Retries a given function until it returns a truthy value or the timeout is reached. + * + * This offers similar functionality to Playwright's [`page.waitForFunction()`](https://playwright.dev/docs/api/class-page#page-wait-for-function) + * method – but with more flexibility and control over the retry attempts. It also defaults to ignoring common errors due to + * the way that Playwright handles browser contexts. + * + * @template T - The type of the value returned by the function. + * @param {Function} fn - The function to retry. It can return a promise or a value. + * @param {number} [timeoutMs=5000] - The maximum time in milliseconds to keep retrying the function. Defaults to 5000ms. + * @param {number} [intervalMs=100] - The delay between each retry attempt in milliseconds. Defaults to 100ms. + * @param {RetryOptions} [options={}] - Optional options for each retry attempt. + * @returns {Promise} - A promise that resolves to the truthy value returned by the function. + * @throws {Error} - Throws an error if the timeout is reached before a truthy value is returned. + */ +export async function retryUntilTruthy( + fn: () => Promise | T, + timeoutMs = 5000, + intervalMs = 100, + options: RetryOptions = {} +): Promise { + const timeoutTime = Date.now() + timeoutMs + while (Date.now() < timeoutTime) { + const result = await retry(fn, options) + if (result) { + return result + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + throw new Error(`Timeout after ${timeoutMs}ms`) +} + /** * Converts an unknown error to a string representation. * @@ -213,13 +266,7 @@ export function resetRetryOptions(): void { * @returns A string representation of the error. */ export function errToString(err: unknown): string { - if ( - typeof err === 'object' && - err && - 'toString' in err && - typeof err.toString === 'function' - ) { - // this should catch Errors and other objects with a toString method + if (err instanceof Error) { return err.toString() } else if (typeof err === 'string') { return err diff --git a/test/retryUntilTruthy.test.ts b/test/retryUntilTruthy.test.ts new file mode 100644 index 0000000..ff3d6db --- /dev/null +++ b/test/retryUntilTruthy.test.ts @@ -0,0 +1,60 @@ +import chai, { expect } from 'chai' +import chaiAsPromised from 'chai-as-promised' +import { retryUntilTruthy } from '../src/utilities' + +chai.use(chaiAsPromised) + +describe('retryUntilTruthy', () => { + it('should retry the function until it returns a truthy value', async () => { + let attempts = 0 + const result = await retryUntilTruthy(async () => { + attempts++ + if (attempts < 3) { + return false + } + return 'success' + }) + expect(result).to.equal('success') + expect(attempts).to.equal(3) + }) + + it('should throw an error if timeout is reached', async () => { + await expect(retryUntilTruthy(async () => false, 100)).to.be.rejectedWith( + 'Timeout after 100ms' + ) + }) + + it('should throw an error if the error does not match defaults', async () => { + await expect( + retryUntilTruthy(async () => { + throw new Error('test error') + }) + ).to.be.rejectedWith('test error') + }) + + it('should throw an error if the error does not match custom errorMatch', async () => { + await expect( + retryUntilTruthy( + async () => { + throw new Error('test error') + }, + undefined, + undefined, + { errorMatch: 'custom error' } + ) + ).to.be.rejectedWith('test error') + }) + + it('should throw an error if the error does not match regex errorMatch', async () => { + await expect( + retryUntilTruthy( + async () => { + throw new Error('test error') + }, + undefined, + undefined, + { errorMatch: /custom error/ } + ) + ).to.be.rejectedWith('test error') + }) +}) diff --git a/test/utilities.test.ts b/test/utilities.test.ts new file mode 100644 index 0000000..5196a94 --- /dev/null +++ b/test/utilities.test.ts @@ -0,0 +1,105 @@ +import { expect } from 'chai' +import { + addTimeoutToPromise, + addTimeout, + retry, + setRetryOptions, + getRetryOptions, + resetRetryOptions, + retryUntilTruthy, + errToString, + HelperFunctionName, +} from '../src/utilities' + +describe('Utilities', () => { + describe('addTimeoutToPromise', () => { + it('should resolve the promise before timeout', async () => { + const result = await addTimeoutToPromise(Promise.resolve('success'), 1000) + expect(result).to.equal('success') + }) + + it('should reject the promise after timeout', async () => { + try { + await addTimeoutToPromise(new Promise(() => null), 100) + } catch (error) { + expect(error.message).to.equal('timeout after 100ms') + } + }) + }) + + // describe('addTimeout', () => { + // it('should call the helper function and resolve before timeout', async () => { + // const helpers = { + // async testHelper() { + // return 'success' + // }, + // } + // const name = 'testHelper' as HelperFunctionName + // const result = await addTimeout(name, 1000, undefined, helpers) + // expect(result).to.equal('success') + // }) + + // it('should call the helper function and reject after timeout', async () => { + // const helpers = { + // async testHelper() { + // return new Promise(() => {}) + // }, + // } + // try { + // await addTimeout('testHelper', 100, undefined, helpers) + // } catch (error) { + // expect(error.message).to.equal('timeout after 100ms') + // } + // }) + // }) + + describe('setRetryOptions', () => { + it('should set the retry options', () => { + const options = setRetryOptions({ retries: 10 }) + expect(options.retries).to.equal(10) + }) + }) + + describe('getRetryOptions', () => { + it('should get the current retry options', () => { + resetRetryOptions() + const options = getRetryOptions() + expect(options.retries).to.equal(20) + }) + }) + + describe('resetRetryOptions', () => { + it('should reset the retry options to default values', () => { + setRetryOptions({ retries: 10 }) + resetRetryOptions() + const options = getRetryOptions() + expect(options.retries).to.equal(20) + }) + }) + + describe('errToString', () => { + it('should convert an Error object to a string', () => { + const error = new Error('test error') + const result = errToString(error) + expect(result).to.equal('Error: test error') + }) + + it('should convert a TypeError to a string', () => { + const error = new TypeError('test error') + const result = errToString(error) + expect(result).to.equal('TypeError: test error') + }) + + it('should return the string directly if the error is a string', () => { + const error = 'test error' + const result = errToString(error) + expect(result).to.equal('test error') + }) + + it('should convert other types to a JSON string', () => { + const error = { message: 'test error' } + const result = errToString(error) + expect(result).to.equal('{"message":"test error"}') + }) + }) +})