feat(events): allow waiting for removeAllListeners (#31941)

This commit is contained in:
Pavel Feldman 2024-08-05 21:14:35 -07:00 committed by GitHub
parent 193013c9ee
commit 3c87f217df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1280 additions and 987 deletions

View file

@ -296,6 +296,18 @@ testing frameworks should explicitly create [`method: Browser.newContext`] follo
### option: Browser.newPage.storageStatePath = %%-csharp-java-context-option-storage-state-path-%% ### option: Browser.newPage.storageStatePath = %%-csharp-java-context-option-storage-state-path-%%
* since: v1.9 * since: v1.9
## async method: Browser.removeAllListeners
* since: v1.47
Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners.
### param: Browser.removeAllListeners.type
* since: v1.47
- `type` ?<[string]>
### option: Browser.removeAllListeners.behavior = %%-remove-all-listeners-options-behavior-%%
* since: v1.47
## async method: Browser.startTracing ## async method: Browser.startTracing
* since: v1.11 * since: v1.11
* langs: java, js, python * langs: java, js, python

View file

@ -1016,6 +1016,18 @@ Creates a new page in the browser context.
Returns all open pages in the context. Returns all open pages in the context.
## async method: BrowserContext.removeAllListeners
* since: v1.47
Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners.
### param: BrowserContext.removeAllListeners.type
* since: v1.47
- `type` ?<[string]>
### option: BrowserContext.removeAllListeners.behavior = %%-remove-all-listeners-options-behavior-%%
* since: v1.47
## property: BrowserContext.request ## property: BrowserContext.request
* since: v1.16 * since: v1.16
* langs: * langs:

View file

@ -3340,6 +3340,17 @@ Specifies the maximum number of times this handler should be called. Unlimited b
By default, after calling the handler Playwright will wait until the overlay becomes hidden, and only then Playwright will continue with the action/assertion that triggered the handler. This option allows to opt-out of this behavior, so that overlay can stay visible after the handler has run. By default, after calling the handler Playwright will wait until the overlay becomes hidden, and only then Playwright will continue with the action/assertion that triggered the handler. This option allows to opt-out of this behavior, so that overlay can stay visible after the handler has run.
## async method: Page.removeAllListeners
* since: v1.47
Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners.
### param: Page.removeAllListeners.type
* since: v1.47
- `type` ?<[string]>
### option: Page.removeAllListeners.behavior = %%-remove-all-listeners-options-behavior-%%
* since: v1.47
## async method: Page.removeLocatorHandler ## async method: Page.removeLocatorHandler
* since: v1.44 * since: v1.44

View file

@ -781,6 +781,16 @@ Whether to allow sites to register Service workers. Defaults to `'allow'`.
* `'allow'`: [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) can be registered. * `'allow'`: [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) can be registered.
* `'block'`: Playwright will block all registration of Service Workers. * `'block'`: Playwright will block all registration of Service Workers.
## remove-all-listeners-options-behavior
* langs: js
* since: v1.47
- `behavior` <[RemoveAllListenersBehavior]<"wait"|"ignoreErrors"|"default">>
Specifies whether to wait for already running listeners and what to do if they throw errors:
* `'default'` - do not wait for current listener calls (if any) to finish, if the listener throws, it may result in unhandled error
* `'wait'` - wait for current listener calls (if any) to finish
* `'ignoreErrors'` - do not wait for current listener calls (if any) to finish, all errors thrown by the listeners after removal are silently caught
## unroute-all-options-behavior ## unroute-all-options-behavior
* langs: js, csharp, python * langs: js, csharp, python
* since: v1.41 * since: v1.41
@ -791,6 +801,7 @@ Specifies whether to wait for already running handlers and what to do if they th
* `'wait'` - wait for current handler calls (if any) to finish * `'wait'` - wait for current handler calls (if any) to finish
* `'ignoreErrors'` - do not wait for current handler calls (if any) to finish, all errors thrown by the handlers after unrouting are silently caught * `'ignoreErrors'` - do not wait for current handler calls (if any) to finish, all errors thrown by the handlers after unrouting are silently caught
## select-options-values ## select-options-values
* langs: java, js, csharp * langs: java, js, csharp
- `values` <[null]|[string]|[ElementHandle]|[Array]<[string]>|[Object]|[Array]<[ElementHandle]>|[Array]<[Object]>> - `values` <[null]|[string]|[ElementHandle]|[Array]<[string]>|[Object]|[Array]<[ElementHandle]>|[Array]<[Object]>>

View file

@ -23,7 +23,7 @@
*/ */
type EventType = string | symbol; type EventType = string | symbol;
type Listener = (...args: any[]) => void; type Listener = (...args: any[]) => any;
type EventMap = Record<EventType, Listener | Listener[]>; type EventMap = Record<EventType, Listener | Listener[]>;
import { EventEmitter as OriginalEventEmitter } from 'events'; import { EventEmitter as OriginalEventEmitter } from 'events';
import type { EventEmitter as EventEmitterType } from 'events'; import type { EventEmitter as EventEmitterType } from 'events';
@ -34,6 +34,8 @@ export class EventEmitter implements EventEmitterType {
private _events: EventMap | undefined = undefined; private _events: EventMap | undefined = undefined;
private _eventsCount = 0; private _eventsCount = 0;
private _maxListeners: number | undefined = undefined; private _maxListeners: number | undefined = undefined;
readonly _pendingHandlers = new Map<EventType, Set<Promise<void>>>();
private _rejectionHandler: ((error: Error) => void) | undefined;
constructor() { constructor() {
if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) { if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) {
@ -66,17 +68,34 @@ export class EventEmitter implements EventEmitterType {
return false; return false;
if (typeof handler === 'function') { if (typeof handler === 'function') {
Reflect.apply(handler, this, args); this._callHandler(type, handler, args);
} else { } else {
const len = handler.length; const len = handler.length;
const listeners = handler.slice(); const listeners = handler.slice();
for (let i = 0; i < len; ++i) for (let i = 0; i < len; ++i)
Reflect.apply(listeners[i], this, args); this._callHandler(type, listeners[i], args);
} }
return true; return true;
} }
private _callHandler(type: EventType, handler: Listener, args: any[]): void {
const promise = Reflect.apply(handler, this, args);
if (!(promise instanceof Promise))
return;
let set = this._pendingHandlers.get(type);
if (!set) {
set = new Set();
this._pendingHandlers.set(type, set);
}
set.add(promise);
promise.catch(e => {
if (this._rejectionHandler)
this._rejectionHandler(e);
else
throw e;
}).finally(() => set.delete(promise));
}
addListener(type: EventType, listener: Listener): this { addListener(type: EventType, listener: Listener): this {
return this._addListener(type, listener, false); return this._addListener(type, listener, false);
} }
@ -214,10 +233,34 @@ export class EventEmitter implements EventEmitterType {
return this.removeListener(type, listener); return this.removeListener(type, listener);
} }
removeAllListeners(type?: string): this { removeAllListeners(type?: EventType): this;
removeAllListeners(type: EventType | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise<void>;
removeAllListeners(type?: string, options?: { behavior?: 'wait'|'ignoreErrors'|'default' }): this | Promise<void> {
this._removeAllListeners(type);
if (!options)
return this;
if (options.behavior === 'wait') {
const errors: Error[] = [];
this._rejectionHandler = error => errors.push(error);
// eslint-disable-next-line internal-playwright/await-promise-in-class-returns
return this._waitFor(type).then(() => {
if (errors.length)
throw errors[0];
});
}
if (options.behavior === 'ignoreErrors')
this._rejectionHandler = () => {};
// eslint-disable-next-line internal-playwright/await-promise-in-class-returns
return Promise.resolve();
}
private _removeAllListeners(type?: string) {
const events = this._events; const events = this._events;
if (!events) if (!events)
return this; return;
// not listening for removeListener, no need to emit // not listening for removeListener, no need to emit
if (!events.removeListener) { if (!events.removeListener) {
@ -230,7 +273,7 @@ export class EventEmitter implements EventEmitterType {
else else
delete events[type]; delete events[type];
} }
return this; return;
} }
// emit removeListener for all listeners on all events // emit removeListener for all listeners on all events
@ -241,12 +284,12 @@ export class EventEmitter implements EventEmitterType {
key = keys[i]; key = keys[i];
if (key === 'removeListener') if (key === 'removeListener')
continue; continue;
this.removeAllListeners(key); this._removeAllListeners(key);
} }
this.removeAllListeners('removeListener'); this._removeAllListeners('removeListener');
this._events = Object.create(null); this._events = Object.create(null);
this._eventsCount = 0; this._eventsCount = 0;
return this; return;
} }
const listeners = events[type]; const listeners = events[type];
@ -258,8 +301,6 @@ export class EventEmitter implements EventEmitterType {
for (let i = listeners.length - 1; i >= 0; i--) for (let i = listeners.length - 1; i >= 0; i--)
this.removeListener(type, listeners[i]); this.removeListener(type, listeners[i]);
} }
return this;
} }
listeners(type: EventType): Listener[] { listeners(type: EventType): Listener[] {
@ -286,6 +327,18 @@ export class EventEmitter implements EventEmitterType {
return this._eventsCount > 0 && this._events ? Reflect.ownKeys(this._events) : []; return this._eventsCount > 0 && this._events ? Reflect.ownKeys(this._events) : [];
} }
private async _waitFor(type?: EventType) {
let promises: Promise<void>[] = [];
if (type) {
promises = [...(this._pendingHandlers.get(type) || [])];
} else {
promises = [];
for (const [, pending] of this._pendingHandlers)
promises.push(...pending);
}
await Promise.all(promises);
}
private _listeners(target: EventEmitter, type: EventType, unwrap: boolean): Listener[] { private _listeners(target: EventEmitter, type: EventType, unwrap: boolean): Listener[] {
const events = target._events; const events = target._events;
@ -310,7 +363,7 @@ function checkListener(listener: any) {
class OnceWrapper { class OnceWrapper {
private _fired = false; private _fired = false;
readonly wrapperFunction: (...args: any[]) => void; readonly wrapperFunction: (...args: any[]) => Promise<void> | void;
readonly _listener: Listener; readonly _listener: Listener;
private _eventEmitter: EventEmitter; private _eventEmitter: EventEmitter;
private _eventType: EventType; private _eventType: EventType;

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,6 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE. // USE OR OTHER DEALINGS IN THE SOFTWARE.
import events from 'events';
import { EventEmitter } from '../../../packages/playwright-core/lib/client/eventEmitter'; import { EventEmitter } from '../../../packages/playwright-core/lib/client/eventEmitter';
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
@ -32,7 +31,6 @@ test('Listener count test', () => {
// Allow any type // Allow any type
emitter.on(123, () => {}); emitter.on(123, () => {});
expect(events.listenerCount(emitter, 'foo')).toEqual(2);
expect(emitter.listenerCount('foo')).toEqual(2); expect(emitter.listenerCount('foo')).toEqual(2);
expect(emitter.listenerCount('bar')).toEqual(0); expect(emitter.listenerCount('bar')).toEqual(0);
expect(emitter.listenerCount('baz')).toEqual(1); expect(emitter.listenerCount('baz')).toEqual(1);

View file

@ -40,7 +40,7 @@ test('EventEmitter listeners with one listener', () => {
expect(listeners).toHaveLength(1); expect(listeners).toHaveLength(1);
expect(listeners[0]).toEqual(listener); expect(listeners[0]).toEqual(listener);
ee.removeAllListeners('foo'); void ee.removeAllListeners('foo');
expect<Array<any>>(ee.listeners('foo')).toHaveLength(0); expect<Array<any>>(ee.listeners('foo')).toHaveLength(0);
expect(Array.isArray(fooListeners)).toBeTruthy(); expect(Array.isArray(fooListeners)).toBeTruthy();

View file

@ -72,7 +72,7 @@ test('add and remove listeners', () => {
e.on('foo', callback1); e.on('foo', callback1);
e.on('foo', callback2); e.on('foo', callback2);
expect(e.listeners('foo')).toHaveLength(2); expect(e.listeners('foo')).toHaveLength(2);
e.removeAllListeners('foo'); void e.removeAllListeners('foo');
expect(e.listeners('foo')).toHaveLength(0); expect(e.listeners('foo')).toHaveLength(0);
}); });

View file

@ -0,0 +1,80 @@
/**
* Copyright 2018 Google Inc. All rights reserved.
* Modifications copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ManualPromise } from '../../../packages/playwright-core/lib/utils/manualPromise';
import { EventEmitter } from '../../../packages/playwright-core/lib/client/eventEmitter';
import { test, expect } from '@playwright/test';
test('should not throw with ignoreErrors', async () => {
const ee = new EventEmitter();
const releaseHandler = new ManualPromise();
ee.on('console', async () => {
await releaseHandler;
throw new Error('Error in console handler');
});
ee.emit('console');
await ee.removeAllListeners('console', { behavior: 'ignoreErrors' });
releaseHandler.resolve();
});
test('should wait', async () => {
const ee = new EventEmitter();
const releaseHandler = new ManualPromise();
let value = 0;
ee.on('console', async () => {
await releaseHandler;
value = 42;
});
ee.emit('console');
const removePromise = ee.removeAllListeners('console', { behavior: 'wait' });
releaseHandler.resolve();
await removePromise;
expect(value).toBe(42);
});
test('should wait all', async () => {
const ee = new EventEmitter();
const releaseHandler = new ManualPromise();
const values = [];
ee.on('a', async () => {
await releaseHandler;
values.push(42);
});
ee.on('b', async () => {
await releaseHandler;
values.push(43);
});
ee.emit('a');
ee.emit('b');
const removePromise = ee.removeAllListeners(undefined, { behavior: 'wait' });
releaseHandler.resolve();
await removePromise;
expect(values).toEqual([42, 43]);
});
test('wait should throw', async () => {
const ee = new EventEmitter();
const releaseHandler = new ManualPromise();
ee.on('console', async () => {
await releaseHandler;
throw new Error('Error in handler');
});
ee.emit('console');
const removePromise = ee.removeAllListeners('console', { behavior: 'wait' });
releaseHandler.resolve();
await expect(removePromise).rejects.toThrow('Error in handler');
});

View file

@ -58,8 +58,8 @@ test('listeners', () => {
const barListeners = ee.listeners('bar'); const barListeners = ee.listeners('bar');
const bazListeners = ee.listeners('baz'); const bazListeners = ee.listeners('baz');
ee.on('removeListener', expectWrapper(['bar', 'baz', 'baz'])); ee.on('removeListener', expectWrapper(['bar', 'baz', 'baz']));
ee.removeAllListeners('bar'); void ee.removeAllListeners('bar');
ee.removeAllListeners('baz'); void ee.removeAllListeners('baz');
let listeners = ee.listeners('foo'); let listeners = ee.listeners('foo');
expect(Array.isArray(listeners)).toBeTruthy(); expect(Array.isArray(listeners)).toBeTruthy();
@ -91,7 +91,7 @@ test('removeAllListeners removes all listeners', () => {
ee.on('bar', () => { }); ee.on('bar', () => { });
ee.on('removeListener', expectWrapper(['foo', 'bar', 'removeListener'])); ee.on('removeListener', expectWrapper(['foo', 'bar', 'removeListener']));
ee.on('removeListener', expectWrapper(['foo', 'bar'])); ee.on('removeListener', expectWrapper(['foo', 'bar']));
ee.removeAllListeners(); void ee.removeAllListeners();
let listeners = ee.listeners('foo'); let listeners = ee.listeners('foo');
expect(Array.isArray(listeners)).toBeTruthy(); expect(Array.isArray(listeners)).toBeTruthy();
@ -120,7 +120,7 @@ test('listener count after removeAllListeners', () => {
ee.on('baz', () => { }); ee.on('baz', () => { });
ee.on('baz', () => { }); ee.on('baz', () => { });
expect(ee.listeners('baz').length).toEqual(expectLength + 1); expect(ee.listeners('baz').length).toEqual(expectLength + 1);
ee.removeAllListeners('baz'); void ee.removeAllListeners('baz');
expect(ee.listeners('baz').length).toEqual(0); expect(ee.listeners('baz').length).toEqual(0);
}); });

View file

@ -28,7 +28,7 @@ class MyEE extends EventEmitter {
super(); super();
this.once(1, cb); this.once(1, cb);
this.emit(1); this.emit(1);
this.removeAllListeners(); void this.removeAllListeners();
} }
} }

View file

@ -34,7 +34,7 @@ test('should support symbols', () => {
ee.emit(foo); ee.emit(foo);
ee.removeAllListeners(); void ee.removeAllListeners();
expect(ee.listeners(foo).length).toEqual(0); expect(ee.listeners(foo).length).toEqual(0);
ee.on(foo, listener); ee.on(foo, listener);

View file

@ -0,0 +1,67 @@
/**
* Copyright 2018 Google Inc. All rights reserved.
* Modifications copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ManualPromise } from '../../packages/playwright-core/lib/utils/manualPromise';
import { test as it, expect } from './pageTest';
// This test is mostly for type checking, the actual tests are in the library/events.
it('should not throw with ignoreErrors', async ({ page }) => {
const reachedHandler = new ManualPromise();
const releaseHandler = new ManualPromise();
page.on('console', async () => {
reachedHandler.resolve();
await releaseHandler;
throw new Error('Error in console handler');
});
await page.evaluate('console.log(1)');
await reachedHandler;
await page.removeAllListeners('console', { behavior: 'ignoreErrors' });
releaseHandler.resolve();
await page.waitForTimeout(1000);
});
it('should wait', async ({ page }) => {
const reachedHandler = new ManualPromise();
const releaseHandler = new ManualPromise();
let value = 0;
page.on('console', async () => {
reachedHandler.resolve();
value = 42;
});
await page.evaluate('console.log(1)');
await reachedHandler;
const removePromise = page.removeAllListeners('console', { behavior: 'wait' });
releaseHandler.resolve();
await removePromise;
expect(value).toBe(42);
});
it('wait should throw', async ({ page }) => {
const reachedHandler = new ManualPromise();
const releaseHandler = new ManualPromise();
page.on('console', async () => {
reachedHandler.resolve();
await releaseHandler;
throw new Error('Error in handler');
});
await page.evaluate('console.log(1)');
await reachedHandler;
const removePromise = page.removeAllListeners('console', { behavior: 'wait' });
releaseHandler.resolve();
await expect(removePromise).rejects.toThrow('Error in handler');
});

View file

@ -56,7 +56,7 @@ module.exports = function lint(documentation, jsSources, apiFileName) {
continue; continue;
} }
for (const member of cls.membersArray) { for (const member of cls.membersArray) {
if (member.kind === 'event') if (member.kind === 'event' || member.alias === 'removeAllListeners')
continue; continue;
const params = methods.get(member.alias); const params = methods.get(member.alias);
if (!params) { if (!params) {

View file

@ -62,6 +62,9 @@ export interface Page {
exposeBinding(name: string, playwrightBinding: (source: BindingSource, arg: JSHandle) => any, options: { handle: true }): Promise<void>; exposeBinding(name: string, playwrightBinding: (source: BindingSource, arg: JSHandle) => any, options: { handle: true }): Promise<void>;
exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any, options?: { handle?: boolean }): Promise<void>; exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any, options?: { handle?: boolean }): Promise<void>;
removeAllListeners(type?: string): this;
removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise<void>;
} }
export interface Frame { export interface Frame {
@ -101,6 +104,14 @@ export interface BrowserContext {
exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any, options?: { handle?: boolean }): Promise<void>; exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any, options?: { handle?: boolean }): Promise<void>;
addInitScript<Arg>(script: PageFunction<Arg, any> | { path?: string, content?: string }, arg?: Arg): Promise<void>; addInitScript<Arg>(script: PageFunction<Arg, any> | { path?: string, content?: string }, arg?: Arg): Promise<void>;
removeAllListeners(type?: string): this;
removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise<void>;
}
export interface Browser {
removeAllListeners(type?: string): this;
removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise<void>;
} }
export interface Worker { export interface Worker {