Skip to content

Commit 7cb2556

Browse files
committed
feat(test runner): cancel operations on test end via default abort signal
Inject a default AbortSignal that fires on test end into every client operation, instead of relying on closing the context to unblock in-flight operations.
1 parent a061d96 commit 7cb2556

9 files changed

Lines changed: 59 additions & 6 deletions

File tree

packages/isomorphic/abortSignal.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,28 @@ export function assertionAbortedMessage(reason: unknown): string {
1818
const detail = reason instanceof Error ? reason.message : reason === undefined || reason === null ? '' : String(reason);
1919
return 'The assertion was aborted' + (detail ? `: ${detail}` : '');
2020
}
21+
22+
export function combineSignals(a?: AbortSignal, b?: AbortSignal): AbortSignal | undefined {
23+
if (!a)
24+
return b;
25+
if (!b)
26+
return a;
27+
if (a.aborted)
28+
return a;
29+
if (b.aborted)
30+
return b;
31+
32+
const controller = new AbortController();
33+
const onA = () => onAbort(a);
34+
const onB = () => onAbort(b);
35+
const onAbort = (source: AbortSignal) => {
36+
controller.abort(source.reason);
37+
a.removeEventListener('abort', onA);
38+
b.removeEventListener('abort', onB);
39+
};
40+
a.addEventListener('abort', onA, { once: true });
41+
b.addEventListener('abort', onB, { once: true });
42+
return controller.signal;
43+
}
44+
45+
export class TestEndedError extends Error {}

