Skip to content

Commit

Permalink
Merge pull request #57 from spaceagetv/feat/retry-until-truthy
Browse files Browse the repository at this point in the history
feat: retryUntilTruthy()
  • Loading branch information
jjeff authored Nov 17, 2024
2 parents f3bc155 + 704e330 commit 4b29e04
Show file tree
Hide file tree
Showing 4 changed files with 290 additions and 13 deletions.
71 changes: 68 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ it throws the error.</p></dd>
<dd><p>Returns the BrowserWindow object that corresponds to the given Playwright page (with retries).</p>
<p>This is basically a wrapper around <code>[app.browserWindow(page)](https://playwright.dev/docs/api/class-electronapplication#electron-application-browser-window)</code>
that retries the operation.</p></dd>
<dt><a href="#retryUntilTruthy">retryUntilTruthy(fn, [timeoutMs], [intervalMs], [options])</a> ⇒ <code>Promise.&lt;T&gt;</code></dt>
<dd><p>Retries a given function until it returns a truthy value or the timeout is reached.</p>
<p>This offers similar functionality to Playwright's <a href="https://playwright.dev/docs/api/class-page#page-wait-for-function"><code>page.waitForFunction()</code></a>
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.</p></dd>
<dt><a href="#stubDialog">stubDialog(app, method, value)</a> ⇒ <code>Promise.&lt;void&gt;</code></dt>
<dd><p>Stub a single dialog method. This is a convenience function that calls <code>stubMultipleDialogs</code>
for a single method.</p>
Expand Down Expand Up @@ -213,7 +218,24 @@ For example, wait for a MenuItem to be enabled... or be visible.. etc</p></dd>
<dt><a href="#addTimeout">addTimeout(functionName, timeoutMs, timeoutMessage, ...args)</a> ⇒ <code>Promise.&lt;T&gt;</code></dt>
<dd><p>Add a timeout to any helper function from this library which returns a Promise.</p></dd>
<dt><a href="#retry">retry(fn, [options])</a> ⇒ <code>Promise.&lt;T&gt;</code></dt>
<dd><p>Retries a function until it returns without throwing an error containing a specific message.</p></dd>
<dd><p>Retries a function until it returns without throwing an error.</p>
<p>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 &quot;context or browser has been closed&quot; or &quot;Promise was collected&quot; 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.</p>
<p>You can simply wrap your Playwright calls in this function to make them more reliable:</p>
<pre class="prettyprint source lang-javascript"><code>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`
})
</code></pre></dd>
<dt><a href="#setRetryOptions">setRetryOptions(options)</a> ⇒</dt>
<dd><p>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().</p></dd>
Expand Down Expand Up @@ -354,6 +376,32 @@ that retries the operation.</p>
| options.retries | <p>The number of retry attempts. Defaults to 5.</p> |
| options.intervalMs | <p>The interval between retries in milliseconds. Defaults to 200.</p> |

<a name="retryUntilTruthy"></a>

## retryUntilTruthy(fn, [timeoutMs], [intervalMs], [options]) ⇒ <code>Promise.&lt;T&gt;</code>
<p>Retries a given function until it returns a truthy value or the timeout is reached.</p>
<p>This offers similar functionality to Playwright's <a href="https://playwright.dev/docs/api/class-page#page-wait-for-function"><code>page.waitForFunction()</code></a>
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.</p>

**Kind**: global function
**Returns**: <code>Promise.&lt;T&gt;</code> - <ul>
<li>A promise that resolves to the truthy value returned by the function.</li>
</ul>
**Throws**:

- <code>Error</code> <ul>
<li>Throws an error if the timeout is reached before a truthy value is returned.</li>
</ul>


| Param | Type | Default | Description |
| --- | --- | --- | --- |
| fn | <code>function</code> | | <p>The function to retry. It can return a promise or a value.</p> |
| [timeoutMs] | <code>number</code> | <code>5000</code> | <p>The maximum time in milliseconds to keep retrying the function. Defaults to 5000ms.</p> |
| [intervalMs] | <code>number</code> | <code>100</code> | <p>The delay between each retry attempt in milliseconds. Defaults to 100ms.</p> |
| [options] | <code>RetryOptions</code> | <code>{}</code> | <p>Optional options for each retry attempt.</p> |

<a name="ElectronAppInfo"></a>

## ElectronAppInfo
Expand Down Expand Up @@ -759,7 +807,24 @@ For example, wait for a MenuItem to be enabled... or be visible.. etc</p>
<a name="retry"></a>

## retry(fn, [options]) ⇒ <code>Promise.&lt;T&gt;</code>
<p>Retries a function until it returns without throwing an error containing a specific message.</p>
<p>Retries a function until it returns without throwing an error.</p>
<p>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 &quot;context or browser has been closed&quot; or &quot;Promise was collected&quot; 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.</p>
<p>You can simply wrap your Playwright calls in this function to make them more reliable:</p>
<pre class="prettyprint source lang-javascript"><code>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`
})
</code></pre>

**Kind**: global function
**Returns**: <code>Promise.&lt;T&gt;</code> - <p>A promise that resolves with the result of the function or rejects with an error or timeout message.</p>
Expand All @@ -772,7 +837,7 @@ For example, wait for a MenuItem to be enabled... or be visible.. etc</p>
| [options.retries] | <code>number</code> | <code>5</code> | <p>The number of retry attempts.</p> |
| [options.intervalMs] | <code>number</code> | <code>200</code> | <p>The delay between each retry attempt in milliseconds.</p> |
| [options.timeoutMs] | <code>number</code> | <code>5000</code> | <p>The maximum time to wait before giving up in milliseconds.</p> |
| [options.errorMatch] | <code>string</code> \| <code>RegExp</code> | <code>&quot;[&#x27;context or browser has been closed&#x27;, &#x27;Promise was collected&#x27;]&quot;</code> | <p>The error message or pattern to match against.</p> |
| [options.errorMatch] | <code>string</code> \| <code>Array.&lt;string&gt;</code> \| <code>RegExp</code> | <code>&quot;[&#x27;context or browser has been closed&#x27;, &#x27;Promise was collected&#x27;, &#x27;Execution context was destroyed&#x27;]&quot;</code> | <p>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.</p> |

<a name="setRetryOptions"></a>

Expand Down
67 changes: 57 additions & 10 deletions src/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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<T>} A promise that resolves with the result of the function or rejects with an error or timeout message.
*/
export async function retry<T>(
Expand Down Expand Up @@ -169,7 +190,7 @@ const currentRetryOptions: RetryOptions = { ...retryDefaults }
*/
export function setRetryOptions(options: Partial<RetryOptions>): RetryOptions {
Object.assign(currentRetryOptions, options)
return retryDefaults
return currentRetryOptions
}

/**
Expand Down Expand Up @@ -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<T>} - 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<T>(
fn: () => Promise<T> | T,
timeoutMs = 5000,
intervalMs = 100,
options: RetryOptions = {}
): Promise<T> {
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.
*
Expand All @@ -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
Expand Down
60 changes: 60 additions & 0 deletions test/retryUntilTruthy.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
105 changes: 105 additions & 0 deletions test/utilities.test.ts
Original file line number Diff line number Diff line change
@@ -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"}')
})
})
})

0 comments on commit 4b29e04

Please sign in to comment.