UNPKG

electron-playwright-helpers

Version:

Helper functions for Electron end-to-end testing using Playwright

327 lines 13.2 kB
"use strict"; 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