diff --git a/src/bidiMapper/modules/context/BrowsingContextImpl.ts b/src/bidiMapper/modules/context/BrowsingContextImpl.ts index 74c3d1ff4..1b84723c7 100644 --- a/src/bidiMapper/modules/context/BrowsingContextImpl.ts +++ b/src/bidiMapper/modules/context/BrowsingContextImpl.ts @@ -98,6 +98,11 @@ export class BrowsingContextImpl { // Set if there is a pending navigation initiated by `BrowsingContext.navigate` command. // The promise is resolved when the navigation is finished or rejected when canceled. #pendingCommandNavigation: Deferred | undefined; + // Flags if the initial navigation to `about:blank` is in progress. + #initialNavigation = true; + // Flags if the navigation is initiated by `browsingContext.navigate` or + // `browsingContext.reload` command. + #navigationInitiatedByNavigationCommand = false; #originalOpener?: string; @@ -409,6 +414,24 @@ export class BrowsingContextImpl { this.#url = params.targetInfo.url; } + #emitNavigationStarted(url: string) { + this.#navigationId = this.#pendingNavigationId ?? uuidv4(); + this.#pendingNavigationId = undefined; + this.#eventManager.registerEvent( + { + type: 'event', + method: ChromiumBidi.BrowsingContext.EventNames.NavigationStarted, + params: { + context: this.id, + navigation: this.#navigationId, + timestamp: BrowsingContextImpl.getTimestamp(), + url, + }, + }, + this.id, + ); + } + #initListeners() { this.#cdpTarget.cdpClient.on('Page.frameNavigated', (params) => { if (this.id !== params.frame.id) { @@ -468,27 +491,24 @@ export class BrowsingContextImpl { if (this.id !== params.frameId) { return; } - // Use `pendingNavigationId` if navigation initiated by BiDi - // `BrowsingContext.navigate` or generate a new navigation id. - this.#navigationId = this.#pendingNavigationId ?? uuidv4(); - this.#pendingNavigationId = undefined; - this.#eventManager.registerEvent( - { - type: 'event', - method: ChromiumBidi.BrowsingContext.EventNames.NavigationStarted, - params: { - context: this.id, - navigation: this.#navigationId, - timestamp: BrowsingContextImpl.getTimestamp(), - // The URL of the navigation that is currently in progress. Although the URL - // is not yet known in case of user-initiated navigations, it is possible to - // provide the URL in case of BiDi-initiated navigations. - // TODO: provide proper URL in case of user-initiated navigations. - url: this.#pendingNavigationUrl ?? 'UNKNOWN', - }, - }, - this.id, - ); + + if (!this.#navigationInitiatedByNavigationCommand) { + // In case of the navigation is not initiated by `browsingContext.navigate` + // command, the `Page.frameRequestedNavigation` is emitted, which means the + // `NavigationStarted` is already emitted as well. + return; + } + + // The `Page.frameRequestedNavigation` is not emitted, as the navigation was + // initiated by `browsingContext.navigate` command. This means the + // `NavigationStarted` event should be emitted now. + + // The URL of the navigation that is currently in progress. Although the URL + // is not yet known in case of user-initiated navigations, it is possible to + // provide the URL in case of BiDi-initiated navigations. + // TODO: provide proper URL in case of user-initiated navigations. + const url = this.#pendingNavigationUrl ?? 'UNKNOWN'; + this.#emitNavigationStarted(url); }); // TODO: don't use deprecated `Page.frameScheduledNavigation` event. @@ -523,7 +543,19 @@ export class BrowsingContextImpl { new UnknownErrorException('navigation aborted'), ); this.#pendingCommandNavigation = undefined; + this.#navigationInitiatedByNavigationCommand = false; + } + if (params.url !== 'about:blank') { + // TODO: cover `about:blank?qwe` case. + // Heuristic to address https://github.com/GoogleChromeLabs/chromium-bidi/issues/2793. + this.#initialNavigation = false; + } + + if (!this.#initialNavigation) { + // Do not emit the event for the initial navigation to `about:blank`. + this.#emitNavigationStarted(params.url); } + this.#pendingNavigationUrl = params.url; }); @@ -558,36 +590,45 @@ export class BrowsingContextImpl { switch (params.name) { case 'DOMContentLoaded': - this.#eventManager.registerEvent( - { - type: 'event', - method: ChromiumBidi.BrowsingContext.EventNames.DomContentLoaded, - params: { - context: this.id, - navigation: this.#navigationId, - timestamp, - url: this.#url, + if (!this.#initialNavigation) { + // Do not emit for the initial navigation. + this.#eventManager.registerEvent( + { + type: 'event', + method: + ChromiumBidi.BrowsingContext.EventNames.DomContentLoaded, + params: { + context: this.id, + navigation: this.#navigationId, + timestamp, + url: this.#url, + }, }, - }, - this.id, - ); + this.id, + ); + } this.#lifecycle.DOMContentLoaded.resolve(); break; case 'load': - this.#eventManager.registerEvent( - { - type: 'event', - method: ChromiumBidi.BrowsingContext.EventNames.Load, - params: { - context: this.id, - navigation: this.#navigationId, - timestamp, - url: this.#url, + if (!this.#initialNavigation) { + // Do not emit for the initial navigation. + this.#eventManager.registerEvent( + { + type: 'event', + method: ChromiumBidi.BrowsingContext.EventNames.Load, + params: { + context: this.id, + navigation: this.#navigationId, + timestamp, + url: this.#url, + }, }, - }, - this.id, - ); + this.id, + ); + } + // The initial navigation is finished. + this.#initialNavigation = false; this.#lifecycle.load.resolve(); break; } @@ -884,6 +925,7 @@ export class BrowsingContextImpl { const navigationId = uuidv4(); this.#pendingNavigationId = navigationId; this.#pendingCommandNavigation = new Deferred(); + this.#navigationInitiatedByNavigationCommand = true; // Navigate and wait for the result. If the navigation fails, the error event is // emitted and the promise is rejected. @@ -949,6 +991,7 @@ export class BrowsingContextImpl { // `#pendingCommandNavigation` can be already rejected and set to undefined. this.#pendingCommandNavigation?.resolve(); + this.#navigationInitiatedByNavigationCommand = false; this.#pendingCommandNavigation = undefined; return { navigation: navigationId, @@ -986,6 +1029,8 @@ export class BrowsingContextImpl { this.#resetLifecycleIfFinished(); + this.#navigationInitiatedByNavigationCommand = true; + await this.#cdpTarget.cdpClient.sendCommand('Page.reload', { ignoreCache, }); diff --git a/tests/browsing_context/test_create.py b/tests/browsing_context/test_create.py index 4e3fa1b9b..88925a485 100644 --- a/tests/browsing_context/test_create.py +++ b/tests/browsing_context/test_create.py @@ -13,57 +13,37 @@ # See the License for the specific language governing permissions and # limitations under the License. import pytest -from anys import ANY_DICT, ANY_STR +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) @pytest.mark.asyncio -async def test_browsingContext_create_eventContextCreatedEmitted( - websocket, read_sorted_messages): - await subscribe(websocket, [ - "browsingContext.contextCreated", "browsingContext.domContentLoaded", - "browsingContext.load" - ]) - - await send_JSON_command(websocket, { - "id": 9, +async def test_browsingContext_create_eventsEmitted(websocket, + read_sorted_messages, + assert_no_more_messages): + await subscribe(websocket, "browsingContext") + + command_id = await send_JSON_command(websocket, { "method": "browsingContext.create", "params": { "type": "tab" } }) - # Read event messages. The order can vary in headless and headful modes, so - # sort is needed: - # * `browsingContext.contextCreated` event. - # * `browsingContext.domContentLoaded` event. - # * `browsingContext.load` event. - [context_created_event, dom_content_loaded_event, - load_event] = await read_sorted_messages(3) - - # Read the `browsingContext.create` command result. It should be sent after - # all the loading events. - command_result = await read_JSON_message(websocket) - - new_context_id = command_result['result']['context'] - - # Assert command done. - assert command_result == { + [command_result, context_created_event] = await read_sorted_messages(2) + assert [command_result, context_created_event] == [{ "type": "success", - "id": 9, + "id": command_id, "result": { - 'context': new_context_id + 'context': ANY_STR } - } - - # Assert "browsingContext.contextCreated" event emitted. - assert { + }, { "type": "event", "method": "browsingContext.contextCreated", "params": { - "context": new_context_id, + "context": ANY_STR, "url": "about:blank", "children": None, "parent": None, @@ -71,91 +51,126 @@ async def test_browsingContext_create_eventContextCreatedEmitted( "originalOpener": None, 'clientWindow': ANY_STR, } - } == context_created_event - - # Assert "browsingContext.domContentLoaded" event emitted. - assert { - "type": "event", - "method": "browsingContext.domContentLoaded", - "params": { - "context": new_context_id, - "navigation": ANY_STR, - "timestamp": ANY_TIMESTAMP, - "url": "about:blank" - } - } == dom_content_loaded_event + }] + assert command_result['result']['context'] == context_created_event[ + 'params']['context'] - # Assert "browsingContext.load" event emitted. - assert { - "type": "event", - "method": "browsingContext.load", - "params": { - "context": new_context_id, - "navigation": ANY_STR, - "timestamp": ANY_TIMESTAMP, - "url": "about:blank" - } - } == load_event + await assert_no_more_messages() @pytest.mark.asyncio -async def test_browsingContext_create_noNavigationEventsEmitted( - websocket, context_id, read_sorted_messages): - pytest.xfail( - "https://github.com/GoogleChromeLabs/chromium-bidi/issues/2793") +async def test_browsingContext_windowOpen_blank_eventsEmitted( + websocket, context_id, read_sorted_messages, assert_no_more_messages): + await subscribe(websocket, "browsingContext") - await subscribe(websocket, [ - "browsingContext.contextCreated", "browsingContext.domContentLoaded", - "browsingContext.load", "browsingContext.navigationStarted" - ]) + command_id = await send_JSON_command( + websocket, { + "method": "script.evaluate", + "params": { + "expression": "window.open('about:blank')", + "target": { + "context": context_id + }, + "resultOwnership": "root", + "awaitPromise": False, + } + }) - command_id = await send_JSON_command(websocket, { - "method": "browsingContext.create", - "params": { - "type": "tab" + [command_result, context_created_event] = await read_sorted_messages(2) + assert [command_result, context_created_event] == [ + AnyExtending({ + "type": "success", + "id": command_id, + }), { + "type": "event", + "method": "browsingContext.contextCreated", + "params": { + "context": ANY_STR, + "url": "about:blank", + "children": None, + "parent": None, + "userContext": "default", + "originalOpener": ANY_STR, + 'clientWindow': ANY_STR, + } } - }) + ] - [command_result, context_created_event] = await read_sorted_messages(2) + await assert_no_more_messages() - assert command_result == AnyExtending({ - 'id': command_id, - 'type': 'success', - }) - assert context_created_event == { - 'method': 'browsingContext.contextCreated', - 'params': { - 'children': None, - 'clientWindow': '', - 'context': ANY_STR, - 'originalOpener': context_id, - 'parent': None, - 'url': 'about:blank', - 'userContext': 'default', - }, - 'type': 'event', - } - new_context_id = context_created_event["params"]["context"] +@pytest.mark.asyncio +async def test_browsingContext_windowOpen_nonBlank_eventsEmitted( + websocket, context_id, read_sorted_messages, assert_no_more_messages, + url_example): + await subscribe(websocket, "browsingContext") - # Assert no other events. command_id = await send_JSON_command( websocket, { "method": "script.evaluate", "params": { - "expression": "1", + "expression": f"window.open('{url_example}')", "target": { - "context": new_context_id, + "context": context_id }, - "awaitPromise": False + "resultOwnership": "root", + "awaitPromise": False, } }) - response = await read_JSON_message(websocket) - assert response == AnyExtending({ - 'id': command_id, - 'type': 'success', - }) + events = await read_sorted_messages(5) + + events == [ + AnyExtending({ + "type": "success", + "id": command_id, + }), + { + "type": "event", + "method": "browsingContext.contextCreated", + "params": { + "context": ANY_STR, + "url": "about:blank", + "children": None, + "parent": None, + "userContext": "default", + "originalOpener": ANY_STR, + 'clientWindow': ANY_STR, + } + }, + { + 'method': 'browsingContext.domContentLoaded', + 'params': { + 'context': ANY_STR, + 'navigation': ANY_STR, + 'timestamp': ANY_TIMESTAMP, + 'url': url_example, + }, + 'type': 'event', + }, + { + 'method': 'browsingContext.load', + 'params': { + 'context': ANY_STR, + 'navigation': ANY_STR, + 'timestamp': ANY_TIMESTAMP, + 'url': url_example, + }, + 'type': 'event', + }, + { + 'method': 'browsingContext.navigationStarted', + 'params': { + 'context': ANY_STR, + 'navigation': ANY_STR, + 'timestamp': ANY_TIMESTAMP, + 'url': url_example, + }, + 'type': 'event', + }, + ] + + await assert_no_more_messages() @pytest.mark.asyncio @@ -249,17 +264,14 @@ async def test_browsingContext_createWithNestedSameOriginContexts_eventContextCr @pytest.mark.asyncio async def test_browsingContext_create_withUserGesture_eventsEmitted( - websocket, context_id, html, url_example, read_sorted_messages): + websocket, context_id, html, url_example, read_sorted_messages, + assert_no_more_messages): LINK_WITH_BLANK_TARGET = html( f'''new tab''') await goto_url(websocket, context_id, LINK_WITH_BLANK_TARGET) - await subscribe(websocket, [ - 'browsingContext.contextCreated', - 'browsingContext.domContentLoaded', - 'browsingContext.load', - ]) + await subscribe(websocket, 'browsingContext') command_id = await send_JSON_command( websocket, { @@ -274,96 +286,74 @@ async def test_browsingContext_create_withUserGesture_eventsEmitted( } }) - # Read event messages. The order can vary, so read all and sort them. Ignore - # optional "browsingContext.domContentLoaded" event for "about:blank" pages. - # Expected sorted messages order: - # 1. Command result. - # 2. "browsingContext.contextCreated" event. - # (optional). "browsingContext.domContentLoaded" event for "about:blank". - # Omitted in headful mode. - # 3. "browsingContext.domContentLoaded" event for the example_url. - # 4. "browsingContext.load" event. - messages = await read_sorted_messages( - 4, lambda m: 'method' not in m or - (m['method'] != 'browsingContext.domContentLoaded') or m['params'][ - 'url'] != 'about:blank') - - # Get the new context id from the "browsingContext.contextCreated" event. - new_context_id = messages[1]['params']['context'] - - assert messages == [{ - 'id': command_id, - 'type': 'success', - 'result': ANY_DICT - }, { - 'type': 'event', - 'method': 'browsingContext.contextCreated', - 'params': { - 'context': ANY_STR, - 'url': 'about:blank', - 'clientWindow': ANY_STR, - 'children': None, - 'parent': None, - 'userContext': 'default', - 'originalOpener': ANY_STR, - } - }, { - 'type': 'event', - 'method': 'browsingContext.domContentLoaded', - 'params': { - 'context': new_context_id, - 'navigation': ANY_STR, - 'timestamp': ANY_TIMESTAMP, - 'url': url_example - } - }, { - 'type': 'event', - 'method': 'browsingContext.load', - 'params': { - 'context': new_context_id, - 'navigation': ANY_STR, - 'timestamp': ANY_TIMESTAMP, - 'url': url_example + messages = await read_sorted_messages(2) + + assert messages == [ + AnyExtending({ + 'id': command_id, + 'type': 'success', + }), { + 'type': 'event', + 'method': 'browsingContext.contextCreated', + 'params': { + 'context': ANY_STR, + 'url': 'about:blank', + 'clientWindow': ANY_STR, + 'children': None, + 'parent': None, + 'userContext': 'default', + 'originalOpener': ANY_STR, + } } - }] + ] + + await assert_no_more_messages() @pytest.mark.asyncio @pytest.mark.parametrize("type", ["window", "tab"]) -async def test_browsingContext_create_withUserContext(websocket, type): - user_context = await execute_command(websocket, { +async def test_browsingContext_create_withUserContext(websocket, type, + assert_no_more_messages, + read_sorted_messages): + result = await execute_command(websocket, { "method": "browser.createUserContext", "params": {} }) + user_context = result["userContext"] - await subscribe(websocket, [ - "browsingContext.contextCreated", "browsingContext.domContentLoaded", - "browsingContext.load" - ]) + await subscribe(websocket, "browsingContext") - result = await execute_command( + command_id = await send_JSON_command( websocket, { "method": "browsingContext.create", "params": { "type": type, - "userContext": user_context["userContext"] + "userContext": user_context } }) - tree = await execute_command(websocket, { - "method": "browsingContext.getTree", - "params": {} - }) + messages = await read_sorted_messages(2) - assert len(tree['contexts']) == 2 + assert messages == [ + AnyExtending({ + 'id': command_id, + 'type': 'success', + }), { + "type": "event", + "method": "browsingContext.contextCreated", + "params": { + "context": ANY_STR, + "url": "about:blank", + "children": None, + "parent": None, + "userContext": user_context, + "originalOpener": None, + 'clientWindow': ANY_STR, + } + } + ] - assert tree["contexts"][1] == AnyExtending({ - 'context': result['context'], - 'url': 'about:blank', - 'userContext': user_context["userContext"], - 'children': [], - 'parent': None - }) + await assert_no_more_messages() @pytest.mark.asyncio diff --git a/tests/browsing_context/test_dom_content_loaded.py b/tests/browsing_context/test_dom_content_loaded.py index 9b78fa85a..f8dc15960 100644 --- a/tests/browsing_context/test_dom_content_loaded.py +++ b/tests/browsing_context/test_dom_content_loaded.py @@ -13,19 +13,71 @@ # See the License for the specific language governing permissions and # limitations under the License. import pytest -from test_helpers import send_JSON_command, subscribe, wait_for_event +from anys import ANY_STR +from test_helpers import (ANY_TIMESTAMP, ANY_UUID, read_JSON_message, + send_JSON_command, subscribe) @pytest.mark.asyncio -async def test_browsingContext_subscribeToAllBrowsingContextEvents_eventReceived( - websocket): - await subscribe(websocket, ["browsingContext"]) +async def test_browsingContext_domContentLoaded_create_notReceived( + websocket, assert_no_more_messages): + await subscribe(websocket, ["browsingContext.domContentLoaded"]) - await send_JSON_command(websocket, { + command_id = await send_JSON_command(websocket, { "method": "browsingContext.create", "params": { "type": "tab" } }) - await wait_for_event(websocket, "browsingContext.domContentLoaded") + response = await read_JSON_message(websocket) + assert response == { + 'id': command_id, + 'result': { + 'context': ANY_STR, + }, + 'type': 'success', + } + + await assert_no_more_messages() + + +@pytest.mark.asyncio +async def test_browsingContext_domContentLoaded_navigate_received( + websocket, context_id, url_example, assert_no_more_messages, + read_sorted_messages): + await subscribe(websocket, ["browsingContext.domContentLoaded"]) + + command_id = await send_JSON_command( + websocket, { + "method": "browsingContext.navigate", + "params": { + "url": url_example, + "wait": "complete", + "context": context_id + } + }) + + messages = await read_sorted_messages(2) + assert messages == [ + { + 'id': command_id, + 'result': { + 'navigation': ANY_UUID, + 'url': url_example, + }, + 'type': 'success', + }, + { + 'method': 'browsingContext.domContentLoaded', + 'params': { + 'context': context_id, + 'navigation': ANY_UUID, + 'timestamp': ANY_TIMESTAMP, + 'url': url_example, + }, + 'type': 'event', + }, + ] + + await assert_no_more_messages() diff --git a/tests/browsing_context/test_load.py b/tests/browsing_context/test_load.py index fe5b6c222..9a84a7fc0 100644 --- a/tests/browsing_context/test_load.py +++ b/tests/browsing_context/test_load.py @@ -13,11 +13,13 @@ # See the License for the specific language governing permissions and # limitations under the License. import pytest -from test_helpers import ANY_TIMESTAMP, read_JSON_message, send_JSON_command +from test_helpers import (ANY_TIMESTAMP, ANY_UUID, read_JSON_message, + send_JSON_command, subscribe) @pytest.mark.asyncio -async def test_browsingContext_noInitialLoadEvents(websocket, html): +async def test_browsingContext_noInitialLoadEvents(websocket, html, + assert_no_more_messages): # Due to the nature, the test does not always fail, even if the # implementation does not guarantee the initial context to be fully loaded. # The test asserts there was no initial "browsingContext.load" emitted @@ -79,3 +81,49 @@ async def test_browsingContext_noInitialLoadEvents(websocket, html): 'url': url } } == resp + assert_no_more_messages() + + +@pytest.mark.asyncio +async def test_browsingContext_load_properNavigation(websocket, context_id, + url_example, + read_sorted_messages, + assert_no_more_messages): + await subscribe(websocket, "browsingContext.load") + + command_id = await send_JSON_command( + websocket, { + "method": "browsingContext.navigate", + "params": { + "url": url_example, + "wait": "none", + "context": context_id + } + }) + + [command_result, load_event] = await read_sorted_messages(2) + assert [command_result, load_event] == [ + { + 'id': command_id, + 'result': { + 'navigation': ANY_UUID, + 'url': url_example, + }, + 'type': 'success', + }, + { + 'method': 'browsingContext.load', + 'params': { + 'context': context_id, + 'navigation': ANY_UUID, + 'timestamp': ANY_TIMESTAMP, + 'url': url_example, + }, + 'type': 'event', + }, + ] + + assert command_result['result']['navigation'] == load_event['params'][ + 'navigation'] + + await assert_no_more_messages() diff --git a/tests/script/test_add_preload_script.py b/tests/script/test_add_preload_script.py index 4679e9bae..69713be53 100644 --- a/tests/script/test_add_preload_script.py +++ b/tests/script/test_add_preload_script.py @@ -411,32 +411,28 @@ async def test_preloadScript_add_loadedInMultipleContexts( @pytest.mark.asyncio async def test_preloadScript_add_loadedInMultipleContexts_withIframes( websocket, context_id, url_all_origins, html, read_sorted_messages): - await execute_command( - websocket, { - "method": "script.addPreloadScript", - "params": { - "functionDeclaration": "() => { window.foo='bar'; }", - } - }) + await subscribe(websocket, ["script.message"]) await goto_url(websocket, context_id, html()) - result = await execute_command( + await execute_command( websocket, { - "method": "script.evaluate", + "method": "script.addPreloadScript", "params": { - "expression": "window.foo", - "target": { - "context": context_id - }, - "awaitPromise": True, - "resultOwnership": "root" + "functionDeclaration": """ + (channel) => { + setTimeout(() => { + channel('preload script executed') + }, 1); + }""", + "arguments": [{ + "type": "channel", + "value": { + "channel": "some_channel_name" + }, + }, ], } }) - assert result["result"] == {"type": "string", "value": 'bar'} - - # Needed to make sure the iFrame loaded. - await subscribe(websocket, ["browsingContext.load"]) # Create a new iframe within the same context. command_id = await send_JSON_command( @@ -456,37 +452,26 @@ async def test_preloadScript_add_loadedInMultipleContexts_withIframes( # Depending on the URL, the iframe can be loaded before or after the script # is done. - [command_result, browsing_context_load] = await read_sorted_messages(2) - assert command_result == { - "type": "success", - "id": command_id, - "result": ANY_DICT - } - assert browsing_context_load == { - 'type': 'event', - "method": "browsingContext.load", - "params": AnyExtending({ - "context": ANY_STR, - "url": url_all_origins - }) - } - - iframe_context_id = browsing_context_load["params"]["context"] - assert iframe_context_id != context_id - - result = await execute_command( - websocket, { - "method": "script.evaluate", - "params": { - "expression": "window.foo", - "target": { - "context": iframe_context_id + [command_result, script_message_event] = await read_sorted_messages(2) + + assert [command_result, script_message_event] == [ + AnyExtending({ + 'id': command_id, + 'type': 'success', + }), + AnyExtending({ + 'method': 'script.message', + 'params': { + 'channel': 'some_channel_name', + 'data': { + 'value': 'preload script executed', }, - "awaitPromise": True, - "resultOwnership": "root" - } - }) - assert result["result"] == {"type": "string", "value": 'bar'} + }, + 'type': 'event', + }), + ] + + assert context_id != script_message_event['params']['source']['context'] @pytest.mark.asyncio diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 00a6d6623..d2bfbb410 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -43,9 +43,15 @@ def get_next_command_id() -> int: async def subscribe(websocket, - events: list[str], - context_ids: list[str] | None = None, + events: list[str] | str, + context_ids: list[str] | str | None = None, channel: str | None = None): + if type(events) is str: + events = [events] + + if type(context_ids) is str: + context_ids = [context_ids] + command: dict = { "method": "session.subscribe", "params": {