diff --git a/docs/plugins/retryFailedStep.md b/docs/plugins/retryFailedStep.md index 4d1522406..2c1dc7897 100644 --- a/docs/plugins/retryFailedStep.md +++ b/docs/plugins/retryFailedStep.md @@ -5,6 +5,7 @@ sidebar: auto title: retryFailedStep --- + ## retryFailedStep @@ -28,10 +29,9 @@ Run tests with plugin enabled: #### Configuration: * `retries` - number of retries (by default 3), -* `when` - function, when to perform a retry (accepts error as parameter) * `factor` - The exponential factor to use. Default is 1.5. -* `minTimeout` - The number of milliseconds before starting the first retry. Default is 1000. -* `maxTimeout` - The maximum number of milliseconds between two retries. Default is Infinity. +* `minTimeout` - The number of milliseconds before starting the first retry. Default is 150. +* `maxTimeout` - The maximum number of milliseconds between two retries. Default is 10000. * `randomize` - Randomizes the timeouts by multiplying with a factor from 1 to 2. Default is false. * `defaultIgnoredSteps` - an array of steps to be ignored for retry. Includes: * `amOnPage` diff --git a/docs/retry.md b/docs/retry.md index a3b29775b..a32d152e9 100644 --- a/docs/retry.md +++ b/docs/retry.md @@ -20,24 +20,36 @@ CodeceptJS provides flexible retry mechanisms to handle flaky tests. Use retries ## Helper Retries -Browser automation helpers (Playwright, Puppeteer, WebDriver) have **built-in retry mechanisms** for element interactions. When you call `I.click('Button')`, Playwright automatically waits for the element to exist, be visible, stable, and enabled — retrying for up to 5 seconds. +Plawright has a built-in retry mechanism for element interactions. When you call `I.click('Button')`, after the element is located Playwright keeps retrying until it is actionable — up to `timeout` (default 5s). -Configure the timeout in your helper settings: +> WebDriver has a different auto-retry option: [smartWait](/webdriver#smartwait) + +Even though the handle exists (from `.all()`), Playwright still waits for it to become visible, stable (not mid-animation), enabled, not covered by an overlay/modal, and not rerendering. ```js helpers: { Playwright: { - timeout: 5000, // retry actions for up to 5 seconds - waitForAction: 100 // wait 100ms before each action + timeout: 5000, // retry the action until the element is actionable + waitForAction: 100 // fixed pause AFTER click/doubleClick/pressKey } } ``` -**Learn more:** [Playwright Helper](/helpers/Playwright), [Timeouts](/timeouts) +What each setting does: + +``` +find element (no wait — fails instantly if locator matches nothing) + → wait up to `timeout` for it to become actionable ← timeout + → perform action + → sleep `waitForAction` ms ← waitForAction (settle pause, not a wait) +``` + +`timeout` covers the action. If the locator matches nothing yet, the step fails immediately. Use [Failed Step Retries](#failed-step-retries) to cover that gap. + ## Failed Step Retries -Automatically retry all failed steps without modifying test code: +CodeceptJS retries all failed steps by default by using the `retryFailedStep` plugin. ```js plugins: { @@ -66,18 +78,36 @@ Scenario('manual retries only', { disableRetryFailedStep: true }, ({ I }) => { }) ``` -Full plugin options: - -| Option | Default | Description | -|--------|---------|-------------| -| `retries` | — | Retries per step | -| `minTimeout` | — | Milliseconds before first retry | -| `maxTimeout` | `Infinity` | Max milliseconds between retries | -| `factor` | — | Exponential backoff multiplier | -| `randomize` | `false` | Randomize timeout intervals | -| `ignoredSteps` | `[]` | Patterns/regex of steps to never retry | -| `deferToScenarioRetries` | `true` | Disable step retries when scenario retries exist | -| `when` | `() => true` | Function receiving error; return `true` to retry | +Defaults: `minTimeout: 150`, `factor: 1.5`, `maxTimeout: 10000`. + + +> See [plugin reference](/plugins/retry-failed-step) for more options + +Retries are calculated via this formula: + +``` +gap(N) = min(minTimeout × factor^(N-1), maxTimeout) +``` + +Practically if step fails it will trigger a retry with increasing delay until `maxTimeout` is reached: + +``` +retries: 2 => 0.15s-0.4s (150,225ms) +retries: 3 => 0.15s-0.7s (150,225,338ms) +retries: 3, minTimeout: 1000 => 1s-4.75s (1s,1.5s,2.25s) +retries: 3, minTimeout: 1000, factor: 2 => 1s-7s (1s,2s,4s) +retries: 5, minTimeout: 1000, factor: 2 => 1s-25s (1s,2s,4s,8s,10s) +``` + +Playwright `timeout` adds to each attempt only when the element is found: + +- `Playwright.timeout: 5000` +- `retries: 2, minTimeout: 1000` + +``` +element not found => 0 + (1s+1s) = 2s +element found but not interactable => 3×5s + (1s+1s) = 17s +``` ## Manual Step Retries diff --git a/lib/plugin/retryFailedStep.js b/lib/plugin/retryFailedStep.js index cbe145910..aae8780b5 100644 --- a/lib/plugin/retryFailedStep.js +++ b/lib/plugin/retryFailedStep.js @@ -8,7 +8,10 @@ const debug = debugModule('codeceptjs:retryFailedStep') const defaultConfig = { retries: 3, defaultIgnoredSteps: ['amOnPage', 'wait*', 'send*', 'execute*', 'run*', 'have*'], + minTimeout: 150, + maxTimeout: 10000, factor: 1.5, + randomize: false, ignoredSteps: [], deferToScenarioRetries: true, } @@ -44,10 +47,9 @@ const RETRY_PRIORITIES = { * #### Configuration: * * * `retries` - number of retries (by default 3), - * * `when` - function, when to perform a retry (accepts error as parameter) * * `factor` - The exponential factor to use. Default is 1.5. - * * `minTimeout` - The number of milliseconds before starting the first retry. Default is 1000. - * * `maxTimeout` - The maximum number of milliseconds between two retries. Default is Infinity. + * * `minTimeout` - The number of milliseconds before starting the first retry. Default is 150. + * * `maxTimeout` - The maximum number of milliseconds between two retries. Default is 10000. * * `randomize` - Randomizes the timeouts by multiplying with a factor from 1 to 2. Default is false. * * `defaultIgnoredSteps` - an array of steps to be ignored for retry. Includes: * * `amOnPage` @@ -89,9 +91,8 @@ const RETRY_PRIORITIES = { * */ export default function (config) { - config = Object.assign(defaultConfig, config) + config = Object.assign({}, defaultConfig, config) config.ignoredSteps = config.ignoredSteps.concat(config.defaultIgnoredSteps) - const customWhen = config.when let enableRetry = false @@ -101,7 +102,6 @@ export default function (config) { if (!store.autoRetries) return false if (err && err.isTerminal) return false if (err && err.message && (err.message.includes('ERR_ABORTED') || err.message.includes('frame was detached') || err.message.includes('Target page, context or browser has been closed'))) return false - if (customWhen) return customWhen(err) return true } config.when = when diff --git a/test/unit/plugin/retryFailedStep_test.js b/test/unit/plugin/retryFailedStep_test.js index 09cab5b10..b25a3f377 100644 --- a/test/unit/plugin/retryFailedStep_test.js +++ b/test/unit/plugin/retryFailedStep_test.js @@ -76,6 +76,35 @@ describe('retryFailedStep', () => { expect(counter).to.equal(2) }) + describe('config', () => { + it('applies default retry timing', () => { + retryFailedStep({}) + const cfg = recorder.retries.find(r => r.deferToScenarioRetries !== undefined) + expect(cfg.retries).to.equal(3) + expect(cfg.minTimeout).to.equal(150) + expect(cfg.maxTimeout).to.equal(10000) + expect(cfg.factor).to.equal(1.5) + }) + + it('overrides retry timing from config', () => { + retryFailedStep({ retries: 5, minTimeout: 1000, maxTimeout: 3000, factor: 2 }) + const cfg = recorder.retries.find(r => r.deferToScenarioRetries !== undefined) + expect(cfg.retries).to.equal(5) + expect(cfg.minTimeout).to.equal(1000) + expect(cfg.maxTimeout).to.equal(3000) + expect(cfg.factor).to.equal(2) + }) + + it('does not leak config between instances', () => { + retryFailedStep({ retries: 5, minTimeout: 1000 }) + recorder.retries = [] + retryFailedStep({}) + const cfg = recorder.retries.find(r => r.deferToScenarioRetries !== undefined) + expect(cfg.retries).to.equal(3) + expect(cfg.minTimeout).to.equal(150) + }) + }) + it('should not retry steps with wait*', async () => { retryFailedStep({ retries: 2, minTimeout: 1 }) event.dispatcher.emit(event.test.before, createTest('test'))