packages/isomorphic/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17+
export * from './abortSignal';
1718
export * from './ariaSnapshot';
1819
export * from './assert';
1920
export * from './base64';

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import { getMetainfo } from '@isomorphic/protocolMetainfo';
18+
import { combineSignals } from '@isomorphic/abortSignal';
1819
import { showInternalStackFrames, stringifyStackFrames } from '@utils/stackTrace';
1920
import { isUnderTest } from '@utils/debug';
2021
import { debugLogger } from '@utils/debugLogger';
@@ -154,16 +155,18 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
154155
return async (params: any, signal: AbortSignal | undefined) => {
155156
return await this._wrapApiCall(async apiZone => {
156157
const validatedParams = validator(params, '', this._validatorToWireContext());
158+
const def = this._connection._defaultOperationSignal;
159+
const effectiveSignal = combineSignals(signal, def?.aborted ? undefined : def);
157160
if (!apiZone.internal && !apiZone.reported) {
158161
// Reporting/tracing/logging this api call for the first time.
159162
apiZone.reported = true;
160163
this._instrumentation.onApiCallBegin(apiZone, { type: this._type, method: prop, params });
161164
logApiCall(this._logger, `=> ${apiZone.apiName} started`);
162-
return await this._connection.sendMessageToServer(this, prop, validatedParams, { ...apiZone, signal });
165+
return await this._connection.sendMessageToServer(this, prop, validatedParams, { ...apiZone, signal: effectiveSignal });
163166
}
164167
// Since this api call is either internal, or has already been reported/traced once,
165168
// passing as internal.
166-
return await this._connection.sendMessageToServer(this, prop, validatedParams, { internal: true, signal });
169+
return await this._connection.sendMessageToServer(this, prop, validatedParams, { internal: true, signal: effectiveSignal });
167170
}, { internal });
168171
};
169172
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import colors from 'colors/safe';
18+
import { TestEndedError } from '@isomorphic/abortSignal';
1819
import { rewriteErrorMessage } from '@utils/stackTrace';
1920
import { isUnderTest } from '@utils/debug';
2021
import { debugLogger } from '@utils/debugLogger';
@@ -87,6 +88,8 @@ export class Connection extends EventEmitter {
8788
readonly headers: HeadersArray;
8889
private _objectFactories = new Map<string, ChannelOwnerFactory>();
8990

91+
_defaultOperationSignal: AbortSignal | undefined;
92+
9093
constructor(localUtils?: LocalUtils, instrumentation?: ClientInstrumentation, headers: HeadersArray = []) {
9194
super();
9295
this._instrumentation = instrumentation || createInstrumentation();
@@ -246,6 +249,9 @@ export class Connection extends EventEmitter {
246249
const parsedError = parseError(error);
247250
if (callback.signal?.aborted && parsedError instanceof AbortError)
248251
parsedError.cause = callback.signal.reason;
252+
// hack to preserve error message strings from @playwright/test
253+
if (parsedError.cause instanceof TestEndedError)
254+
rewriteErrorMessage(parsedError, parsedError.cause.message);
249255
parsedError.log = log || [];
250256
rewriteErrorMessage(parsedError, parsedError.message + formatCallLog(log));
251257
const detailsValidator = maybeFindValidator(callback.type, callback.method, 'ErrorDetails');

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
4242
_defaultContextTimeout?: number;
4343
_defaultContextNavigationTimeout?: number;
4444

45+
get _defaultOperationSignal(): AbortSignal | undefined {
46+
return this._connection._defaultOperationSignal;
47+
}
48+
49+
set _defaultOperationSignal(signal: AbortSignal | undefined) {
50+
this._connection._defaultOperationSignal = signal;
51+
}
52+
4553
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) {
4654
super(parent, type, guid, initializer);
4755
this.request = new APIRequest(this);

packages/playwright/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,9 +384,11 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures, UtilityTestFixt
384384

385385
playwright._defaultContextTimeout = actionTimeout || 0;
386386
playwright._defaultContextNavigationTimeout = navigationTimeout || 0;
387+
playwright._defaultOperationSignal = testInfo._operationAbortController.signal;
387388
await use();
388389
playwright._defaultContextTimeout = undefined;
389390
playwright._defaultContextNavigationTimeout = undefined;
391+
playwright._defaultOperationSignal = undefined;
390392
}, { auto: 'all-hooks-included', title: 'context configuration', box: true } as any],
391393

392394
_contextFactory: [async ({ browser, video, _reuseContext, _combinedContextOptions /** mitigate dep-via-auto lack of traceability */ }, use, testInfo) => {
@@ -420,8 +422,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures, UtilityTestFixt
420422
if (closed)
421423
return;
422424
closed = true;
423-
const closeReason = testInfo.status === 'timedOut' ? 'Test timeout of ' + testInfo.timeout + 'ms exceeded.' : 'Test ended.';
424-
await context.close({ reason: closeReason });
425+
await context.close();
425426
const preserveVideo = captureVideo && shouldPreserveVideo(videoMode, testInfo);
426427
if (preserveVideo) {
427428
const { pagesWithVideo: pagesForVideo } = contexts.get(context)!;

packages/playwright/src/worker/testInfo.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import fs from 'fs';
1818
import path from 'path';
1919

2020
import { ManualPromise } from '@isomorphic/manualPromise';
21+
import { TestEndedError } from '@isomorphic/abortSignal';
2122
import { captureRawStack, stringifyStackFrames, filteredStackTrace } from '@utils/stackTrace';
2223
import { escapeWithQuotes } from '@isomorphic/stringUtils';
2324
import { monotonicTime } from '@isomorphic/time';
@@ -94,6 +95,7 @@ export class TestInfoImpl implements TestInfo {
9495
readonly _uniqueSymbol;
9596

9697
private _interruptedPromise = new ManualPromise<void>();
98+
readonly _operationAbortController = new AbortController();
9799
_lastStepId = 0;
98100
private readonly _requireFile: string;
99101
readonly _projectInternal: commonConfig.FullProjectInternal;
@@ -404,6 +406,11 @@ export class TestInfoImpl implements TestInfo {
404406
throw new TestAbortError('Test aborted' + (message ? ': ' + message : ''));
405407
}
406408

409+
_abortOperations(reason: string) {
410+
if (!this._operationAbortController.signal.aborted)
411+
this._operationAbortController.abort(new TestEndedError(reason));
412+
}
413+
407414
_interrupt() {
408415
// Mark as interrupted so we can ignore TimeoutError thrown by interrupt() call.
409416
this._interruptedPromise.resolve();

packages/playwright/src/worker/workerMain.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,9 @@ export class WorkerMain extends ProcessRunner {
452452

453453
testInfo._tracing.didFinishTestFunctionAndAfterEachHooks();
454454

455+
const abortReason = testInfo.status === 'timedOut' ? 'Test timeout of ' + testInfo.timeout + 'ms exceeded.' : 'Test ended.';
456+
testInfo._abortOperations(abortReason);
457+
455458
try {
456459
// Teardown test-scoped fixtures. Attribute to 'test' so that users understand
457460
// they should probably increase the test timeout to fix this issue.

tests/playwright-test/test-step.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -630,14 +630,13 @@ pw:api | Launch browser
630630
pw:api | Create page @ a.test.ts:6
631631
pw:api |Set content @ a.test.ts:15
632632
pw:api |Click locator('div') @ a.test.ts:16
633-
pw:api |↪ error: Error: page.click: Target page, context or browser has been closed
634633
hook |After Hooks
635634
hook | afterAll hook @ a.test.ts:9
636635
pw:api | Close context @ a.test.ts:10
637636
hook |Worker Cleanup
638637
fixture | Fixture "browser"
639638
|Test timeout of 2000ms exceeded.
640-
|Error: page.click: Target page, context or browser has been closed
639+
|AbortError: page.click: Test timeout of 2000ms exceeded.
641640
`);
642641
});
643642

0 commit comments

Comments
 (0)