diff --git a/moli-cdp-smoke/README.md b/moli-cdp-smoke/README.md index 68e0acb184..fc36859100 100644 --- a/moli-cdp-smoke/README.md +++ b/moli-cdp-smoke/README.md @@ -151,7 +151,7 @@ or identical scenario counts: | Fetch body streams | Buffered seeking and explicit rejection of continuation after body take | Sequential reads; EOF may require a separate empty read | | WebSocket events and URL blocking | Extra generic request events, payload lengths, and WebSocket URL blocking | Dedicated WebSocket events and payload data; URL blocking excludes WebSockets | | Hosted file URLs | Capability error before navigation/interception | File navigation and interception; missing file returns `ERR_FILE_NOT_FOUND` | -| Other capability boundaries | Stack timing privacy, unsupported drag/CPU throttling, PI rename extension | Observed stack timing, successful drag/throttling, rejected PI rename | +| Other capability boundaries | Stack timing privacy, unsupported CPU throttling, PI rename extension | Observed stack timing, successful throttling, rejected PI rename | | Response/manifest/idle details | Plaintext TextTrack bodies, document-relative data manifest URLs, same-site idle persistence | Base64 TextTrack bodies, data manifest URL fallbacks, cross-document idle reset | The `dom-input` text-editing checks were calibrated on 2026-09-12 against @@ -190,6 +190,17 @@ observed, but it does not replace the executable probe when one can be run. If Chromium cannot be tested, document that limitation explicitly instead of presenting an inferred expectation as verified behavior. +The native drag scenarios were calibrated on 2026-10-28 against Playwright's +Chrome for Testing 145.0.7632.6. The `dom` group checks drag eligibility and +movement thresholds, native `effectAllowed: "uninitialized"`, frontend-session +isolation, cancellation, protected dragenter/dragover data, client-edited replay +payloads, and accepted or rejected drops. Chromium reports all operations as +`-1`, while Moli reports the supported mask `19`; the shared assertion compares +the copy/link/move bits. Default text drops into writable input, textarea, and +contenteditable targets need no author-canceled dragover. They use copy for a +draggable element source: `copyMove` allows the drop, while `linkMove` rejects it. +The same scenarios run against Moli's current build. + The `Fetch.getResponseBody` text encoding was calibrated on 2026-10-19 against Debian `/usr/bin/chromium` 145.0.7632.116. A fresh page enabled `Network` and `Fetch` with `requestStage: "Response"`, navigated to a local UTF-8 HTML fixture, 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 062228281d..1b7d2e038f 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py @@ -15,6 +15,7 @@ from ..config import is_moli_browser from .drop_input import run_drop_input_workflow from ..assertions import SmokeError, assert_equal, wait_until from ..progress import await_with_progress +from .drag_interception import run_drag_interception_workflows async def run_dom_input_group(state: SmokeState) -> None: @@ -237,6 +238,7 @@ async def run_dom_input_group(state: SmokeState) -> None: state.record("auth_style_hydrated_click_redirect") await run_locator_input_workflows(state) + await run_drag_interception_workflows(state) await run_user_facing_locator_workflows(state) await run_role_selector_state_workflows(state) await run_playwright_expect_matcher_workflows(state) @@ -358,6 +360,7 @@ async def run_locator_input_workflows(state: SmokeState) -> None:
drop
`; window.__keys = []; + window.__dragEvents = []; const textInput = document.getElementById('text'); const hoverTarget = document.getElementById('hover'); const hoverChild = document.getElementById('hover-child'); @@ -366,10 +369,15 @@ async def run_locator_input_workflows(state: SmokeState) -> None: textInput.addEventListener('keydown', event => window.__keys.push(`${event.key}:${event.shiftKey}:${event.ctrlKey || event.metaKey}`)); hoverTarget.addEventListener('mouseenter', () => window.__hovered = true); hoverChild.addEventListener('click', () => window.__hoverChildClicked = true); - dragSource.addEventListener('dragstart', event => event.dataTransfer.setData('text/plain', 'dragged-value')); + dragSource.addEventListener('dragstart', event => { + window.__dragEvents.push('start'); + event.dataTransfer.setData('text/plain', 'dragged-value'); + }); + dragSource.addEventListener('dragend', () => window.__dragEvents.push('end')); dropTarget.addEventListener('dragover', event => event.preventDefault()); dropTarget.addEventListener('drop', event => { event.preventDefault(); + window.__dragEvents.push('drop'); dropTarget.textContent = event.dataTransfer.getData('text/plain'); }); }""" @@ -437,13 +445,13 @@ async def run_locator_input_workflows(state: SmokeState) -> None: ) assert_equal(form_state, {"checked": True, "radio": "b", "select": "two"}, "checkbox/radio/select state") - drag = page.locator("#drag").drag_to(page.locator("#drop"), timeout=1_000) - if is_moli_browser(): - await _expect_drag_interception_boundary(drag, "Moli drag interception boundary") - assert_equal(await page.text_content("#drop"), "drop", "unsupported drag/drop does not dispatch") - else: - await drag - assert_equal(await page.text_content("#drop"), "dragged-value", "Chromium drag/drop dispatches") + await page.locator("#drag").drag_to(page.locator("#drop"), timeout=1_000) + assert_equal(await page.text_content("#drop"), "dragged-value", "drag/drop transfers the native payload") + assert_equal( + await page.evaluate("() => window.__dragEvents"), + ["start", "drop", "end"], + "drag/drop dispatches dragend on the source after drop", + ) state.record("locator_input_workflows") @@ -1809,17 +1817,6 @@ async def _expect_playwright_error(awaitable: object) -> str: raise SmokeError("expected Playwright command to fail") -async def _expect_drag_interception_boundary(awaitable: object, label: str) -> str: - error = await _expect_playwright_error(awaitable) - explicit_unsupported = "Input.setInterceptDrags" in error and "not supported" in error - bounded_drag_interception = ( - "Timeout 1000ms exceeded" in error and "performing move and up action" in error - ) - if not explicit_unsupported and not bounded_drag_interception: - raise SmokeError(f"{label}: expected drag interception boundary, got: {error}") - return error - - async def run_selector_eval_and_handle_conversion_workflows(state: SmokeState) -> None: # Reduced from Playwright eval-on-selector*.spec.ts, # elementhandle-content-frame.spec.ts, and jshandle-as-element.spec.ts. diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/drag_interception.py b/moli-cdp-smoke/moli_cdp_smoke/groups/drag_interception.py new file mode 100644 index 0000000000..8360a7ef65 --- /dev/null +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/drag_interception.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import asyncio +from contextlib import AsyncExitStack + +from ..assertions import assert_equal +from ..diagnostics import new_cdp_session +from ..helpers import capture_layout +from . import SmokeState +from .drag_protocol_contracts import run_drag_protocol_contracts + + +async def run_drag_interception_workflows(state: SmokeState) -> None: + page = state.page + await page.set_content( + """ + +
source
target
+ + """ + ) + await capture_layout(page) + + await page.locator("#source").drag_to(page.locator("#target"), timeout=5_000) + events = await page.evaluate("() => window.__dragLog") + assert_equal([event["type"] for event in events if event["type"] in ("dragstart", "drop", "dragend")], + ["dragstart", "drop", "dragend"], "Playwright dragTo completes the native source lifecycle") + assert_equal(next(event for event in events if event["type"] == "drop")["data"], + "native-payload", "Playwright dragTo transfers the native payload") + await page.evaluate("() => window.__dragLog = []") + state.record("locator_native_drag_to") + + async with AsyncExitStack() as cleanup: + first = await new_cdp_session(page.context, page) + cleanup.push_async_callback(first.detach) + second = await new_cdp_session(page.context, page) + cleanup.push_async_callback(second.detach) + intercepted: dict[str, list[dict]] = {"first": [], "second": []} + ready = {"first": asyncio.Event(), "second": asyncio.Event()} + + def capture(name: str, event: dict) -> None: + intercepted[name].append(event) + ready[name].set() + + first.on("Input.dragIntercepted", lambda event: capture("first", event)) + second.on("Input.dragIntercepted", lambda event: capture("second", event)) + await first.send("Input.setInterceptDrags", {"enabled": True}) + await second.send("Input.setInterceptDrags", {"enabled": False}) + + async def mouse(session, kind: str, x: float, y: float, buttons: int) -> None: + await session.send("Input.dispatchMouseEvent", { + "type": kind, "x": x, "y": y, "buttons": buttons, + "button": "none" if kind == "mouseMoved" and not buttons else "left", + }) + + async def log() -> list[dict]: + return await page.evaluate("() => window.__dragLog") + + async def start(session, name: str) -> dict: + ready[name].clear() + await page.evaluate("() => window.__dragLog = []") + await mouse(session, "mousePressed", 40, 40, 1) + await mouse(session, "mouseMoved", 50, 50, 1) + await asyncio.wait_for(ready[name].wait(), timeout=5) + return intercepted[name][-1]["data"] + + async def replay(session, kind: str, data: dict) -> None: + await session.send("Input.dispatchDragEvent", { + "type": kind, "x": 250, "y": 40, "data": data, + }) + + # A plain element cannot start a native drag merely by moving the mouse. + await mouse(first, "mousePressed", 40, 160, 1) + await mouse(first, "mouseMoved", 90, 160, 1) + await mouse(first, "mouseReleased", 90, 160, 0) + assert_equal(await log(), [], "ordinary held mouse movement does not start a drag") + assert_equal(intercepted["first"], [], "ordinary movement is not intercepted") + + # Movement inside the threshold preserves the candidate for a later move. + await mouse(first, "mousePressed", 40, 40, 1) + await mouse(first, "mouseMoved", 41, 41, 1) + assert_equal(await log(), [], "sub-threshold movement does not start a drag") + assert_equal(intercepted["first"], [], "sub-threshold movement is not intercepted") + await mouse(first, "mouseMoved", 50, 50, 1) + await asyncio.wait_for(ready["first"].wait(), timeout=5) + data = intercepted["first"][-1]["data"] + # Chromium encodes all operations as -1; Moli uses the supported bits 19. + assert_equal(data["dragOperationsMask"] & 19, 19, "native default allows copy/link/move") + starts = [event for event in await log() if event["type"] == "dragstart"] + assert_equal(len(starts), 1, "threshold crossing starts one native drag") + assert_equal(starts[0]["allowed"], "uninitialized", "native effectAllowed starts uninitialized") + assert_equal(starts[0]["data"], "native-payload", "dragstart has read/write data access") + assert_equal(intercepted["second"], [], "interception event stays with the enabled session") + await replay(first, "dragCancel", data) + await mouse(first, "mouseReleased", 50, 50, 0) + assert_equal( + [event["type"] for event in await log() if event["type"] in ("dragstart", "dragend")], + ["dragstart", "dragend"], "cancel ends the source once", + ) + + data = await start(first, "first") + for kind in ("dragEnter", "dragOver", "drop"): + await replay(first, kind, data) + events = await log() + assert_equal( + [event["type"] for event in events if event["type"] in ("dragstart", "drop", "dragend")], + ["dragstart", "drop", "dragend"], "accepted replay completes the source lifecycle", + ) + for event in events: + if event["type"] in ("dragenter", "dragover"): + assert_equal(event["data"], "", f"{event['type']} protects the payload") + assert_equal(event["types"], ["text/plain"], f"{event['type']} exposes types") + assert_equal(next(event for event in events if event["type"] == "drop")["data"], + "native-payload", "drop can read the original payload") + await mouse(first, "mouseReleased", 250, 40, 0) + + data = await start(first, "first") + data = {**data, "items": [{"mimeType": "text/plain", "data": "client-payload"}]} + for kind in ("dragEnter", "drop"): + await replay(first, kind, data) + events = await log() + assert_equal(next(event for event in events if event["type"] == "drop")["data"], + "client-payload", "replay consumes the client's explicit payload") + assert_equal([event["type"] for event in events if event["type"] == "dragend"], + ["dragend"], "replacing replay data still ends the source once") + await mouse(first, "mouseReleased", 250, 40, 0) + + # Drop performs a fresh dragover; a target that refuses it must see no drop. + await page.evaluate("() => window.__acceptDrop = false") + data = await start(first, "first") + await replay(first, "dragEnter", data) + await replay(first, "drop", data) + events = await log() + assert_equal([event for event in events if event["type"] == "drop"], [], + "a refusing target receives no drop") + assert_equal([event["effect"] for event in events if event["type"] == "dragend"], + ["none"], "a rejected drop ends with no operation") + await mouse(first, "mouseReleased", 250, 40, 0) + + # Disable one session without overriding the other session's configuration. + await second.send("Input.setInterceptDrags", {"enabled": True}) + await first.send("Input.setInterceptDrags", {"enabled": False}) + first_count = len(intercepted["first"]) + data = await start(second, "second") + assert_equal(len(intercepted["first"]), first_count, "other session receives no interception event") + await replay(second, "dragCancel", data) + await mouse(second, "mouseReleased", 50, 50, 0) + await second.send("Input.setInterceptDrags", {"enabled": False}) + state.record("drag_interception_lifecycle_and_session_isolation") + await run_native_text_drop_workflows(state) + await run_drag_protocol_contracts(state) + + +async def run_native_text_drop_workflows(state: SmokeState) -> None: + page = state.page + cases = [ + ("", "payload", "uninitialized"), + ("", "payload", "uninitialized"), + ("
", "payload", "uninitialized"), + ("", "", "uninitialized"), + ("", "", "uninitialized"), + ("", "", "uninitialized"), + ("
", "", "uninitialized"), + ] + for markup in ("", "", + "
"): + cases.extend([(markup, "payload", "copyMove"), (markup, "", "linkMove")]) + for markup, expected, allowed in cases: + label = f"{markup}, effectAllowed={allowed}" + await page.set_content( + """
source
""" + markup + """""" + ) + await capture_layout(page) + await page.locator("#source").drag_to(page.locator("#target"), timeout=5_000) + result = await page.evaluate("""() => { + const target=document.getElementById('target'); + return {events:window.__defaultDropLog, value:target.value ?? target.textContent}; + }""") + assert_equal(result["value"], expected, f"default drop into {label}") + drops = [event for event in result["events"] if event["type"] == "drop"] + assert_equal(len(drops), int(bool(expected)), f"default acceptance for {label}") + assert_equal([event["effect"] for event in result["events"] if event["type"] == "dragend"], + ["copy" if expected else "none"], f"source finishes with the default operation for {label}") + for event in result["events"]: + if event["type"] == "dragover": + assert_equal(event["canceled"], False, f"author does not accept dragover for {label}") + assert_equal(event["data"], "", f"dragover protects default drop data for {label}") + state.record("native_text_drop_default_acceptance") diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/drag_protocol_contracts.py b/moli-cdp-smoke/moli_cdp_smoke/groups/drag_protocol_contracts.py new file mode 100644 index 0000000000..66ccf3a006 --- /dev/null +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/drag_protocol_contracts.py @@ -0,0 +1,335 @@ +"""Drag payload, event-view, and pointer contracts checked against Chromium.""" + +from __future__ import annotations + +import asyncio +from contextlib import AsyncExitStack, asynccontextmanager +from pathlib import Path +from tempfile import TemporaryDirectory + +from ..assertions import assert_equal +from ..diagnostics import new_cdp_session +from ..helpers import capture_layout +from . import SmokeState + + +_BOARD = """
source
A
B
""" + +_INSTALL = """options => { + const contract = window.__dragContract = {events:[], views:[], stringCallbacks:[]}; + const identities = new WeakMap(); + let nextIdentity = 1; + const identity = value => { + if (!identities.has(value)) identities.set(value, nextIdentity++); + return identities.get(value); + }; + const node = value => value ? (value.id || value.nodeName) : null; + const oldView = view => ({ + type:view.type, data:view.dt.getData('text/plain'), types:Array.from(view.dt.types), + itemCount:view.items.length, fileNames:Array.from(view.files,file=>file.name), + savedTypes:Array.from(view.types), kind:view.item?.kind ?? null, + fileKind:view.fileItem?.kind ?? null, file:view.fileItem?.getAsFile()?.name ?? null, + }); + contract.sourceState = () => { + const view=contract.sourceView; + if(!view) return null; + return {data:view.dt.getData('text/plain'),nativeData:view.dt.getData('application/x-native'), + types:Array.from(view.dt.types),items:Array.from(view.items,item=>({kind:item.kind,type:item.type})), + files:Array.from(view.dt.files,file=>file.name),savedTypes:Array.from(view.types), + savedItem:{kind:view.item.kind,type:view.item.type}, + savedFile:view.fileItem ? {kind:view.fileItem.kind,type:view.fileItem.type, + name:view.fileItem.getAsFile()?.name ?? null} : null}; + }; + for (const type of ['dragstart','drag','dragenter','dragover','dragleave','drop','dragend']) { + document.addEventListener(type, event => { + const dt=event.dataTransfer; + if(type==='dragstart') { + dt.setData('text/plain','native-secret'); + dt.setData('application/x-native','native-only'); + if(options.sourceFile) dt.items.add(new File(['native-file-payload'],'source-file.txt',{type:'text/plain'})); + dt.effectAllowed='copyMove'; + } + if(type==='dragover' && ['a','b'].includes(event.target.id)) event.preventDefault(); + if(type==='drop') event.preventDefault(); + const view={type,dt,items:dt.items,types:dt.types,files:dt.files,item:dt.items[0], + fileItem:Array.from(dt.items).find(item=>item.kind==='file')}; + if(type==='dragstart') contract.sourceView=view; + const record={type,target:node(event.target),relatedTarget:node(event.relatedTarget), + cancelable:event.cancelable,dt:identity(dt),data:dt.getData('text/plain'), + types:Array.from(dt.types),allowed:dt.effectAllowed,effect:dt.dropEffect}; + if(contract.sourceView) record.source=contract.sourceState(); + if(type==='drop') { + record.oldViews=contract.views.filter(view=>['dragenter','dragover'].includes(view.type)).map(oldView); + contract.dropFiles=Array.from(dt.files); + contract.dropView=view; + if(options.strings) { + for(const previous of contract.views) if(previous.item) previous.item.getAsString(data => + contract.stringCallbacks.push({type:'old:'+previous.type,data})); + contract.dropString = new Promise(resolve => { + view.item.getAsString(data => resolve({data,kind:view.item.kind,type:view.item.type})); + }); + } + } + if(options.strings && type!=='drop' && view.item) view.item.getAsString(data => + contract.stringCallbacks.push({type,data})); + contract.views.push(view); + contract.events.push(record); + }); + } + for (const type of ['pointerdown','pointermove','pointerup','pointercancel', + 'gotpointercapture','lostpointercapture','mousedown','mousemove','mouseup']) { + document.addEventListener(type,event => { + contract.events.push({type,target:node(event.target),buttons:event.buttons,cancelable:event.cancelable}); + if(options.capture && type==='pointerdown' && event.target.id==='source') + event.target.setPointerCapture(event.pointerId); + }); + } +}""" + + +def _data(value: str | None, mask: int = 1, mime: str = "text/plain") -> dict: + return { + "items": [] if value is None else [{"mimeType": mime, "data": value}], + "dragOperationsMask": mask, + } + + +async def _mouse(session, kind: str, x: int, y: int, buttons: int) -> None: + await session.send("Input.dispatchMouseEvent", { + "type": kind, "x": x, "y": y, "buttons": buttons, + "button": "none" if kind == "mouseMoved" and not buttons else "left", + }) + + +async def _drag(session, kind: str, data: dict, x: int = 250) -> None: + await session.send("Input.dispatchDragEvent", {"type": kind, "x": x, "y": 40, "data": data}) + + +async def _events(page) -> list[dict]: + return await page.evaluate("() => window.__dragContract.events") + + +async def _drag_step(page, session, kind: str, data: dict, x: int = 250) -> list[dict]: + start = len(await _events(page)) + await _drag(session, kind, data, x) + return [event for event in (await _events(page))[start:] + if event["type"] in ("dragenter", "dragover", "dragleave", "drop")] + + +@asynccontextmanager +async def _board(state: SmokeState, *, native: bool = True, capture: bool = False, + strings: bool = False, source_file: bool = False, page=None): + page = state.page if page is None else page + await page.set_content(_BOARD) + await page.evaluate(_INSTALL, {"capture": capture, "strings": strings, "sourceFile": source_file}) + await capture_layout(page) + session = await new_cdp_session(page.context, page) + intercepted = [] + ready = asyncio.Event() + + def on_intercepted(event: dict) -> None: + intercepted.append(event) + ready.set() + + session.on("Input.dragIntercepted", on_intercepted) + try: + await session.send("Input.setInterceptDrags", {"enabled": native}) + if native: + await _mouse(session, "mouseMoved", 40, 40, 0) + await _mouse(session, "mousePressed", 40, 40, 1) + await _mouse(session, "mouseMoved", 55, 50, 1) + await asyncio.wait_for(ready.wait(), timeout=5) + yield session, intercepted + finally: + # dragCancel really ends intercepted native drags in Chromium. Its + # cancelDragging command can leave that particular source pending. + await _drag(session, "dragCancel", _data(None)) + await _mouse(session, "mouseReleased", 250, 40, 0) + await session.send("Input.setInterceptDrags", {"enabled": False}) + await session.detach() + + +async def _payload_contracts(state: SmokeState) -> None: + page = state.page + async with _board(state) as (session, intercepted): + assert_equal(intercepted[0]["data"]["items"][0]["data"], "native-secret", "source interception payload") + enter = await _drag_step(page, session, "dragEnter", _data(None)) + assert_equal([(event["type"], event["types"]) for event in enter], + [("dragenter", [])], "an explicitly empty enter replaces the native secret") + drop = await _drag_step(page, session, "drop", _data("drop-replacement")) + assert_equal([(event["type"], event["types"], event["data"]) for event in drop], + [("dragover", [], ""), ("drop", ["text/plain"], "drop-replacement")], + "drop pre-over keeps enter data; drop consumes its own payload") + + async with _board(state) as (session, _): + await _drag_step(page, session, "dragEnter", _data("enter-secret")) + over = await _drag_step(page, session, "dragOver", _data("ignored-over", 16, "application/x-over")) + assert_equal([(event["type"], event["types"], event["allowed"]) for event in over], + [("dragover", ["text/plain"], "move")], "over updates mask without replacing enter items") + drop = await _drag_step(page, session, "drop", _data("drop-replacement", 1)) + assert_equal([(event["type"], event["types"], event["allowed"], event["data"]) for event in drop], + [("dragover", ["text/plain"], "copy", ""), + ("drop", ["text/plain"], "copy", "drop-replacement")], + "drop updates the pre-over mask and replaces data only for drop") + + async with _board(state) as (session, _): + await _drag_step(page, session, "dragEnter", _data("enter-secret")) + drop = await _drag_step(page, session, "drop", _data(None)) + assert_equal([(event["type"], event["types"], event["data"]) for event in drop], + [("dragover", ["text/plain"], ""), ("drop", [], "")], + "an explicitly empty drop cannot reuse enter payload") + state.record("drag_command_payload_and_operation_replacement") + + +async def _view_contracts(state: SmokeState) -> None: + page = state.page + source_types = ["text/plain", "application/x-native", "Files"] + protected_source = { + "data": "", "nativeData": "", "types": source_types, + "items": [{"kind": "string", "type": "text/plain"}, + {"kind": "string", "type": "application/x-native"}, + {"kind": "file", "type": "text/plain"}], + "files": [], "savedTypes": source_types, + "savedItem": {"kind": "string", "type": "text/plain"}, + "savedFile": {"kind": "file", "type": "text/plain", "name": None}, + } + async with _board(state, source_file=True) as (session, _): + assert_equal(await page.evaluate("() => window.__dragContract.sourceState()"), protected_source, + "the retained source view exposes metadata but protects data and files after dragstart") + packet = _data("target-data", 17) + for kind in ("dragEnter", "dragOver", "drop"): + await _drag_step(page, session, kind, packet) + events = await _events(page) + start = next(event for event in events if event["type"] == "dragstart") + end = next(event for event in events if event["type"] == "dragend") + target = [event for event in events if event["type"] in ("dragenter", "dragover", "drop")] + assert_equal(start["dt"], end["dt"], "source dragend retains the source DataTransfer identity") + assert_equal(end["effect"], "move", "accepted copyMove without explicit dropEffect prefers move") + assert_equal(start["source"], {**protected_source, "data": "native-secret", "nativeData": "native-only", + "files": ["source-file.txt"], + "savedFile": {"kind": "file", "type": "text/plain", "name": "source-file.txt"}}, + "the source is writable and its File is readable inside dragstart") + assert_equal(len({event["dt"] for event in target}), len(target), + "distinct target updates use distinct DataTransfer views") + assert_equal(start["dt"] in {event["dt"] for event in target}, False, + "target views do not borrow the source's writable object") + for event in events: + if "source" in event and event["type"] != "dragstart": + assert_equal(event["source"], protected_source, + f"retained source metadata survives {event['type']} without exposing native data") + if event["type"] in ("dragstart", "drag", "dragend"): + assert_equal(event["cancelable"], event["type"] != "dragend", + f"source {event['type']} cancellation contract") + assert_equal(await page.evaluate("() => window.__dragContract.sourceState()"), + {"data": "", "nativeData": "", "types": [], "items": [], "files": [], + "savedTypes": source_types, "savedItem": {"kind": "", "type": ""}, + "savedFile": {"kind": "", "type": "", "name": None}}, + "the source becomes disabled only after dragend dispatch finishes") + + with TemporaryDirectory(prefix="moli-drag-views-") as directory: + file = Path(directory) / "payload.txt" + file.write_text("retained-file-payload", encoding="utf-8") + async with _board(state, native=False, strings=True) as (session, _): + packet = {**_data("enter-data"), "files": [str(file)]} + for kind in ("dragEnter", "dragOver"): + await _drag_step(page, session, kind, packet) + await _drag_step(page, session, "drop", {**packet, "items": _data("drop-data")["items"]}) + callback = await page.evaluate("async () => await window.__dragContract.dropString") + assert_equal(callback, {"data": "drop-data", "kind": "", "type": ""}, + "queued getAsString reads its captured value after the item becomes disabled") + drop = next(event for event in await _events(page) if event["type"] == "drop") + for view in drop["oldViews"]: + assert_equal({key: view[key] for key in ("data", "types", "itemCount", "fileNames", "kind", "fileKind", "file")}, + {"data": "", "types": [], "itemCount": 0, "fileNames": [], + "kind": "", "fileKind": "", "file": None}, + "saved protected target views cannot regain access during drop") + assert_equal(view["savedTypes"], ["text/plain", "Files"], "saved types array is a snapshot") + result = await page.evaluate("""async () => { + const contract=window.__dragContract; + return {protectedCallbacks:contract.stringCallbacks, + capturedFiles:await Promise.all(contract.dropFiles.map(async file=>({name:file.name,data:await file.text()}))), + data:contract.dropView.dt.getData('text/plain'),types:Array.from(contract.dropView.dt.types), + items:contract.dropView.dt.items.length,files:contract.dropView.dt.files.length}; + }""") + assert_equal(result, {"protectedCallbacks": [], + "capturedFiles": [{"name": "payload.txt", "data": "retained-file-payload"}], + "data": "", "types": [], "items": 0, "files": 0}, + "completed stores stay disabled while captured File objects remain readable") + state.record("drag_data_transfer_view_access_and_async_callbacks") + + +async def _target_contracts(state: SmokeState) -> None: + page = state.page + async with _board(state, native=False) as (session, _): + packet = _data("target-data", 17) + steps = [] + for kind, x in (("dragEnter", 250), ("dragOver", 250), ("dragOver", 450), + ("dragOver", 250), ("drop", 250)): + steps.append(await _drag_step(page, session, kind, packet, x)) + expected = [ + [("dragenter", "a", None)], [("dragover", "a", None)], + [("dragenter", "b", "a"), ("dragleave", "a", "b")], + [("dragenter", "a", "b"), ("dragleave", "b", "a")], + [("dragover", "a", None), ("drop", "a", None)], + ] + assert_equal([[(event["type"], event["target"], event["relatedTarget"]) for event in step] for step in steps], + expected, "target changes enter new before leaving old, without an extra over") + for step in steps[2:4]: + assert_equal(step[0]["dt"], step[1]["dt"], "one target transition shares one event view") + events = [event for step in steps for event in step] + for event in events: + assert_equal(event["cancelable"], event["type"] != "dragleave", f"{event['type']} cancellation contract") + assert_equal(event["effect"], "none", "plain target event views start with no explicit dropEffect") + state.record("drag_target_transition_order_related_target_and_views") + + +async def _pointer_contracts(state: SmokeState) -> None: + async with AsyncExitStack() as cleanup: + # Give pointer capture its own Page, independent of the earlier external + # drag cases and the pointer streams those commands created. + page = await state.page.context.new_page() + cleanup.push_async_callback(page.close) + async with _board(state, capture=True, page=page) as (session, intercepted): + await page.wait_for_function( + "() => window.__dragContract.events.some(event => event.type === 'lostpointercapture')", + timeout=5_000, + ) + initial = await _events(page) + start = next(index for index, event in enumerate(initial) if event["type"] == "dragstart") + assert_equal([event["type"] for event in initial[start:start + 3]], + ["dragstart", "pointercancel", "lostpointercapture"], + "native drag cancels the pointer before releasing capture") + assert_equal([(event["buttons"], event["cancelable"]) for event in initial[start + 1:start + 3]], + [(0, False), (0, False)], "pointer cancellation and capture loss have no pressed buttons") + assert_equal(any(event["type"] == "gotpointercapture" for event in initial[:start]), True, + "the source actually acquired capture before dragging") + before = len(initial) + await _mouse(session, "mouseMoved", 60, 50, 1) + await _mouse(session, "mouseMoved", 250, 40, 1) + ordinary = [event["type"] for event in (await _events(page))[before:] + if event["type"].startswith("pointer") or event["type"] in ("mousemove", "mouseup")] + assert_equal(ordinary, [], "an active native drag suppresses the ordinary pointer/mouse stream") + await _drag(session, "dragCancel", intercepted[0]["data"]) + end = [event for event in await _events(page) if event["type"] == "dragend"] + assert_equal([(event["effect"], event["cancelable"]) for event in end], [("none", False)], + "effective cancellation ends the source exactly once") + before = len(await _events(page)) + await _mouse(session, "mouseMoved", 60, 50, 1) + await _mouse(session, "mouseReleased", 60, 50, 0) + resumed = [event["type"] for event in (await _events(page))[before:]] + assert_equal(resumed, ["pointermove", "mousemove", "pointerup", "mouseup"], + "a completed dragCancel restores ordinary input even before the held button is released") + assert_equal(len(intercepted), 1, "held movement after cancellation cannot start a second drag") + + state.record("native_drag_pointer_cancel_capture_and_stream_recovery") + + +async def run_drag_protocol_contracts(state: SmokeState) -> None: + await _payload_contracts(state) + await _view_contracts(state) + await _target_contracts(state) + await _pointer_contracts(state) diff --git a/moli-core/src/page/input_support.rs b/moli-core/src/page/input_support.rs index 0415a6b385..ae88da0019 100644 --- a/moli-core/src/page/input_support.rs +++ b/moli-core/src/page/input_support.rs @@ -95,6 +95,7 @@ pub trait PageInputExt { delta_y: f64, pointer: RendererPointerEventProperties, modifiers: u8, + intercept_drags: bool, ) -> Result; fn finish_dispatch_mouse_event_at_point_with_outcome( &mut self, @@ -161,6 +162,7 @@ pub trait PageInputExt { ) -> Result; async fn clear_active_drag_data_transfer_async(&mut self) -> Result<()>; fn start_clear_active_drag_data_transfer(&self) -> Result; + fn start_cancel_dragging(&self) -> Result; fn finish_clear_active_drag_data_transfer( &mut self, completion: CompletedPageCommand, @@ -261,6 +263,7 @@ impl PageInputExt for Page { delta_y, RendererPointerEventProperties::default(), 0, + false, ) } @@ -276,6 +279,7 @@ impl PageInputExt for Page { delta_y: f64, pointer: RendererPointerEventProperties, modifiers: u8, + intercept_drags: bool, ) -> Result { self.start_page_command(RendererPageCommand::DispatchMouseEventAtPoint { x, @@ -288,6 +292,7 @@ impl PageInputExt for Page { delta_y, pointer, modifiers, + intercept_drags, }) } @@ -413,6 +418,10 @@ impl PageInputExt for Page { self.start_page_command(RendererPageCommand::ClearActiveDragDataTransfer) } + fn start_cancel_dragging(&self) -> Result { + self.start_page_command(RendererPageCommand::CancelDragging) + } + fn finish_clear_active_drag_data_transfer( &mut self, completion: CompletedPageCommand, diff --git a/moli-core/src/page/mod.rs b/moli-core/src/page/mod.rs index 204b6ed94c..80466c9d58 100644 --- a/moli-core/src/page/mod.rs +++ b/moli-core/src/page/mod.rs @@ -106,7 +106,7 @@ pub use moli_renderer_v8::{ RendererDomFocusOutcome, RendererDomMutationEvent, RendererDomMutationEventBatch, RendererDomSnapshotCaptureOptions, RendererDomSnapshotCapturePayload, RendererDragData, RendererDragDataItem, RendererDraggedDirectory, RendererDraggedFile, RendererFrameToken, - RendererInputDispatchOutcome, RendererInspectorCommandRoute, + RendererInputDispatchOutcome, RendererInspectorCommandRoute, RendererInterceptedDrag, RendererJavaScriptDialogCompletion, RendererJavaScriptDialogId, RendererJavaScriptDialogResult, RendererJavaScriptDialogSource, RendererLayoutMetrics, RendererLifecycleEpoch, RendererLifecycleEventStamp, RendererLifecycleStartReason, RendererLifecycleTerminationStamp, @@ -417,6 +417,7 @@ impl Page { delta_y, pointer: RendererPointerEventProperties::default(), modifiers: 0, + intercept_drags: false, }; let reply = self.dispatch_page_command_async(command).await?; expect_page_reply!( diff --git a/moli-file-api/src/data_transfer.rs b/moli-file-api/src/data_transfer.rs index 15b414ce13..69fbfde565 100644 --- a/moli-file-api/src/data_transfer.rs +++ b/moli-file-api/src/data_transfer.rs @@ -3,7 +3,7 @@ const DRAG_MODIFIER_CTRL: u8 = 2; const DRAG_MODIFIER_META: u8 = 4; const DRAG_MODIFIER_SHIFT: u8 = 8; -/// Permissions granted to a DataTransfer view of a native data store. +/// Access granted to a drag data store while a native drag event is dispatched. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DragDataStoreMode { ReadWrite, @@ -86,12 +86,16 @@ pub fn drag_operations_mask_from_effect_allowed(effect_allowed: &str) -> i32 { } pub fn preferred_drop_effect_from_mask(mask: i32) -> &'static str { - if (mask & 1) != 0 { + // Chromium prefers move for constrained source operations, but uses copy + // for all/uninitialized rather than treating that mask as move-first. + if (mask & 19) == 19 { + "copy" + } else if (mask & 16) != 0 { + "move" + } else if (mask & 1) != 0 { "copy" } else if (mask & 2) != 0 { "link" - } else if (mask & 16) != 0 { - "move" } else { "none" } @@ -219,6 +223,21 @@ pub fn child_entry_full_path(parent_path: Option<&str>, name: &str) -> String { mod tests { use super::*; + #[test] + fn effect_allowed_round_trips_through_drag_operations_mask() { + for effect in [ + "none", "copy", "link", "move", "copyLink", "copyMove", "linkMove", "all", + ] { + let mask = drag_operations_mask_from_effect_allowed(effect); + assert_eq!(drag_effect_allowed_from_mask(mask), effect); + } + assert_eq!( + drag_operations_mask_from_effect_allowed("uninitialized"), + 19 + ); + assert_eq!(drag_operations_mask_from_effect_allowed("bogus"), 0); + } + #[test] fn normalizes_legacy_drag_data_type_aliases() { assert_eq!(normalize_drag_data_type(" text "), "text/plain"); @@ -242,7 +261,8 @@ mod tests { assert_eq!(drag_effect_allowed_from_mask(1 | 2 | 16), "all"); assert_eq!(preferred_drop_effect_from_mask(1 | 2 | 16), "copy"); - assert_eq!(preferred_drop_effect_from_mask(2 | 16), "link"); + assert_eq!(preferred_drop_effect_from_mask(2 | 16), "move"); + assert_eq!(preferred_drop_effect_from_mask(1 | 16), "move"); assert_eq!(preferred_drop_effect_from_mask(16), "move"); assert_eq!(preferred_drop_effect_from_mask(0), "none"); } diff --git a/moli-protocol/src/conn/dispatch_tests/command_dispatch.rs b/moli-protocol/src/conn/dispatch_tests/command_dispatch.rs index 9a2cd19e1d..4801f3d6a0 100644 --- a/moli-protocol/src/conn/dispatch_tests/command_dispatch.rs +++ b/moli-protocol/src/conn/dispatch_tests/command_dispatch.rs @@ -2128,18 +2128,11 @@ fn command_dispatch_completes_input_owner_commands_without_legacy_fallback() { let step = conn.start_command_dispatch(&raw); assert_eq!( complete_messages(step), - vec![json!({ - "id": 1001, - "error": { - "code": -32000, - "message": crate::domains::input::SET_INTERCEPT_DRAGS_UNSUPPORTED_MESSAGE - } - })] + vec![json!({ "id": 1001, "result": {} })] ); assert!( - conn.browser_context - .as_ref() - .is_some_and(|context| !context.active_page_target().input_intercept_drags_enabled) + conn.target_page_session_state_for_session(None) + .is_some_and(|state| state.input_intercept_drags_enabled) ); let raw = serde_json::to_string(&json!({ diff --git a/moli-protocol/src/conn/state/page_target_host.rs b/moli-protocol/src/conn/state/page_target_host.rs index 1b2b244da4..73ebabb692 100644 --- a/moli-protocol/src/conn/state/page_target_host.rs +++ b/moli-protocol/src/conn/state/page_target_host.rs @@ -51,8 +51,6 @@ pub struct PageTargetHost { pub(crate) tls_verify_host_override: Option, pub(crate) environment_owner: moli_core::ProcessEnvironmentOwner, pub(crate) effective_emulation_state: EffectiveTargetEmulationState, - pub(crate) input_intercept_drags_enabled: bool, - pub(crate) input_drag_intercepted: bool, pub(crate) css_enabled: bool, pub(crate) document_cookie_manager_surface: BrowserContextCookieManagerSurface, pub(crate) dom_remote_object_node_cache: HashMap, @@ -78,8 +76,6 @@ impl PageTargetHost { tls_verify_host_override: None, environment_owner: Default::default(), effective_emulation_state: EffectiveTargetEmulationState::default(), - input_intercept_drags_enabled: false, - input_drag_intercepted: false, css_enabled: false, document_cookie_manager_surface: BrowserContextCookieManagerSurface::default(), dom_remote_object_node_cache: HashMap::new(), diff --git a/moli-protocol/src/conn/state/session.rs b/moli-protocol/src/conn/state/session.rs index af43204906..717b3dece5 100644 --- a/moli-protocol/src/conn/state/session.rs +++ b/moli-protocol/src/conn/state/session.rs @@ -182,8 +182,6 @@ impl PageTargetHost { || self.tls_verify_host_override.is_some() || self.environment_owner.has_override() || self.effective_emulation_state != super::EffectiveTargetEmulationState::default() - || self.input_intercept_drags_enabled - || self.input_drag_intercepted || self.css_enabled || self.fetch_owner.config_snapshot() != super::fetch::TargetFetchConfig::default() } @@ -198,8 +196,6 @@ impl PageTargetHost { self.http_proxy_override = None; self.http_no_proxy_override = None; self.tls_verify_host_override = None; - self.input_intercept_drags_enabled = false; - self.input_drag_intercepted = false; self.css_enabled = false; } } @@ -430,6 +426,7 @@ impl PageScreencastSessionState { #[derive(Debug, Clone)] pub(crate) struct TargetPageSessionState { pub(crate) input_events_ignored: bool, + pub(crate) input_intercept_drags_enabled: bool, pub(crate) page_domain_enabled: bool, pub(crate) page_domain_subscription_generation: u64, pub(crate) page_lifecycle_events: bool, @@ -448,6 +445,7 @@ impl Default for TargetPageSessionState { fn default() -> Self { Self { input_events_ignored: false, + input_intercept_drags_enabled: false, page_domain_enabled: false, page_domain_subscription_generation: 0, page_lifecycle_events: false, @@ -493,6 +491,7 @@ impl TargetPageSessionState { impl PartialEq for TargetPageSessionState { fn eq(&self, other: &Self) -> bool { self.input_events_ignored == other.input_events_ignored + && self.input_intercept_drags_enabled == other.input_intercept_drags_enabled && self.page_domain_enabled == other.page_domain_enabled && self.page_lifecycle_events == other.page_lifecycle_events && self.audits == other.audits diff --git a/moli-protocol/src/conn/state/tests.rs b/moli-protocol/src/conn/state/tests.rs index 69293a8dc9..b761b973ea 100644 --- a/moli-protocol/src/conn/state/tests.rs +++ b/moli-protocol/src/conn/state/tests.rs @@ -116,6 +116,7 @@ fn page_domain_subscription_generation_tracks_distinct_enable_lifetimes() { fn input_event_ignore_state_survives_document_context_replacement() { let mut state = TargetPageSessionState { input_events_ignored: true, + input_intercept_drags_enabled: true, ..Default::default() }; @@ -125,6 +126,10 @@ fn input_event_ignore_state_survives_document_context_replacement() { state.input_events_ignored, "Chromium retains each Inspector session's input ignore handle across renderer navigation" ); + assert!( + state.input_intercept_drags_enabled, + "drag interception belongs to the DevTools session, not the replaced Document" + ); } #[test] diff --git a/moli-protocol/src/conn/state_access/input.rs b/moli-protocol/src/conn/state_access/input.rs index 0e8da9b85f..9edb3c8850 100644 --- a/moli-protocol/src/conn/state_access/input.rs +++ b/moli-protocol/src/conn/state_access/input.rs @@ -12,6 +12,19 @@ use moli_core::page::{ }; impl ProtocolWorkflowState<'_> { + pub(crate) fn input_page_is_current( + &self, + owner: &CommandOwnerScope, + page_owner: &TargetPageResidenceIdentity, + host: Option<&crate::conn::ProtocolPageHostScope>, + ) -> bool { + host.is_none_or(|host| host.is_current(self.connection)) + && self + .connection + .target_page_residence_identity_for_owner(owner) + .as_ref() + == Some(page_owner) + } pub(crate) fn settle_input_page_command( self, owner: &TargetPageResidenceIdentity, @@ -31,13 +44,7 @@ impl ProtocolWorkflowState<'_> { host: Option<&crate::conn::ProtocolPageHostScope>, activation: RendererPendingDownloadActivation, ) -> Result> { - if host.is_some_and(|host| !host.is_current(self.connection)) - || self - .connection - .target_page_residence_identity_for_owner(owner) - .as_ref() - != Some(page_owner) - { + if !self.input_page_is_current(owner, page_owner, host) { return Ok(None); } self.connection diff --git a/moli-protocol/src/domains/input.rs b/moli-protocol/src/domains/input.rs index 2fa31e4755..c9605c5d33 100644 --- a/moli-protocol/src/domains/input.rs +++ b/moli-protocol/src/domains/input.rs @@ -55,8 +55,6 @@ pub(crate) const DISPATCH_TOUCH_EVENT_UNSUPPORTED_MESSAGE: &str = "Input.dispatc pub(crate) const EMULATE_TOUCH_FROM_MOUSE_EVENT_UNSUPPORTED_MESSAGE: &str = "Input.emulateTouchFromMouseEvent is not supported: coordinate-based touch emulation requires layout hit testing."; pub(crate) const SYNTHESIZE_TAP_GESTURE_UNSUPPORTED_MESSAGE: &str = "Input.synthesizeTapGesture is not supported: coordinate-based tap gestures require layout hit testing."; pub(crate) const DISPATCH_DRAG_EVENT_UNSUPPORTED_MESSAGE: &str = "Input.dispatchDragEvent is not supported: coordinate-based drag input requires layout hit testing."; -pub(crate) const SET_INTERCEPT_DRAGS_UNSUPPORTED_MESSAGE: &str = - "Input.setInterceptDrags is not supported: drag interception events are not implemented."; #[derive(Deserialize)] struct SetIgnoreInputEventsParams { @@ -129,6 +127,8 @@ struct PendingInputCommandStartError { enum InputAdmissionError { #[error("InvalidParams")] InvalidParams, + #[error("NoBrowserContext")] + NoBrowserContext, #[error("{0}")] Unsupported(&'static str), #[error("UnsupportedDevToolsCommand")] @@ -145,6 +145,7 @@ enum PendingInputCommandKind { DispatchKeyEvent, DispatchTouchEvent, DispatchDragEvent, + CancelDragging, SynthesizeTapGesture, InsertText, } @@ -321,7 +322,7 @@ pub(crate) fn try_start_input_command_dispatch( }; match action { InputAction::CancelDragging => { - InputCommandDispatchStep::Complete(drag::cancel_dragging_command_output_plan(conn, cmd)) + start_input_command_dispatch_after_noop_checks(conn, cmd, action) } InputAction::SetInterceptDrags => InputCommandDispatchStep::Complete( drag::set_intercept_drags_command_output_plan(conn, cmd), @@ -675,9 +676,27 @@ fn start_pending_input_command( |page| page.start_insert_text_into_active_control(&text), ) } - InputAction::CancelDragging - | InputAction::SetIgnoreInputEvents - | InputAction::SetInterceptDrags => Ok(None), + InputAction::CancelDragging => { + let slot = conn + .runtime_session_owner_slot_for_owner(&owner) + .ok_or_else(|| { + PendingInputCommandStartError::renderer_error( + InputAdmissionError::NoBrowserContext, + ) + })?; + if slot.loaded_page().is_none() { + // An attached target with no renderer cannot own an active drag. + return Ok(None); + } + start_page_input_command( + conn, + cmd.id, + &owner, + PendingInputCommandKind::CancelDragging, + |page| page.start_cancel_dragging(), + ) + } + InputAction::SetIgnoreInputEvents | InputAction::SetInterceptDrags => Ok(None), } } @@ -1126,6 +1145,7 @@ fn start_devtools_dispatch_mouse_event_command( tilt_y: command.tilt_y, twist: command.twist, }; + let intercept_drags = drag::intercept_drags_enabled(conn, owner); start_page_input_command( conn, command_id, @@ -1143,6 +1163,7 @@ fn start_devtools_dispatch_mouse_event_command( command.delta_y, pointer, command.modifiers, + intercept_drags, ) }, ) @@ -1426,6 +1447,7 @@ async fn complete_input_operation( PendingInputCommandKind::DispatchTouchEvent | PendingInputCommandKind::SynthesizeTapGesture => "touch event page command", PendingInputCommandKind::DispatchDragEvent => "drag event page command", + PendingInputCommandKind::CancelDragging => "cancel dragging page command", PendingInputCommandKind::DispatchKeyEvent => "key event page command", _ => unreachable!(), }; @@ -1669,6 +1691,12 @@ async fn handle_input_dispatch_outcome( .await; } } + if let Some(drag) = outcome.intercepted_drag + && driver.with_state(|state| state.input_page_is_current(&action_owner, owner, host))? + { + out.events_mut() + .push(drag::drag_intercepted_event(session_id, &drag)); + } // A navigation triggered by input is frozen by the renderer command turn // into a concrete owner-action record. Protocol ingress schedules that // action independently; this command path must not rescan mutable Page diff --git a/moli-protocol/src/domains/input/drag.rs b/moli-protocol/src/domains/input/drag.rs index f792b54337..5aba88f32c 100644 --- a/moli-protocol/src/domains/input/drag.rs +++ b/moli-protocol/src/domains/input/drag.rs @@ -1,14 +1,20 @@ use super::*; +use crate::conn::build_event; +use moli_core::page::RendererInterceptedDrag; +use serde_json::{Value, json}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SetInterceptDragsParams { - #[serde(default)] enabled: bool, } +/// Chromium reports a drag that starts while interception is on through +/// `Input.dragIntercepted` and leaves the rest of the drag to the client, which +/// replays it with `Input.dispatchDragEvent`. The flag set here travels with +/// every mouse event to the renderer, which decides whether `dragstart` ran. pub(super) fn set_intercept_drags_command_output_plan( - _conn: &mut CdpConnection, + conn: &mut CdpConnection, cmd: &Cmd<'_>, ) -> CommandOutputPlan { let params: SetInterceptDragsParams = match cmd.get_params() { @@ -17,18 +23,49 @@ pub(super) fn set_intercept_drags_command_output_plan( return CommandOutputPlan::error(-32602, "InvalidParams"); } }; - let _ = params.enabled; - CommandOutputPlan::error(-32000, SET_INTERCEPT_DRAGS_UNSUPPORTED_MESSAGE) -} - -pub(super) fn cancel_dragging_command_output_plan( - conn: &mut CdpConnection, - cmd: &Cmd<'_>, -) -> CommandOutputPlan { - if !conn.mutate_target_page_state_for_session(cmd.session_id, |state| { - state.input_drag_intercepted = false; - }) { + if conn + .with_target_devtools_session_state_for_session_mut(cmd.session_id, |state| { + state.page_session_state.input_intercept_drags_enabled = params.enabled; + }) + .is_none() + { return CommandOutputPlan::error(-32000, "NoBrowserContext"); } CommandOutputPlan::success() } + +pub(super) fn intercept_drags_enabled(conn: &CdpConnection, owner: &CommandOwnerScope) -> bool { + conn.target_page_session_state_for_owner(owner) + .is_some_and(|state| state.input_intercept_drags_enabled) +} + +/// Builds the `Input.dragIntercepted` event for a drag the renderer started. +pub(super) fn drag_intercepted_event( + session_id: Option<&str>, + drag: &RendererInterceptedDrag, +) -> BackgroundProtocolEvent { + let items: Vec = drag + .items + .iter() + .map(|item| { + let mut value = json!({ "mimeType": item.mime_type, "data": item.data }); + if let Some(title) = &item.title { + value["title"] = json!(title); + } + if let Some(base_url) = &item.base_url { + value["baseURL"] = json!(base_url); + } + value + }) + .collect(); + BackgroundProtocolEvent::immediate(build_event( + "Input.dragIntercepted", + json!({ + "data": { + "items": items, + "dragOperationsMask": drag.drag_operations_mask, + } + }), + session_id, + )) +} diff --git a/moli-protocol/src/domains/input/tests.rs b/moli-protocol/src/domains/input/tests.rs index f084b07352..054eccba03 100644 --- a/moli-protocol/src/domains/input/tests.rs +++ b/moli-protocol/src/domains/input/tests.rs @@ -458,6 +458,37 @@ async fn coordinate_mouse_event_without_document_still_reports_no_document_loade ctx.expect_error(103, -32000, "NoDocumentLoaded"); } +#[tokio::test(flavor = "multi_thread")] +async fn cancel_dragging_without_a_loaded_page_preserves_idle_success() { + let mut ctx = TestContext::new(); + let mut bc = BrowserContext::new("BID-idle-drag".into()); + bc.set_active_target_id("TID-idle-drag"); + ctx.with_fixture(|conn| conn.install_browser_context_fixture_for_test(bc)); + assert!(ctx.with_fixture(|conn| { + conn.runtime_session_owner_slot(None) + .expect("target slot") + .loaded_page() + .is_none() + })); + ctx.process_async(json!({ "id": 106, "method": "Input.cancelDragging" })) + .await; + ctx.expect_result(106, json!({}), None); + assert!( + ctx.with_fixture(|conn| conn + .runtime_session_owner_slot(None) + .expect("target slot") + .loaded_page() + .is_none()), + "idle cancellation must not instantiate a Page just to clear a nonexistent drag" + ); + + let mut without_context = TestContext::new(); + without_context + .process_async(json!({ "id": 107, "method": "Input.cancelDragging" })) + .await; + without_context.expect_error(107, -32000, "NoBrowserContext"); +} + #[tokio::test(flavor = "multi_thread")] async fn coordinate_mouse_down_applies_chromium_focus_default_action() { let mut ctx = TestContext::new(); @@ -781,7 +812,7 @@ async fn mock_layout_policy_rejects_coordinate_input_without_dispatching() { } #[tokio::test(flavor = "multi_thread")] -async fn set_intercept_drags_fails_explicitly_without_drag_interception_events() { +async fn set_intercept_drags_toggles_only_session_state() { let mut ctx = TestContext::new(); with_loaded_document( &mut ctx, @@ -789,54 +820,464 @@ async fn set_intercept_drags_fails_explicitly_without_drag_interception_events() ) .await; + for (id, enabled) in [(12, true), (13, false)] { + ctx.process_async(json!({ + "id": id, + "method": "Input.setInterceptDrags", + "params": { "enabled": enabled } + })) + .await; + ctx.expect_result(id, json!({}), None); + assert_eq!( + ctx.with_fixture(|conn| conn + .target_page_session_state_for_session(None) + .expect("Page session") + .input_intercept_drags_enabled), + enabled, + ); + } + ctx.process_async(json!({ - "id": 12, + "id": 14, + "method": "Input.setInterceptDrags", + "params": { "enabled": "yes" } + })) + .await; + ctx.expect_error(14, -32602, "InvalidParams"); +} + +#[test] +fn drag_interception_reads_the_explicit_target_owner_instead_of_the_active_target() { + let ctx = TestContext::new(); + let mut browser_context = BrowserContext::new("BID-drag-owner".to_owned()); + browser_context.set_active_target_id("TID-active".to_owned()); + browser_context.stage_background_target( + "TID-explicit".to_owned(), + None, + "about:blank".to_owned(), + None, + None, + ); + ctx.with_fixture(|conn| { + conn.install_browser_context_fixture_for_test(browser_context); + let active = CommandOwnerScope::capture(conn, None); + let explicit = CommandOwnerScope::for_route( + conn.target_session_route_for_target_id("TID-explicit") + .expect("explicit Page route"), + ); + assert_eq!(active.session_id(), None); + assert_eq!(explicit.session_id(), None); + for (active_enabled, explicit_enabled) in [(true, false), (false, true)] { + let browser_context = conn.browser_context.as_mut().expect("browser context"); + browser_context.active_page_target_mut().devtools_sessions + [moli_page_types::DevToolsSessionKey::Primary] + .page_session_state + .input_intercept_drags_enabled = active_enabled; + browser_context + .background_target_mut("TID-explicit") + .expect("background Page") + .devtools_sessions[moli_page_types::DevToolsSessionKey::Primary] + .page_session_state + .input_intercept_drags_enabled = explicit_enabled; + assert_eq!(drag::intercept_drags_enabled(conn, &active), active_enabled); + assert_eq!( + drag::intercept_drags_enabled(conn, &explicit), + explicit_enabled, + "route owners without a sessionId must retain their explicit target authority" + ); + } + }); +} + +const DRAG_PAGE: &str = r#" +
+
+ +"#; + +async fn dispatch_drag_mouse( + ctx: &mut TestContext, + id: u64, + event_type: &str, + y: u32, + buttons: u32, +) { + dispatch_drag_mouse_for_session(ctx, id, event_type, y, buttons, None).await; +} + +async fn dispatch_drag_mouse_for_session( + ctx: &mut TestContext, + id: u64, + event_type: &str, + y: u32, + buttons: u32, + session_id: Option<&str>, +) { + let mut command = json!({ + "id": id, + "method": "Input.dispatchMouseEvent", + "params": { + "type": event_type, + "x": INPUT_HIT_X, + "y": y, + "button": "left", + "buttons": buttons + } + }); + if let Some(session_id) = session_id { + command["sessionId"] = json!(session_id); + } + ctx.process_async(command).await; + ctx.expect_result(id, json!({}), session_id); +} + +#[tokio::test(flavor = "multi_thread")] +async fn mouse_drag_with_interception_reports_drag_intercepted_and_replays_through_drag_events() { + let mut ctx = TestContext::new(); + with_loaded_document(&mut ctx, DRAG_PAGE).await; + ctx.capture_fixture_layout(None).await; + + ctx.process_async(json!({ + "id": 1, "method": "Input.setInterceptDrags", "params": { "enabled": true } })) .await; + ctx.expect_result(1, json!({}), None); - ctx.expect_error(12, -32000, SET_INTERCEPT_DRAGS_UNSUPPORTED_MESSAGE); + dispatch_drag_mouse(&mut ctx, 2, "mousePressed", 20, 1).await; + dispatch_drag_mouse(&mut ctx, 3, "mouseMoved", 120, 1).await; + + let intercepted = json!({ + "data": { + "items": [ + { "mimeType": "text/plain", "data": "payload" }, + { "mimeType": "application/x-moli", "data": "custom" } + ], + "dragOperationsMask": 16 + } + }); + ctx.expect_event("Input.dragIntercepted", Some(&intercepted)); + assert_eq!( + evaluate_string(&mut ctx, "window.__drag.join('|')").await, + "dragstart" + ); + + for (id, drag_type) in [(4, "dragEnter"), (5, "dragOver"), (6, "drop")] { + ctx.process_async(json!({ + "id": id, + "method": "Input.dispatchDragEvent", + "params": { + "type": drag_type, + "x": INPUT_HIT_X, + "y": 120, + "data": intercepted["data"] + } + })) + .await; + ctx.expect_result(id, json!({}), None); + if drag_type == "dragEnter" { + assert_eq!( + evaluate_string(&mut ctx, "window.__drag.join('|')").await, + "dragstart|dragenter::text/plain,application/x-moli" + ); + } else if drag_type == "dragOver" { + assert_eq!( + evaluate_string(&mut ctx, "window.__drag.join('|')").await, + "dragstart|dragenter::text/plain,application/x-moli|dragover" + ); + } + } + + assert_eq!( + evaluate_string(&mut ctx, "window.__drag.join('|')").await, + "dragstart|dragenter::text/plain,application/x-moli|dragover|dragover|drop:custom|dragend" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn mouse_drag_without_interception_emits_no_drag_intercepted_event() { + let mut ctx = TestContext::new(); + with_loaded_document(&mut ctx, DRAG_PAGE).await; + ctx.capture_fixture_layout(None).await; + + dispatch_drag_mouse(&mut ctx, 2, "mousePressed", 20, 1).await; + dispatch_drag_mouse(&mut ctx, 3, "mouseMoved", 120, 1).await; + assert_eq!( + evaluate_string(&mut ctx, "window.__drag.join('|')").await, + "dragstart|dragenter::text/plain,application/x-moli" + ); + dispatch_drag_mouse(&mut ctx, 4, "mouseReleased", 120, 0).await; + + assert!( + ctx.take_all() + .iter() + .all(|message| message["method"] != "Input.dragIntercepted") + ); + assert_eq!( + evaluate_string(&mut ctx, "window.__drag.join('|')").await, + "dragstart|dragenter::text/plain,application/x-moli|dragover|drop:custom|dragend" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn pending_drag_interception_uses_the_admitted_command_configuration() { + let mut ctx = TestContext::new(); + with_loaded_document(&mut ctx, DRAG_PAGE).await; + ctx.capture_fixture_layout(None).await; + ctx.process_async(json!({ + "id": 70, "method": "Input.setInterceptDrags", "params": { "enabled": true } + })) + .await; + ctx.expect_result(70, json!({}), None); + dispatch_drag_mouse(&mut ctx, 71, "mousePressed", 20, 1).await; + let raw = json!({ + "id": 72, "method": "Input.dispatchMouseEvent", + "params": { "type": "mouseMoved", "x": INPUT_HIT_X, "y": 120, + "button": "left", "buttons": 1 } + }) + .to_string(); + let pending = match ctx.with_fixture(|conn| conn.start_command_dispatch(&raw)) { + CdpCommandTaskStep::Pending(pending) => pending, + CdpCommandTaskStep::Complete(_) => { + panic!("mouse movement must wait for its renderer outcome") + } + }; + // The enqueued command already owns interception, even if the frontend + // disables it before protocol consumes that command's renderer reply. + ctx.process_async(json!({ + "id": 73, "method": "Input.setInterceptDrags", "params": { "enabled": false } + })) + .await; + ctx.expect_result(73, json!({}), None); + let completed = ctx.drive_owned_wait(pending.wait()).await; + let outcome = ctx + .state + .with_state(|state| { + state.complete_command_dispatch_with_context( + completed, + &mut crate::conn::CommandDispatchContext::default(), + ) + }) + .expect("live test owner"); + let (messages, scheduler_events) = ctx.complete_command_task_step_for_test(outcome).await; + assert!(scheduler_events.is_empty()); + assert!(messages.contains(&json!({ "id": 72, "result": {} }))); + let intercepted = messages + .iter() + .filter(|message| message["method"] == "Input.dragIntercepted") + .collect::>(); + assert_eq!( + intercepted.len(), + 1, + "the renderer has already intercepted this drag and must notify its issuing frontend" + ); + assert_eq!( + intercepted[0]["params"]["data"]["items"][0]["data"], + "payload" + ); + assert_eq!( + evaluate_string(&mut ctx, "window.__drag.join('|')").await, + "dragstart" + ); + ctx.process_async(json!({ "id": 74, "method": "Input.cancelDragging" })) + .await; + ctx.expect_result(74, json!({}), None); } #[tokio::test(flavor = "multi_thread")] async fn cancel_dragging_clears_reachable_drag_state_and_succeeds_when_idle() { - let mut ctx = TestContext::new(); - with_loaded_document(&mut ctx, "drag state").await; - { - let fixture_value = true; - ctx.with_fixture(|conn| { - conn.browser_context - .as_mut() - .expect("browser context") - .active_page_target_mut() - .input_drag_intercepted = fixture_value; - }); + for intercept in [false, true] { + let mut ctx = TestContext::new(); + with_loaded_document(&mut ctx, DRAG_PAGE).await; + ctx.capture_fixture_layout(None).await; + ctx.process_async(json!({ + "id": 1, "method": "Input.setInterceptDrags", "params": { "enabled": intercept } + })) + .await; + ctx.expect_result(1, json!({}), None); + dispatch_drag_mouse(&mut ctx, 2, "mousePressed", 20, 1).await; + dispatch_drag_mouse(&mut ctx, 3, "mouseMoved", 120, 1).await; + assert!( + evaluate_string(&mut ctx, "window.__drag.join('|')") + .await + .starts_with("dragstart") + ); + ctx.process_async(json!({ "id": 13, "method": "Input.cancelDragging" })) + .await; + ctx.expect_result(13, json!({}), None); + let terminal = "JSON.stringify({ends:window.__drag.filter(e=>e==='dragend').length,drops:window.__drag.filter(e=>e.startsWith('drop:')).length})"; + assert_eq!( + evaluate_string(&mut ctx, terminal).await, + r#"{"ends":1,"drops":0}"# + ); + dispatch_drag_mouse(&mut ctx, 14, "mouseReleased", 120, 0).await; + ctx.process_async(json!({ "id": 15, "method": "Input.cancelDragging", "params": {} })) + .await; + ctx.expect_result(15, json!({}), None); + assert_eq!( + evaluate_string(&mut ctx, terminal).await, + r#"{"ends":1,"drops":0}"#, + "mouse release and repeated cancellation must not reuse the completed drag" + ); } +} - ctx.process_async(json!({ - "id": 13, - "method": "Input.cancelDragging" - })) - .await; - ctx.expect_result(13, json!({}), None); +#[tokio::test(flavor = "multi_thread")] +async fn drag_interception_is_scoped_to_the_issuing_session_and_released_on_detach() { + let mut ctx = TestContext::new(); + with_loaded_document(&mut ctx, DRAG_PAGE).await; + ctx.capture_fixture_layout(None).await; ctx.with_fixture(|conn| { - assert!(!{ - conn.browser_context - .as_ref() - .expect("browser context") - .active_page_target() - .input_drag_intercepted - }) + let browser_context = conn.browser_context.as_mut().expect("browser context"); + browser_context.attach_active_session("SID-primary"); + assert!(browser_context.assign_attached_session_to_target("TID-1", "SID-drag".to_owned())); }); - ctx.process_async(json!({ - "id": 14, - "method": "Input.cancelDragging", - "params": {} + "id": 40, "method": "Input.setInterceptDrags", "sessionId": "SID-drag", + "params": { "enabled": true } })) .await; - ctx.expect_result(14, json!({}), None); + ctx.expect_result(40, json!({}), Some("SID-drag")); + assert!(ctx.with_fixture(|conn| { + conn.target_page_session_state_for_session(Some("SID-drag")) + .is_some_and(|state| state.input_intercept_drags_enabled) + })); + assert!(!ctx.with_fixture(|conn| { + conn.target_page_session_state_for_session(Some("SID-primary")) + .is_some_and(|state| state.input_intercept_drags_enabled) + })); + + // Another attached frontend must still drive ordinary native dragging. + for (id, event_type, y, buttons) in [ + (41, "mousePressed", 20, 1), + (42, "mouseMoved", 120, 1), + (43, "mouseReleased", 120, 0), + ] { + dispatch_drag_mouse_for_session(&mut ctx, id, event_type, y, buttons, Some("SID-primary")) + .await; + } + assert!( + evaluate_string(&mut ctx, "window.__drag.join('|')") + .await + .contains("drop:custom") + ); + assert!( + ctx.take_all() + .iter() + .all(|message| message["method"] != "Input.dragIntercepted") + ); + evaluate_string(&mut ctx, "window.__drag = []; 'reset'").await; + + dispatch_drag_mouse_for_session(&mut ctx, 44, "mousePressed", 20, 1, Some("SID-drag")).await; + dispatch_drag_mouse_for_session(&mut ctx, 45, "mouseMoved", 120, 1, Some("SID-drag")).await; + let intercepted = ctx + .take_all() + .into_iter() + .filter(|message| message["method"] == "Input.dragIntercepted") + .collect::>(); + assert_eq!(intercepted.len(), 1); + assert_eq!( + intercepted[0]["sessionId"], "SID-drag", + "the triggering frontend alone receives the intercepted data" + ); + assert_eq!( + evaluate_string(&mut ctx, "window.__drag.join('|')").await, + "dragstart" + ); + // Switching to a frontend without interception must release the old drag, + // without exposing its payload or starting another drag from the held press. + dispatch_drag_mouse_for_session(&mut ctx, 46, "mouseMoved", 120, 1, Some("SID-primary")).await; + assert_eq!( + evaluate_string(&mut ctx, "window.__drag.join('|')").await, + "dragstart|dragend" + ); + dispatch_drag_mouse_for_session(&mut ctx, 47, "mouseReleased", 120, 0, Some("SID-primary")) + .await; + + ctx.process_async(json!({ + "id": 48, "method": "Input.setInterceptDrags", "sessionId": "SID-drag", + "params": { "enabled": false } + })) + .await; + ctx.expect_result(48, json!({}), Some("SID-drag")); + evaluate_string(&mut ctx, "window.__drag = []; 'reset'").await; + for (id, event_type, y, buttons) in [ + (49, "mousePressed", 20, 1), + (50, "mouseMoved", 120, 1), + (51, "mouseReleased", 120, 0), + ] { + dispatch_drag_mouse_for_session(&mut ctx, id, event_type, y, buttons, Some("SID-drag")) + .await; + } + assert!( + evaluate_string(&mut ctx, "window.__drag.join('|')") + .await + .contains("drop:custom") + ); + assert!( + ctx.take_all() + .iter() + .all(|message| message["method"] != "Input.dragIntercepted") + ); + + ctx.process_async(json!({ + "id": 52, "method": "Input.setInterceptDrags", "sessionId": "SID-drag", + "params": { "enabled": true } + })) + .await; + ctx.expect_result(52, json!({}), Some("SID-drag")); + ctx.process_async(json!({ + "id": 53, "method": "Target.detachFromTarget", "params": { "sessionId": "SID-drag" } + })) + .await; + ctx.expect_result(53, json!({}), None); + assert!(ctx.with_fixture(|conn| { + conn.target_page_session_state_for_session(Some("SID-drag")) + .is_none() + })); + evaluate_string(&mut ctx, "window.__drag = []; 'reset'").await; + for (id, event_type, y, buttons) in [ + (54, "mousePressed", 20, 1), + (55, "mouseMoved", 120, 1), + (56, "mouseReleased", 120, 0), + ] { + dispatch_drag_mouse_for_session(&mut ctx, id, event_type, y, buttons, Some("SID-primary")) + .await; + } + assert!( + evaluate_string(&mut ctx, "window.__drag.join('|')") + .await + .contains("drop:custom") + ); + assert!( + ctx.take_all() + .iter() + .all(|message| message["method"] != "Input.dragIntercepted") + ); } #[tokio::test(flavor = "multi_thread")] diff --git a/moli-protocol/src/domains/target/tests/tests_target_attachment.rs b/moli-protocol/src/domains/target/tests/tests_target_attachment.rs index 218234af0f..49c2a737e5 100644 --- a/moli-protocol/src/domains/target/tests/tests_target_attachment.rs +++ b/moli-protocol/src/domains/target/tests/tests_target_attachment.rs @@ -1696,8 +1696,10 @@ async fn detach_from_target() { }, ); bc.active_page_target_mut().css_enabled = true; - bc.active_page_target_mut().input_intercept_drags_enabled = true; - bc.active_page_target_mut().input_drag_intercepted = true; + bc.active_page_target_mut().devtools_sessions + [moli_page_types::DevToolsSessionKey::Primary] + .page_session_state + .input_intercept_drags_enabled = true; bc.active_page_target_mut().fetch_owner.configure( Some(sid.clone()), true, @@ -1746,8 +1748,12 @@ async fn detach_from_target() { .bypass_service_worker() ); assert!(!bc.active_page_target().css_enabled); - assert!(!bc.active_page_target().input_intercept_drags_enabled); - assert!(!bc.active_page_target().input_drag_intercepted); + assert!( + !bc.active_page_target().devtools_sessions + [moli_page_types::DevToolsSessionKey::Primary] + .page_session_state + .input_intercept_drags_enabled + ); assert!(!bc.active_page_target().fetch_owner.is_enabled()); assert!(!bc.active_page_target().fetch_owner.handle_auth_requests()); assert!( diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index bb5499f992..52f7575be3 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -129,6 +129,7 @@ mod performance_observer_runtime; mod performance_runtime; mod range; mod range_algorithms; +pub(crate) use range_algorithms::document_selection_drag_text_at_point; mod range_live; mod range_surface; mod resize_observer_runtime; @@ -315,8 +316,8 @@ pub(crate) use self::file_api::{ native_data_transfer_drop_effect, native_data_transfer_drop_effect_is_initialized, native_data_transfer_effect_allowed, native_data_transfer_file_count, native_data_transfer_files, native_data_transfer_get_data, native_data_transfer_has_type, - populate_native_drag_data_store, protect_native_drag_source_view, - readonly_data_transfer_for_input, replace_drag_data_store_contents, + native_set_drag_drop_effect, populate_native_drag_data_store, protect_native_drag_source_view, + read_string_drag_data, readonly_data_transfer_for_input, replace_drag_data_store_contents, update_drag_data_store_operations_mask, }; pub(crate) use self::file_api::{ diff --git a/moli-renderer-v8/src/context_bootstrap/file_api.rs b/moli-renderer-v8/src/context_bootstrap/file_api.rs index 14cd9f51a8..be852231c7 100644 --- a/moli-renderer-v8/src/context_bootstrap/file_api.rs +++ b/moli-renderer-v8/src/context_bootstrap/file_api.rs @@ -17,8 +17,8 @@ pub(crate) use data_transfer::{ is_branded_data_transfer_object, native_data_transfer_drop_effect, native_data_transfer_drop_effect_is_initialized, native_data_transfer_effect_allowed, native_data_transfer_file_count, native_data_transfer_files, native_data_transfer_get_data, - native_data_transfer_has_type, populate_native_drag_data_store, - protect_native_drag_source_view, readonly_data_transfer_for_input, + native_data_transfer_has_type, native_set_drag_drop_effect, populate_native_drag_data_store, + protect_native_drag_source_view, read_string_drag_data, readonly_data_transfer_for_input, replace_drag_data_store_contents, update_drag_data_store_operations_mask, }; pub(crate) use data_transfer::{ diff --git a/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer.rs b/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer.rs index 207cdd2936..721aaa1c32 100644 --- a/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer.rs +++ b/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer.rs @@ -1,14 +1,17 @@ use super::*; use crate::blob; use crate::native_bridge::throw_dom_exception; -use crate::runtime::{RendererDragData, RendererDraggedDirectory, RendererDraggedFile}; +use crate::runtime::{ + RendererDragData, RendererDragDataItem, RendererDraggedDirectory, RendererDraggedFile, +}; use crate::util::{get_private_object, get_private_value, set_private_value}; use crate::web_api_interfaces; use crate::webidl; use moli_file_api::data_transfer::{ DataTransferItemSummary, child_entry_full_path, clear_data_removes_item, contains_string_item_type, data_transfer_types_from_items, drag_effect_allowed_from_mask, - normalize_drag_data_type, valid_drop_effect, valid_effect_allowed, + drag_operations_mask_from_effect_allowed, normalize_drag_data_type, valid_drop_effect, + valid_effect_allowed, }; use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; @@ -474,6 +477,46 @@ pub(crate) fn native_set_drag_drop_effect<'s>( } } +/// Reads the string items and `effectAllowed` that `dragstart` listeners left +/// in the shared store, in the shape CDP reports as `Input.DragData`. +pub(crate) fn read_string_drag_data<'s>( + scope: &mut v8::PinScope<'s, '_>, + store: &NativeDragDataStore, +) -> (Vec, i32) { + let backing = store.local(scope); + let mask = drag_operations_mask_from_effect_allowed( + &private_string_property(scope, backing, DATA_TRANSFER_EFFECT_ALLOWED_SLOT) + .unwrap_or_else(|| "uninitialized".to_owned()), + ); + let mut items = Vec::new(); + let Some(item_array) = + private_property_as_array(scope, backing, DATA_TRANSFER_STORE_ITEMS_SLOT) + else { + return (items, mask); + }; + for index in 0..item_array.length() { + let Some(item) = item_array + .get_index(scope, index) + .and_then(|value| v8::Local::::try_from(value).ok()) + else { + continue; + }; + if item_kind(scope, item).as_deref() != Some("string") { + continue; + } + items.push(RendererDragDataItem { + mime_type: item_type(scope, item).unwrap_or_default(), + // Native interception serializes the store, independently of the + // access granted to the last JavaScript event listener. + data: private_string_property(scope, item, DATA_TRANSFER_ITEM_STRING_SLOT) + .unwrap_or_default(), + title: None, + base_url: None, + }); + } + (items, mask) +} + fn private_string_property<'s>( scope: &mut v8::PinScope<'s, '_>, object: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/context_bootstrap/range.rs b/moli-renderer-v8/src/context_bootstrap/range.rs index 0e415c2275..133b1b4cd6 100644 --- a/moli-renderer-v8/src/context_bootstrap/range.rs +++ b/moli-renderer-v8/src/context_bootstrap/range.rs @@ -8,10 +8,11 @@ mod validation; pub(super) use exceptions::throw_named_dom_exception; pub(super) use object::{ - RANGE_WRAPPER_INTERNAL_FIELD_COUNT, current_document_object, initialize_range_object, - live_range_from_static_range, native_range_boundary_handles, native_range_boundary_point, - new_range_for_document, range_boundary_container_object, range_boundary_offset, - range_is_collapsed, range_native_record_handle, set_range_boundary, static_range_snapshot, + RANGE_WRAPPER_INTERNAL_FIELD_COUNT, RangeBoundaryHandle, RangeBoundaryHandles, + current_document_object, initialize_range_object, live_range_from_static_range, + native_range_boundary_handles, native_range_boundary_point, new_range_for_document, + range_boundary_container_object, range_boundary_offset, range_is_collapsed, + range_native_record_handle, set_range_boundary, static_range_snapshot, }; pub(super) use static_storage::{StaticRangeInitMembers, initialize_static_range_object}; pub(super) use validation::{ diff --git a/moli-renderer-v8/src/context_bootstrap/range_algorithms.rs b/moli-renderer-v8/src/context_bootstrap/range_algorithms.rs index 79f76cdbb1..9eda0bf1cc 100644 --- a/moli-renderer-v8/src/context_bootstrap/range_algorithms.rs +++ b/moli-renderer-v8/src/context_bootstrap/range_algorithms.rs @@ -1,7 +1,8 @@ use super::super::util::node_wrapper_from_handle; use super::range::{ - RangeBoundarySide, native_range_boundary_handles, native_range_boundary_point, - range_boundary_container_object, range_boundary_offset, range_is_collapsed, set_range_boundary, + RangeBoundaryHandle, RangeBoundaryHandles, RangeBoundarySide, native_range_boundary_handles, + native_range_boundary_point, range_boundary_container_object, range_boundary_offset, + range_is_collapsed, set_range_boundary, }; use super::*; use crate::document_runtime::DomHandle; @@ -51,3 +52,53 @@ pub(in crate::context_bootstrap::range_algorithms) use ordering::common_ancestor pub(in crate::context_bootstrap::range_algorithms) use mutation::{ range_insert_node_handle_at_boundary, range_select_node_handle, }; + +/// Resolve browser-initiated selection drags through the same native Range +/// geometry and visible-text serializer used by the Selection Web API. +pub(crate) fn document_selection_drag_text_at_point( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut crate::native_bridge::JsContextHost, + handle: DomHandle, + x: f64, + y: f64, +) -> Option { + let runtime = unsafe { &*runtime_ptr }; + let host = runtime.dom_host(); + let document = host.owner_document_handle(handle)?; + let selection = runtime.document_selection_snapshot(document)?; + let (mut start, mut end) = (selection.start, selection.end); + match crate::range_boundary::point_order_in_dom( + host, + start.container, + start.offset, + end.container, + end.offset, + )? { + std::cmp::Ordering::Equal => return None, + std::cmp::Ordering::Greater => std::mem::swap(&mut start, &mut end), + std::cmp::Ordering::Less => {} + } + let boundaries = RangeBoundaryHandles { + start: RangeBoundaryHandle { + container: start.container, + offset: start.offset, + }, + end: RangeBoundaryHandle { + container: end.container, + offset: end.offset, + }, + }; + let rects = geometry::native_range_client_rects_for_boundaries(scope, boundaries)?.ok()?; + if !rects.iter().any(|rect| { + rect.width > 0.0 + && rect.height > 0.0 + && x >= rect.left + && x <= rect.right + && y >= rect.top + && y <= rect.bottom + }) { + return None; + } + text::range_selection_string_contents_for_boundaries(scope, boundaries) + .filter(|text| !text.is_empty()) +} diff --git a/moli-renderer-v8/src/context_bootstrap/range_algorithms/geometry.rs b/moli-renderer-v8/src/context_bootstrap/range_algorithms/geometry.rs index 7235cb87c5..6bc93b73dc 100644 --- a/moli-renderer-v8/src/context_bootstrap/range_algorithms/geometry.rs +++ b/moli-renderer-v8/src/context_bootstrap/range_algorithms/geometry.rs @@ -11,6 +11,13 @@ fn native_range_client_rects<'s>( range: v8::Local<'s, v8::Object>, ) -> Option, moli_layout::LayoutError>> { let boundaries = native_range_boundary_handles(scope, range)?; + native_range_client_rects_for_boundaries(scope, boundaries) +} + +pub(super) fn native_range_client_rects_for_boundaries( + scope: &mut v8::PinScope<'_, '_>, + boundaries: RangeBoundaryHandles, +) -> Option, moli_layout::LayoutError>> { let host_ptr = context_host_ptr_from_global_bridge(scope)?; let host = unsafe { &*host_ptr }; if !host.layout_policy().uses_real_layout() { diff --git a/moli-renderer-v8/src/context_bootstrap/range_algorithms/text.rs b/moli-renderer-v8/src/context_bootstrap/range_algorithms/text.rs index b0c6d27780..ad5d9a9bc3 100644 --- a/moli-renderer-v8/src/context_bootstrap/range_algorithms/text.rs +++ b/moli-renderer-v8/src/context_bootstrap/range_algorithms/text.rs @@ -52,6 +52,31 @@ pub(in crate::context_bootstrap) fn range_selection_string_contents<'s>( range_boundary_offset(scope, range, RangeBoundarySide::End) as u32, ) }; + range_selection_string_contents_for_boundaries( + scope, + RangeBoundaryHandles { + start: RangeBoundaryHandle { + container: start, + offset: start_offset, + }, + end: RangeBoundaryHandle { + container: end, + offset: end_offset, + }, + }, + ) +} + +pub(super) fn range_selection_string_contents_for_boundaries( + scope: &mut v8::PinScope<'_, '_>, + boundaries: RangeBoundaryHandles, +) -> Option { + let (start, start_offset, end, end_offset) = ( + boundaries.start.container, + boundaries.start.offset, + boundaries.end.container, + boundaries.end.offset, + ); if start == end && start_offset == end_offset { return Some(String::new()); } diff --git a/moli-renderer-v8/src/devtools/command/nested.rs b/moli-renderer-v8/src/devtools/command/nested.rs index 4c38ae7447..169a65252e 100644 --- a/moli-renderer-v8/src/devtools/command/nested.rs +++ b/moli-renderer-v8/src/devtools/command/nested.rs @@ -113,6 +113,7 @@ impl RendererPageCommand { | Self::DispatchMouseEventAtPoint { .. } | Self::DispatchTouchEvent { .. } | Self::DispatchDragEventAtPoint { .. } + | Self::CancelDragging | Self::ClearActiveDragDataTransfer | Self::InsertTextIntoActiveControl(..) | Self::DispatchKeyEvent { .. } diff --git a/moli-renderer-v8/src/lib.rs b/moli-renderer-v8/src/lib.rs index cabebd4eb6..d6a11b1310 100644 --- a/moli-renderer-v8/src/lib.rs +++ b/moli-renderer-v8/src/lib.rs @@ -277,16 +277,16 @@ pub use runtime::{ RendererGeometryQuad, RendererInputDispatchOutcome, RendererInspectorCommandEnvelope, RendererInspectorCommandRoute, RendererInspectorIngressTicket, RendererInspectorProtocolConfiguration, RendererInspectorProtocolConfigurationCommand, - RendererInspectorSessionRestoreSnapshot, RendererJavaScriptDialogCompletion, - RendererJavaScriptDialogId, RendererJavaScriptDialogResult, RendererJavaScriptDialogSource, - RendererLayoutMetrics, RendererLifecycleDecider, RendererLifecycleDecision, - RendererLifecycleEpoch, RendererLifecycleEventStamp, RendererLifecycleSnapshot, - RendererLifecycleStartReason, RendererLifecycleTerminationStamp, RendererMainDocumentCommit, - RendererMainDocumentResource, RendererMoliDomMemoryDiagnostics, RendererMoliMemoryDiagnostics, - RendererMoliMemoryScopeDiagnostics, RendererMoliRuntimeMemoryDiagnostics, - RendererNativeCommandReadyResponse, RendererNativeCommandResponseGuard, - RendererNativeCommandTerminal, RendererNativeOperation, RendererNativeOperationStep, - RendererNativeProtocolError, RendererNativeProtocolNotification, + RendererInspectorSessionRestoreSnapshot, RendererInterceptedDrag, + RendererJavaScriptDialogCompletion, RendererJavaScriptDialogId, RendererJavaScriptDialogResult, + RendererJavaScriptDialogSource, RendererLayoutMetrics, RendererLifecycleDecider, + RendererLifecycleDecision, RendererLifecycleEpoch, RendererLifecycleEventStamp, + RendererLifecycleSnapshot, RendererLifecycleStartReason, RendererLifecycleTerminationStamp, + RendererMainDocumentCommit, RendererMainDocumentResource, RendererMoliDomMemoryDiagnostics, + RendererMoliMemoryDiagnostics, RendererMoliMemoryScopeDiagnostics, + RendererMoliRuntimeMemoryDiagnostics, RendererNativeCommandReadyResponse, + RendererNativeCommandResponseGuard, RendererNativeCommandTerminal, RendererNativeOperation, + RendererNativeOperationStep, RendererNativeProtocolError, RendererNativeProtocolNotification, RendererNativeProtocolResponse, RendererNativeProtocolStateUpdate, RendererNavigationInitiator, RendererOutputCursor, RendererOutputFence, RendererOutputFenceLeaseId, RendererOutputItem, RendererOutputPublication, RendererOutputPublicationOrdering, RendererOutputRecord, diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index d943a3bf1d..497741a59d 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -272,11 +272,12 @@ pub(in crate::native_bridge::element) use events::construct_event; pub(crate) use events::construct_focus_event; pub(crate) use events::{ InputEventCoordinates, NodePublicEventDispatchOutcome, TextEditInputType, TouchEventPoint, - construct_command_event, construct_drag_event, construct_drop_input_event, - construct_input_event, construct_interest_event, construct_keyboard_event, - construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers, - construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event, - construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target, + construct_command_event, construct_drag_event, construct_drag_event_with_related_target, + construct_drop_input_event, construct_input_event, construct_interest_event, + construct_keyboard_event, construct_mouse_event_with_detail_and_modifiers, + construct_mouse_event_with_modifiers, construct_mouse_event_with_related_target_and_modifiers, + construct_pointer_event, construct_pointer_event_with_modifiers, + construct_pointer_event_with_related_target, construct_pointer_event_with_related_target_and_modifiers, construct_simple_event, construct_submit_event, construct_toggle_event, construct_touch_event, construct_touch_event_with_points, construct_wheel_event, dispatch_beforeinput, diff --git a/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs b/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs index 23bc2c6e35..482ee122cb 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs @@ -755,6 +755,7 @@ fn click_handle_internal( triggered_top_level_navigation: false, pending_download: None, pending_file_chooser: None, + intercepted_drag: None, }; } if focus_behavior == ClickFocusBehavior::Apply @@ -974,6 +975,7 @@ fn click_handle_internal( triggered_top_level_navigation: false, pending_download: None, pending_file_chooser: None, + intercepted_drag: None, } } @@ -990,6 +992,7 @@ fn input_dispatch_outcome_after_click( && unsafe { &*runtime_ptr }.has_pending_location_navigation(), pending_download, pending_file_chooser, + intercepted_drag: None, } } diff --git a/moli-renderer-v8/src/native_bridge/element/events.rs b/moli-renderer-v8/src/native_bridge/element/events.rs index 976ed3a3dc..244643db2e 100644 --- a/moli-renderer-v8/src/native_bridge/element/events.rs +++ b/moli-renderer-v8/src/native_bridge/element/events.rs @@ -6,8 +6,8 @@ mod dispatch; pub(crate) use self::constructors::construct_focus_event; pub(crate) use self::constructors::{ InputEventCoordinates, TextEditInputType, TouchEventPoint, construct_command_event, - construct_drag_event, construct_drop_input_event, construct_input_event, - construct_interest_event, construct_keyboard_event, + construct_drag_event, construct_drag_event_with_related_target, construct_drop_input_event, + construct_input_event, construct_interest_event, construct_keyboard_event, construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers, construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event, construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target, diff --git a/moli-renderer-v8/src/native_bridge/element/events/constructors.rs b/moli-renderer-v8/src/native_bridge/element/events/constructors.rs index 44c8648638..61e0eda32e 100644 --- a/moli-renderer-v8/src/native_bridge/element/events/constructors.rs +++ b/moli-renderer-v8/src/native_bridge/element/events/constructors.rs @@ -68,6 +68,7 @@ struct DragEventInitDeclaration<'scope> { meta_key: bool, shift_key: bool, data_transfer: v8::Local<'scope, v8::Value>, + related_target: Option>, } #[derive(WebApiObject)] @@ -637,7 +638,11 @@ pub(crate) fn construct_pointer_event_with_related_target_and_modifiers<'s>( let modifier_keys = modifier_key_state(modifiers); let init = PointerEventInitDeclaration::new( !is_enter_or_leave, - !is_enter_or_leave, + !is_enter_or_leave + && !matches!( + event_type, + "pointercancel" | "gotpointercapture" | "lostpointercapture" + ), true, coordinates.screen_x, coordinates.screen_y, @@ -672,11 +677,29 @@ pub(crate) fn construct_drag_event<'s>( coordinates: InputEventCoordinates, data_transfer: v8::Local<'s, v8::Value>, modifiers: u8, +) -> Option> { + construct_drag_event_with_related_target( + scope, + event_type, + coordinates, + data_transfer, + None, + modifiers, + ) +} + +pub(crate) fn construct_drag_event_with_related_target<'s>( + scope: &mut v8::PinScope<'s, '_>, + event_type: &str, + coordinates: InputEventCoordinates, + data_transfer: v8::Local<'s, v8::Value>, + related_target: Option>, + modifiers: u8, ) -> Option> { let modifier_keys = modifier_key_state(modifiers); let init = DragEventInitDeclaration::new( true, - true, + !matches!(event_type, "dragleave" | "dragend"), true, coordinates.screen_x, coordinates.screen_y, @@ -687,6 +710,7 @@ pub(crate) fn construct_drag_event<'s>( modifier_keys.meta, modifier_keys.shift, data_transfer, + related_target, ) .bind(scope) .ok()?; diff --git a/moli-renderer-v8/src/native_bridge/element/forms/submission.rs b/moli-renderer-v8/src/native_bridge/element/forms/submission.rs index 637d36830c..34ec3d6931 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/submission.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/submission.rs @@ -128,6 +128,7 @@ fn perform_implicit_form_submission( && unsafe { &*runtime_ptr }.has_pending_location_navigation(), pending_download: None, pending_file_chooser: None, + intercepted_drag: None, } } diff --git a/moli-renderer-v8/src/runtime/mod.rs b/moli-renderer-v8/src/runtime/mod.rs index 8776e974bd..6c75eaa46e 100644 --- a/moli-renderer-v8/src/runtime/mod.rs +++ b/moli-renderer-v8/src/runtime/mod.rs @@ -370,13 +370,13 @@ pub use self::page_surface::{ RendererDragDataItem, RendererDraggedDirectory, RendererDraggedFile, RendererGeometryQuad, RendererInputDispatchOutcome, RendererInspectorProtocolConfiguration, RendererInspectorProtocolConfigurationCommand, RendererInspectorSessionRestoreSnapshot, - RendererJavaScriptDialogId, RendererJavaScriptDialogSource, RendererLayoutMetrics, - RendererMainDocumentCommit, RendererMoliDomMemoryDiagnostics, RendererMoliMemoryDiagnostics, - RendererMoliMemoryScopeDiagnostics, RendererMoliRuntimeMemoryDiagnostics, - RendererNavigationInitiator, RendererPageCommand, RendererPageCommandPostResponseContinuation, - RendererPageCookieFacadeSnapshotReply, RendererPageCreationDiagnostics, - RendererPageDiagnosticsSnapshot, RendererPageDumpFormat, RendererPageDumpOptions, - RendererPageDumpStripOptions, RendererPageReply, RendererPageView, + RendererInterceptedDrag, RendererJavaScriptDialogId, RendererJavaScriptDialogSource, + RendererLayoutMetrics, RendererMainDocumentCommit, RendererMoliDomMemoryDiagnostics, + RendererMoliMemoryDiagnostics, RendererMoliMemoryScopeDiagnostics, + RendererMoliRuntimeMemoryDiagnostics, RendererNavigationInitiator, RendererPageCommand, + RendererPageCommandPostResponseContinuation, RendererPageCookieFacadeSnapshotReply, + RendererPageCreationDiagnostics, RendererPageDiagnosticsSnapshot, RendererPageDumpFormat, + RendererPageDumpOptions, RendererPageDumpStripOptions, RendererPageReply, RendererPageView, RendererPendingDownloadActivation, RendererPendingDownloadResponse, RendererPendingFileChooserActivation, RendererPendingJavaScriptDialog, RendererPendingPopupActivation, RendererPendingSameDocumentNavigation, diff --git a/moli-renderer-v8/src/runtime/page_commands.rs b/moli-renderer-v8/src/runtime/page_commands.rs index 493ef1eb9f..c186a3b19f 100644 --- a/moli-renderer-v8/src/runtime/page_commands.rs +++ b/moli-renderer-v8/src/runtime/page_commands.rs @@ -122,6 +122,7 @@ impl PageVm { delta_y, pointer, modifiers, + intercept_drags: _, } if event_name == "wheel" => self .queue_wheel_event( x, @@ -146,8 +147,9 @@ impl PageVm { delta_y, pointer, modifiers, - } => self - .dispatch_mouse_event_at_point_with_pointer( + intercept_drags, + } => { + self.dispatch_mouse_event_at_point_with_pointer( x, y, &event_name, @@ -158,8 +160,10 @@ impl PageVm { delta_y, pointer, modifiers, + intercept_drags, ) - .map(RendererPageReply::InputDispatchOutcome), + .map(RendererPageReply::InputDispatchOutcome) + } RendererPageCommand::DispatchTouchEvent { points, event_name, @@ -180,6 +184,9 @@ impl PageVm { self.clear_active_drag_data_transfer()?; Ok(RendererPageReply::Unit) } + RendererPageCommand::CancelDragging => self + .cancel_dragging() + .map(RendererPageReply::InputDispatchOutcome), RendererPageCommand::MutateDocumentNodeAttribute { reference, mutation, diff --git a/moli-renderer-v8/src/runtime/page_dom.rs b/moli-renderer-v8/src/runtime/page_dom.rs index a81a7634b4..0bdf99476a 100644 --- a/moli-renderer-v8/src/runtime/page_dom.rs +++ b/moli-renderer-v8/src/runtime/page_dom.rs @@ -594,6 +594,7 @@ impl PageVm { delta_y: f64, pointer: RendererPointerEventProperties, modifiers: u8, + intercept_drags: bool, ) -> Result { let mut outcome = self .vm_mut() @@ -608,6 +609,7 @@ impl PageVm { delta_y, pointer, modifiers, + intercept_drags, )?; self.bind_input_dispatch_file_chooser_backend_node_id(&mut outcome); Ok(outcome) @@ -626,6 +628,12 @@ impl PageVm { Ok(outcome) } + pub(crate) fn cancel_dragging(&mut self) -> Result { + let mut outcome = self.vm_mut().cancel_dragging()?; + self.bind_input_dispatch_file_chooser_backend_node_id(&mut outcome); + Ok(outcome) + } + pub(crate) fn dispatch_drag_event_at_point( &mut self, x: f64, diff --git a/moli-renderer-v8/src/runtime/page_surface.rs b/moli-renderer-v8/src/runtime/page_surface.rs index 0bbe352eb3..50a731a03e 100644 --- a/moli-renderer-v8/src/runtime/page_surface.rs +++ b/moli-renderer-v8/src/runtime/page_surface.rs @@ -479,12 +479,26 @@ pub struct RendererDragData { pub drag_operations_mask: i32, } +/// String data of a drag that started while the client intercepts drags. +/// +/// Files and directories are not carried: only string items set through +/// `DataTransfer.setData` during `dragstart` leave the renderer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RendererInterceptedDrag { + pub items: Vec, + pub drag_operations_mask: i32, +} + #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct RendererInputDispatchOutcome { pub handled: bool, pub triggered_top_level_navigation: bool, pub pending_download: Option, pub pending_file_chooser: Option, + /// Set when `dragstart` ran uncanceled and the command asked to intercept + /// drags. The renderer then leaves `dragenter`, `dragover` and `drop` to + /// `RendererPageCommand::DispatchDragEventAtPoint`. + pub intercepted_drag: Option, } #[derive(Debug, Clone, PartialEq)] @@ -4824,6 +4838,8 @@ pub enum RendererPageCommand { delta_y: f64, pointer: RendererPointerEventProperties, modifiers: u8, + /// `Input.setInterceptDrags` state of the issuing DevTools session. + intercept_drags: bool, }, DispatchTouchEvent { points: Vec, @@ -4838,6 +4854,7 @@ pub enum RendererPageCommand { modifiers: u8, }, ClearActiveDragDataTransfer, + CancelDragging, InsertTextIntoActiveControl(String), DispatchKeyEvent { event_name: String, diff --git a/moli-renderer-v8/src/runtime/page_vm/page_action_window.rs b/moli-renderer-v8/src/runtime/page_vm/page_action_window.rs index 547d7babc9..57d7a6a792 100644 --- a/moli-renderer-v8/src/runtime/page_vm/page_action_window.rs +++ b/moli-renderer-v8/src/runtime/page_vm/page_action_window.rs @@ -111,6 +111,7 @@ impl PageVm { triggered_top_level_navigation: false, pending_download: None, pending_file_chooser: None, + intercepted_drag: None, }) } @@ -214,6 +215,7 @@ impl PageVm { event.delta_y, event.pointer, event.modifiers, + false, ); if let Err(error) = result && first_error.is_none() diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/file_system_directory_reader.rs b/moli-renderer-v8/src/runtime/page_vm/tests/file_system_directory_reader.rs index 089c2edf92..141d657817 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/file_system_directory_reader.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/file_system_directory_reader.rs @@ -29,6 +29,7 @@ const dropTarget = document.createElement("div"); dropTarget.id = "directory-reader-drop-target"; dropTarget.style.width = "100px"; dropTarget.style.height = "100px"; +dropTarget.addEventListener("dragover", event => event.preventDefault()); dropTarget.addEventListener("drop", event => { globalThis.__directoryEntry = event.dataTransfer.items[0].webkitGetAsEntry(); @@ -39,14 +40,14 @@ document.body.appendChild(dropTarget); "#, )?; page_vm.vm_mut().publish_layout_for_test()?; - let outcome = page_vm.dispatch_drag_event_at_point( - 10.0, - 10.0, - "drop", - directory_drag_data(file_count), - 0, - )?; - assert!(outcome.handled, "the directory drop fixture must dispatch"); + let data = directory_drag_data(file_count); + for event in ["dragenter", "dragover", "drop"] { + let outcome = page_vm.dispatch_drag_event_at_point(10.0, 10.0, event, data.clone(), 0)?; + assert!( + outcome.handled, + "the accepted directory drag fixture must dispatch {event}" + ); + } assert_eq!( page_vm.vm_mut().eval( "__directoryEntry.isDirectory && \ diff --git a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/dom_mutation_state.rs b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/dom_mutation_state.rs index 268e40b9b4..33915bb120 100644 --- a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/dom_mutation_state.rs +++ b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/dom_mutation_state.rs @@ -448,6 +448,7 @@ window.parserPendingPointerCapture = installPendingPointerCaptureTarget('parser- 0.0, RendererPointerEventProperties::default(), 0, + false, ) .expect("JS baseline mousedown should set then clear pending capture"); page_vm @@ -462,6 +463,7 @@ window.parserPendingPointerCapture = installPendingPointerCaptureTarget('parser- 0.0, RendererPointerEventProperties::default(), 0, + false, ) .expect("JS baseline mouseup should not use removed pending capture"); let js_log = page_vm @@ -486,6 +488,7 @@ window.parserPendingPointerCapture = installPendingPointerCaptureTarget('parser- 0.0, RendererPointerEventProperties::default(), 0, + false, ) .expect("parser baseline mousedown should set pending capture"); @@ -527,6 +530,7 @@ window.parserPendingPointerCapture = installPendingPointerCaptureTarget('parser- 0.0, RendererPointerEventProperties::default(), 0, + false, ) .expect("parser baseline mouseup should not use removed pending capture"); let parser_log = page_vm @@ -618,6 +622,7 @@ window.parserPendingPointerCaptureReparent = 0.0, RendererPointerEventProperties::default(), 0, + false, ) .expect("JS baseline mousedown should set then clear pending capture by reparent"); page_vm @@ -632,6 +637,7 @@ window.parserPendingPointerCaptureReparent = 0.0, RendererPointerEventProperties::default(), 0, + false, ) .expect("JS baseline mouseup should not use reparented pending capture"); let js_log = page_vm @@ -656,6 +662,7 @@ window.parserPendingPointerCaptureReparent = 0.0, RendererPointerEventProperties::default(), 0, + false, ) .expect("parser baseline mousedown should set pending capture"); @@ -715,6 +722,7 @@ window.parserPendingPointerCaptureReparent = 0.0, RendererPointerEventProperties::default(), 0, + false, ) .expect("parser baseline mouseup should not use reparented pending capture"); let parser_log = page_vm diff --git a/moli-renderer-v8/src/runtime/tests.rs b/moli-renderer-v8/src/runtime/tests.rs index dbf1c33f16..deedf96c6b 100644 --- a/moli-renderer-v8/src/runtime/tests.rs +++ b/moli-renderer-v8/src/runtime/tests.rs @@ -360,6 +360,7 @@ async fn dispatch_wheel_for_action_window_test( delta_y, pointer: RendererPointerEventProperties::default(), modifiers: 0, + intercept_drags: false, }) .await .expect("wheel action should enter the renderer action window"); diff --git a/moli-renderer-v8/src/runtime/tests/extracted/input_dispatch.rs b/moli-renderer-v8/src/runtime/tests/extracted/input_dispatch.rs index 1f1e1f6d66..95acd1e769 100644 --- a/moli-renderer-v8/src/runtime/tests/extracted/input_dispatch.rs +++ b/moli-renderer-v8/src/runtime/tests/extracted/input_dispatch.rs @@ -72,6 +72,7 @@ async fn mouse_input_completion_publishes_handler_navigation_once() { delta_y: 0.0, pointer: RendererPointerEventProperties::default(), modifiers: 0, + intercept_drags: false, }) .await .expect("input command"); @@ -219,6 +220,7 @@ async fn mouse_input_completion_preserves_standalone_and_child_navigation_scope( delta_y: 0.0, pointer: RendererPointerEventProperties::default(), modifiers: 0, + intercept_drags: false, }) .await .expect("scope input command"); diff --git a/moli-renderer-v8/src/runtime/tests/extracted/owner_lifecycle.rs b/moli-renderer-v8/src/runtime/tests/extracted/owner_lifecycle.rs index ce1bf47948..57184039ee 100644 --- a/moli-renderer-v8/src/runtime/tests/extracted/owner_lifecycle.rs +++ b/moli-renderer-v8/src/runtime/tests/extracted/owner_lifecycle.rs @@ -20,6 +20,7 @@ async fn owner_loop_runs_file_reading_callback_without_retry_command() { r#"
drop
diff --git a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-contenteditable-inheritance-basic.html b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-contenteditable-inheritance-basic.html index c37d44707d..a61a7212f2 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-contenteditable-inheritance-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-contenteditable-inheritance-basic.html @@ -11,6 +11,7 @@ const host = document.getElementById("host"); const child = document.getElementById("child"); const events = []; let phase = "allowed"; +let finishActions = null; function selectedText() { const selection = getSelection(); @@ -37,49 +38,52 @@ window.__advanceContenteditableInheritancePhase = function () { resetSelection(); const complete = new Promise(function (resolve) { - host.addEventListener("drop", function (event) { - events.push( - phase + - "-drop:" + - event.target.id + - ":" + - event.dataTransfer.getData("text") + - ":" + - child.textContent + - ":" + - selectedText(), - ); - if (phase === "blocked") { - resolve(); - } - }); + finishActions = resolve; +}); - host.addEventListener("beforeinput", function (event) { - events.push( - phase + - "-beforeinput:" + - event.target.id + - ":" + - child.textContent + - ":" + - selectedText(), - ); - }); +window.__finishContenteditableInheritance = function () { + finishActions(); +}; - host.addEventListener("input", function (event) { - const selection = getSelection(); - events.push( - phase + - "-input:" + - event.target.id + - ":" + - child.textContent + - ":" + - selection.anchorOffset + - ":" + - selection.focusOffset, - ); - }); +host.addEventListener("drop", function (event) { + events.push( + phase + + "-drop:" + + event.target.id + + ":" + + event.dataTransfer.getData("text") + + ":" + + child.textContent + + ":" + + selectedText(), + ); +}); + +host.addEventListener("beforeinput", function (event) { + events.push( + phase + + "-beforeinput:" + + event.target.id + + ":" + + child.textContent + + ":" + + selectedText(), + ); +}); + +host.addEventListener("input", function (event) { + const selection = getSelection(); + events.push( + phase + + "-input:" + + event.target.id + + ":" + + child.textContent + + ":" + + selection.anchorOffset + + ":" + + selection.focusOffset, + ); }); promise_test(async function () { @@ -90,10 +94,10 @@ promise_test(async function () { "allowed-drop:child:allowed-text:hello world:world", "allowed-beforeinput:host:hello world:world", "allowed-input:host:hello allowed-text:18:18", - "blocked-drop:child:blocked-text:hello world:world", ], - "contenteditable drop should inherit editing host and stop at contenteditable=false", + "inherited contenteditable accepts the drop, while contenteditable=false rejects drop and editing events", ); assert_equals(child.textContent, "hello world", "contenteditable=false child was not edited"); + assert_equals(selectedText(), "world", "rejected drop preserves the selection inside the non-editable child"); }, "Input.dispatchDragEvent applies contenteditable inheritance for text drop defaults"); diff --git a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-effect-negotiation-basic.html b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-effect-negotiation-basic.html index ac57b05014..7519772dc7 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-effect-negotiation-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-effect-negotiation-basic.html @@ -82,13 +82,14 @@ promise_test(async function () { [ "blocked-enter:none:copy", "blocked-over:move:copy", - "blocked-drop:blocked-text:none:copy:hello world", + "blocked-over:move:copy", "allowed-enter:none:copyMove", "allowed-over:move:copyMove", + "allowed-over:move:copyMove", "allowed-drop:allowed-text:none:copyMove:hello world", "allowed-input:hello allowed-text:18:18", ], - "independent drop views start with none, while the negotiated operation gates default insertion", + "an author-accepted drop is dispatched only when its operation is allowed by the source", ); }, "Input.dispatchDragEvent gates text drop defaults on effect negotiation"); diff --git a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-file-drop-basic.html b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-file-drop-basic.html index e01262e4d3..f70886e32d 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-file-drop-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-file-drop-basic.html @@ -8,16 +8,20 @@ const input = document.getElementById("upload"); const events = []; let phase = "default"; -let preventedDropResolve = null; let defaultDropFile = null; let preventedDropFile = null; let defaultDropTransfer = null; let preventedDropTransfer = null; +let finishActions = null; -const preventedDrop = new Promise(function (resolve) { - preventedDropResolve = resolve; +const actionsFinished = new Promise(function (resolve) { + finishActions = resolve; }); +window.__finishDragFileDrop = function () { + finishActions(); +}; + window.__advanceDragFileDropPhase = function () { phase = "prevented"; }; @@ -44,7 +48,6 @@ input.addEventListener("drop", function (event) { ":" + input.files[0].name, ); - setTimeout(preventedDropResolve, 0); return; } @@ -83,7 +86,7 @@ input.addEventListener("change", function () { }); promise_test(async function () { - await preventedDrop; + await actionsFinished; assert_array_equals( events, [ diff --git a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-file-multiple-basic.html b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-file-multiple-basic.html index 125dd22878..33ad1d9ecf 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-file-multiple-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-file-multiple-basic.html @@ -8,15 +8,19 @@ const input = document.getElementById("upload"); const events = []; let phase = "single"; -let multipleChangeResolve = null; let multipleDropFiles = null; let singleSelection = null; let droppedTransfer = null; +let finishActions = null; -const multipleChanged = new Promise(function (resolve) { - multipleChangeResolve = resolve; +const actionsFinished = new Promise(function (resolve) { + finishActions = resolve; }); +window.__finishMultipleFileDrop = function () { + finishActions(); +}; + window.__enableMultipleFileDropPhase = function () { singleSelection = Array.from(input.files).map(file => file.name); input.multiple = true; @@ -59,23 +63,19 @@ input.addEventListener("change", function () { ":" + Array.from(input.files).map(file => file.name).join(","), ); - if (phase === "multiple") { - multipleChangeResolve(); - } }); promise_test(async function () { - await multipleChanged; - assert_array_equals(singleSelection, [], "single-file input rejects multiple files without changing selection"); + await actionsFinished; + assert_array_equals(singleSelection, [], "single-file input rejects a multiple-file drag without changing selection"); assert_array_equals( events, [ - "single-drop:2:first.txt,second.txt:0", "multiple-drop:2:alpha.txt,beta.txt:0", "multiple-input:2:alpha.txt,beta.txt:C:\\fakepath\\alpha.txt", "multiple-change:2:alpha.txt,beta.txt", ], - "single-file default editing rejects multiple files; multiple input retains both and dispatches input/change", + "single-file input rejects the drag before drop; multiple input accepts both files and emits input/change", ); assert_equals(input.files.length, 2, "multiple input keeps both dropped files"); assert_equals(input.files[0].name, "alpha.txt", "first multiple file"); @@ -90,7 +90,7 @@ promise_test(async function () { assert_equals(await input.files[1].text(), "beta", "second multiple file bytes"); assert_equals(await multipleDropFiles[0].text(), "alpha", "drop DataTransfer first file bytes"); assert_equals(await multipleDropFiles[1].text(), "beta", "drop DataTransfer second file bytes"); - assert_equals(droppedTransfer.files.length, 0, "completed drop disables its DataTransfer while captured Files remain readable"); + assert_equals(droppedTransfer.files.length, 0, "completed drop disables the retained DataTransfer, while captured Files stay readable"); assert_equals(input.value, "C:\\fakepath\\alpha.txt", "fakepath reflects first selected file"); }, "Input.dispatchDragEvent respects file input multiple selection semantics"); diff --git a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-lifecycle-basic.html b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-lifecycle-basic.html index 015f898dc0..6f8d002cac 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-lifecycle-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-lifecycle-basic.html @@ -78,6 +78,8 @@ promise_test(async function () { "dragenter:true::none:copyMove", "dragover-before:false:", "dragover-after::move", + "dragover-before:false:", + "dragover-after::move", "drop:false:drop-text:none:hello world", "input:hello drop-text:15:15", ], diff --git a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-operation-mask-basic.html b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-operation-mask-basic.html index 0d2893cd90..6601bd83d3 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-operation-mask-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-operation-mask-basic.html @@ -6,24 +6,20 @@