diff --git a/moli-cdp-smoke/README.md b/moli-cdp-smoke/README.md index 5a1751abc..0911563eb 100644 --- a/moli-cdp-smoke/README.md +++ b/moli-cdp-smoke/README.md @@ -127,6 +127,7 @@ Covered well: Playwright `page.wait_for_selector()` / `locator.wait_for()` attached, visible, hidden, detached, and enabled-click auto-wait behavior. - Chromium inspector-protocol derived samples for `Page.domContentEventFired` before `Page.loadEventFired`, `Page.frameStartedLoading` / `Page.frameStoppedLoading`, non-empty `Page.frameAttached.parentFrameId`, `Page.getFrameTree`, dynamic and nested child-frame event fan-out across auxiliary sessions with session-local lifecycle/disable state, `Page.navigate` fragment navigation, `Page.getAppManifest` default/loading/parsing/error/redirect/dynamic-link contracts, successful-result caching and link invalidation, plus its `Manifest` Network request and terminal lifecycle, `Page.getLayoutMetrics`, `Runtime.executionContextCreated`, `Runtime.evaluate(returnByValue)` and exception details, session-local `Input.setIgnoreInputEvents` aggregation/navigation/detach behavior, `Input.insertText` bypass, idle `Input.cancelDragging`, `Audits.issueAdded` Quirks/CSP shape, replay ordering, navigation storage reset and session-local enable/disable, `Log.entryAdded` network metadata, buffered replay ordering, session-local delivery cursors, target-shared `Log.clear`, violations-report state and validation, `IO.resolveBlob` session-local object resolution and reopenable `blob:` streams, session-local `Performance.enable/disable`, strict time-domain transitions, disabled `Performance.getMetrics`, `Emulation.setCPUThrottlingRate`, `Profiler.start` / `Profiler.stop` CPU profiles and CPU-throttling profile workflow, error contracts, auxiliary CDP-session profiler isolation across navigation and detach/reattach, `console.profile` / `console.profileEnd`, precise coverage / best-effort coverage including not-started error, counter reset, and detailed block coverage, `DOM.getAttributes`, `DOM.querySelector(All)` including default-depth node-path publication through ordered `DOM.setChildNodes` events, deep ancestry expansion, repeat suppression, and the chromedp `NodeReady` contract, live `DOMDebugger.getEventListeners`, session-owned event-listener breakpoint pause/re-pause/navigation/detach behavior, session-owned XHR/fetch breakpoint URL matching, synchronous pause data, navigation/child-frame/worker scope, multi-owner sequencing and detach behavior, live-node DOM mutation breakpoints for single-node and multi-child `DocumentFragment` insertion batches, connected-node removal/insertion phases with one pre-pause `DOM.childNodeRemoved`, same-value attributes and node removal with owner/peer data, parser mutation no-pause behavior, unbound-node path ordering, disable/navigation cleanup, and the `DOM.getNodeForLocation` hit-test capability boundary. +- Raw `Input.dispatchKeyEvent` and `Input.dispatchMouseEvent` commands whose DOM handlers initiate a top-level Page replacement; both command responses must remain successful and the replacement Page must immediately accept follow-up CDP work. Focused Rust coverage separately holds the renderer ACK so the cleanup branch itself is deterministic. - Chromium-calibrated `Tracing` browser-global ownership, duplicate start and peer end errors, data-source start acknowledgement before the `Tracing.start` response, exactly-once synchronous start responses, stop-before-ack response/error ordering, response-before-data ordering, cross-session clock markers, bounded `ReportEvents`, JSON `ReturnAsStream` through `IO.read`, and owner-detach cleanup. The CPU-profiler configuration additionally requires real V8 `Profile` / `ProfileChunk` events, non-empty samples with aligned `timeDeltas`, and named hot functions across navigation replacement, dedicated/shared worker teardown, and closed page targets. The same content contract is applied to the real agent-browser profiler artifact. Proto, gzip, Perfetto, system tracing, and periodic buffer reporting remain explicit unsupported boundaries rather than mock output. - Chromium-calibrated live DOM mutation mirroring and editing: shallow/deep `DOM.getDocument` projections, `DOM.characterDataModified`, `DOM.childNodeCountUpdated`, `DOM.childNodeInserted`, `DOM.childNodeRemoved`, and event-before-response contracts for `DOM.moveTo`, `DOM.setAttributesAsText`, `DOM.setNodeName`, `DOM.setNodeValue`, and `DOM.setOuterHTML`, including same-value character-data writes, processing-instruction `xml` renaming, and returned frontend node identity. - Chromium-calibrated Inspector depth-boundary projection: `DOM.getDocument` and `DOM.requestChildNodes` still publish a container's only text child at depth zero, including the common `Example Domain` shape, while containers with multiple children remain collapsed. diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py b/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py index 7863ed575..7c8cef521 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import re import urllib.parse @@ -192,6 +193,7 @@ async def run_dom_input_group(state: SmokeState) -> None: await run_locator_composition_workflows(state) await run_keyboard_editing_workflows(state) await run_cdp_control_key_name_workflow(state) + await run_cdp_input_navigation_replacement_workflows(state) await run_mouse_event_workflows(state) await run_fill_input_type_workflows(state) await run_check_input_workflows(state) @@ -1030,6 +1032,98 @@ async def run_cdp_control_key_name_workflow(state: SmokeState) -> None: await page.close() +async def run_cdp_input_navigation_replacement_workflows(state: SmokeState) -> None: + page = state.page + cdp = state.cdp + + key_destination = f"{state.fixture}/plain?input-navigation=key" + await page.set_content( + f""" + + + """, + wait_until="domcontentloaded", + ) + await page.focus("#navigation-field") + async with page.expect_navigation( + url=key_destination, + wait_until="domcontentloaded", + timeout=10_000, + ): + key_result = await asyncio.wait_for( + cdp.send( + "Input.dispatchKeyEvent", + { + "type": "keyDown", + "key": "Enter", + "code": "Enter", + "text": "", + "unmodifiedText": "", + "windowsVirtualKeyCode": 13, + "nativeVirtualKeyCode": 13, + }, + ), + timeout=5, + ) + assert_equal(key_result, {}, "CDP key input response across Page replacement") + assert_equal( + await page.text_content("main"), + "plain ok", + "CDP key input replacement Page remains usable", + ) + + mouse_destination = f"{state.fixture}/plain?input-navigation=mouse" + await page.set_content( + f""" + + + + """, + wait_until="domcontentloaded", + ) + async with page.expect_navigation( + url=mouse_destination, + wait_until="domcontentloaded", + timeout=10_000, + ): + mouse_result = await asyncio.wait_for( + cdp.send( + "Input.dispatchMouseEvent", + { + "type": "mousePressed", + "x": 10, + "y": 10, + "button": "left", + "buttons": 1, + "clickCount": 1, + }, + ), + timeout=5, + ) + assert_equal(mouse_result, {}, "CDP mouse input response across Page replacement") + assert_equal( + await page.text_content("main"), + "plain ok", + "CDP mouse input replacement Page remains usable", + ) + state.record( + "cdp_input_navigation_replacement_liveness", + {"methods": ["Input.dispatchKeyEvent", "Input.dispatchMouseEvent"]}, + ) + + async def run_mouse_event_workflows(state: SmokeState) -> None: page = state.page diff --git a/moli-webdriver-smoke/README.md b/moli-webdriver-smoke/README.md index 3f9041746..abe5dc5c7 100644 --- a/moli-webdriver-smoke/README.md +++ b/moli-webdriver-smoke/README.md @@ -35,12 +35,14 @@ liveness. The baseline was executed on 2026-08-09 with Debian Chromium - Classic navigation, current URL, title, page source, CSS/XPath element lookup and identity, element text/tag/displayed/enabled/rect/attribute/property/computed label/computed role, send keys, clear form controls with input/change events, file upload, click, execute script, + key actions whose handler performs a top-level Page replacement while the action request is completing, explicit screenshot unsupported errors, cookies, alerts, unhandled prompt behavior, window-scoped prompt switching, shadow roots, page-side SharedWorker probe without polluting window handles, `document.open()` replacement stale-element behavior, and headless window state surface. - WebDriver BiDi `session.status`, `session.new`, `browsingContext.create`, `session.subscribe`, `browsingContext.navigate`, DOMContentLoaded lifecycle - events, `input.performActions`, `input.releaseActions`, element-origin input + events, `input.performActions`, `input.releaseActions`, key actions that trigger a top-level Page + replacement and remain successful through the replacement lifecycle, element-origin input action routing without asserting layout hit-test effects, `input.setFiles`, `network.getData`, `browser.setDownloadBehavior`, `browsingContext.downloadWillBegin`/`downloadEnd`, `network.setCacheBehavior`, diff --git a/moli-webdriver-smoke/moli_webdriver_smoke/fixture.py b/moli-webdriver-smoke/moli_webdriver_smoke/fixture.py index 687c37b8b..e2b764a95 100644 --- a/moli-webdriver-smoke/moli_webdriver_smoke/fixture.py +++ b/moli-webdriver-smoke/moli_webdriver_smoke/fixture.py @@ -80,6 +80,12 @@ class FixtureServer: if route == "/webdriver/actions": self._send_html(self._actions_page()) return + if route == "/webdriver/input-navigation": + self._send_html(self._input_navigation_page()) + return + if route == "/webdriver/input-navigation-complete": + self._send_html(self._input_navigation_complete_page()) + return if route == "/webdriver/form": self._send_html(self._form_page()) return @@ -274,6 +280,29 @@ class FixtureServer: root.innerHTML = 'shadow ready'; +""" + + @staticmethod + def _input_navigation_page() -> str: + return """ + +WebDriver Input Navigation + + + + +""" + + @staticmethod + def _input_navigation_complete_page() -> str: + return """ + +WebDriver Input Navigation Complete +
input navigation complete
""" @staticmethod diff --git a/moli-webdriver-smoke/moli_webdriver_smoke/groups/bidi.py b/moli-webdriver-smoke/moli_webdriver_smoke/groups/bidi.py index 6040abe90..6d0dc797c 100644 --- a/moli-webdriver-smoke/moli_webdriver_smoke/groups/bidi.py +++ b/moli-webdriver-smoke/moli_webdriver_smoke/groups/bidi.py @@ -490,6 +490,83 @@ async def _run_bidi_input_smoke( ) record(results, "bidi_input_set_files", {"context": context}) + input_navigation_url = f"{fixture}/webdriver/input-navigation" + input_navigation_destination = f"{fixture}/webdriver/input-navigation-complete" + await _navigate_complete( + websocket, + 20, + context, + input_navigation_url, + "BiDi input-navigation setup", + ) + focus = await _evaluate_remote_value( + websocket, + 21, + context, + "document.getElementById('navigation-field').focus(); document.activeElement.id", + "BiDi input-navigation focus", + ) + assert_equal(focus["value"], "navigation-field", "BiDi input-navigation active element") + + await _send( + websocket, + 22, + "input.performActions", + { + "context": context, + "actions": [ + { + "type": "key", + "id": "navigation-keyboard", + "actions": [{"type": "keyDown", "value": "\ue007"}], + } + ], + }, + ) + action_messages = await asyncio.wait_for(_recv_until_id(websocket, 22), timeout=5) + action = action_messages[-1] + assert_equal( + action["type"], + "success", + f"BiDi input action responds across Page replacement: {action!r}", + ) + assert_equal(action["result"], {}, "BiDi input-navigation action result") + + lifecycle = _find_bidi_event( + action_messages, + "browsingContext.domContentLoaded", + lambda message: message.get("params", {}).get("url") == input_navigation_destination, + ) + if lifecycle is None: + lifecycle = await _recv_until_bidi_event( + websocket, + "browsingContext.domContentLoaded", + "BiDi input-navigation DOMContentLoaded", + lambda message: message.get("params", {}).get("url") == input_navigation_destination, + ) + assert_equal( + lifecycle["params"]["context"], + context, + "BiDi input-navigation lifecycle context", + ) + + await _send(websocket, 23, "input.releaseActions", {"context": context}) + release = await _recv_success(websocket, 23, "BiDi input-navigation releaseActions") + assert_equal(release["result"], {}, "BiDi input-navigation release result") + marker = await _evaluate_remote_value( + websocket, + 24, + context, + "document.getElementById('input-navigation-complete')?.textContent", + "BiDi input-navigation replacement marker", + ) + assert_equal( + marker["value"], + "input navigation complete", + "BiDi input-navigation replacement Page remains usable", + ) + record(results, "bidi_input_navigation_replacement", {"context": context}) + async def _run_bidi_network_get_data_smoke( websocket: Any, context: str, fixture: str, results: list[dict[str, Any]] diff --git a/moli-webdriver-smoke/moli_webdriver_smoke/groups/classic.py b/moli-webdriver-smoke/moli_webdriver_smoke/groups/classic.py index 4996a42d8..820245ff0 100644 --- a/moli-webdriver-smoke/moli_webdriver_smoke/groups/classic.py +++ b/moli-webdriver-smoke/moli_webdriver_smoke/groups/classic.py @@ -51,6 +51,29 @@ async def _wait_for_alert_text( return +async def _wait_for_current_url( + client: ClassicClient, + session_id: str, + expected: str, + *, + timeout: float = 5.0, +) -> None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + observed: list[str] = [] + while True: + actual = classic_value(client.get(f"/session/{session_id}/url")) + if actual == expected: + return + if not observed or observed[-1] != actual: + observed.append(actual) + if loop.time() >= deadline: + raise AssertionError( + f"timed out waiting for Classic URL {expected!r}; observed {observed!r}" + ) + await asyncio.sleep(0.01) + + async def run_classic_group( endpoint: str, fixture: str, @@ -86,6 +109,7 @@ def _classic_scenarios() -> tuple[tuple[str, ClassicScenario], ...]: return ( ("classic_navigation_element_script", _run_navigation_element_script_smoke), ("classic_document_open_replacement_stale_element", _run_document_open_replacement_stale_element_smoke), + ("classic_input_navigation_replacement", _run_input_navigation_replacement_smoke), ("classic_clear_form", _run_clear_form_smoke), ("classic_file_upload", _run_file_upload_smoke), ("classic_alert_prompt", _run_alert_smoke), @@ -446,6 +470,61 @@ async def _run_document_open_replacement_stale_element_smoke( ) +async def _run_input_navigation_replacement_smoke( + client: ClassicClient, + fixture: str, + session_id: str, + results: list[dict[str, Any]], +) -> None: + page_url = f"{fixture}/webdriver/input-navigation" + destination = f"{fixture}/webdriver/input-navigation-complete" + assert_equal( + client.post(f"/session/{session_id}/url", {"url": page_url}), + {"value": None}, + "Classic input-navigation setup", + ) + + field = client.post( + f"/session/{session_id}/element", + {"using": "css selector", "value": "#navigation-field"}, + ) + field_id = classic_element_id(field) + assert_equal( + client.post(f"/session/{session_id}/element/{field_id}/click"), + {"value": None}, + "Classic input-navigation focus", + ) + + actions = client.post( + f"/session/{session_id}/actions", + { + "actions": [ + { + "type": "key", + "id": "navigation-keyboard", + "actions": [{"type": "keyDown", "value": "\ue007"}], + } + ] + }, + ) + assert_equal( + actions, + {"value": None}, + "Classic input action responds across Page replacement", + ) + await _wait_for_current_url(client, session_id, destination) + assert_true( + "input navigation complete" in classic_value(client.get(f"/session/{session_id}/source")), + "Classic input action replacement Page remains usable", + ) + assert_equal( + client.delete(f"/session/{session_id}/actions"), + {"value": None}, + "Classic input action release after Page replacement", + ) + record(results, "classic_input_navigation_replacement", {"url": destination}) + + def _assert_classic_stale_element(action: Callable[[], Any], label: str) -> None: try: action()