From 99fc673fdbb913a6c5eb7dfb2373895c86eb4ec6 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 29 May 2026 11:49:07 -0700 Subject: [PATCH 1/8] feat(webview): synthesize keyboard and mouse input Stock WebKit exposes no trusted Input domain, so input must be synthesized with DOM events. Synthetic events do not trigger the browser's default behaviors, so: - keyboard: emulate default text insertion (honoring the selection) on a non-prevented keypress, so press/type actually update inputs. - mouse: fire the full out/leave -> over/enter -> move sequence (mouse and pointer flavors) when the hovered element changes, so hover-driven UI reacts. The page-side dispatch lives in a typed, tsc-checked injected script (packages/injected/src/webview/webViewInput.ts), bundled and installed into every frame via BrowserContext.extendInjectedScript; wvInput drives it through window.__pwWebViewInput. Removes the now-passing input-dependent tests from the webview expectations. --- packages/injected/src/webview/webViewInput.ts | 292 ++++++++++++++++++ .../src/server/webkit/DEPS.list | 1 + .../src/server/webkit/webview/wvBrowser.ts | 2 + .../src/server/webkit/webview/wvInput.ts | 114 ++----- .../expectations/webkit-webview-page.txt | 8 - utils/generate_injected.js | 6 + 6 files changed, 327 insertions(+), 96 deletions(-) create mode 100644 packages/injected/src/webview/webViewInput.ts diff --git a/packages/injected/src/webview/webViewInput.ts b/packages/injected/src/webview/webViewInput.ts new file mode 100644 index 0000000000000..a6f6eb33abf2b --- /dev/null +++ b/packages/injected/src/webview/webViewInput.ts @@ -0,0 +1,292 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { InjectedScript } from '../injectedScript'; + +// Page-side input dispatch for the stock WebKit (WebView) backend. +// +// Stock WebKit only exposes the upstream Web Inspector Protocol, which has no +// trusted Input domain. Input is therefore synthesized with DOM events. Because +// synthetic events do not trigger the browser's default behaviors (typing, +// hover transitions), we emulate them here. This module is injected into every +// page via BrowserContext.extendInjectedScript so that the server-side wvInput +// can drive it through `window.__pwWebViewInput`. + +type Modifiers = { + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; +}; + +export type KeyEventParams = Modifiers & { + code: string; + key: string; + keyCode: number; + location: number; + repeat: boolean; + // Present for printable keys; absent for non-text keys (arrows, modifiers, etc.). + text?: string; +}; + +export type MouseEventParams = Modifiers & { + type: 'mousedown' | 'mouseup' | 'click' | 'auxclick' | 'dblclick' | 'contextmenu'; + x: number; + y: number; + button: number; + buttons: number; + clickCount: number; +}; + +export type MouseMoveParams = Modifiers & { + x: number; + y: number; + button: number; + buttons: number; +}; + +export type WheelParams = Modifiers & { + x: number; + y: number; + deltaX: number; + deltaY: number; +}; + +export type TapParams = Modifiers & { + x: number; + y: number; +}; + +const kTrustedSynthetic = '__pwTrustedSynthetic'; + +function markAndDispatch(node: EventTarget, event: Event): boolean { + Object.defineProperty(event, kTrustedSynthetic, { value: true }); + return node.dispatchEvent(event); +} + +export class WebViewInput { + private _window: Window & typeof globalThis; + private _document: Document; + private _hoverTarget: Element | null = null; + + constructor(injectedScript: InjectedScript) { + this._window = injectedScript.window; + this._document = injectedScript.document; + } + + // Descend through open shadow roots so synthetic events land on the actual + // element under the pointer rather than on the shadow host. + private _deepElementFromPoint(x: number, y: number): Element | null { + let el = this._document.elementFromPoint(x, y); + while (el && el.shadowRoot) { + const inner = el.shadowRoot.elementFromPoint(x, y); + if (!inner || inner === el) + break; + el = inner; + } + return el; + } + + private _insertText(target: Element | null, text: string) { + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + const start = target.selectionStart ?? target.value.length; + const end = target.selectionEnd ?? target.value.length; + target.value = target.value.slice(0, start) + text + target.value.slice(end); + const pos = start + text.length; + try { + target.setSelectionRange(pos, pos); + } catch { + } + target.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: false, data: text, inputType: 'insertText' })); + } else if (target && (target as HTMLElement).isContentEditable) { + this._document.execCommand('insertText', false, text); + } + } + + keydown(params: KeyEventParams) { + const target = this._document.activeElement || this._document.body; + if (!target) + return; + const init: KeyboardEventInit = { + bubbles: true, + cancelable: true, + view: this._window, + code: params.code, + key: params.key, + keyCode: params.keyCode, + which: params.keyCode, + location: params.location, + repeat: params.repeat, + ctrlKey: params.ctrlKey, + shiftKey: params.shiftKey, + altKey: params.altKey, + metaKey: params.metaKey, + }; + const notPrevented = markAndDispatch(target, new KeyboardEvent('keydown', init)); + if (params.text === undefined) + return; + const charCode = params.text.charCodeAt(0); + const charNotPrevented = markAndDispatch(target, new KeyboardEvent('keypress', { ...init, charCode, keyCode: charCode, which: charCode })); + // Synthetic key events do not perform the browser's default text-insertion, + // so emulate it here (honoring the current selection) unless the page + // cancelled keydown/keypress. + if (notPrevented && charNotPrevented) + this._insertText(target, params.text); + } + + keyup(params: KeyEventParams) { + const target = this._document.activeElement || this._document.body; + if (!target) + return; + const event = new KeyboardEvent('keyup', { + bubbles: true, + cancelable: true, + view: this._window, + code: params.code, + key: params.key, + keyCode: params.keyCode, + which: params.keyCode, + location: params.location, + ctrlKey: params.ctrlKey, + shiftKey: params.shiftKey, + altKey: params.altKey, + metaKey: params.metaKey, + }); + markAndDispatch(target, event); + } + + insertText(text: string) { + this._insertText(this._document.activeElement, text); + } + + mouseMove(params: MouseMoveParams) { + const target = this._deepElementFromPoint(params.x, params.y) || this._document.documentElement; + const base: MouseEventInit = { + bubbles: true, + cancelable: true, + view: this._window, + clientX: params.x, + clientY: params.y, + screenX: params.x, + screenY: params.y, + button: params.button, + buttons: params.buttons, + ctrlKey: params.ctrlKey, + shiftKey: params.shiftKey, + altKey: params.altKey, + metaKey: params.metaKey, + }; + const pointer: PointerEventInit = { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true }; + // A real pointer move fires the full out/leave -> over/enter -> move sequence + // (both mouse and pointer flavors) whenever the element under the pointer + // changes. Hover-driven UI (e.g. interstitials listening for mouseover / + // pointerover) does not react otherwise. + const prev = this._hoverTarget; + if (prev !== target) { + if (prev && prev.isConnected) { + markAndDispatch(prev, new PointerEvent('pointerout', { ...pointer, relatedTarget: target })); + markAndDispatch(prev, new MouseEvent('mouseout', { ...base, relatedTarget: target })); + markAndDispatch(prev, new PointerEvent('pointerleave', { ...pointer, bubbles: false, cancelable: false, relatedTarget: target })); + markAndDispatch(prev, new MouseEvent('mouseleave', { ...base, bubbles: false, cancelable: false, relatedTarget: target })); + } + markAndDispatch(target, new PointerEvent('pointerover', { ...pointer, relatedTarget: prev })); + markAndDispatch(target, new MouseEvent('mouseover', { ...base, relatedTarget: prev })); + markAndDispatch(target, new PointerEvent('pointerenter', { ...pointer, bubbles: false, cancelable: false, relatedTarget: prev })); + markAndDispatch(target, new MouseEvent('mouseenter', { ...base, bubbles: false, cancelable: false, relatedTarget: prev })); + this._hoverTarget = target; + } + markAndDispatch(target, new PointerEvent('pointermove', pointer)); + markAndDispatch(target, new MouseEvent('mousemove', base)); + } + + mouseEvent(params: MouseEventParams) { + const target = this._deepElementFromPoint(params.x, params.y) || this._document.documentElement; + const event = new MouseEvent(params.type, { + bubbles: true, + cancelable: true, + view: this._window, + clientX: params.x, + clientY: params.y, + screenX: params.x, + screenY: params.y, + button: params.button, + buttons: params.buttons, + detail: params.clickCount, + ctrlKey: params.ctrlKey, + shiftKey: params.shiftKey, + altKey: params.altKey, + metaKey: params.metaKey, + }); + markAndDispatch(target, event); + } + + wheel(params: WheelParams) { + const target = this._deepElementFromPoint(params.x, params.y) || this._document.documentElement; + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + view: this._window, + clientX: params.x, + clientY: params.y, + screenX: params.x, + screenY: params.y, + deltaX: params.deltaX, + deltaY: params.deltaY, + deltaMode: 0, + ctrlKey: params.ctrlKey, + shiftKey: params.shiftKey, + altKey: params.altKey, + metaKey: params.metaKey, + }); + markAndDispatch(target, event); + this._window.scrollBy(params.deltaX, params.deltaY); + } + + tap(params: TapParams) { + const target = this._deepElementFromPoint(params.x, params.y) || this._document.documentElement; + const init: MouseEventInit = { + bubbles: true, + cancelable: true, + view: this._window, + clientX: params.x, + clientY: params.y, + screenX: params.x, + screenY: params.y, + ctrlKey: params.ctrlKey, + shiftKey: params.shiftKey, + altKey: params.altKey, + metaKey: params.metaKey, + }; + try { + const touch = new Touch({ identifier: 0, target, clientX: params.x, clientY: params.y, screenX: params.x, screenY: params.y, pageX: params.x, pageY: params.y, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1 }); + markAndDispatch(target, new TouchEvent('touchstart', { ...init, touches: [touch], targetTouches: [touch], changedTouches: [touch] })); + markAndDispatch(target, new TouchEvent('touchend', { ...init, touches: [], targetTouches: [], changedTouches: [touch] })); + } catch { + } + markAndDispatch(target, new MouseEvent('mousedown', { ...init, button: 0, buttons: 1, detail: 1 })); + markAndDispatch(target, new MouseEvent('mouseup', { ...init, button: 0, buttons: 0, detail: 1 })); + markAndDispatch(target, new MouseEvent('click', { ...init, button: 0, buttons: 0, detail: 1 })); + } +} + +// Installed by injectedScript.extend(): `new WebViewInputInstaller(injectedScript)`. +class WebViewInputInstaller { + constructor(injectedScript: InjectedScript) { + (injectedScript.window as any).__pwWebViewInput = new WebViewInput(injectedScript); + } +} + +export default WebViewInputInstaller; diff --git a/packages/playwright-core/src/server/webkit/DEPS.list b/packages/playwright-core/src/server/webkit/DEPS.list index 67e194822dae6..6935c13606896 100644 --- a/packages/playwright-core/src/server/webkit/DEPS.list +++ b/packages/playwright-core/src/server/webkit/DEPS.list @@ -2,6 +2,7 @@ @isomorphic/** @utils/** ../ +../../generated/ ../registry/ node_modules/jpeg-js node_modules/pngjs diff --git a/packages/playwright-core/src/server/webkit/webview/wvBrowser.ts b/packages/playwright-core/src/server/webkit/webview/wvBrowser.ts index 3480dfa163c69..56c840cee628d 100644 --- a/packages/playwright-core/src/server/webkit/webview/wvBrowser.ts +++ b/packages/playwright-core/src/server/webkit/webview/wvBrowser.ts @@ -28,6 +28,7 @@ import { helper } from '../../helper'; import { perMessageDeflate } from '../../transport'; import { getUserAgent } from '../../userAgent'; import { BrowserContext } from '../../browserContext'; +import * as rawWebViewInputSource from '../../../generated/webViewInputSource'; import { DialogBridge } from './dialogBridge'; import { WVConnection } from './wvConnection'; import { WVPage } from './wvPage'; @@ -207,6 +208,7 @@ export class WVBrowser extends Browser { throw new Error(`No Mobile Safari tabs found at ${this._proxyBase}/json — open Safari first.`); } this._page = this._firstTab().page; + await this._context.extendInjectedScript(rawWebViewInputSource.source); } private _firstTab(): TabEntry { diff --git a/packages/playwright-core/src/server/webkit/webview/wvInput.ts b/packages/playwright-core/src/server/webkit/webview/wvInput.ts index afce325482fc7..526ba8e297eaa 100644 --- a/packages/playwright-core/src/server/webkit/webview/wvInput.ts +++ b/packages/playwright-core/src/server/webkit/webview/wvInput.ts @@ -30,18 +30,6 @@ function modifierFlags(modifiers: Set) { }; } -// Inlined into the page-side script; descends through open shadow roots so synthetic -// events land on the actual element under the pointer rather than on the shadow host. -const kDeepElementFromPointSrc = `(x, y) => { - let el = document.elementFromPoint(x, y); - while (el && el.shadowRoot) { - const inner = el.shadowRoot.elementFromPoint(x, y); - if (!inner || inner === el) break; - el = inner; - } - return el; -}`; - function buttonToNumber(button: types.MouseButton | 'none'): number { if (button === 'left') return 0; @@ -72,46 +60,22 @@ export class RawKeyboardImpl implements input.RawKeyboard { async keydown(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { const { code, keyCode, key, text, location } = description; - const mods = modifierFlags(modifiers); - const charCode = text ? text.charCodeAt(0) : 0; - const expr = `(() => { - const t = document.activeElement || document.body; - const init = { bubbles: true, cancelable: true, view: window, code: ${JSON.stringify(code)}, key: ${JSON.stringify(key)}, keyCode: ${keyCode}, which: ${keyCode}, location: ${location}, repeat: ${autoRepeat}, ctrlKey: ${mods.ctrlKey}, shiftKey: ${mods.shiftKey}, altKey: ${mods.altKey}, metaKey: ${mods.metaKey} }; - const dispatch = e => { Object.defineProperty(e, '__pwTrustedSynthetic', { value: true }); t.dispatchEvent(e); }; - dispatch(new KeyboardEvent('keydown', init)); - ${text ? `dispatch(new KeyboardEvent('keypress', { ...init, charCode: ${charCode}, keyCode: ${charCode}, which: ${charCode} }));` : ''} - })()`; - await evalInPage(progress, this._session, expr); + const params = { + code, key, keyCode, location, repeat: autoRepeat, + ...modifierFlags(modifiers), + ...(text ? { text } : {}), + }; + await callWebViewInput(progress, this._session, 'keydown', params); } async keyup(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription): Promise { const { code, keyCode, key, location } = description; - const mods = modifierFlags(modifiers); - const expr = `(() => { - const t = document.activeElement || document.body; - const event = new KeyboardEvent('keyup', { bubbles: true, cancelable: true, view: window, code: ${JSON.stringify(code)}, key: ${JSON.stringify(key)}, keyCode: ${keyCode}, which: ${keyCode}, location: ${location}, ctrlKey: ${mods.ctrlKey}, shiftKey: ${mods.shiftKey}, altKey: ${mods.altKey}, metaKey: ${mods.metaKey} }); - Object.defineProperty(event, '__pwTrustedSynthetic', { value: true }); - t.dispatchEvent(event); - })()`; - await evalInPage(progress, this._session, expr); + const params = { code, key, keyCode, location, ...modifierFlags(modifiers) }; + await callWebViewInput(progress, this._session, 'keyup', params); } async sendText(progress: Progress, text: string): Promise { - const expr = `(() => { - const text = ${JSON.stringify(text)}; - const t = document.activeElement; - if (t && (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement)) { - const start = t.selectionStart ?? t.value.length; - const end = t.selectionEnd ?? t.value.length; - t.value = t.value.slice(0, start) + text + t.value.slice(end); - const pos = start + text.length; - try { t.setSelectionRange(pos, pos); } catch {} - t.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: false, data: text, inputType: 'insertText' })); - } else if (t && t.isContentEditable) { - document.execCommand('insertText', false, text); - } - })()`; - await evalInPage(progress, this._session, expr); + await callWebViewInput(progress, this._session, 'insertText', text); } } @@ -123,52 +87,40 @@ export class RawMouseImpl implements input.RawMouse { } async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { - await this._dispatchMouse(progress, 'mousemove', x, y, buttonToNumber(button), toButtonsMask(buttons), modifiers, 0); + await callWebViewInput(progress, this._session, 'mouseMove', { + x, y, button: buttonToNumber(button), buttons: toButtonsMask(buttons), ...modifierFlags(modifiers), + }); } async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { const buttonCode = buttonToNumber(button); - await this._dispatchMouse(progress, 'mousedown', x, y, buttonCode, toButtonsMask(buttons), modifiers, clickCount); + const buttonsMask = toButtonsMask(buttons); + await this._mouseEvent(progress, 'mousedown', x, y, buttonCode, buttonsMask, modifiers, clickCount); if (button === 'right') - await this._dispatchMouse(progress, 'contextmenu', x, y, buttonCode, toButtonsMask(buttons), modifiers, clickCount); + await this._mouseEvent(progress, 'contextmenu', x, y, buttonCode, buttonsMask, modifiers, clickCount); } async up(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { const buttonCode = buttonToNumber(button); const buttonsMask = toButtonsMask(buttons); - await this._dispatchMouse(progress, 'mouseup', x, y, buttonCode, buttonsMask, modifiers, clickCount); + await this._mouseEvent(progress, 'mouseup', x, y, buttonCode, buttonsMask, modifiers, clickCount); if (clickCount > 0) { // Non-primary buttons fire 'auxclick'; primary fires 'click'. const clickType = button === 'left' ? 'click' : 'auxclick'; - await this._dispatchMouse(progress, clickType, x, y, buttonCode, buttonsMask, modifiers, clickCount); + await this._mouseEvent(progress, clickType, x, y, buttonCode, buttonsMask, modifiers, clickCount); if (clickCount === 2) - await this._dispatchMouse(progress, 'dblclick', x, y, buttonCode, buttonsMask, modifiers, clickCount); + await this._mouseEvent(progress, 'dblclick', x, y, buttonCode, buttonsMask, modifiers, clickCount); } } async wheel(progress: Progress, x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { - const mods = modifierFlags(modifiers); - const expr = `(() => { - const x = ${x}, y = ${y}; - const target = (${kDeepElementFromPointSrc})(x, y) || document.documentElement; - const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, screenX: x, screenY: y, deltaX: ${deltaX}, deltaY: ${deltaY}, deltaMode: 0, ctrlKey: ${mods.ctrlKey}, shiftKey: ${mods.shiftKey}, altKey: ${mods.altKey}, metaKey: ${mods.metaKey} }); - Object.defineProperty(event, '__pwTrustedSynthetic', { value: true }); - target.dispatchEvent(event); - window.scrollBy(${deltaX}, ${deltaY}); - })()`; - await evalInPage(progress, this._session, expr); + await callWebViewInput(progress, this._session, 'wheel', { x, y, deltaX, deltaY, ...modifierFlags(modifiers) }); } - private async _dispatchMouse(progress: Progress, type: string, x: number, y: number, button: number, buttons: number, modifiers: Set, clickCount: number) { - const mods = modifierFlags(modifiers); - const expr = `(() => { - const x = ${x}, y = ${y}; - const target = (${kDeepElementFromPointSrc})(x, y) || document.documentElement; - const event = new MouseEvent(${JSON.stringify(type)}, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, screenX: x, screenY: y, button: ${button}, buttons: ${buttons}, detail: ${clickCount}, ctrlKey: ${mods.ctrlKey}, shiftKey: ${mods.shiftKey}, altKey: ${mods.altKey}, metaKey: ${mods.metaKey} }); - Object.defineProperty(event, '__pwTrustedSynthetic', { value: true }); - target.dispatchEvent(event); - })()`; - await evalInPage(progress, this._session, expr); + private async _mouseEvent(progress: Progress, type: string, x: number, y: number, button: number, buttons: number, modifiers: Set, clickCount: number) { + await callWebViewInput(progress, this._session, 'mouseEvent', { + type, x, y, button, buttons, clickCount, ...modifierFlags(modifiers), + }); } } @@ -180,27 +132,13 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { } async tap(progress: Progress, x: number, y: number, modifiers: Set) { - const mods = modifierFlags(modifiers); - const expr = `(() => { - const x = ${x}, y = ${y}; - const target = (${kDeepElementFromPointSrc})(x, y) || document.documentElement; - const init = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, screenX: x, screenY: y, ctrlKey: ${mods.ctrlKey}, shiftKey: ${mods.shiftKey}, altKey: ${mods.altKey}, metaKey: ${mods.metaKey} }; - const dispatch = e => { Object.defineProperty(e, '__pwTrustedSynthetic', { value: true }); target.dispatchEvent(e); }; - try { - const touch = new Touch({ identifier: 0, target, clientX: x, clientY: y, screenX: x, screenY: y, pageX: x, pageY: y, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1 }); - dispatch(new TouchEvent('touchstart', { ...init, touches: [touch], targetTouches: [touch], changedTouches: [touch] })); - dispatch(new TouchEvent('touchend', { ...init, touches: [], targetTouches: [], changedTouches: [touch] })); - } catch {} - dispatch(new MouseEvent('mousedown', { ...init, button: 0, buttons: 1, detail: 1 })); - dispatch(new MouseEvent('mouseup', { ...init, button: 0, buttons: 0, detail: 1 })); - dispatch(new MouseEvent('click', { ...init, button: 0, buttons: 0, detail: 1 })); - })()`; - await evalInPage(progress, this._session, expr); + await callWebViewInput(progress, this._session, 'tap', { x, y, ...modifierFlags(modifiers) }); } } -async function evalInPage(progress: Progress, session: WVSession | undefined, expression: string): Promise { +async function callWebViewInput(progress: Progress, session: WVSession | undefined, method: string, arg: any): Promise { if (!session) throw new Error('Page is not initialized'); + const expression = `window.__pwWebViewInput.${method}(${JSON.stringify(arg)})`; await progress.race(session.send('Runtime.evaluate', { expression, returnByValue: true } as any)); } diff --git a/tests/webview/expectations/webkit-webview-page.txt b/tests/webview/expectations/webkit-webview-page.txt index ea66ffa6073dc..942b92c3b27df 100644 --- a/tests/webview/expectations/webkit-webview-page.txt +++ b/tests/webview/expectations/webkit-webview-page.txt @@ -216,11 +216,9 @@ page/page-screenshot.spec.ts › page screenshot animations › should not captu # deserves individual triage. # ============================================================================ page/elementhandle-bounding-box.spec.ts › should handle scroll offset and click [fail] -page/elementhandle-press.spec.ts › should not modify selection when focused [fail] page/elementhandle-press.spec.ts › should not select existing value [fail] page/elementhandle-press.spec.ts › should reset selection when not focused [fail] page/elementhandle-press.spec.ts › should work [fail] -page/elementhandle-type.spec.ts › should not modify selection when focused [fail] page/elementhandle-type.spec.ts › should not select existing value [fail] page/elementhandle-type.spec.ts › should reset selection when not focused [fail] page/elementhandle-type.spec.ts › should work [fail] @@ -254,12 +252,8 @@ page/locator-query.spec.ts › should allow some, but not all nested frameLocato page/network-post-data.spec.ts › should return correct postData buffer for utf-8 body [fail] page/network-post-data.spec.ts › should throw on invalid JSON in post data [fail] page/page-add-init-script.spec.ts › init script should run only once in iframe [fail] -page/page-add-locator-handler.spec.ts › should wait for hidden by default 2 [fail] page/page-add-locator-handler.spec.ts › should work when owner frame detaches [fail] page/page-add-locator-handler.spec.ts › should work with locator.hover() [fail] -page/page-add-locator-handler.spec.ts › should work with locator.waitFor [fail] -page/page-add-locator-handler.spec.ts › should work with times: option [fail] -page/page-add-locator-handler.spec.ts › should work › mouseover 1 times [fail] page/page-aria-snapshot-ai.spec.ts › emit generic roles for nodes w/o roles [fail] page/page-aria-snapshot-ai.spec.ts › should include cursor pointer hint [fail] page/page-aria-snapshot-ai.spec.ts › should not generate refs for elements with pointer-events:none [fail] @@ -409,7 +403,6 @@ page/elementhandle-scroll-into-view.spec.ts › should wait for display:none to page/frame-evaluate.spec.ts › should work in iframes that interrupted initial javascript url navigation [fail] page/frame-goto.spec.ts › should reject when frame detaches [fail] page/locator-misc-2.spec.ts › should pressSequentially [fail] -page/page-add-locator-handler.spec.ts › should throw when handler times out [fail] page/page-aria-snapshot-ai.spec.ts › should snapshot a locator inside an iframe [fail] page/page-click-react.spec.ts › should not retarget when element changes on hover [fail] page/page-click.spec.ts › should not wait with noAutoWaiting 2 [fail] @@ -770,7 +763,6 @@ page/network-post-data.spec.ts › should get post data for file/blob [fail] page/network-post-data.spec.ts › should get post data for navigator.sendBeacon api calls [fail] page/page-add-init-script.spec.ts › should remove init script after dispose [fail] page/page-add-init-script.spec.ts › should work after a cross origin navigation [fail] -page/page-add-locator-handler.spec.ts › should work [fail] page/page-autowaiting-basic.spec.ts › should await cross-process navigation when clicking anchor [fail] page/page-autowaiting-basic.spec.ts › should await form-get on click [fail] page/page-autowaiting-no-hang.spec.ts › clicking in the middle of navigation that commits [fail] diff --git a/utils/generate_injected.js b/utils/generate_injected.js index e661df9d5e7f0..022e069a83b97 100644 --- a/utils/generate_injected.js +++ b/utils/generate_injected.js @@ -74,6 +74,12 @@ const injectedScripts = [ path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'), true, ], + [ + path.join(ROOT, 'packages', 'injected', 'src', 'webview', 'webViewInput.ts'), + path.join(ROOT, 'packages', 'injected', 'lib'), + path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'), + true, + ], [ path.join(ROOT, 'packages', 'playwright-ct-core', 'src', 'injected', 'index.ts'), path.join(ROOT, 'packages', 'playwright-ct-core', 'lib', 'injected', 'packed'), From fa25da73f34d0eaa718503b4348c4cd1df860130 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 29 May 2026 12:59:24 -0700 Subject: [PATCH 2/8] fix(webview): install input dispatcher via bootstrap; support shadow-DOM typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Injecting window.__pwWebViewInput through extendInjectedScript was racy: it re-injects asynchronously after a navigation, so input dispatched immediately after page.goto ran before the dispatcher existed and silently no-op'd. Install it through the page bootstrap script instead, which runs synchronously on every new document before page scripts — no race. Also descend through open shadow roots when resolving the active element, so typing/insertText targets the focused element inside a shadow root rather than the shadow host. Removes the now-passing page-keyboard tests from the webview expectations. The remaining keyboard failures need native default actions that synthetic events cannot trigger on stock WebKit (caret movement, selectAll, clipboard, undo, button activation) or unimplemented features (contentFrame, keyIdentifier). --- packages/injected/src/webview/webViewInput.ts | 21 +++++++++++++------ .../src/server/webkit/webview/wvBrowser.ts | 2 -- .../src/server/webkit/webview/wvPage.ts | 18 ++++++++++++++-- .../expectations/webkit-webview-page.txt | 14 +------------ 4 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/injected/src/webview/webViewInput.ts b/packages/injected/src/webview/webViewInput.ts index a6f6eb33abf2b..ddb7b4a3d815d 100644 --- a/packages/injected/src/webview/webViewInput.ts +++ b/packages/injected/src/webview/webViewInput.ts @@ -21,9 +21,9 @@ import type { InjectedScript } from '../injectedScript'; // Stock WebKit only exposes the upstream Web Inspector Protocol, which has no // trusted Input domain. Input is therefore synthesized with DOM events. Because // synthetic events do not trigger the browser's default behaviors (typing, -// hover transitions), we emulate them here. This module is injected into every -// page via BrowserContext.extendInjectedScript so that the server-side wvInput -// can drive it through `window.__pwWebViewInput`. +// hover transitions), we emulate them here. This module is installed on every +// document via the WV page bootstrap script so that the server-side wvInput can +// drive it through `window.__pwWebViewInput`. type Modifiers = { ctrlKey: boolean; @@ -100,6 +100,15 @@ export class WebViewInput { return el; } + // The focused element may live inside one or more shadow roots, where + // document.activeElement only reports the outermost shadow host. + private _deepActiveElement(): Element | null { + let active = this._document.activeElement; + while (active && active.shadowRoot && active.shadowRoot.activeElement) + active = active.shadowRoot.activeElement; + return active; + } + private _insertText(target: Element | null, text: string) { if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { const start = target.selectionStart ?? target.value.length; @@ -117,7 +126,7 @@ export class WebViewInput { } keydown(params: KeyEventParams) { - const target = this._document.activeElement || this._document.body; + const target = this._deepActiveElement() || this._document.body; if (!target) return; const init: KeyboardEventInit = { @@ -148,7 +157,7 @@ export class WebViewInput { } keyup(params: KeyEventParams) { - const target = this._document.activeElement || this._document.body; + const target = this._deepActiveElement() || this._document.body; if (!target) return; const event = new KeyboardEvent('keyup', { @@ -169,7 +178,7 @@ export class WebViewInput { } insertText(text: string) { - this._insertText(this._document.activeElement, text); + this._insertText(this._deepActiveElement(), text); } mouseMove(params: MouseMoveParams) { diff --git a/packages/playwright-core/src/server/webkit/webview/wvBrowser.ts b/packages/playwright-core/src/server/webkit/webview/wvBrowser.ts index 56c840cee628d..3480dfa163c69 100644 --- a/packages/playwright-core/src/server/webkit/webview/wvBrowser.ts +++ b/packages/playwright-core/src/server/webkit/webview/wvBrowser.ts @@ -28,7 +28,6 @@ import { helper } from '../../helper'; import { perMessageDeflate } from '../../transport'; import { getUserAgent } from '../../userAgent'; import { BrowserContext } from '../../browserContext'; -import * as rawWebViewInputSource from '../../../generated/webViewInputSource'; import { DialogBridge } from './dialogBridge'; import { WVConnection } from './wvConnection'; import { WVPage } from './wvPage'; @@ -208,7 +207,6 @@ export class WVBrowser extends Browser { throw new Error(`No Mobile Safari tabs found at ${this._proxyBase}/json — open Safari first.`); } this._page = this._firstTab().page; - await this._context.extendInjectedScript(rawWebViewInputSource.source); } private _firstTab(): TabEntry { diff --git a/packages/playwright-core/src/server/webkit/webview/wvPage.ts b/packages/playwright-core/src/server/webkit/webview/wvPage.ts index ac5f195c82530..fa36aa44f24dd 100644 --- a/packages/playwright-core/src/server/webkit/webview/wvPage.ts +++ b/packages/playwright-core/src/server/webkit/webview/wvPage.ts @@ -28,6 +28,7 @@ import * as dom from '../../dom'; import { TargetClosedError } from '../../errors'; import { helper } from '../../helper'; import { saveGlobalsSnapshotSource } from '../../javascript'; +import * as rawWebViewInputSource from '../../../generated/webViewInputSource'; import * as network from '../../network'; import { Page, PageBinding } from '../../page'; import { WVSession } from './wvConnection'; @@ -186,8 +187,9 @@ export class WVPage implements PageDelegate { session.sendMayFail('Network.addInterception', { url: '.*', stage: 'request', isRegex: true }), ]); } - // Inject the dialog bridge into the currently-loaded document too — - // bootstrap only applies to future navigations. + // Inject the page-side input dispatcher and dialog bridge into the + // currently-loaded document too — bootstrap only applies to future navigations. + await session.sendMayFail('Runtime.evaluate', { expression: webViewInputBootstrapSource, returnByValue: true } as any); if (this._dialogEndpoint) { await session.sendMayFail('Runtime.evaluate', { expression: dialogBridgeSource(this._dialogEndpoint), @@ -612,6 +614,7 @@ export class WVPage implements PageDelegate { scripts.push('if (!window.GestureEvent) window.GestureEvent = function GestureEvent() {};'); scripts.push(this._publicKeyCredentialScript()); scripts.push(bindingBridgeSource); + scripts.push(webViewInputBootstrapSource); if (this._dialogEndpoint) scripts.push(dialogBridgeSource(this._dialogEndpoint)); scripts.push(...this._page.allInitScripts().map(script => script.source)); @@ -996,6 +999,17 @@ function isLoadedSecurely(url: string, timing: network.ResourceTiming) { } const BINDING_CALL_TAG = '__pw_binding_call__'; +// Install the page-side input dispatcher (window.__pwWebViewInput) on every +// document. Runs synchronously via Page.setBootstrapScript before page scripts, +// so input dispatched right after a navigation never races a missing dispatcher. +// The bundled module's installer only reads `.window`/`.document`, so a minimal +// shim stands in for the InjectedScript it normally receives. +const webViewInputBootstrapSource = `(() => { + const module = {}; + ${rawWebViewInputSource.source} + new (module.exports.default())({ window, document }); +})()`; + const bindingBridgeSource = ` if (!window['${PageBinding.kBindingName}']) { Object.defineProperty(window, '${PageBinding.kBindingName}', { diff --git a/tests/webview/expectations/webkit-webview-page.txt b/tests/webview/expectations/webkit-webview-page.txt index 942b92c3b27df..b34130568840c 100644 --- a/tests/webview/expectations/webkit-webview-page.txt +++ b/tests/webview/expectations/webkit-webview-page.txt @@ -319,7 +319,6 @@ page/page-goto.spec.ts › should send referer of cross-origin URL [fail] page/page-goto.spec.ts › should work with cross-process that fails before committing [fail] page/page-history.spec.ts › page.goBack should work with HistoryAPI [fail] page/page-history.spec.ts › page.reload should work with data url [fail] -page/page-keyboard.spec.ts › locator should pressSequentially with namedKeys [fail] page/page-keyboard.spec.ts › should be able to prevent selectAll [fail] page/page-keyboard.spec.ts › should dispatch a click event on a button when Space gets pressed [fail] page/page-keyboard.spec.ts › should dispatch insertText after context menu was opened [fail] @@ -327,20 +326,11 @@ page/page-keyboard.spec.ts › should expose keyIdentifier in webkit [fail] page/page-keyboard.spec.ts › should move around the selection in a contenteditable [fail] page/page-keyboard.spec.ts › should move to the start of the document [fail] page/page-keyboard.spec.ts › should move with the arrow keys [fail] -page/page-keyboard.spec.ts › should not type canceled events [fail] -page/page-keyboard.spec.ts › should press Enter [fail] -page/page-keyboard.spec.ts › should send a character with ElementHandle.press [fail] +page/page-keyboard.spec.ts › should scroll with PageDown [fail] page/page-keyboard.spec.ts › should support MacOS shortcuts [fail] page/page-keyboard.spec.ts › should support simple copy-pasting [fail] page/page-keyboard.spec.ts › should support simple cut-pasting [fail] page/page-keyboard.spec.ts › should support undo-redo [fail] -page/page-keyboard.spec.ts › should type all kinds of characters [fail] -page/page-keyboard.spec.ts › should type emoji [fail] -page/page-keyboard.spec.ts › should type into a textarea @smoke [fail] -page/page-keyboard.spec.ts › should type repeatedly in contenteditable in shadow dom [fail] -page/page-keyboard.spec.ts › should type repeatedly in contenteditable in shadow dom with nested elements [fail] -page/page-keyboard.spec.ts › should type repeatedly in input in shadow dom [fail] -page/page-keyboard.spec.ts › should type with namedKeys [fail] page/page-localstorage.spec.ts › localStorage.removeItem removes a single item [fail] page/page-mouse.spec.ts › down and up should generate click [fail] page/page-mouse.spec.ts › should click the document @smoke [fail] @@ -458,8 +448,6 @@ page/page-history.spec.ts › page.goForward during renderer-initiated navigatio page/page-history.spec.ts › page.reload should work on a page with a hash [fail] page/page-history.spec.ts › page.reload should work with cross-origin redirect [fail] page/page-history.spec.ts › should reload proper page [fail] -page/page-keyboard.spec.ts › should scroll with PageDown [fail] -page/page-keyboard.spec.ts › should work after a cross origin navigation [fail] page/page-leaks.spec.ts › click should not leak [fail] page/page-leaks.spec.ts › expect should not leak [fail] page/page-leaks.spec.ts › fill should not leak [fail] From 9258264694d4497c927c62253bbc7f4fecaea9e1 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 29 May 2026 13:01:22 -0700 Subject: [PATCH 3/8] test(webview): move 'type emoji into an iframe' to the contentFrame section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It fails because elementHandle.contentFrame is unimplemented, not because of generic structural/popup limitations — group it with the other frame-element-and-ownership skips whose comment documents that cause. --- tests/webview/expectations/webkit-webview-page.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/webview/expectations/webkit-webview-page.txt b/tests/webview/expectations/webkit-webview-page.txt index b34130568840c..83d6abf9a7cbd 100644 --- a/tests/webview/expectations/webkit-webview-page.txt +++ b/tests/webview/expectations/webkit-webview-page.txt @@ -106,6 +106,7 @@ page/page-click.spec.ts › should click the button inside an iframe [fail] page/page-click.spec.ts › should not hang when frame is detached [fail] page/page-drag.spec.ts › Drag and drop › should work inside iframe [fail] page/page-evaluate.spec.ts › should throw when frame is detached [fail] +page/page-keyboard.spec.ts › should type emoji into an iframe [fail] page/page-network-request.spec.ts › should work for subframe navigation request [fail] page/page-wait-for-function.spec.ts › should throw when frame is detached [fail] page/page-wait-for-selector-1.spec.ts › page.waitForSelector is shortcut for main frame [fail] @@ -666,7 +667,6 @@ page/page-goto.spec.ts › should capture cross-process iframe navigation reques page/page-goto.spec.ts › should capture iframe navigation request [fail] page/page-goto.spec.ts › should wait for load when iframe attaches and detaches [fail] page/page-goto.spec.ts › should work with lazy loading iframes [fail] -page/page-keyboard.spec.ts › should type emoji into an iframe [fail] page/page-network-idle.spec.ts › should wait for networkidle from the popup [fail] page/page-network-idle.spec.ts › should wait for networkidle when iframe attaches and detaches [fail] page/page-network-idle.spec.ts › should wait for networkidle when navigating iframe [fail] From cac1bf6cdd8ea2612303edeb3f1399c78fdf55d6 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 29 May 2026 13:15:03 -0700 Subject: [PATCH 4/8] feat(webview): synthesize KeyboardEvent.keyIdentifier keyIdentifier is a legacy WebKit-only property that cannot be supplied via the KeyboardEvent constructor, so synthetic key events reported "". Compute it from the virtual key code (mirroring WebCore's keyIdentifierForWindowsKeyCode) and define it on the event before dispatch. Removes 'should expose keyIdentifier in webkit' from the webview expectations. --- packages/injected/src/webview/webViewInput.ts | 48 +++++++++++++++++-- .../expectations/webkit-webview-page.txt | 1 - 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/injected/src/webview/webViewInput.ts b/packages/injected/src/webview/webViewInput.ts index ddb7b4a3d815d..e941fa823e176 100644 --- a/packages/injected/src/webview/webViewInput.ts +++ b/packages/injected/src/webview/webViewInput.ts @@ -77,6 +77,47 @@ function markAndDispatch(node: EventTarget, event: Event): boolean { return node.dispatchEvent(event); } +// Legacy WebKit-only KeyboardEvent.keyIdentifier (a DOM Level 3 draft property +// dropped by every other engine). It cannot be supplied via the constructor, so +// compute it from the virtual key code and define it on the event before +// dispatch. Mirrors WebCore's keyIdentifierForWindowsKeyCode. +const kNamedKeyIdentifiers: Record = { + 8: 'U+0008', // Backspace + 9: 'U+0009', // Tab + 13: 'Enter', + 16: 'Shift', + 17: 'Control', + 18: 'Alt', + 27: 'U+001B', // Escape + 33: 'PageUp', + 34: 'PageDown', + 35: 'End', + 36: 'Home', + 37: 'Left', + 38: 'Up', + 39: 'Right', + 40: 'Down', + 45: 'Insert', + 46: 'U+007F', // Delete +}; + +function keyIdentifierFor(keyCode: number, key: string): string { + const named = kNamedKeyIdentifiers[keyCode]; + if (named !== undefined) + return named; + if (keyCode >= 112 && keyCode <= 135) + return 'F' + (keyCode - 111); + if (key.length === 1) + return 'U+' + key.toUpperCase().charCodeAt(0).toString(16).toUpperCase().padStart(4, '0'); + return ''; +} + +function dispatchKeyEvent(node: EventTarget, type: string, init: KeyboardEventInit, keyCode: number, key: string): boolean { + const event = new KeyboardEvent(type, init); + Object.defineProperty(event, 'keyIdentifier', { value: keyIdentifierFor(keyCode, key), configurable: true }); + return markAndDispatch(node, event); +} + export class WebViewInput { private _window: Window & typeof globalThis; private _document: Document; @@ -144,7 +185,7 @@ export class WebViewInput { altKey: params.altKey, metaKey: params.metaKey, }; - const notPrevented = markAndDispatch(target, new KeyboardEvent('keydown', init)); + const notPrevented = dispatchKeyEvent(target, 'keydown', init, params.keyCode, params.key); if (params.text === undefined) return; const charCode = params.text.charCodeAt(0); @@ -160,7 +201,7 @@ export class WebViewInput { const target = this._deepActiveElement() || this._document.body; if (!target) return; - const event = new KeyboardEvent('keyup', { + dispatchKeyEvent(target, 'keyup', { bubbles: true, cancelable: true, view: this._window, @@ -173,8 +214,7 @@ export class WebViewInput { shiftKey: params.shiftKey, altKey: params.altKey, metaKey: params.metaKey, - }); - markAndDispatch(target, event); + }, params.keyCode, params.key); } insertText(text: string) { diff --git a/tests/webview/expectations/webkit-webview-page.txt b/tests/webview/expectations/webkit-webview-page.txt index 83d6abf9a7cbd..bcd9f2c694a27 100644 --- a/tests/webview/expectations/webkit-webview-page.txt +++ b/tests/webview/expectations/webkit-webview-page.txt @@ -323,7 +323,6 @@ page/page-history.spec.ts › page.reload should work with data url [fail] page/page-keyboard.spec.ts › should be able to prevent selectAll [fail] page/page-keyboard.spec.ts › should dispatch a click event on a button when Space gets pressed [fail] page/page-keyboard.spec.ts › should dispatch insertText after context menu was opened [fail] -page/page-keyboard.spec.ts › should expose keyIdentifier in webkit [fail] page/page-keyboard.spec.ts › should move around the selection in a contenteditable [fail] page/page-keyboard.spec.ts › should move to the start of the document [fail] page/page-keyboard.spec.ts › should move with the arrow keys [fail] From 7fdc2550a80544d20a72a40100a89ba5b918161c Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 29 May 2026 14:07:02 -0700 Subject: [PATCH 5/8] test(webview): unskip elementhandle-type / elementhandle-press tests The keyboard input fixes make these pass; the only remaining failures in both files are the 'should work with number input' tests, which are it.fail on WebKit upstream and therefore already expected. --- tests/webview/expectations/webkit-webview-page.txt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/webview/expectations/webkit-webview-page.txt b/tests/webview/expectations/webkit-webview-page.txt index bcd9f2c694a27..5c39bc972a6ca 100644 --- a/tests/webview/expectations/webkit-webview-page.txt +++ b/tests/webview/expectations/webkit-webview-page.txt @@ -217,12 +217,6 @@ page/page-screenshot.spec.ts › page screenshot animations › should not captu # deserves individual triage. # ============================================================================ page/elementhandle-bounding-box.spec.ts › should handle scroll offset and click [fail] -page/elementhandle-press.spec.ts › should not select existing value [fail] -page/elementhandle-press.spec.ts › should reset selection when not focused [fail] -page/elementhandle-press.spec.ts › should work [fail] -page/elementhandle-type.spec.ts › should not select existing value [fail] -page/elementhandle-type.spec.ts › should reset selection when not focused [fail] -page/elementhandle-type.spec.ts › should work [fail] page/expect-boolean.spec.ts › toBeAttached › with frameLocator [fail] page/expect-boolean.spec.ts › toBeVisible › with frameLocator [fail] page/expect-boolean.spec.ts › toBeVisible › with frameLocator 2 [fail] From 4d3fdfa0588b431f59dfccbab212d54c4221f419 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 29 May 2026 14:13:58 -0700 Subject: [PATCH 6/8] test(webview): unskip locator/page press and type tests The keyboard input fixes also make these pass: - locator-misc-2: should press @smoke, should type, should pressSequentially - page-basic: page.press should work --- tests/webview/expectations/webkit-webview-page.txt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/webview/expectations/webkit-webview-page.txt b/tests/webview/expectations/webkit-webview-page.txt index 5c39bc972a6ca..352488eb3e263 100644 --- a/tests/webview/expectations/webkit-webview-page.txt +++ b/tests/webview/expectations/webkit-webview-page.txt @@ -240,9 +240,7 @@ page/frame-goto.spec.ts › should continue after client redirect [fail] page/interception.spec.ts › should intercept network activity from worker 2 [fail] page/locator-frame.spec.ts › should click in lazy iframe [fail] page/locator-misc-1.spec.ts › should clear input [fail] -page/locator-misc-2.spec.ts › should press @smoke [fail] page/locator-misc-2.spec.ts › should scroll zero-sized element into view [fail] -page/locator-misc-2.spec.ts › should type [fail] page/locator-query.spec.ts › should allow some, but not all nested frameLocators [fail] page/network-post-data.spec.ts › should return correct postData buffer for utf-8 body [fail] page/network-post-data.spec.ts › should throw on invalid JSON in post data [fail] @@ -259,7 +257,6 @@ page/page-autowaiting-basic.spec.ts › should report navigation in the log when page/page-autowaiting-basic.spec.ts › should work with waitForLoadState(load) [fail] page/page-basic.spec.ts › async stacks should work [fail] page/page-basic.spec.ts › has navigator.webdriver set to true [fail] -page/page-basic.spec.ts › page.press should work [fail] page/page-basic.spec.ts › page.url should include hashes [fail] page/page-basic.spec.ts › should have sane user agent [fail] page/page-click-react.spec.ts › should not retarget when element is recycled on hover [fail] @@ -386,7 +383,6 @@ page/elementhandle-scroll-into-view.spec.ts › should throw for detached elemen page/elementhandle-scroll-into-view.spec.ts › should wait for display:none to become visible [fail] page/frame-evaluate.spec.ts › should work in iframes that interrupted initial javascript url navigation [fail] page/frame-goto.spec.ts › should reject when frame detaches [fail] -page/locator-misc-2.spec.ts › should pressSequentially [fail] page/page-aria-snapshot-ai.spec.ts › should snapshot a locator inside an iframe [fail] page/page-click-react.spec.ts › should not retarget when element changes on hover [fail] page/page-click.spec.ts › should not wait with noAutoWaiting 2 [fail] From 5a76d65de8ee6070e44357ebdb6022da572bcc41 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 29 May 2026 14:45:24 -0700 Subject: [PATCH 7/8] fix(webview): fire textInput and use its native insertion Typing previously dispatched keydown/keypress and then manually set the value, so the textInput (TextEvent) that real WebKit fires between keypress and input was missing. Dispatch it via initTextEvent and let its default action perform the insertion (which also produces the real beforeinput/input), matching the platform. Enter's text is '\r' but the inserted/textInput data is a newline. Adds a cross-browser test asserting the keydown/keypress/textInput/input/keyup sequence when typing a character. --- packages/injected/src/webview/webViewInput.ts | 20 ++++++++++++++----- tests/page/page-keyboard.spec.ts | 13 ++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/injected/src/webview/webViewInput.ts b/packages/injected/src/webview/webViewInput.ts index e941fa823e176..a38a013d2d9bd 100644 --- a/packages/injected/src/webview/webViewInput.ts +++ b/packages/injected/src/webview/webViewInput.ts @@ -190,11 +190,21 @@ export class WebViewInput { return; const charCode = params.text.charCodeAt(0); const charNotPrevented = markAndDispatch(target, new KeyboardEvent('keypress', { ...init, charCode, keyCode: charCode, which: charCode })); - // Synthetic key events do not perform the browser's default text-insertion, - // so emulate it here (honoring the current selection) unless the page - // cancelled keydown/keypress. - if (notPrevented && charNotPrevented) - this._insertText(target, params.text); + if (!notPrevented || !charNotPrevented) + return; + // Real WebKit fires a `textInput` (TextEvent) whose default action performs + // the insertion (and the subsequent beforeinput/input). Replicate it; the + // event's default does the insertion, so we do not insert manually. Enter's + // text is '\r' but the inserted/textInput data is a newline. + this._dispatchTextInput(target, params.text === '\r' ? '\n' : params.text); + } + + private _dispatchTextInput(target: EventTarget, text: string) { + // TextEvent has no usable constructor in WebKit — initTextEvent is the only + // way to create one (initTextEvent(type, bubbles, cancelable, view, data)). + const event = this._document.createEvent('TextEvent') as any; + event.initTextEvent('textInput', true, true, this._window, text); + markAndDispatch(target, event); } keyup(params: KeyEventParams) { diff --git a/tests/page/page-keyboard.spec.ts b/tests/page/page-keyboard.spec.ts index 954b590c51bad..56f879afb2fba 100644 --- a/tests/page/page-keyboard.spec.ts +++ b/tests/page/page-keyboard.spec.ts @@ -85,6 +85,19 @@ it('insertText should only emit input event', async ({ page, server }) => { expect(await events.jsonValue()).toEqual(['input']); }); +it('should emit keydown, keypress, textInput and input when typing a character', async ({ page }) => { + await page.setContent(``); + const events = await page.evaluateHandle(() => { + const events: string[] = []; + for (const type of ['keydown', 'keypress', 'textInput', 'input', 'keyup']) + document.querySelector('input').addEventListener(type, () => events.push(type)); + return events; + }); + await page.focus('input'); + await page.keyboard.press('f'); + expect(await events.jsonValue()).toEqual(['keydown', 'keypress', 'textInput', 'input', 'keyup']); +}); + it('should report shiftKey', async ({ page, server, browserName, platform }) => { it.fail(browserName === 'firefox' && platform === 'darwin'); From bf90b8d8c14585cded72addcfbf76924f704f353 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 29 May 2026 15:14:43 -0700 Subject: [PATCH 8/8] chore(webview): simplify input installer and trim architecture comments Drop the WebViewInputInstaller indirection (a leftover from the extendInjectedScript design); the bootstrap now constructs WebViewInput directly with (window, document) and assigns it. Remove architecture-explaining comments, keeping only the browser-specific ones. --- packages/injected/src/webview/webViewInput.ts | 30 +++---------------- .../src/server/webkit/webview/wvPage.ts | 7 +---- 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/packages/injected/src/webview/webViewInput.ts b/packages/injected/src/webview/webViewInput.ts index a38a013d2d9bd..4b293c0059997 100644 --- a/packages/injected/src/webview/webViewInput.ts +++ b/packages/injected/src/webview/webViewInput.ts @@ -14,17 +14,6 @@ * limitations under the License. */ -import type { InjectedScript } from '../injectedScript'; - -// Page-side input dispatch for the stock WebKit (WebView) backend. -// -// Stock WebKit only exposes the upstream Web Inspector Protocol, which has no -// trusted Input domain. Input is therefore synthesized with DOM events. Because -// synthetic events do not trigger the browser's default behaviors (typing, -// hover transitions), we emulate them here. This module is installed on every -// document via the WV page bootstrap script so that the server-side wvInput can -// drive it through `window.__pwWebViewInput`. - type Modifiers = { ctrlKey: boolean; shiftKey: boolean; @@ -123,9 +112,9 @@ export class WebViewInput { private _document: Document; private _hoverTarget: Element | null = null; - constructor(injectedScript: InjectedScript) { - this._window = injectedScript.window; - this._document = injectedScript.document; + constructor(window: Window & typeof globalThis, document: Document) { + this._window = window; + this._document = document; } // Descend through open shadow roots so synthetic events land on the actual @@ -249,10 +238,6 @@ export class WebViewInput { metaKey: params.metaKey, }; const pointer: PointerEventInit = { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true }; - // A real pointer move fires the full out/leave -> over/enter -> move sequence - // (both mouse and pointer flavors) whenever the element under the pointer - // changes. Hover-driven UI (e.g. interstitials listening for mouseover / - // pointerover) does not react otherwise. const prev = this._hoverTarget; if (prev !== target) { if (prev && prev.isConnected) { @@ -341,11 +326,4 @@ export class WebViewInput { } } -// Installed by injectedScript.extend(): `new WebViewInputInstaller(injectedScript)`. -class WebViewInputInstaller { - constructor(injectedScript: InjectedScript) { - (injectedScript.window as any).__pwWebViewInput = new WebViewInput(injectedScript); - } -} - -export default WebViewInputInstaller; +export default WebViewInput; diff --git a/packages/playwright-core/src/server/webkit/webview/wvPage.ts b/packages/playwright-core/src/server/webkit/webview/wvPage.ts index fa36aa44f24dd..e043cd4635b0e 100644 --- a/packages/playwright-core/src/server/webkit/webview/wvPage.ts +++ b/packages/playwright-core/src/server/webkit/webview/wvPage.ts @@ -999,15 +999,10 @@ function isLoadedSecurely(url: string, timing: network.ResourceTiming) { } const BINDING_CALL_TAG = '__pw_binding_call__'; -// Install the page-side input dispatcher (window.__pwWebViewInput) on every -// document. Runs synchronously via Page.setBootstrapScript before page scripts, -// so input dispatched right after a navigation never races a missing dispatcher. -// The bundled module's installer only reads `.window`/`.document`, so a minimal -// shim stands in for the InjectedScript it normally receives. const webViewInputBootstrapSource = `(() => { const module = {}; ${rawWebViewInputSource.source} - new (module.exports.default())({ window, document }); + window.__pwWebViewInput = new (module.exports.default())(window, document); })()`; const bindingBridgeSource = `