test: cover input navigation through public protocols

This commit is contained in:
ldm0
2026-08-16 04:06:37 +08:00
committed by Donough Liu
parent 515df47845
commit f05dc0a4fa
6 changed files with 283 additions and 1 deletions
+1
View File
@@ -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:<uuid>` 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 `<title>Example Domain</title>` shape, while containers with multiple children remain collapsed.
@@ -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"""
<input id="navigation-field" autofocus>
<script>
const navigationField = document.getElementById("navigation-field");
navigationField.addEventListener("keydown", event => {{
if (event.key === "Enter") location.href = {json.dumps(key_destination)};
}});
</script>
""",
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"""
<style>
body {{ margin: 0; }}
#navigation-button {{ position: fixed; left: 0; top: 0; width: 200px; height: 100px; }}
</style>
<button id="navigation-button">navigate</button>
<script>
document.getElementById("navigation-button").addEventListener("mousedown", () => {{
location.href = {json.dumps(mouse_destination)};
}});
</script>
""",
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
+3 -1
View File
@@ -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`,
@@ -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 = '<span id="shadow-text" class="shadow-item">shadow ready</span><button id="shadow-button" class="shadow-item">Shadow</button>';
</script>
</body>
</html>"""
@staticmethod
def _input_navigation_page() -> str:
return """<!doctype html>
<html>
<head><title>WebDriver Input Navigation</title></head>
<body>
<input id="navigation-field" autofocus>
<script>
document.getElementById("navigation-field").addEventListener("keydown", event => {
if (event.key === "Enter") location.href = "/webdriver/input-navigation-complete";
});
</script>
</body>
</html>"""
@staticmethod
def _input_navigation_complete_page() -> str:
return """<!doctype html>
<html>
<head><title>WebDriver Input Navigation Complete</title></head>
<body><main id="input-navigation-complete">input navigation complete</main></body>
</html>"""
@staticmethod
@@ -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]]
@@ -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()