Skip to content

Commit d669fcd

Browse files
authored
chore: step error recovery framework (#36996)
1 parent 27f4ec6 commit d669fcd

File tree

15 files changed

+253
-26
lines changed

15 files changed

+253
-26
lines changed

packages/playwright-core/src/client/channelOwner.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { methodMetainfo } from '../utils/isomorphic/protocolMetainfo';
2020
import { captureLibraryStackTrace } from './clientStackTrace';
2121
import { stringifyStackFrames } from '../utils/isomorphic/stackTrace';
2222

23-
import type { ClientInstrumentation } from './clientInstrumentation';
23+
import type { ClientInstrumentation, RecoverFromApiErrorHandler } from './clientInstrumentation';
2424
import type { Connection } from './connection';
2525
import type { Logger } from './types';
2626
import type { ValidatorContext } from '../protocol/validator';
@@ -199,7 +199,14 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
199199
else
200200
e.stack = '';
201201
if (!options?.internal) {
202+
const recoveryHandlers: RecoverFromApiErrorHandler[] = [];
202203
apiZone.error = e;
204+
this._instrumentation.onApiCallRecovery(apiZone, e, recoveryHandlers);
205+
for (const handler of recoveryHandlers) {
206+
const recoverResult = await handler();
207+
if (recoverResult.status === 'recovered')
208+
return recoverResult.value as R;
209+
}
203210
logApiCall(this._platform, logger, `<= ${apiZone.apiName} failed`);
204211
this._instrumentation.onApiCallEnd(apiZone);
205212
}

packages/playwright-core/src/client/clientInstrumentation.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,20 @@ export interface ApiCallData {
2828
error?: Error;
2929
}
3030

31+
export type RecoverFromApiErrorResult = {
32+
status: 'recovered' | 'failed';
33+
value?: string | number | boolean | undefined;
34+
};
35+
36+
export type RecoverFromApiErrorHandler = () => Promise<RecoverFromApiErrorResult>;
37+
3138
export interface ClientInstrumentation {
3239
addListener(listener: ClientInstrumentationListener): void;
3340
removeListener(listener: ClientInstrumentationListener): void;
3441
removeAllListeners(): void;
3542
onApiCallBegin(apiCall: ApiCallData, channel: { type: string, method: string, params?: Record<string, any> }): void;
36-
onApiCallEnd(apiCal: ApiCallData): void;
43+
onApiCallRecovery(apiCall: ApiCallData, error: Error, recoveryHandlers: RecoverFromApiErrorHandler[]): void;
44+
onApiCallEnd(apiCall: ApiCallData): void;
3745
onWillPause(options: { keepTestTimeout: boolean }): void;
3846

3947
runAfterCreateBrowserContext(context: BrowserContext): Promise<void>;
@@ -44,6 +52,7 @@ export interface ClientInstrumentation {
4452

4553
export interface ClientInstrumentationListener {
4654
onApiCallBegin?(apiCall: ApiCallData, channel: { type: string, method: string, params?: Record<string, any> }): void;
55+
onApiCallRecovery?(apiCall: ApiCallData, error: Error, recoveryHandlers: RecoverFromApiErrorHandler[]): void;
4756
onApiCallEnd?(apiCall: ApiCallData): void;
4857
onWillPause?(options: { keepTestTimeout: boolean }): void;
4958

packages/playwright/src/common/ipc.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { serializeCompilationCache } from '../transform/compilationCache';
2121
import type { ConfigLocation, FullConfigInternal } from './config';
2222
import type { ReporterDescription, TestInfoError, TestStatus } from '../../types/test';
2323
import type { SerializedCompilationCache } from '../transform/compilationCache';
24+
import type { RecoverFromStepErrorResult } from '@testIsomorphic/testServerInterface';
2425

2526
export type ConfigCLIOverrides = {
2627
debug?: boolean;
@@ -66,6 +67,7 @@ export type WorkerInitParams = {
6667
projectId: string;
6768
config: SerializedConfig;
6869
artifactsDir: string;
70+
recoverFromStepErrors: boolean;
6971
};
7072

7173
export type TestBeginPayload = {
@@ -105,6 +107,14 @@ export type StepBeginPayload = {
105107
location?: { file: string, line: number, column: number };
106108
};
107109

110+
export type StepRecoverFromErrorPayload = {
111+
testId: string;
112+
stepId: string;
113+
error: TestInfoErrorImpl;
114+
};
115+
116+
export type ResumeAfterStepErrorPayload = RecoverFromStepErrorResult;
117+
108118
export type StepEndPayload = {
109119
testId: string;
110120
stepId: string;

packages/playwright/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,11 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
286286
if (data.apiName === 'tracing.group')
287287
tracingGroupSteps.push(step);
288288
},
289+
onApiCallRecovery: (data, error, recoveryHandlers) => {
290+
const step = data.userData as TestStepInternal;
291+
if (step)
292+
recoveryHandlers.push(() => step.recoverFromStepError(error));
293+
},
289294
onApiCallEnd: data => {
290295
// "tracing.group" step will end later, when "tracing.groupEnd" finishes.
291296
if (data.apiName === 'tracing.group')

packages/playwright/src/isomorphic/testServerConnection.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import * as events from './events';
1818

1919
import type { TestServerInterface, TestServerInterfaceEvents } from '@testIsomorphic/testServerInterface';
20+
import type * as reporterTypes from '../../types/testReporter';
2021

2122
// -- Reuse boundary -- Everything below this line is reused in the vscode extension.
2223

@@ -68,12 +69,14 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
6869
readonly onStdio: events.Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>;
6970
readonly onTestFilesChanged: events.Event<{ testFiles: string[] }>;
7071
readonly onLoadTraceRequested: events.Event<{ traceUrl: string }>;
72+
readonly onRecoverFromStepError: events.Event<{ stepId: string, message: string, location: reporterTypes.Location }>;
7173

7274
private _onCloseEmitter = new events.EventEmitter<void>();
7375
private _onReportEmitter = new events.EventEmitter<any>();
7476
private _onStdioEmitter = new events.EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>();
7577
private _onTestFilesChangedEmitter = new events.EventEmitter<{ testFiles: string[] }>();
7678
private _onLoadTraceRequestedEmitter = new events.EventEmitter<{ traceUrl: string }>();
79+
private _onRecoverFromStepErrorEmitter = new events.EventEmitter<{ stepId: string, message: string, location: reporterTypes.Location }>();
7780

7881
private _lastId = 0;
7982
private _transport: TestServerTransport;
@@ -87,6 +90,7 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
8790
this.onStdio = this._onStdioEmitter.event;
8891
this.onTestFilesChanged = this._onTestFilesChangedEmitter.event;
8992
this.onLoadTraceRequested = this._onLoadTraceRequestedEmitter.event;
93+
this.onRecoverFromStepError = this._onRecoverFromStepErrorEmitter.event;
9094

9195
this._transport = transport;
9296
this._transport.onmessage(data => {
@@ -147,6 +151,8 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
147151
this._onTestFilesChangedEmitter.fire(params);
148152
else if (method === 'loadTraceRequested')
149153
this._onLoadTraceRequestedEmitter.fire(params);
154+
else if (method === 'recoverFromStepError')
155+
this._onRecoverFromStepErrorEmitter.fire(params);
150156
}
151157

152158
async initialize(params: Parameters<TestServerInterface['initialize']>[0]): ReturnType<TestServerInterface['initialize']> {
@@ -241,6 +247,10 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
241247
await this._sendMessage('closeGracefully', params);
242248
}
243249

250+
async resumeAfterStepError(params: Parameters<TestServerInterface['resumeAfterStepError']>[0]): Promise<void> {
251+
await this._sendMessage('resumeAfterStepError', params);
252+
}
253+
244254
close() {
245255
try {
246256
this._transport.close();

packages/playwright/src/isomorphic/testServerInterface.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,20 @@ import type * as reporterTypes from '../../types/testReporter';
2222

2323
export type ReportEntry = JsonEvent;
2424

25+
export type RecoverFromStepErrorResult = {
26+
stepId: string;
27+
status: 'recovered' | 'failed';
28+
value?: string | number | boolean | undefined;
29+
};
30+
2531
export interface TestServerInterface {
2632
initialize(params: {
2733
serializer?: string,
2834
closeOnDisconnect?: boolean,
2935
interceptStdio?: boolean,
3036
watchTestDirs?: boolean,
3137
populateDependenciesOnList?: boolean,
38+
recoverFromStepErrors?: boolean,
3239
}): Promise<void>;
3340

3441
ping(params: {}): Promise<void>;
@@ -113,6 +120,8 @@ export interface TestServerInterface {
113120
stopTests(params: {}): Promise<void>;
114121

115122
closeGracefully(params: {}): Promise<void>;
123+
124+
resumeAfterStepError(params: RecoverFromStepErrorResult): Promise<void>;
116125
}
117126

118127
export interface TestServerInterfaceEvents {
@@ -127,4 +136,5 @@ export interface TestServerInterfaceEventEmitters {
127136
dispatchEvent(event: 'stdio', params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }): void;
128137
dispatchEvent(event: 'testFilesChanged', params: { testFiles: string[] }): void;
129138
dispatchEvent(event: 'loadTraceRequested', params: { traceUrl: string }): void;
139+
dispatchEvent(event: 'recoverFromStepError', params: { stepId: string, message: string, location: reporterTypes.Location }): void;
130140
}

packages/playwright/src/matchers/expect.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -365,20 +365,37 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
365365

366366
const step = testInfo._addStep(stepInfo);
367367

368-
const reportStepError = (e: Error | unknown) => {
368+
const reportStepError = (isAsync: boolean, e: Error | unknown) => {
369369
const jestError = isJestError(e) ? e : null;
370-
const error = jestError ? new ExpectError(jestError, customMessage, stackFrames) : e;
370+
const expectError = jestError ? new ExpectError(jestError, customMessage, stackFrames) : undefined;
371371
if (jestError?.matcherResult.suggestedRebaseline) {
372372
// NOTE: this is a workaround for the fact that we can't pass the suggested rebaseline
373373
// for passing matchers. See toMatchAriaSnapshot for a counterpart.
374374
step.complete({ suggestedRebaseline: jestError?.matcherResult.suggestedRebaseline });
375375
return;
376376
}
377+
378+
const error = expectError ?? e;
377379
step.complete({ error });
378-
if (this._info.isSoft)
379-
testInfo._failWithError(error);
380-
else
381-
throw error;
380+
381+
if (!isAsync || !expectError) {
382+
if (this._info.isSoft)
383+
testInfo._failWithError(error);
384+
else
385+
throw error;
386+
return;
387+
}
388+
389+
// Recoverable async failure.
390+
return (async () => {
391+
const recoveryResult = await step.recoverFromStepError(expectError);
392+
if (recoveryResult.status === 'recovered')
393+
return recoveryResult.value as any;
394+
if (this._info.isSoft)
395+
testInfo._failWithError(expectError);
396+
else
397+
throw expectError;
398+
})();
382399
};
383400

384401
const finalizer = () => {
@@ -390,11 +407,11 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
390407
const callback = () => matcher.call(target, ...args);
391408
const result = currentZone().with('stepZone', step).run(callback);
392409
if (result instanceof Promise)
393-
return result.then(finalizer).catch(reportStepError);
410+
return result.then(finalizer).catch(reportStepError.bind(null, true));
394411
finalizer();
395412
return result;
396413
} catch (e) {
397-
reportStepError(e);
414+
void reportStepError(false, e);
398415
}
399416
};
400417
}

packages/playwright/src/runner/dispatcher.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ import type { ProcessExitData } from './processHost';
2626
import type { TestGroup } from './testGroups';
2727
import type { TestError, TestResult, TestStep } from '../../types/testReporter';
2828
import type { FullConfigInternal } from '../common/config';
29-
import type { AttachmentPayload, DonePayload, RunPayload, SerializedConfig, StepBeginPayload, StepEndPayload, TeardownErrorsPayload, TestBeginPayload, TestEndPayload, TestOutputPayload } from '../common/ipc';
29+
import type { AttachmentPayload, DonePayload, RunPayload, SerializedConfig, StepBeginPayload, StepEndPayload, StepRecoverFromErrorPayload, TeardownErrorsPayload, TestBeginPayload, TestEndPayload, TestOutputPayload } from '../common/ipc';
3030
import type { Suite } from '../common/test';
3131
import type { TestCase } from '../common/test';
3232
import type { ReporterV2 } from '../reporters/reporterV2';
3333
import type { RegisteredListener } from 'playwright-core/lib/utils';
34+
import type { RecoverFromStepErrorResult } from '@testIsomorphic/testServerInterface';
3435

3536

3637
export type EnvByProjectId = Map<string, Record<string, string | undefined>>;
@@ -218,7 +219,8 @@ export class Dispatcher {
218219
_createWorker(testGroup: TestGroup, parallelIndex: number, loaderData: SerializedConfig) {
219220
const projectConfig = this._config.projects.find(p => p.id === testGroup.projectId)!;
220221
const outputDir = projectConfig.project.outputDir;
221-
const worker = new WorkerHost(testGroup, parallelIndex, loaderData, this._extraEnvByProjectId.get(testGroup.projectId) || {}, outputDir);
222+
const recoverFromStepErrors = this._failureTracker.canRecoverFromStepError();
223+
const worker = new WorkerHost(testGroup, parallelIndex, loaderData, recoverFromStepErrors, this._extraEnvByProjectId.get(testGroup.projectId) || {}, outputDir);
222224
const handleOutput = (params: TestOutputPayload) => {
223225
const chunk = chunkFromParams(params);
224226
if (worker.didFail()) {
@@ -396,6 +398,26 @@ class JobDispatcher {
396398
this._reporter.onStepEnd?.(test, result, step);
397399
}
398400

401+
private _onStepRecoverFromError(resumeAfterStepError: (result: RecoverFromStepErrorResult) => void, params: StepRecoverFromErrorPayload) {
402+
const data = this._dataByTestId.get(params.testId);
403+
if (!data) {
404+
resumeAfterStepError({ stepId: params.stepId, status: 'failed' });
405+
return;
406+
}
407+
const { steps } = data;
408+
const step = steps.get(params.stepId);
409+
if (!step) {
410+
resumeAfterStepError({ stepId: params.stepId, status: 'failed' });
411+
return;
412+
}
413+
414+
const testError: TestError = {
415+
...params.error,
416+
location: step.location,
417+
};
418+
this._failureTracker.recoverFromStepError(params.stepId, testError, resumeAfterStepError);
419+
}
420+
399421
private _onAttach(params: AttachmentPayload) {
400422
const data = this._dataByTestId.get(params.testId)!;
401423
if (!data) {
@@ -560,12 +582,14 @@ class JobDispatcher {
560582
}),
561583
};
562584
worker.runTestGroup(runPayload);
585+
const resumeAfterStepError = worker.resumeAfterStepError.bind(worker);
563586

564587
this._listeners = [
565588
eventsHelper.addEventListener(worker, 'testBegin', this._onTestBegin.bind(this)),
566589
eventsHelper.addEventListener(worker, 'testEnd', this._onTestEnd.bind(this)),
567590
eventsHelper.addEventListener(worker, 'stepBegin', this._onStepBegin.bind(this)),
568591
eventsHelper.addEventListener(worker, 'stepEnd', this._onStepEnd.bind(this)),
592+
eventsHelper.addEventListener(worker, 'stepRecoverFromError', this._onStepRecoverFromError.bind(this, resumeAfterStepError)),
569593
eventsHelper.addEventListener(worker, 'attach', this._onAttach.bind(this)),
570594
eventsHelper.addEventListener(worker, 'done', this._onDone.bind(this)),
571595
eventsHelper.addEventListener(worker, 'exit', this.onExit.bind(this)),

packages/playwright/src/runner/failureTracker.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,30 @@
1414
* limitations under the License.
1515
*/
1616

17-
import type { TestResult } from '../../types/testReporter';
17+
import type { RecoverFromStepErrorResult } from '@testIsomorphic/testServerInterface';
18+
import type { TestResult, TestError } from '../../types/testReporter';
1819
import type { FullConfigInternal } from '../common/config';
1920
import type { Suite, TestCase } from '../common/test';
2021

22+
export type RecoverFromStepErrorHandler = (stepId: string, error: TestError) => Promise<RecoverFromStepErrorResult>;
23+
2124
export class FailureTracker {
2225
private _failureCount = 0;
2326
private _hasWorkerErrors = false;
2427
private _rootSuite: Suite | undefined;
28+
private _recoverFromStepErrorHandler: RecoverFromStepErrorHandler | undefined;
2529

2630
constructor(private _config: FullConfigInternal) {
2731
}
2832

33+
canRecoverFromStepError(): boolean {
34+
return !!this._recoverFromStepErrorHandler;
35+
}
36+
37+
setRecoverFromStepErrorHandler(recoverFromStepErrorHandler: RecoverFromStepErrorHandler) {
38+
this._recoverFromStepErrorHandler = recoverFromStepErrorHandler;
39+
}
40+
2941
onRootSuite(rootSuite: Suite) {
3042
this._rootSuite = rootSuite;
3143
}
@@ -36,6 +48,14 @@ export class FailureTracker {
3648
++this._failureCount;
3749
}
3850

51+
recoverFromStepError(stepId: string, error: TestError, resumeAfterStepError: (result: RecoverFromStepErrorResult) => void) {
52+
if (!this._recoverFromStepErrorHandler) {
53+
resumeAfterStepError({ stepId, status: 'failed' });
54+
return;
55+
}
56+
void this._recoverFromStepErrorHandler(stepId, error).then(resumeAfterStepError).catch(() => {});
57+
}
58+
3959
onWorkerError() {
4060
this._hasWorkerErrors = true;
4161
}

0 commit comments

Comments
 (0)