electron-playwright-helpers
Version:
Helper functions for Electron end-to-end testing using Playwright
327 lines • 13.2 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.addTimeoutToPromise = addTimeoutToPromise;
exports.addTimeout = addTimeout;
exports.retry = retry;
exports.setRetryOptions = setRetryOptions;
exports.getRetryOptions = getRetryOptions;
exports.resetRetryOptions = resetRetryOptions;
exports.isRetryOptions = isRetryOptions;
exports.retryUntilTruthy = retryUntilTruthy;
exports.errToString = errToString;
const helpers = __importStar(require("./"));
/**
* Add a timeout to any Promise
*
* @category Utilities
* @see addTimeout
*
* @param promise - the promise to add a timeout to - must be a Promise
* @param timeoutMs - the timeout in milliseconds - defaults to 5000
* @param timeoutMessage - optional - the message to return if the timeout is reached
*
* @returns {Promise<T>} the result of the original promise if it resolves before the timeout
*/
async function addTimeoutToPromise(promise, timeoutMs = 5000, timeoutMessage) {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(timeoutMessage
? new Error(timeoutMessage)
: new Error(`timeout after ${timeoutMs}ms`));
}, timeoutMs);
promise
.then((result) => {
resolve(result);
})
.catch((error) => {
reject(error);
});
});
}
/**
* Add a timeout to any helper function from this library which returns a Promise.
*
* @category Utilities
*
* @param functionName - the name of the helper function to call
* @param timeoutMs - the timeout in milliseconds - defaults to 5000
* @param timeoutMessage - optional - the message to return if the timeout is reached
* @param args - any arguments to pass to the helper function
*
* @returns {Promise<T>} the result of the helper function if it resolves before the timeout
*/
function addTimeout(functionName, timeoutMs = 5000, timeoutMessage, ...args) {
return addTimeoutToPromise(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
helpers[functionName](...args), timeoutMs, timeoutMessage);
}
/**
* 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.
*
*
* @example
*
* 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
*
* @template T The type of the value returned by the function.
* @param {Function} fn The function to retry.
* @param {RetryOptions} [options={}] The options for retrying the function.
* @param {number} [options.timeout=5000] The maximum time to wait before giving up in milliseconds.
* @param {number} [options.poll=200] The delay between each retry attempt in milliseconds.
* @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.
*/
async function retry(fn, options = {}) {
const { poll, timeout, errorMatch } = {
...getRetryOptions(),
...options,
};
let lastErr;
const startTime = Date.now();
let tries = 0;
const shouldContinue = () => {
// always run once
if (tries < 1)
return true;
// if retries are disabled, don't run a second time
if (options.disable)
return false;
// if timeout is not reached, keep trying
if (Date.now() - startTime < timeout) {
return true;
}
return false;
};
while (shouldContinue()) {
tries++;
try {
// Do it!
return await fn();
}
catch (err) {
lastErr = err;
const errString = errToString(err);
if ((typeof errorMatch === 'string' &&
!errString.toLowerCase().includes(errorMatch.toLowerCase())) ||
(errorMatch instanceof RegExp && !errorMatch.test(errString)) ||
(Array.isArray(errorMatch) &&
!errorMatch.some((match) => errString.toLowerCase().includes(match.toLowerCase())))) {
// it's not a matching error, throw immediately
throw err;
}
if (!shouldContinue()) {
if (options.disable) {
// if matching error was thrown, but retries are disabled
// just return undefined
return;
}
break;
}
if (poll === 'raf') {
if (typeof window !== 'undefined' && window.requestAnimationFrame) {
// we're in a renderer environment and can use requestAnimationFrame
await new Promise((resolve) => requestAnimationFrame(resolve));
}
else {
// we're in Node.js or another environment without requestAnimationFrame
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
else {
await new Promise((resolve) => setTimeout(resolve, poll));
}
}
}
const errMessage = lastErr ? ' Last throw: ' + errToString(lastErr) : '';
throw new Error(`retry()::Timeout after ${timeout}ms.${errMessage}`);
}
const retryDefaults = {
disable: false,
poll: 200,
timeout: 5000,
errorMatch: [
'context or browser has been closed',
'Promise was collected',
// Execution context was destroyed, most likely because of a navigation.
'Execution context was destroyed',
// "Cannot read properties of undefined (reading 'getOwnerBrowserWindow')"
`reading 'getOwnerBrowserWindow'`,
],
};
const currentRetryOptions = { ...retryDefaults };
/**
* 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().
*
* @category Utilities
*
* @param options - A partial object containing the retry options to be set.
* @returns The updated retry options.
*/
function setRetryOptions(options) {
Object.assign(currentRetryOptions, options);
return currentRetryOptions;
}
/**
* Gets the current default retry options.
*
* @category Utilities
*
* @returns The current retry options.
*/
function getRetryOptions() {
return { ...currentRetryOptions };
}
/**
* Resets the retry options to their default values.
*
* The default values are:
* - retries: 20
* - intervalMs: 200
* - timeoutMs: 5000
* - errorMatch: 'context or browser has been closed'
*
* @category Utilities
*/
function resetRetryOptions() {
Object.assign(currentRetryOptions, retryDefaults);
}
function isRetryOptions(options) {
if (typeof options !== 'object' || options === null) {
// if it's not an object
return false;
}
const validKeys = Object.keys(retryDefaults);
// if every one of the keys in the passed object is a valid key
return Object.keys(options).every((key) => validKeys.includes(key));
}
/**
* 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.
*
* @example
*
* ```javascript
* test('my test', async () => {
* // this will fail immediately if Playwright's context gets weird:
* const oldWay = await page.waitForFunction(() => document.body.classList.contains('ready'))
*
* // this will not fail if Playwright's context gets weird:
* const newWay = await retryUntilTruthy(() =>
* page.evaluate(() => document.body.classList.contains('ready'))
* )
* })
* ```
*
* @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. It should NOT return void/undefined.
* @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 {number} [options.retryTimeout=5000] - The maximum time in milliseconds to wait for an individual try to return a result. Defaults to 5000ms.
* @param {number} [options.retryPoll=200] - The delay between each retry attempt in milliseconds. Defaults to 200ms.
* @param {string|string[]|RegExp} [options.retryErrorMatch] - The error message or pattern to match against. Errors that don't match will throw immediately.
* @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.
*/
async function retryUntilTruthy(fn, options = {}) {
const { timeout = 5000, poll = 100, retryPoll, retryTimeout, retryErrorMatch, retryDisable, } = options;
const retryOptions = {
...(retryPoll !== undefined && { poll: retryPoll }),
...(retryTimeout !== undefined && { timeout: retryTimeout }),
...(retryErrorMatch && { errorMatch: retryErrorMatch }),
...(retryDisable !== undefined && { disable: retryDisable }),
};
const timeoutTime = Date.now() + timeout;
while (Date.now() < timeoutTime) {
const result = await retry(fn, retryOptions);
if (result) {
return result;
}
if (poll === 'raf') {
if (typeof window !== 'undefined' && window.requestAnimationFrame) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
else {
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
else {
await new Promise((resolve) => setTimeout(resolve, poll));
}
}
throw new Error(`retryUntilTruthy::Timeout after ${timeout}ms`);
}
/**
* Converts an unknown error to a string representation.
*
* This function handles different types of errors and attempts to convert them
* to a string in a meaningful way. It checks if the error is an object with a
* `toString` method and uses that method if available. If the error is a string,
* it returns the string directly. For other types, it converts the error to a
* JSON string.
*
* @category Utilities
*
* @param err - The unknown error to be converted to a string.
* @returns A string representation of the error.
*/
function errToString(err) {
if (err instanceof Error) {
return err.toString();
}
else if (typeof err === 'string') {
return err;
}
else {
return JSON.stringify(err);
}
}
//# sourceMappingURL=utilities.js.map