diff --git a/README.md b/README.md index cb12c0ae6e..71b11f0ab9 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,12 @@ Refer to the documentation at [.pre-commit-config.yaml](.pre-commit-config.yaml) pre-commit install --hook-type pre-push ``` +Re-installing pre-commit locally: + +``` +pre-commit clean && pip install pre-commit +``` + ### Starting WebDriver BiDi Server This will run the server on port `8080`: diff --git a/src/bidiMapper/modules/cdp/CdpTarget.ts b/src/bidiMapper/modules/cdp/CdpTarget.ts index a448157538..86cbb0d09a 100644 --- a/src/bidiMapper/modules/cdp/CdpTarget.ts +++ b/src/bidiMapper/modules/cdp/CdpTarget.ts @@ -18,7 +18,7 @@ import type {Protocol} from 'devtools-protocol'; import type {CdpClient} from '../../../cdp/CdpClient.js'; -import {BiDiModule} from '../../../protocol/chromium-bidi.js'; +import {Bluetooth} from '../../../protocol/chromium-bidi.js'; import type {ChromiumBidi, Session} from '../../../protocol/protocol.js'; import {Deferred} from '../../../utils/Deferred.js'; import {EventEmitter} from '../../../utils/EventEmitter.js'; @@ -364,7 +364,9 @@ export class CdpTarget extends EventEmitter { } async toggleDeviceAccessIfNeeded(): Promise { - const enabled = this.isSubscribedTo(BiDiModule.Bluetooth); + const enabled = this.isSubscribedTo( + Bluetooth.EventNames.RequestDevicePromptUpdated, + ); if (this.#deviceAccessEnabled === enabled) { return; } diff --git a/src/bidiMapper/modules/context/BrowsingContextStorage.ts b/src/bidiMapper/modules/context/BrowsingContextStorage.ts index df271cf053..625b3ab97c 100644 --- a/src/bidiMapper/modules/context/BrowsingContextStorage.ts +++ b/src/bidiMapper/modules/context/BrowsingContextStorage.ts @@ -110,7 +110,10 @@ export class BrowsingContextStorage { return null; } const maybeContext = this.findContext(id); - const parentId = maybeContext?.parentId ?? null; + if (!maybeContext) { + return null; + } + const parentId = maybeContext.parentId ?? null; if (parentId === null) { return id; } diff --git a/src/bidiMapper/modules/network/NetworkRequest.ts b/src/bidiMapper/modules/network/NetworkRequest.ts index 7f01aba4ab..7f28043e56 100644 --- a/src/bidiMapper/modules/network/NetworkRequest.ts +++ b/src/bidiMapper/modules/network/NetworkRequest.ts @@ -808,12 +808,20 @@ export class NetworkRequest { this.#phaseChanged(); this.#emittedEvents[event.method] = true; - this.#eventManager.registerEvent( - Object.assign(event, { - type: 'event' as const, - }), - this.#context, - ); + if (this.#context) { + this.#eventManager.registerEvent( + Object.assign(event, { + type: 'event' as const, + }), + this.#context, + ); + } else { + this.#eventManager.registerGlobalEvent( + Object.assign(event, { + type: 'event' as const, + }), + ); + } } #getBaseEventParams(phase?: Network.InterceptPhase): Network.BaseParameters { diff --git a/src/bidiMapper/modules/network/NetworkStorage.spec.ts b/src/bidiMapper/modules/network/NetworkStorage.spec.ts index 8eecbbb8d5..b17205aec1 100644 --- a/src/bidiMapper/modules/network/NetworkStorage.spec.ts +++ b/src/bidiMapper/modules/network/NetworkStorage.spec.ts @@ -89,10 +89,10 @@ describe('NetworkStorage', () => { ); // Subscribe to the `network` module globally eventManager.subscriptionManager.subscribe( - ChromiumBidi.BiDiModule.Network, + [ChromiumBidi.BiDiModule.Network], // Verify that the Request send the message // To the correct context - MockCdpNetworkEvents.defaultFrameId, + [MockCdpNetworkEvents.defaultFrameId], null, ); eventManager.on(EventManagerEvents.Event, ({message, event}) => { diff --git a/src/bidiMapper/modules/script/Realm.ts b/src/bidiMapper/modules/script/Realm.ts index 2cffe7f5b0..35f9c6aab6 100644 --- a/src/bidiMapper/modules/script/Realm.ts +++ b/src/bidiMapper/modules/script/Realm.ts @@ -237,7 +237,7 @@ export abstract class Realm { #registerEvent(event: ChromiumBidi.Event) { if (this.associatedBrowsingContexts.length === 0) { - this.#eventManager.registerEvent(event, null); + this.#eventManager.registerGlobalEvent(event); } else { for (const browsingContext of this.associatedBrowsingContexts) { this.#eventManager.registerEvent(event, browsingContext.id); diff --git a/src/bidiMapper/modules/session/EventManager.ts b/src/bidiMapper/modules/session/EventManager.ts index 9de32d6e9c..98c7b0fe80 100644 --- a/src/bidiMapper/modules/session/EventManager.ts +++ b/src/bidiMapper/modules/session/EventManager.ts @@ -18,11 +18,11 @@ import type {BidiPlusChannel} from '../../../protocol/chromium-bidi.js'; import { ChromiumBidi, + InvalidArgumentException, type BrowsingContext, } from '../../../protocol/protocol.js'; import {Buffer} from '../../../utils/Buffer.js'; import {DefaultMap} from '../../../utils/DefaultMap.js'; -import {distinctValues} from '../../../utils/DistinctValues.js'; import {EventEmitter} from '../../../utils/EventEmitter.js'; import {IdWrapper} from '../../../utils/IdWrapper.js'; import type {Result} from '../../../utils/result.js'; @@ -30,7 +30,11 @@ import {OutgoingMessage} from '../../OutgoingMessage.js'; import type {BrowsingContextStorage} from '../context/BrowsingContextStorage.js'; import {assertSupportedEvent} from './events.js'; -import {SubscriptionManager} from './SubscriptionManager.js'; +import { + difference, + SubscriptionManager, + unrollEvents, +} from './SubscriptionManager.js'; class EventWrapper { readonly #idWrapper = new IdWrapper(); @@ -144,7 +148,7 @@ export class EventManager extends EventEmitter { registerEvent( event: ChromiumBidi.Event, - contextId: BrowsingContext.BrowsingContext | null, + contextId: BrowsingContext.BrowsingContext, ): void { this.registerPromiseEvent( Promise.resolve({ @@ -156,9 +160,19 @@ export class EventManager extends EventEmitter { ); } + registerGlobalEvent(event: ChromiumBidi.Event): void { + this.registerGlobalPromiseEvent( + Promise.resolve({ + kind: 'success', + value: event, + }), + event.method, + ); + } + registerPromiseEvent( event: Promise>, - contextId: BrowsingContext.BrowsingContext | null, + contextId: BrowsingContext.BrowsingContext, eventName: ChromiumBidi.EventNames, ): void { const eventWrapper = new EventWrapper(event, contextId); @@ -178,11 +192,29 @@ export class EventManager extends EventEmitter { } } + registerGlobalPromiseEvent( + event: Promise>, + eventName: ChromiumBidi.EventNames, + ): void { + const eventWrapper = new EventWrapper(event, null); + const sortedChannels = + this.#subscriptionManager.getChannelsSubscribedToEventGlobally(eventName); + this.#bufferEvent(eventWrapper, eventName); + // Send events to channels in the subscription priority. + for (const channel of sortedChannels) { + this.emit(EventManagerEvents.Event, { + message: OutgoingMessage.createFromPromise(event, channel), + event: eventName, + }); + this.#markEventSent(eventWrapper, channel, eventName); + } + } + async subscribe( eventNames: ChromiumBidi.EventNames[], - contextIds: (BrowsingContext.BrowsingContext | null)[], + contextIds: BrowsingContext.BrowsingContext[], channel: BidiPlusChannel, - ): Promise { + ): Promise { for (const name of eventNames) { assertSupportedEvent(name); } @@ -195,17 +227,44 @@ export class EventManager extends EventEmitter { } } - // List of the subscription items that were actually added. Each contains a specific - // event and context. No module event (like "network") or global context subscription - // (like null) are included. - const addedSubscriptionItems: SubscriptionItem[] = []; + const unrolledEventNames = new Set(unrollEvents(eventNames)); + const subscribeStepEvents = new Map>(); + const subscriptionNavigableIds = new Set( + contextIds.length + ? contextIds.map((contextId) => { + const id = + this.#browsingContextStorage.findTopLevelContextId(contextId); + if (!id) { + throw new InvalidArgumentException('Invalid context id'); + } + return id; + }) + : this.#browsingContextStorage.getTopLevelContexts().map((c) => c.id), + ); - for (const eventName of eventNames) { - for (const contextId of contextIds) { - addedSubscriptionItems.push( - ...this.#subscriptionManager.subscribe(eventName, contextId, channel), - ); + for (const eventName of unrolledEventNames) { + const subscribedNavigableIds = new Set( + this.#browsingContextStorage + .getTopLevelContexts() + .map((c) => c.id) + .filter((id) => { + return this.#subscriptionManager.isSubscribedTo(eventName, id); + }), + ); + subscribeStepEvents.set( + eventName, + difference(subscriptionNavigableIds, subscribedNavigableIds), + ); + } + + const subscription = this.#subscriptionManager.subscribe( + eventNames, + contextIds, + channel, + ); + for (const eventName of subscription.eventNames) { + for (const contextId of subscriptionNavigableIds) { for (const eventWrapper of this.#getBufferedEvents( eventName, contextId, @@ -224,26 +283,26 @@ export class EventManager extends EventEmitter { } } - // Iterate over all new subscription items and call hooks if any. There can be - // duplicates, e.g. when subscribing to the whole module and some specific event in - // the same time ("network", "network.responseCompleted"). `distinctValues` guarantees - // that hooks are called only once per pair event + context. - distinctValues(addedSubscriptionItems).forEach(({contextId, event}) => { - this.#subscribeHooks.get(event).forEach((hook) => hook(contextId)); - }); + for (const [eventName, contextIds] of subscribeStepEvents) { + for (const contextId of contextIds) { + this.#subscribeHooks.get(eventName).forEach((hook) => hook(contextId)); + } + } await this.toggleModulesIfNeeded(); + + return subscription.id; } async unsubscribe( eventNames: ChromiumBidi.EventNames[], - contextIds: (BrowsingContext.BrowsingContext | null)[], + contextIds: BrowsingContext.BrowsingContext[], channel: BidiPlusChannel, ): Promise { for (const name of eventNames) { assertSupportedEvent(name); } - this.#subscriptionManager.unsubscribeAll(eventNames, contextIds, channel); + this.#subscriptionManager.unsubscribe(eventNames, contextIds, channel); await this.toggleModulesIfNeeded(); } diff --git a/src/bidiMapper/modules/session/SessionProcessor.ts b/src/bidiMapper/modules/session/SessionProcessor.ts index c5edef1367..0410188448 100644 --- a/src/bidiMapper/modules/session/SessionProcessor.ts +++ b/src/bidiMapper/modules/session/SessionProcessor.ts @@ -145,13 +145,15 @@ export class SessionProcessor { async subscribe( params: Session.SubscriptionRequest, channel: BidiPlusChannel = null, - ): Promise { - await this.#eventManager.subscribe( + ): Promise { + const subscription = await this.#eventManager.subscribe( params.events as ChromiumBidi.EventNames[], - params.contexts ?? [null], + params.contexts ?? [], channel, ); - return {}; + return { + subscription, + }; } async unsubscribe( @@ -160,7 +162,7 @@ export class SessionProcessor { ): Promise { await this.#eventManager.unsubscribe( params.events as ChromiumBidi.EventNames[], - params.contexts ?? [null], + params.contexts ?? [], channel, ); return {}; diff --git a/src/bidiMapper/modules/session/SubscriptionManager.spec.ts b/src/bidiMapper/modules/session/SubscriptionManager.spec.ts index 57e41341be..2db28e718d 100644 --- a/src/bidiMapper/modules/session/SubscriptionManager.spec.ts +++ b/src/bidiMapper/modules/session/SubscriptionManager.spec.ts @@ -75,48 +75,13 @@ describe('SubscriptionManager', () => { subscriptionManager = new SubscriptionManager(browsingContextStorage); }); - describe('subscribe should return list of added subscriptions', () => { - describe('specific context', () => { - it('new subscription', () => { - expect( - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL), - ).to.deep.equal([{event: SOME_EVENT, contextId: SOME_CONTEXT}]); - }); - - it('existing subscription', () => { - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); - expect( - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL), - ).to.deep.equal([]); - }); - }); - - describe('global', () => { - it('new subscription', () => { - expect( - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL), - ).to.deep.equal([ - {event: SOME_EVENT, contextId: SOME_CONTEXT}, - {event: SOME_EVENT, contextId: ANOTHER_CONTEXT}, - ]); - }); - - it('existing subscription', () => { - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); - expect( - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL), - ).to.deep.equal([{event: SOME_EVENT, contextId: ANOTHER_CONTEXT}]); - }); - }); - }); - it('should subscribe twice to global and specific event in proper order', () => { - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, null, ANOTHER_CHANNEL); - subscriptionManager.subscribe(ANOTHER_EVENT, null, ANOTHER_CHANNEL); - subscriptionManager.subscribe(ALL_EVENTS, null, SOME_CHANNEL); - subscriptionManager.subscribe(ALL_EVENTS, null, SOME_CHANNEL); - subscriptionManager.subscribe(YET_ANOTHER_EVENT, null, ANOTHER_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], ANOTHER_CHANNEL); + subscriptionManager.subscribe([ANOTHER_EVENT], [], ANOTHER_CHANNEL); + subscriptionManager.subscribe([ALL_EVENTS], [], SOME_CHANNEL); + subscriptionManager.subscribe([ALL_EVENTS], [], SOME_CHANNEL); + subscriptionManager.subscribe([YET_ANOTHER_EVENT], [], ANOTHER_CHANNEL); // `SOME_EVENT` was fist subscribed in `SOME_CHANNEL`. expect( @@ -144,9 +109,9 @@ describe('SubscriptionManager', () => { ).to.deep.equal([SOME_CHANNEL, ANOTHER_CHANNEL]); }); - describe('with null context', () => { + describe('globally', () => { it('should send proper event in any context', () => { - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -156,7 +121,7 @@ describe('SubscriptionManager', () => { }); it('should not send wrong event', () => { - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); expect( subscriptionManager.getChannelsSubscribedToEvent( ANOTHER_EVENT, @@ -166,8 +131,8 @@ describe('SubscriptionManager', () => { }); it('should unsubscribe', () => { - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); - subscriptionManager.unsubscribe(SOME_EVENT, null, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); + subscriptionManager.unsubscribe([SOME_EVENT], [], SOME_CHANNEL); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -178,11 +143,11 @@ describe('SubscriptionManager', () => { describe('unsubscribe all', () => { it('atomicity: does not unsubscribe when there is no subscription', () => { - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); expect(() => - subscriptionManager.unsubscribeAll( + subscriptionManager.unsubscribe( [SOME_EVENT, ANOTHER_EVENT], - [null], + [], SOME_CHANNEL, ), ).to.throw('No subscription found'); @@ -195,11 +160,11 @@ describe('SubscriptionManager', () => { }); it('happy path', () => { - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); - subscriptionManager.subscribe(ANOTHER_EVENT, null, SOME_CHANNEL); - subscriptionManager.unsubscribeAll( + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); + subscriptionManager.subscribe([ANOTHER_EVENT], [], SOME_CHANNEL); + subscriptionManager.unsubscribe( [SOME_EVENT, ANOTHER_EVENT], - [null], + [], SOME_CHANNEL, ); expect( @@ -215,12 +180,54 @@ describe('SubscriptionManager', () => { ), ).to.deep.equal([]); }); + + it('happy path 2', () => { + subscriptionManager.subscribe( + [SOME_EVENT, ANOTHER_EVENT], + [], + SOME_CHANNEL, + ); + subscriptionManager.subscribe( + [SOME_EVENT, YET_ANOTHER_EVENT], + [], + SOME_CHANNEL, + ); + subscriptionManager.unsubscribe([SOME_EVENT], [], SOME_CHANNEL); + expect( + subscriptionManager.getChannelsSubscribedToEvent( + SOME_EVENT, + SOME_CONTEXT, + ), + ).to.deep.equal([]); + }); + }); + + it('should handle partial global unsubscribe', () => { + subscriptionManager.subscribe( + [SOME_EVENT, ANOTHER_EVENT], + [], + SOME_CHANNEL, + ); + subscriptionManager.unsubscribe([SOME_EVENT], [], SOME_CHANNEL); + expect(() => { + subscriptionManager.unsubscribe( + [SOME_EVENT, ANOTHER_EVENT], + [], + SOME_CHANNEL, + ); + }).to.throw('No subscription found'); + expect( + subscriptionManager.getChannelsSubscribedToEvent( + ANOTHER_EVENT, + ANOTHER_CONTEXT, + ), + ).to.deep.equal([SOME_CHANNEL]); }); it('should not unsubscribe specific context subscription', () => { - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); - subscriptionManager.unsubscribe(SOME_EVENT, null, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); + subscriptionManager.unsubscribe([SOME_EVENT], [], SOME_CHANNEL); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -230,8 +237,8 @@ describe('SubscriptionManager', () => { }); it('should subscribe in proper order', () => { - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, null, ANOTHER_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], ANOTHER_CHANNEL); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -241,10 +248,10 @@ describe('SubscriptionManager', () => { }); it('should re-subscribe in proper order', () => { - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, null, ANOTHER_CHANNEL); - subscriptionManager.unsubscribe(SOME_EVENT, null, SOME_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], ANOTHER_CHANNEL); + subscriptionManager.unsubscribe([SOME_EVENT], [], SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -254,9 +261,9 @@ describe('SubscriptionManager', () => { }); it('should re-subscribe global and specific context in proper order', () => { - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, null, ANOTHER_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], ANOTHER_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -268,7 +275,7 @@ describe('SubscriptionManager', () => { describe('with some context', () => { it('should send proper event in proper context', () => { - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -277,8 +284,53 @@ describe('SubscriptionManager', () => { ).to.deep.equal([SOME_CHANNEL]); }); + it('should send proper event in proper context after unsubscribe', () => { + subscriptionManager.subscribe( + [SOME_EVENT], + [SOME_CONTEXT, ANOTHER_CONTEXT], + SOME_CHANNEL, + ); + subscriptionManager.unsubscribe( + [SOME_EVENT], + [SOME_CONTEXT], + SOME_CHANNEL, + ); + expect( + subscriptionManager.getChannelsSubscribedToEvent( + SOME_EVENT, + ANOTHER_CONTEXT, + ), + ).to.deep.equal([SOME_CHANNEL]); + }); + + it('should error if unsubscribe is invalid', () => { + subscriptionManager.subscribe( + [SOME_EVENT], + [SOME_CONTEXT, ANOTHER_CONTEXT], + SOME_CHANNEL, + ); + subscriptionManager.unsubscribe( + [SOME_EVENT], + [SOME_CONTEXT], + SOME_CHANNEL, + ); + expect(() => { + subscriptionManager.unsubscribe( + [SOME_EVENT], + [SOME_CONTEXT, ANOTHER_CONTEXT], + SOME_CHANNEL, + ); + }).to.throw('No subscription found'); + expect( + subscriptionManager.getChannelsSubscribedToEvent( + SOME_EVENT, + ANOTHER_CONTEXT, + ), + ).to.deep.equal([SOME_CHANNEL]); + }); + it('should not send proper event in wrong context', () => { - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -288,7 +340,7 @@ describe('SubscriptionManager', () => { }); it('should not send wrong event in proper context', () => { - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); expect( subscriptionManager.getChannelsSubscribedToEvent( ANOTHER_EVENT, @@ -298,8 +350,12 @@ describe('SubscriptionManager', () => { }); it('should unsubscribe', () => { - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); - subscriptionManager.unsubscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); + subscriptionManager.unsubscribe( + [SOME_EVENT], + [SOME_CONTEXT], + SOME_CHANNEL, + ); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -309,8 +365,12 @@ describe('SubscriptionManager', () => { }); it('should unsubscribe the module', () => { - subscriptionManager.subscribe(ALL_EVENTS, SOME_CONTEXT, SOME_CHANNEL); - subscriptionManager.unsubscribe(ALL_EVENTS, SOME_CONTEXT, SOME_CHANNEL); + subscriptionManager.subscribe([ALL_EVENTS], [SOME_CONTEXT], SOME_CHANNEL); + subscriptionManager.unsubscribe( + [ALL_EVENTS], + [SOME_CONTEXT], + SOME_CHANNEL, + ); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -320,8 +380,12 @@ describe('SubscriptionManager', () => { }); it('should not unsubscribe the module if not subscribed', () => { - subscriptionManager.subscribe(ALL_EVENTS, SOME_CONTEXT, SOME_CHANNEL); - subscriptionManager.unsubscribe(ALL_EVENTS, SOME_CONTEXT, SOME_CHANNEL); + subscriptionManager.subscribe([ALL_EVENTS], [SOME_CONTEXT], SOME_CHANNEL); + subscriptionManager.unsubscribe( + [ALL_EVENTS], + [SOME_CONTEXT], + SOME_CHANNEL, + ); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -331,9 +395,13 @@ describe('SubscriptionManager', () => { }); it('should not unsubscribe global subscription', () => { - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); - subscriptionManager.unsubscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); + subscriptionManager.unsubscribe( + [SOME_EVENT], + [SOME_CONTEXT], + SOME_CHANNEL, + ); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -343,8 +411,12 @@ describe('SubscriptionManager', () => { }); it('should subscribe in proper order', () => { - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, ANOTHER_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); + subscriptionManager.subscribe( + [SOME_EVENT], + [SOME_CONTEXT], + ANOTHER_CHANNEL, + ); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -354,10 +426,18 @@ describe('SubscriptionManager', () => { }); it('should re-subscribe in proper order', () => { - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, ANOTHER_CHANNEL); - subscriptionManager.unsubscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); + subscriptionManager.subscribe( + [SOME_EVENT], + [SOME_CONTEXT], + ANOTHER_CHANNEL, + ); + subscriptionManager.unsubscribe( + [SOME_EVENT], + [SOME_CONTEXT], + SOME_CHANNEL, + ); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -367,9 +447,9 @@ describe('SubscriptionManager', () => { }); it('should re-subscribe global and specific context in proper order', () => { - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, null, ANOTHER_CHANNEL); - subscriptionManager.subscribe(SOME_EVENT, null, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], ANOTHER_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [], SOME_CHANNEL); expect( subscriptionManager.getChannelsSubscribedToEvent( SOME_EVENT, @@ -382,8 +462,8 @@ describe('SubscriptionManager', () => { describe('with nested contexts', () => { it('should subscribe to top-level context when subscribed to nested context', () => { subscriptionManager.subscribe( - SOME_EVENT, - SOME_NESTED_CONTEXT, + [SOME_EVENT], + [SOME_NESTED_CONTEXT], SOME_CHANNEL, ); expect( @@ -396,8 +476,8 @@ describe('SubscriptionManager', () => { it('should not subscribe to top-level context when subscribed to nested context of another context', () => { subscriptionManager.subscribe( - SOME_EVENT, - ANOTHER_NESTED_CONTEXT, + [SOME_EVENT], + [ANOTHER_NESTED_CONTEXT], SOME_CHANNEL, ); expect( @@ -409,10 +489,10 @@ describe('SubscriptionManager', () => { }); it('should unsubscribe from top-level context when unsubscribed from nested context', () => { - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); subscriptionManager.unsubscribe( - SOME_EVENT, - SOME_NESTED_CONTEXT, + [SOME_EVENT], + [SOME_NESTED_CONTEXT], SOME_CHANNEL, ); expect( @@ -424,11 +504,11 @@ describe('SubscriptionManager', () => { }); it('should not unsubscribe from top-level context when unsubscribed from nested context in different channel', () => { - subscriptionManager.subscribe(SOME_EVENT, SOME_CONTEXT, SOME_CHANNEL); + subscriptionManager.subscribe([SOME_EVENT], [SOME_CONTEXT], SOME_CHANNEL); expect(() => { subscriptionManager.unsubscribe( - SOME_EVENT, - SOME_NESTED_CONTEXT, + [SOME_EVENT], + [SOME_NESTED_CONTEXT], ANOTHER_CHANNEL, ); }).to.throw('No subscription found'); @@ -534,14 +614,14 @@ describe('SubscriptionManager', () => { describe('module', () => { it('should return true global subscription', () => { subscriptionManager.subscribe( - ChromiumBidi.Network.EventNames.ResponseCompleted, - null, + [ChromiumBidi.BiDiModule.Network], + [], SOME_CHANNEL, ); expect( subscriptionManager.isSubscribedTo( - ChromiumBidi.BiDiModule.Network, + ChromiumBidi.Network.EventNames.ResponseCompleted, SOME_CONTEXT, ), ).to.equal(true); @@ -549,29 +629,29 @@ describe('SubscriptionManager', () => { it('should return true specific context subscription', () => { subscriptionManager.subscribe( - ChromiumBidi.Network.EventNames.ResponseCompleted, - SOME_CONTEXT, + [ChromiumBidi.Network.EventNames.ResponseCompleted], + [SOME_CONTEXT], SOME_CHANNEL, ); expect( subscriptionManager.isSubscribedTo( - ChromiumBidi.BiDiModule.Network, + ChromiumBidi.Network.EventNames.ResponseCompleted, SOME_CONTEXT, ), ).to.equal(true); }); - it('should return true for module subscription', () => { + it('should return true for any event of a module subscription', () => { subscriptionManager.subscribe( - ChromiumBidi.BiDiModule.Network, - null, + [ChromiumBidi.BiDiModule.Network], + [], SOME_CHANNEL, ); expect( subscriptionManager.isSubscribedTo( - ChromiumBidi.BiDiModule.Network, + ChromiumBidi.Network.EventNames.BeforeRequestSent, SOME_CONTEXT, ), ).to.equal(true); @@ -579,14 +659,14 @@ describe('SubscriptionManager', () => { it('should return true for nested context when subscribed to top level ancestor context', () => { subscriptionManager.subscribe( - ChromiumBidi.BiDiModule.Network, - SOME_CONTEXT, + [ChromiumBidi.BiDiModule.Network], + [SOME_CONTEXT], SOME_CHANNEL, ); expect( subscriptionManager.isSubscribedTo( - ChromiumBidi.BiDiModule.Network, + ChromiumBidi.Network.EventNames.ResponseCompleted, SOME_NESTED_CONTEXT, ), ).to.equal(true); @@ -594,14 +674,14 @@ describe('SubscriptionManager', () => { it('should return false for nested context when subscribed to another context', () => { subscriptionManager.subscribe( - ChromiumBidi.BiDiModule.Network, - SOME_CONTEXT, + [ChromiumBidi.BiDiModule.Network], + [SOME_CONTEXT], SOME_CHANNEL, ); expect( subscriptionManager.isSubscribedTo( - ChromiumBidi.BiDiModule.Network, + ChromiumBidi.Network.EventNames.ResponseCompleted, ANOTHER_NESTED_CONTEXT, ), ).to.equal(false); @@ -610,7 +690,7 @@ describe('SubscriptionManager', () => { it('should return false with no subscriptions', () => { expect( subscriptionManager.isSubscribedTo( - ChromiumBidi.BiDiModule.Network, + ChromiumBidi.Network.EventNames.ResponseCompleted, SOME_CONTEXT, ), ).to.equal(false); @@ -620,8 +700,8 @@ describe('SubscriptionManager', () => { describe('event', () => { it('should return true global subscription', () => { subscriptionManager.subscribe( - ChromiumBidi.Network.EventNames.ResponseCompleted, - null, + [ChromiumBidi.Network.EventNames.ResponseCompleted], + [], SOME_CHANNEL, ); @@ -635,8 +715,8 @@ describe('SubscriptionManager', () => { it('should return true specific context subscription', () => { subscriptionManager.subscribe( - ChromiumBidi.Network.EventNames.ResponseCompleted, - SOME_CONTEXT, + [ChromiumBidi.Network.EventNames.ResponseCompleted], + [SOME_CONTEXT], SOME_CHANNEL, ); @@ -650,8 +730,8 @@ describe('SubscriptionManager', () => { it('should return true for module subscription', () => { subscriptionManager.subscribe( - ChromiumBidi.BiDiModule.Network, - null, + [ChromiumBidi.BiDiModule.Network], + [], SOME_CHANNEL, ); @@ -665,8 +745,8 @@ describe('SubscriptionManager', () => { it('should return true for nested context when subscribed to top level ancestor context', () => { subscriptionManager.subscribe( - ChromiumBidi.Network.EventNames.ResponseCompleted, - SOME_CONTEXT, + [ChromiumBidi.Network.EventNames.ResponseCompleted], + [SOME_CONTEXT], SOME_CHANNEL, ); @@ -680,8 +760,8 @@ describe('SubscriptionManager', () => { it('should return false for nested context when subscribed to another context', () => { subscriptionManager.subscribe( - ChromiumBidi.Network.EventNames.ResponseCompleted, - SOME_CONTEXT, + [ChromiumBidi.Network.EventNames.ResponseCompleted], + [SOME_CONTEXT], SOME_CHANNEL, ); diff --git a/src/bidiMapper/modules/session/SubscriptionManager.ts b/src/bidiMapper/modules/session/SubscriptionManager.ts index 0bf5596e7d..72098a9261 100644 --- a/src/bidiMapper/modules/session/SubscriptionManager.ts +++ b/src/bidiMapper/modules/session/SubscriptionManager.ts @@ -20,12 +20,11 @@ import { type BrowsingContext, ChromiumBidi, InvalidArgumentException, + NoSuchFrameException, } from '../../../protocol/protocol.js'; +import {uuidv4} from '../../../utils/uuid.js'; import type {BrowsingContextStorage} from '../context/BrowsingContextStorage.js'; -import type {SubscriptionItem} from './EventManager.js'; -import {isCdpEvent, isDeprecatedCdpEvent} from './events.js'; - /** * Returns the cartesian product of the given arrays. * @@ -75,18 +74,17 @@ export function unrollEvents( return [...allEvents.values()]; } +export type Subscription = { + id: string; + // Empty set means a global subscription. + topLevelTraversableIds: Set; + // Never empty. + eventNames: Set; + channel: BidiPlusChannel; +}; + export class SubscriptionManager { - #subscriptionPriority = 0; - // BrowsingContext `null` means the event has subscription across all the - // browsing contexts. - // Channel `null` means no `channel` should be added. - #channelToContextToEventMap = new Map< - BidiPlusChannel, - Map< - BrowsingContext.BrowsingContext | null, - Map - > - >(); + #subscriptions: Subscription[] = []; #browsingContextStorage: BrowsingContextStorage; constructor(browsingContextStorage: BrowsingContextStorage) { @@ -94,127 +92,86 @@ export class SubscriptionManager { } getChannelsSubscribedToEvent( - eventMethod: ChromiumBidi.EventNames, - contextId: BrowsingContext.BrowsingContext | null, + eventName: ChromiumBidi.EventNames, + contextId: BrowsingContext.BrowsingContext, ): BidiPlusChannel[] { - const prioritiesAndChannels = Array.from( - this.#channelToContextToEventMap.keys(), - ) - .map((channel) => ({ - priority: this.#getEventSubscriptionPriorityForChannel( - eventMethod, - contextId, - channel, - ), - channel, - })) - .filter(({priority}) => priority !== null) as { - priority: number; - channel: BidiPlusChannel; - }[]; - - // Sort channels by priority. - return prioritiesAndChannels - .sort((a, b) => a.priority - b.priority) - .map(({channel}) => channel); - } + const channels = new Set(); - #getEventSubscriptionPriorityForChannel( - eventMethod: ChromiumBidi.EventNames, - contextId: BrowsingContext.BrowsingContext | null, - channel: BidiPlusChannel, - ): null | number { - const contextToEventMap = this.#channelToContextToEventMap.get(channel); - if (contextToEventMap === undefined) { - return null; + for (const subscription of this.#subscriptions) { + if (this.#isSubscribedTo(subscription, eventName, contextId)) { + channels.add(subscription.channel); + } } - const maybeTopLevelContextId = - this.#browsingContextStorage.findTopLevelContextId(contextId); - - // `null` covers global subscription. - const relevantContexts = [...new Set([null, maybeTopLevelContextId])]; - - // Get all the subscription priorities. - const priorities: number[] = relevantContexts - .map((context) => { - // Get the priority for exact event name - const priority = contextToEventMap.get(context)?.get(eventMethod); - // For CDP we can't provide specific event name when subscribing - // to the module directly. - // Because of that we need to see event `cdp` exists in the map. - if (isCdpEvent(eventMethod)) { - const cdpPriority = contextToEventMap - .get(context) - ?.get(ChromiumBidi.BiDiModule.Cdp); - // If we subscribe to the event directly and `cdp` module as well - // priority will be different we take minimal priority - return priority && cdpPriority - ? Math.min(priority, cdpPriority) - : // At this point we know that we have subscribed - // to only one of the two - (priority ?? cdpPriority); - } - // https://github.com/GoogleChromeLabs/chromium-bidi/issues/2844. - if (isDeprecatedCdpEvent(eventMethod)) { - const cdpPriority = contextToEventMap - .get(context) - ?.get(ChromiumBidi.BiDiModule.DeprecatedCdp); - // If we subscribe to the event directly and `cdp` module as well - // priority will be different we take minimal priority - return priority && cdpPriority - ? Math.min(priority, cdpPriority) - : // At this point we know that we have subscribed - // to only one of the two - (priority ?? cdpPriority); - } - return priority; - }) - .filter((p) => p !== undefined); + return Array.from(channels); + } + + getChannelsSubscribedToEventGlobally( + eventName: ChromiumBidi.EventNames, + ): BidiPlusChannel[] { + const channels = new Set(); - if (priorities.length === 0) { - // Not subscribed, return null. - return null; + for (const subscription of this.#subscriptions) { + if (this.#isSubscribedTo(subscription, eventName)) { + channels.add(subscription.channel); + } } - // Return minimal priority. - return Math.min(...priorities); + return Array.from(channels); } - /** - * @param module BiDi+ module - * @param contextId `null` == globally subscribed - * - * @returns - */ - isSubscribedTo( + #isSubscribedTo( + subscription: Subscription, moduleOrEvent: ChromiumBidi.EventNames, - contextId: BrowsingContext.BrowsingContext | null = null, + contextId?: BrowsingContext.BrowsingContext, ): boolean { - const topLevelContext = - this.#browsingContextStorage.findTopLevelContextId(contextId); + let includesEvent = false; + for (const eventName of subscription.eventNames) { + // This also covers the `cdp` case where + // we don't unroll the event names + if ( + // Event explicitly subscribed + eventName === moduleOrEvent || + // Event subscribed via module + eventName === moduleOrEvent.split('.').at(0) || + // Event explicitly subscribed compared to module + eventName.split('.').at(0) === moduleOrEvent + ) { + includesEvent = true; + break; + } + } - for (const browserContextToEventMap of this.#channelToContextToEventMap.values()) { - for (const [id, eventMap] of browserContextToEventMap.entries()) { - // Not subscribed to this context or globally - if (topLevelContext !== id && id !== null) { - continue; - } + if (!includesEvent) { + return false; + } - for (const event of eventMap.keys()) { - // This also covers the `cdp` case where - // we don't unroll the event names - if ( - // Event explicitly subscribed - event === moduleOrEvent || - // Event subscribed via module - event === moduleOrEvent.split('.').at(0) || - // Event explicitly subscribed compared to module - event.split('.').at(0) === moduleOrEvent - ) { - return true; - } - } + // global subscription. + if (subscription.topLevelTraversableIds.size === 0) { + return true; + } + + const topLevelContext = contextId + ? this.#browsingContextStorage.findTopLevelContextId(contextId) + : null; + + if ( + topLevelContext !== null && + subscription.topLevelTraversableIds.has(topLevelContext) + ) { + return true; + } + + return false; + } + + isSubscribedTo( + moduleOrEvent: ChromiumBidi.EventNames, + contextId: BrowsingContext.BrowsingContext, + ): boolean { + for (const subscription of this.#subscriptions) { + if (this.#isSubscribedTo(subscription, moduleOrEvent, contextId)) { + return true; } } @@ -232,164 +189,194 @@ export class SubscriptionManager { * not subscribed before the command. */ subscribe( - event: ChromiumBidi.EventNames, - contextId: BrowsingContext.BrowsingContext | null, + eventNames: ChromiumBidi.EventNames[], + contextIds: BrowsingContext.BrowsingContext[], channel: BidiPlusChannel, - ): SubscriptionItem[] { + ): Subscription { // All the subscriptions are handled on the top-level contexts. - contextId = this.#browsingContextStorage.findTopLevelContextId(contextId); - - // Check if subscribed event is a whole module - switch (event) { - case ChromiumBidi.BiDiModule.BrowsingContext: - return Object.values(ChromiumBidi.BrowsingContext.EventNames) - .map((specificEvent) => - this.subscribe(specificEvent, contextId, channel), - ) - .flat(); - case ChromiumBidi.BiDiModule.Log: - return Object.values(ChromiumBidi.Log.EventNames) - .map((specificEvent) => - this.subscribe(specificEvent, contextId, channel), - ) - .flat(); - case ChromiumBidi.BiDiModule.Network: - return Object.values(ChromiumBidi.Network.EventNames) - .map((specificEvent) => - this.subscribe(specificEvent, contextId, channel), - ) - .flat(); - case ChromiumBidi.BiDiModule.Script: - return Object.values(ChromiumBidi.Script.EventNames) - .map((specificEvent) => - this.subscribe(specificEvent, contextId, channel), - ) - .flat(); - case ChromiumBidi.BiDiModule.Bluetooth: - return Object.values(ChromiumBidi.Bluetooth.EventNames) - .map((specificEvent) => - this.subscribe(specificEvent, contextId, channel), - ) - .flat(); - default: - // Intentionally left empty. - } - - if (!this.#channelToContextToEventMap.has(channel)) { - this.#channelToContextToEventMap.set(channel, new Map()); - } - const contextToEventMap = this.#channelToContextToEventMap.get(channel)!; - - if (!contextToEventMap.has(contextId)) { - contextToEventMap.set(contextId, new Map()); - } - const eventMap = contextToEventMap.get(contextId)!; - - const affectedContextIds = ( - contextId === null - ? this.#browsingContextStorage.getTopLevelContexts().map((c) => c.id) - : [contextId] - ) - // There can be contexts that are already subscribed to the event. Do not include - // them to the output. - .filter((contextId) => !this.isSubscribedTo(event, contextId)); - - if (!eventMap.has(event)) { - // Add subscription only if it's not already subscribed. - eventMap.set(event, this.#subscriptionPriority++); - } - - return affectedContextIds.map((contextId) => ({ - event, - contextId, - })); + const subscription: Subscription = { + id: uuidv4(), + eventNames: new Set(unrollEvents(eventNames)), + topLevelTraversableIds: new Set( + contextIds.map((contextId) => { + const topLevelContext = + this.#browsingContextStorage.findTopLevelContextId(contextId); + if (!topLevelContext) { + throw new NoSuchFrameException( + `Top-level navigable not found for context id ${contextId}`, + ); + } + return topLevelContext; + }), + ), + channel, + }; + this.#subscriptions.push(subscription); + return subscription; } /** * Unsubscribes atomically from all events in the given contexts and channel. + * + * This is a legacy spec branch to unsubscribe by attributes. */ - unsubscribeAll( - events: ChromiumBidi.EventNames[], - contextIds: (BrowsingContext.BrowsingContext | null)[], + unsubscribe( + inputEventNames: ChromiumBidi.EventNames[], + inputContextIds: BrowsingContext.BrowsingContext[], channel: BidiPlusChannel, ) { - // Assert all contexts are known. - for (const contextId of contextIds) { - if (contextId !== null) { - this.#browsingContextStorage.getContext(contextId); + const eventNames = new Set(unrollEvents(inputEventNames)); + + for (const contextId of inputContextIds) { + // Validation that contexts exist. + this.#browsingContextStorage.getContext(contextId); + } + const topLevelTraversables = new Set( + inputContextIds.map((contextId) => { + const topLevelContext = + this.#browsingContextStorage.findTopLevelContextId(contextId); + if (!topLevelContext) { + throw new NoSuchFrameException( + `Top-level navigable not found for context id ${contextId}`, + ); + } + return topLevelContext; + }), + ); + + const isGlobalUnsubscribe = topLevelTraversables.size === 0; + const newSubscriptions: Subscription[] = []; + const eventsMatched = new Set(); + const contextsMatched = new Set(); + for (const subscription of this.#subscriptions) { + if (subscription.channel !== channel) { + newSubscriptions.push(subscription); + continue; + } + // Skip subscriptions when none of the event names match. + if (intersection(subscription.eventNames, eventNames).size === 0) { + newSubscriptions.push(subscription); + continue; + } + if (isGlobalUnsubscribe) { + // Skip non-global subscriptions. + if (subscription.topLevelTraversableIds.size !== 0) { + newSubscriptions.push(subscription); + continue; + } + const subscriptionEventNames = new Set(subscription.eventNames); + for (const eventName of eventNames) { + if (subscriptionEventNames.has(eventName)) { + eventsMatched.add(eventName); + subscriptionEventNames.delete(eventName); + } + } + // If some events remain in the subscription, we keep it. + if (subscriptionEventNames.size !== 0) { + newSubscriptions.push({ + ...subscription, + eventNames: subscriptionEventNames, + }); + } + } else { + // Skip global subscriptions. + if (subscription.topLevelTraversableIds.size === 0) { + newSubscriptions.push(subscription); + continue; + } + + // Splitting context subscriptions. + const eventMap = new Map< + ChromiumBidi.EventNames, + Set + >(); + for (const eventName of subscription.eventNames) { + eventMap.set(eventName, new Set(subscription.topLevelTraversableIds)); + } + for (const eventName of eventNames) { + const eventContextSet = eventMap.get(eventName); + if (!eventContextSet) { + continue; + } + for (const toRemoveId of topLevelTraversables) { + if (eventContextSet.has(toRemoveId)) { + contextsMatched.add(toRemoveId); + eventsMatched.add(eventName); + eventContextSet.delete(toRemoveId); + } + } + if (eventContextSet.size === 0) { + eventMap.delete(eventName); + } + } + for (const [eventName, remainingContextIds] of eventMap) { + const partialSubscription: Subscription = { + id: subscription.id, + channel: subscription.channel, + eventNames: new Set([eventName]), + topLevelTraversableIds: remainingContextIds, + }; + newSubscriptions.push(partialSubscription); + } } } - const eventContextPairs: [ - eventName: ChromiumBidi.EventNames, - contextId: BrowsingContext.BrowsingContext | null, - ][] = cartesianProduct(unrollEvents(events), contextIds); - - // Assert all unsubscriptions are valid. - // If any of the unsubscriptions are invalid, do not unsubscribe from anything. - eventContextPairs - .map(([event, contextId]) => - this.#checkUnsubscribe(event, contextId, channel), - ) - .forEach((unsubscribe) => unsubscribe()); + // If some events did not match, it is an invalid request. + if (!equal(eventsMatched, eventNames)) { + throw new InvalidArgumentException('No subscription found'); + } + + // If some contexts did not match, it is an invalid request. + if (!isGlobalUnsubscribe && !equal(contextsMatched, topLevelTraversables)) { + throw new InvalidArgumentException('No subscription found'); + } + + // Committing the new subscriptions. + this.#subscriptions = newSubscriptions; } /** - * Unsubscribes from the event in the given context and channel. - * Syntactic sugar for "unsubscribeAll". + * Unsubscribes by subscriptionId. */ - unsubscribe( - eventName: ChromiumBidi.EventNames, - contextId: BrowsingContext.BrowsingContext | null, - channel: BidiPlusChannel, - ) { - this.unsubscribeAll([eventName], [contextId], channel); + unsubscribeById(_subscription: string) { + // TODO: implement. } +} - #checkUnsubscribe( - event: ChromiumBidi.EventNames, - contextId: BrowsingContext.BrowsingContext | null, - channel: BidiPlusChannel, - ): () => void { - // All the subscriptions are handled on the top-level contexts. - contextId = this.#browsingContextStorage.findTopLevelContextId(contextId); - - if (!this.#channelToContextToEventMap.has(channel)) { - throw new InvalidArgumentException( - `Cannot unsubscribe from ${event}, ${ - contextId === null ? 'null' : contextId - }. No subscription found.`, - ); - } - const contextToEventMap = this.#channelToContextToEventMap.get(channel)!; - - if (!contextToEventMap.has(contextId)) { - throw new InvalidArgumentException( - `Cannot unsubscribe from ${event}, ${ - contextId === null ? 'null' : contextId - }. No subscription found.`, - ); - } - const eventMap = contextToEventMap.get(contextId)!; - - if (!eventMap.has(event)) { - throw new InvalidArgumentException( - `Cannot unsubscribe from ${event}, ${ - contextId === null ? 'null' : contextId - }. No subscription found.`, - ); +/** + * Replace with Set.prototype.intersection once Node 20 is dropped. + */ +function intersection(setA: Set, setB: Set): Set { + const result = new Set(); + for (const a of setA) { + if (setB.has(a)) { + result.add(a); } + } + return result; +} - return () => { - eventMap.delete(event); +/** + * Replace with Set.prototype.difference once Node 20 is dropped. + */ +export function difference(setA: Set, setB: Set): Set { + const result = new Set(); + for (const a of setA) { + if (!setB.has(a)) { + result.add(a); + } + } + return result; +} - // Clean up maps if empty. - if (eventMap.size === 0) { - contextToEventMap.delete(event); - } - if (contextToEventMap.size === 0) { - this.#channelToContextToEventMap.delete(channel); - } - }; +function equal(setA: Set, setB: Set): boolean { + if (setA.size !== setB.size) { + return false; + } + for (const a of setA) { + if (!setB.has(a)) { + return false; + } } + return true; } diff --git a/src/utils/DistinctValues.spec.ts b/src/utils/DistinctValues.spec.ts deleted file mode 100644 index bb5004b7ab..0000000000 --- a/src/utils/DistinctValues.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright 2024 Google LLC. - * 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 {expect} from 'chai'; - -import {deterministicJSONStringify, distinctValues} from './DistinctValues.js'; - -describe('functions from DistinctValues.ts', () => { - describe('deterministicJSONStringify', () => { - it('should sort properties alphabetically', () => { - const data = {c: 3, b: 2, a: 1}; - const expected = '{"a":1,"b":2,"c":3}'; - const result = deterministicJSONStringify(data); - expect(result).to.equal(expected); - }); - - it('should handle arrays (preserving order)', () => { - const data = [3, 1, 2]; - const expected = '[3,1,2]'; - const result = deterministicJSONStringify(data); - expect(result).to.equal(expected); - }); - - describe('should handling data type', () => { - it('string', () => { - const data = 'Hello'; - const expected = '"Hello"'; - const result = deterministicJSONStringify(data); - expect(result).to.equal(expected); - }); - - it('number', () => { - const data = 42; - const expected = '42'; - const result = deterministicJSONStringify(data); - expect(result).to.equal(expected); - }); - - it('boolean', () => { - const data = true; - const expected = 'true'; - const result = deterministicJSONStringify(data); - expect(result).to.equal(expected); - }); - - it('null', () => { - const data = null; - const expected = 'null'; - const result = deterministicJSONStringify(data); - expect(result).to.equal(expected); - }); - - it('undefined', () => { - const data = undefined; - const expected = undefined; - const result = deterministicJSONStringify(data); - expect(result).to.equal(expected); - }); - }); - - describe('should handling different nested data types', () => { - it('string', () => { - const data = {nested: 'Hello'}; - const expected = '{"nested":"Hello"}'; - const result = deterministicJSONStringify(data); - expect(result).to.equal(expected); - }); - - it('number', () => { - const data = {nested: 42}; - const expected = '{"nested":42}'; - const result = deterministicJSONStringify(data); - expect(result).to.equal(expected); - }); - - it('boolean', () => { - const data = {nested: true}; - const expected = '{"nested":true}'; - const result = deterministicJSONStringify(data); - expect(result).to.equal(expected); - }); - - it('null', () => { - const data = {nested: null}; - const expected = '{"nested":null}'; - const result = deterministicJSONStringify(data); - expect(result).to.equal(expected); - }); - - it('undefined', () => { - const data = {nested: undefined}; - const expected = '{}'; - const result = deterministicJSONStringify(data); - expect(result).to.equal(expected); - }); - }); - }); - - describe('distinctValues', () => { - it('should return distinct primitive values', () => { - const arr = [1, 2, 2, 3, 'hello', 'hello', true, true]; - const expected = [1, 2, 3, 'hello', true]; - const result = distinctValues(arr); - expect(result).to.have.deep.members(expected); // Check for deep equality (order doesn't matter) - }); - - it('should treat deep equal objects as the same', () => { - const arr = [{a: 1}, {a: 1}, {b: 2}, {a: 1, b: 2}, {b: 2}, {b: 2, a: 1}]; - const expected = [{a: 1}, {b: 2}, {a: 1, b: 2}]; - const result = distinctValues(arr); - expect(result).to.have.deep.members(expected); - }); - - it('should handle nested objects', () => { - const arr = [ - {a: 1, nested: {c: 3, d: 4}}, - {a: 1, nested: {d: 4, c: 3}}, // Same as the first - {b: 2, nested: {d: 4}}, - ]; - const expected = [ - {a: 1, nested: {c: 3, d: 4}}, - {b: 2, nested: {d: 4}}, - ]; - const result = distinctValues(arr); - expect(result).to.have.deep.members(expected); - }); - - it('should handle mixed data types', () => { - const arr = [1, 'hello', {a: 1}, true, {a: 1}, 'hello']; - const expected = [1, 'hello', {a: 1}, true]; - const result = distinctValues(arr); - expect(result).to.have.deep.members(expected); - }); - }); -}); diff --git a/src/utils/DistinctValues.ts b/src/utils/DistinctValues.ts deleted file mode 100644 index 3960f65de8..0000000000 --- a/src/utils/DistinctValues.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2024 Google LLC. - * 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. - */ - -/** - * Returns an array of distinct values. Order is not guaranteed. - * @param values - The values to filter. Should be JSON-serializable. - * @return - An array of distinct values. - */ -export function distinctValues(values: T[]): T[] { - const map = new Map(); - for (const value of values) { - map.set(deterministicJSONStringify(value), value); - } - return Array.from(map.values()); -} - -/** - * Returns a stringified version of the object with keys sorted. This is required to - * ensure that the stringified version of an object is deterministic independent of the - * order of keys. - * @param obj - * @return {string} - */ -export function deterministicJSONStringify(obj: unknown) { - return JSON.stringify(normalizeObject(obj)); -} - -function normalizeObject(obj: any) { - if ( - obj === undefined || - obj === null || - Array.isArray(obj) || - typeof obj !== 'object' - ) { - return obj; - } - - // Copy the original object key and values to a new object in sorted order. - const newObj: any = {}; - for (const key of Object.keys(obj).sort()) { - const value = obj[key]; - newObj[key] = normalizeObject(value); // Recursively sort nested objects - } - - return newObj; -} diff --git a/tests/browsing_context/test_create.py b/tests/browsing_context/test_create.py index dc20f9aa32..4e87e69ecf 100644 --- a/tests/browsing_context/test_create.py +++ b/tests/browsing_context/test_create.py @@ -14,9 +14,9 @@ # limitations under the License. import pytest from anys import ANY_STR -from test_helpers import (ANY_TIMESTAMP, AnyExtending, execute_command, - get_tree, goto_url, read_JSON_message, - send_JSON_command, subscribe) +from test_helpers import (ANY_TIMESTAMP, ANY_UUID, AnyExtending, + execute_command, get_tree, goto_url, + read_JSON_message, send_JSON_command, subscribe) @pytest.mark.asyncio @@ -418,7 +418,9 @@ async def test_browsingContext_subscribe_to_contextCreated_emits_for_existing( assert resp == { 'id': subscribe_command_id, 'type': 'success', - 'result': {} + 'result': { + 'subscription': ANY_UUID, + } } diff --git a/tests/log/test_log_entry_added.py b/tests/log/test_log_entry_added.py index 87c5638c75..2fe164105d 100644 --- a/tests/log/test_log_entry_added.py +++ b/tests/log/test_log_entry_added.py @@ -17,8 +17,8 @@ import pytest from anys import ANY_STR -from test_helpers import (ANY_TIMESTAMP, read_JSON_message, send_JSON_command, - subscribe, wait_for_event) +from test_helpers import (ANY_TIMESTAMP, ANY_UUID, read_JSON_message, + send_JSON_command, subscribe, wait_for_event) @pytest.mark.asyncio @@ -523,5 +523,7 @@ async def test_runtimeException_buffered(websocket, context_id): assert { "type": "success", "id": subscribe_command_id, - "result": {} + "result": { + "subscription": ANY_UUID + } } == resp diff --git a/tests/session/test_subscription.py b/tests/session/test_subscription.py index b1cda25554..b54800aac1 100644 --- a/tests/session/test_subscription.py +++ b/tests/session/test_subscription.py @@ -18,9 +18,9 @@ import pytest from anys import ANY_DICT, ANY_STR, AnyWithEntries -from test_helpers import (ANY_TIMESTAMP, AnyExtending, execute_command, - get_next_command_id, read_JSON_message, - send_JSON_command, subscribe) +from test_helpers import (ANY_TIMESTAMP, ANY_UUID, AnyExtending, + execute_command, get_next_command_id, + read_JSON_message, send_JSON_command, subscribe) @pytest.mark.asyncio @@ -50,7 +50,7 @@ async def test_subscribeWithoutContext_subscribesToEventsInAllContexts( "events": ["browsingContext.load"] } }) - assert result == {} + assert result == {'subscription': ANY_UUID} # Navigate to some page. await send_JSON_command( @@ -80,7 +80,7 @@ async def test_subscribeWithContext_subscribesToEventsInGivenContext( "contexts": [context_id] } }) - assert result == {} + assert result == {"subscription": ANY_UUID} # Navigate to some page. await send_JSON_command( @@ -520,13 +520,12 @@ async def test_subscribeWithoutContext_bufferedEventsFromNotClosedContextsAreRet async def test_unsubscribeIsAtomic(websocket, context_id, iframe_id): await subscribe(websocket, ["log.entryAdded"], [iframe_id]) - with pytest.raises( - Exception, - match=re.compile( - str({ - "error": "invalid argument", - "message": 'Cannot unsubscribe from network.responseCompleted, .*. No subscription found.' - }))): + with pytest.raises(Exception, + match=re.compile( + str({ + "error": "invalid argument", + "message": 'No subscription found' + }))): await execute_command( websocket, {