Skip to content

Commit 350d96c

Browse files
authored
feat(test runner): conditional retries based on error (#43929)
1 parent 7565d47 commit 350d96c

16 files changed

Lines changed: 271 additions & 24 deletions

File tree

‎docs/src/test-api/class-fullproject.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ See [`property: TestProject.repeatEach`].
6262
* since: v1.10
6363
- type: <[int]>
6464

65-
See [`property: TestProject.retries`].
65+
See [`property: TestProject.retries`]. When retries are configured as a list, this is the largest `count` in the list.
6666

6767
## property: FullProject.teardown
6868
* since: v1.34

‎docs/src/test-api/class-test.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -583,9 +583,11 @@ Execution mode. Learn more about the execution modes [here](../test-parallel.md)
583583

584584
### option: Test.describe.configure.retries
585585
* since: v1.28
586-
- `retries` <[int]>
586+
- `retries` <[int]|[Array]<[Object]>>
587+
- `count` <[int]> The maximum number of retries given to a failed test when this entry applies.
588+
- `condition` ?<[RegExp]> Regular expression matched against the error messages of the failed attempt. An entry without a condition always applies.
587589

588-
The number of retries for each test.
590+
The number of retries for each test. Set to a list to make the number of retries depend on the error, see [`property: TestConfig.retries`].
589591

590592
### option: Test.describe.configure.timeout
591593
* since: v1.28

‎docs/src/test-api/class-testconfig.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,10 +502,14 @@ Whether to skip entries from `.gitignore` when searching for test files. By defa
502502

503503
## property: TestConfig.retries
504504
* since: v1.10
505-
- type: ?<[int]>
505+
- type: ?<[int]|[Array]<[Object]>>
506+
- `count` <[int]> The maximum number of retries given to a failed test when this entry applies.
507+
- `condition` ?<[RegExp]> Regular expression matched against the error messages of the failed attempt. An entry without a condition always applies.
506508

507509
The maximum number of retry attempts given to failed tests. By default failing tests are not retried. Learn more about [test retries](../test-retries.md#retries).
508510

511+
When set to a list, the number of retries depends on the error. After each failed attempt, Playwright takes the largest `count` among the entries that apply, and retries the test when it has run fewer times than that. The latest failed attempt decides: a test that first fails with a matching error and then with a different one only gets the retries of the latter. Worker crashes and timeouts are matched by their error message like any other failure.
512+
509513
**Usage**
510514

511515
```js title="playwright.config.ts"
@@ -516,6 +520,19 @@ export default defineConfig({
516520
});
517521
```
518522

523+
Retry any failure once, and up to three times when the browser or the worker crashed:
524+
525+
```js title="playwright.config.ts"
526+
import { defineConfig } from '@playwright/test';
527+
528+
export default defineConfig({
529+
retries: [
530+
{ count: 1 },
531+
{ count: 3, condition: /Target crashed|worker process exited unexpectedly/ },
532+
],
533+
});
534+
```
535+
519536
## property: TestConfig.retryStrategy
520537
* since: v1.62
521538
- type: "RetryStrategy" ?<"immediate"|"isolated">

‎docs/src/test-api/class-testproject.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,10 +273,14 @@ Whether to skip entries from `.gitignore` when searching for test files. By defa
273273

274274
## property: TestProject.retries
275275
* since: v1.10
276-
- type: ?<[int]>
276+
- type: ?<[int]|[Array]<[Object]>>
277+
- `count` <[int]> The maximum number of retries given to a failed test when this entry applies.
278+
- `condition` ?<[RegExp]> Regular expression matched against the error messages of the failed attempt. An entry without a condition always applies.
277279

278280
The maximum number of retry attempts given to failed tests. Learn more about [test retries](../test-retries.md#retries).
279281

282+
Set to a list to make the number of retries depend on the error, see [`property: TestConfig.retries`].
283+
280284
Use [`method: Test.describe.configure`] to change the number of retries for a specific file or a group of tests.
281285

282286
Use [`property: TestConfig.retries`] to change this option for all projects.

‎docs/src/test-reporter-api/class-testcase.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Results for each run of this test.
7272
* since: v1.10
7373
- type: <[int]>
7474

75-
The maximum number of retries given to this test in the configuration.
75+
The maximum number of retries given to this test in the configuration. With conditional retries, this is updated after each failed attempt to the number of retries that applies to that failure.
7676

7777
Learn more about [test retries](../test-retries.md#retries).
7878

‎docs/src/test-retries-js.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,25 @@ test.describe(() => {
127127
});
128128
```
129129

130+
### Conditional retries
131+
132+
The number of retries can depend on the error. Give a list of entries, each with a `count` and an optional `condition` regular expression. After each failed attempt, the largest `count` among the entries whose condition matches an error message of that attempt applies. An entry without a condition always applies.
133+
134+
```js title="playwright.config.ts"
135+
import { defineConfig } from '@playwright/test';
136+
137+
export default defineConfig({
138+
retries: [
139+
// Retry any failure once.
140+
{ count: 1 },
141+
// Retry up to three times when the browser or the worker crashed.
142+
{ count: 3, condition: /Target crashed|worker process exited unexpectedly/ },
143+
],
144+
});
145+
```
146+
147+
Worker crashes and timeouts are matched by their error message like any other failure. The latest failed attempt decides: a test that first crashes and then fails on an assertion only gets the retries of the assertion failure.
148+
130149
## Serial mode
131150

132151
Use [`method: Test.describe.serial`] to group dependent tests to ensure they will always run together and in order. If one of the tests fails, all subsequent tests are skipped. All tests in the group are retried together.

‎packages/playwright/src/common/config.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import path from 'path';
2020

2121
import { packageJSON } from '../package';
2222
import { getPackageJsonPath, mergeObjects, takeFirst } from '../util';
23+
import { maxRetries, normalizeRetries } from './retries';
2324

2425
import type { Config, Fixtures, Metadata, PlaywrightWorkerOptions, Project, ReporterDescription } from '../../types/test';
2526
import type { TestRunnerPluginRegistration } from '../plugins';
2627
import type { ConfigCLIOverrides } from './ipc';
28+
import type { RetriesEntry } from './retries';
2729
import type { Location } from '../../types/testReporter';
2830
import type { FullConfig, FullProject } from '../../types/testReporter';
2931

@@ -164,6 +166,7 @@ export class FullProjectInternal {
164166
readonly respectGitIgnore: boolean;
165167
readonly snapshotPathTemplate: string | undefined;
166168
readonly workers: number | undefined;
169+
readonly retries: RetriesEntry[];
167170
readonly default: boolean;
168171
id = '';
169172
deps: FullProjectInternal[] = [];
@@ -183,14 +186,15 @@ export class FullProjectInternal {
183186
if (typeof configCLIOverrides.use?.trace === 'string' && typeof configTrace === 'object' && configTrace)
184187
use.trace = { ...configTrace, mode: configCLIOverrides.use.trace };
185188

189+
const retries = normalizeRetries(takeFirst<number | RetriesEntry[]>(configCLIOverrides.retries, projectConfig.retries, config.retries, 0));
186190
this.project = {
187191
grep: takeFirst(projectConfig.grep, config.grep, defaultGrep),
188192
grepInvert: takeFirst(projectConfig.grepInvert, config.grepInvert, null),
189193
outputDir: takeFirst(configCLIOverrides.outputDir, pathResolve(configDir, projectConfig.outputDir), pathResolve(configDir, config.outputDir), path.join(packageJsonDir, 'test-results')),
190194
// Note: we either apply the cli override for repeatEach or not, depending on whether the
191195
// project is top-level vs dependency. See collectProjectsAndTestFiles in loadUtils.
192196
repeatEach: takeFirst(projectConfig.repeatEach, config.repeatEach, 1),
193-
retries: takeFirst(configCLIOverrides.retries, projectConfig.retries, config.retries, 0),
197+
retries: maxRetries(retries),
194198
metadata: takeFirst(projectConfig.metadata, config.metadata, {}),
195199
name: takeFirst(projectConfig.name, config.name, ''),
196200
testDir,
@@ -208,6 +212,7 @@ export class FullProjectInternal {
208212
if (server.port !== undefined && server.url !== undefined)
209213
throw new Error(`Either 'port' or 'url' should be specified in projects[${this.project.name}].webServer.`);
210214
}
215+
this.retries = retries;
211216
this.fullyParallel = takeFirst(configCLIOverrides.fullyParallel, projectConfig.fullyParallel, config.fullyParallel, undefined);
212217
this.expect = takeFirst(projectConfig.expect, config.expect, {});
213218
if (this.expect.toHaveScreenshot?.stylePath) {

‎packages/playwright/src/common/configLoader.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,19 @@ function validateProject(file: string, project: Project, title: string) {
274274
}
275275

276276
if ('retries' in project && project.retries !== undefined) {
277-
if (typeof project.retries !== 'number' || project.retries < 0)
278-
throw errorWithFile(file, `${title}.retries must be a non-negative number`);
277+
if (Array.isArray(project.retries)) {
278+
for (let index = 0; index < project.retries.length; index++) {
279+
const entry = project.retries[index];
280+
if (!entry || typeof entry !== 'object')
281+
throw errorWithFile(file, `${title}.retries[${index}] must be an object`);
282+
if (typeof entry.count !== 'number' || entry.count < 0)
283+
throw errorWithFile(file, `${title}.retries[${index}].count must be a non-negative number`);
284+
if (entry.condition !== undefined && !isRegExp(entry.condition))
285+
throw errorWithFile(file, `${title}.retries[${index}].condition must be a RegExp`);
286+
}
287+
} else if (typeof project.retries !== 'number' || project.retries < 0) {
288+
throw errorWithFile(file, `${title}.retries must be a non-negative number or an array of { count, condition } objects`);
289+
}
279290
}
280291

281292
if ('testDir' in project && project.testDir !== undefined) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export type RetriesEntry = { count: number, condition?: RegExp };
18+
export type SerializedRetriesEntry = { count: number, condition?: { source: string, flags: string } };
19+
20+
export function normalizeRetries(retries: number | RetriesEntry[]): RetriesEntry[] {
21+
if (typeof retries === 'number')
22+
return [{ count: retries }];
23+
return retries.map(entry => ({ ...entry }));
24+
}
25+
26+
export function maxRetries(entries: RetriesEntry[]): number {
27+
return entries.reduce((max, entry) => Math.max(max, entry.count), 0);
28+
}
29+
30+
export function computeRetries(entries: RetriesEntry[], errors: { message?: string }[]): number {
31+
const matching = entries.filter(entry => !entry.condition || errors.some(error => !!(error.message ?? '').match(entry.condition!)));
32+
return maxRetries(matching);
33+
}
34+
35+
export function serializeRetries(entries: RetriesEntry[] | undefined): SerializedRetriesEntry[] | undefined {
36+
return entries?.map(entry => ({
37+
count: entry.count,
38+
...(entry.condition ? { condition: { source: entry.condition.source, flags: entry.condition.flags } } : {}),
39+
}));
40+
}
41+
42+
export function parseRetries(data: SerializedRetriesEntry[] | undefined): RetriesEntry[] | undefined {
43+
return data?.map(entry => ({
44+
count: entry.count,
45+
...(entry.condition ? { condition: new RegExp(entry.condition.source, entry.condition.flags) } : {}),
46+
}));
47+
}

‎packages/playwright/src/common/suiteUtils.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import { calculateSha1 } from '@utils/crypto';
2020
import { toPosixPath } from '@utils/fileUtils';
2121

2222
import { createFileMatcher, forceRegExp, parseLocationArg } from '../util';
23+
import { maxRetries } from './retries';
2324

2425
import type { FullProjectInternal } from './config';
26+
import type { RetriesEntry } from './retries';
2527
import type { Suite, TestCase } from './test';
2628
import type { Matcher, TestCaseFilter } from '../util';
2729

@@ -52,7 +54,7 @@ export function bindFileSuiteToProject(project: FullProjectInternal, suite: Suit
5254
test._projectId = project.id;
5355

5456
// Inherit properties from parent suites.
55-
let inheritedRetries: number | undefined;
57+
let inheritedRetries: RetriesEntry[] | undefined;
5658
let inheritedTimeout: number | undefined;
5759
for (let parentSuite: Suite | undefined = suite; parentSuite; parentSuite = parentSuite.parent) {
5860
if (parentSuite._staticAnnotations.length)
@@ -66,7 +68,8 @@ export function bindFileSuiteToProject(project: FullProjectInternal, suite: Suit
6668
if (inheritedTimeout === undefined && parentSuite._timeout !== undefined)
6769
inheritedTimeout = parentSuite._timeout;
6870
}
69-
test.retries = inheritedRetries ?? project.project.retries;
71+
test._retries = inheritedRetries ?? project.retries;
72+
test.retries = maxRetries(test._retries);
7073
test.timeout = inheritedTimeout ?? project.project.timeout;
7174

7275
// Skip annotations imply skipped expectedStatus.

0 commit comments

Comments
 (0)