Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions packages/injected/src/webview/webViewInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ function markAndDispatch(node: EventTarget, event: Event): boolean {
return node.dispatchEvent(event);
}

function isFrameOwner(element: Element | null): element is HTMLIFrameElement | HTMLFrameElement {
return !!element && (element.localName === 'iframe' || element.localName === 'frame');
}

// 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
Expand Down Expand Up @@ -145,6 +149,19 @@ export class WebViewInput {
return el;
}

positionInIFrame(x: number, y: number): { iframe: HTMLIFrameElement | HTMLFrameElement | null, x: number, y: number } {
const target = this._deepElementFromPoint(x, y);
if (!isFrameOwner(target))
return { iframe: null, x, y };
const frameRect = target.getBoundingClientRect();
const frameStyle = this._window.getComputedStyle(target);
return {
iframe: target,
x: x - frameRect.left - parseFloat(frameStyle.borderLeftWidth) - parseFloat(frameStyle.paddingLeft),
y: y - frameRect.top - parseFloat(frameStyle.borderTopWidth) - parseFloat(frameStyle.paddingTop),
};
}

// The focused element may live inside one or more shadow roots, where
// document.activeElement only reports the outermost shadow host.
private _deepActiveElement(): Element | null {
Expand All @@ -154,6 +171,11 @@ export class WebViewInput {
return active;
}

activeIFrame(): HTMLIFrameElement | HTMLFrameElement | null {
const active = this._deepActiveElement();
return isFrameOwner(active) ? active : null;
}

private _insertText(target: Element | null, text: string) {
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
const start = target.selectionStart ?? target.value.length;
Expand Down Expand Up @@ -269,6 +291,7 @@ export class WebViewInput {
metaKey: params.metaKey,
};
const pointer: PointerEventInit = { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true };
let lastTask = Promise.resolve();
const prev = this._hoverTarget;
if (prev !== target) {
if (prev && prev.isConnected) {
Expand All @@ -280,13 +303,29 @@ export class WebViewInput {
void this._postTask(() => markAndDispatch(target, new PointerEvent('pointerover', { ...pointer, relatedTarget: prev })));
void this._postTask(() => markAndDispatch(target, new MouseEvent('mouseover', { ...base, relatedTarget: prev })));
void this._postTask(() => markAndDispatch(target, new PointerEvent('pointerenter', { ...pointer, bubbles: false, cancelable: false, relatedTarget: prev })));
void this._postTask(() => markAndDispatch(target, new MouseEvent('mouseenter', { ...base, bubbles: false, cancelable: false, relatedTarget: prev })));
lastTask = this._postTask(() => markAndDispatch(target, new MouseEvent('mouseenter', { ...base, bubbles: false, cancelable: false, relatedTarget: prev })));
this._hoverTarget = target;
}
// Movements within an <iframe> are not dispatched in the owner context.
if (isFrameOwner(target))
return lastTask;
void this._postTask(() => markAndDispatch(target, new PointerEvent('pointermove', pointer)));
return this._postTask(() => markAndDispatch(target, new MouseEvent('mousemove', base)));
}

clearHover(): Promise<void> {
const prev = this._hoverTarget;
this._hoverTarget = null;
if (!prev?.isConnected)
return Promise.resolve();
const base: MouseEventInit = { bubbles: true, cancelable: true, view: this._window, relatedTarget: null };
const pointer: PointerEventInit = { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true };
void this._postTask(() => markAndDispatch(prev, new PointerEvent('pointerout', pointer)));
void this._postTask(() => markAndDispatch(prev, new MouseEvent('mouseout', base)));
void this._postTask(() => markAndDispatch(prev, new PointerEvent('pointerleave', { ...pointer, bubbles: false, cancelable: false })));
return this._postTask(() => markAndDispatch(prev, new MouseEvent('mouseleave', { ...base, bubbles: false, cancelable: false })));
}

mouseEvent(params: MouseEventParams): Promise<void> {
// Resolve the hit target at dispatch time, not enqueue time: a queued move
// ahead of this may reveal an overlay that should receive the press.
Expand Down Expand Up @@ -350,7 +389,7 @@ export class WebViewInput {
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 });
const touch = new Touch({ identifier: 0, target, clientX: params.x, clientY: params.y, screenX: params.x, screenY: params.y, pageX: params.x + this._window.scrollX, pageY: params.y + this._window.scrollY, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1 });
void this._postTask(() => markAndDispatch(target, new TouchEvent('touchstart', { ...init, touches: [touch], targetTouches: [touch], changedTouches: [touch] })));
void this._postTask(() => markAndDispatch(target, new TouchEvent('touchend', { ...init, touches: [], targetTouches: [], changedTouches: [touch] })));
} catch {
Expand Down
11 changes: 9 additions & 2 deletions packages/playwright-core/src/server/chromium/crNetworkManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ export class CRNetworkManager {
sessionInfo.eventListeners.push(...[
eventsHelper.addEventListener(session, 'Network.webSocketCreated', e => this._page!.frameManager.onWebSocketCreated(e.requestId, e.url)),
eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', event => this._onWebSocketWillSendHandshakeRequest(event)),
eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page!.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText, headersObjectToArray(e.response.headers, '\n'))),
eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page!.frameManager.onWebSocketResponse(e.requestId, {
status: e.response.status,
statusText: e.response.statusText,
headers: headersObjectToArray(e.response.headers, '\n'),
})),
eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, this._timestampToWallTimeMsForWebSocket(e.requestId, e.timestamp))),
eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, this._timestampToWallTimeMsForWebSocket(e.requestId, e.timestamp))),
eventsHelper.addEventListener(session, 'Network.webSocketClosed', event => this._onWebSocketClosed(event)),
Expand Down Expand Up @@ -527,7 +531,10 @@ export class CRNetworkManager {
_onWebSocketWillSendHandshakeRequest(event: Protocol.Network.webSocketWillSendHandshakeRequestPayload) {
const wallTimeMs = event.wallTime * 1000;
this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp * 1000);
this._page!.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers, '\n'), wallTimeMs);
this._page!.frameManager.onWebSocketRequest(event.requestId, {
headers: headersObjectToArray(event.request.headers, '\n'),
wallTimeMs,
});
}

_onWebSocketClosed(event: Protocol.Network.webSocketClosedPayload) {
Expand Down
20 changes: 13 additions & 7 deletions packages/playwright-core/src/server/firefox/ffPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,25 +150,31 @@ export class FFPage implements PageDelegate {
url.protocol = url.protocol === 'https' ? 'wss' : 'ws';

this._page.frameManager.onWebSocketCreated(requestId, url.toString());
this._page.frameManager.onWebSocketRequest(requestId, request.headers);
this._page.frameManager.onWebSocketResponse(requestId, response.status, response.statusText, response.headers);
this._page.frameManager.onWebSocketRequest(requestId, request);
this._page.frameManager.onWebSocketResponse(requestId, response);
this._page.frameManager.webSocketClosed(requestId);
return;
}
}

_onWebSocketOpened(event: Protocol.Page.webSocketOpenedPayload) {
const socketId = webSocketId(event.frameId, event.wsid);
const request = this._webSocketRequests.get(event.requestId);
assert(request);

const response = this._webSocketResponses.get(event.requestId);
assert(response);
// A `WebSocket` opened inside a worker is reported here, but its upgrade request is
// never seen by the network stack, so there is no handshake metadata to attach.
// TODO: Remove this workaround and make `requestData` required in `FrameManager.onWebSocketRequest`
// once Playwright's bundled Firefox includes https://phabricator.services.mozilla.com/D310690.
if (!request || !response) {
this._page.frameManager.onWebSocketRequest(socketId);
return;
}

this._webSocketRequests.delete(event.requestId);
this._webSocketResponses.delete(event.requestId);

this._page.frameManager.onWebSocketRequest(webSocketId(event.frameId, event.wsid), request.headers);
this._page.frameManager.onWebSocketResponse(webSocketId(event.frameId, event.wsid), response.status, response.statusText, response.headers);
this._page.frameManager.onWebSocketRequest(socketId, request);
this._page.frameManager.onWebSocketResponse(socketId, response);
}

_onWebSocketClosed(event: Protocol.Page.webSocketClosedPayload) {
Expand Down
15 changes: 8 additions & 7 deletions packages/playwright-core/src/server/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,29 +433,30 @@ export class FrameManager {
this._webSockets.set(requestId, ws);
}

onWebSocketRequest(requestId: string, headers: types.HeadersArray, wallTimeMs?: number) {
onWebSocketRequest(requestId: string, requestData?: { headers: types.HeadersArray, wallTimeMs?: number }) {
const ws = this._webSockets.get(requestId);
if (!ws)
return;

ws.setWallTimeMs(wallTimeMs);
ws.setWallTimeMs(requestData?.wallTimeMs);

if (ws.markAsNotified()) {
this._page.emit(Page.Events.WebSocket, ws);
this._page.browserContext.emit(BrowserContext.Events.WebSocket, ws, this._page);
}

ws.requestSent(headers);
if (requestData)
ws.requestSent(requestData.headers);
}

onWebSocketResponse(requestId: string, status: number, statusText: string, headers: types.HeadersArray) {
onWebSocketResponse(requestId: string, responseData: { status: number, statusText: string, headers: types.HeadersArray }) {
const ws = this._webSockets.get(requestId);
if (!ws)
return;

ws.responseReceived(status, statusText, headers);
if (status >= 400)
ws.error(`${statusText}: ${status}`);
ws.responseReceived(responseData.status, responseData.statusText, responseData.headers);
if (responseData.status >= 400)
ws.error(`${responseData.statusText}: ${responseData.status}`);
}

onWebSocketFrameSent(requestId: string, opcode: number, data: string, wallTimeMs: number) {
Expand Down
86 changes: 47 additions & 39 deletions packages/playwright-core/src/server/webkit/webview/wvInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
import * as input from '../../input';

import type * as types from '../../types';
import type { WVSession } from './wvConnection';
import type * as frames from '../../frames';
import type { Func1 } from '../../javascript';
import type { Progress } from '../../progress';
import type { WVPage } from './wvPage';

function modifierFlags(modifiers: Set<types.KeyboardModifier>) {
return {
Expand Down Expand Up @@ -51,11 +53,16 @@ function toButtonsMask(buttons: Set<types.MouseButton>): number {
return mask;
}

async function evaluateInFrame<Arg>(progress: Progress, frame: frames.Frame, pageFunction: Func1<Arg, void>, arg: Arg): Promise<void> {
const context = await progress.race(frame.mainContext());
await progress.race(context.evaluate(pageFunction, arg));
}

export class RawKeyboardImpl implements input.RawKeyboard {
private _session: WVSession | undefined;
private _page: WVPage;

setSession(session: WVSession) {
this._session = session;
constructor(page: WVPage) {
this._page = page;
}

async keydown(progress: Progress, modifiers: Set<types.KeyboardModifier>, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise<void> {
Expand All @@ -65,31 +72,48 @@ export class RawKeyboardImpl implements input.RawKeyboard {
...modifierFlags(modifiers),
...(text ? { text } : {}),
};
await callWebViewInput(progress, this._session, 'keydown', params);
const frame = await this._page.deepestFocusedFrame(progress);
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.keydown(p), params);
}

async keyup(progress: Progress, modifiers: Set<types.KeyboardModifier>, keyName: string, description: input.KeyDescription): Promise<void> {
const { code, keyCode, key, location } = description;
const params = { code, key, keyCode, location, ...modifierFlags(modifiers) };
await callWebViewInput(progress, this._session, 'keyup', params);
const frame = await this._page.deepestFocusedFrame(progress);
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.keyup(p), params);
}

async sendText(progress: Progress, text: string): Promise<void> {
await callWebViewInput(progress, this._session, 'insertText', text);
const frame = await this._page.deepestFocusedFrame(progress);
await evaluateInFrame(progress, frame, t => (globalThis as any).__pwWebViewInput.insertText(t), text);
}
}

export class RawMouseImpl implements input.RawMouse {
private _session: WVSession | undefined;
private _page: WVPage;
private _lastHoveredFrames: frames.Frame[] = [];

setSession(session: WVSession) {
this._session = session;
constructor(page: WVPage) {
this._page = page;
}

async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, forClick: boolean): Promise<void> {
await callWebViewInput(progress, this._session, 'mouseMove', {
x, y, button: buttonToNumber(button), buttons: toButtonsMask(buttons), ...modifierFlags(modifiers),
});
const path = await this._page.framePointerPath(progress, x, y);
const params = { button: buttonToNumber(button), buttons: toButtonsMask(buttons), ...modifierFlags(modifiers) };
// Each frame tracks its own hover target, so as the pointer crosses an <iframe>
// boundary it must leave the frames it is no longer within (deepest first) before
// entering the frames along the new path. A move that stays within the same frames
// leaves none of them, so no cross-frame mouseout/mouseleave is dispatched.
const hoveredFrames = path.map(entry => entry.frame);
const attachedFrames = this._page._page.frameManager.frames();
for (const frame of this._lastHoveredFrames.reverse()) {
if (hoveredFrames.includes(frame) || !attachedFrames.includes(frame))
continue;
await evaluateInFrame(progress, frame, () => (globalThis as any).__pwWebViewInput.clearHover(), undefined);
}
this._lastHoveredFrames = hoveredFrames;
for (const { frame, point } of path)
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.mouseMove(p), { ...params, ...point });
}

async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, clickCount: number): Promise<void> {
Expand All @@ -114,43 +138,27 @@ export class RawMouseImpl implements input.RawMouse {
}

async wheel(progress: Progress, x: number, y: number, buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, deltaX: number, deltaY: number): Promise<void> {
await callWebViewInput(progress, this._session, 'wheel', { x, y, deltaX, deltaY, ...modifierFlags(modifiers) });
const { frame, point } = await this._page.deepestFrameForPoint(progress, x, y);
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.wheel(p), { ...point, deltaX, deltaY, ...modifierFlags(modifiers) });
}

private async _mouseEvent(progress: Progress, type: string, x: number, y: number, button: number, buttons: number, modifiers: Set<types.KeyboardModifier>, clickCount: number) {
await callWebViewInput(progress, this._session, 'mouseEvent', {
type, x, y, button, buttons, clickCount, ...modifierFlags(modifiers),
const { frame, point } = await this._page.deepestFrameForPoint(progress, x, y);
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.mouseEvent(p), {
type, ...point, button, buttons, clickCount, ...modifierFlags(modifiers),
});
}
}

export class RawTouchscreenImpl implements input.RawTouchscreen {
private _session: WVSession | undefined;
private _page: WVPage;

setSession(session: WVSession) {
this._session = session;
constructor(page: WVPage) {
this._page = page;
}

async tap(progress: Progress, x: number, y: number, modifiers: Set<types.KeyboardModifier>) {
await callWebViewInput(progress, this._session, 'tap', { x, y, ...modifierFlags(modifiers) });
}
}

async function callWebViewInput(progress: Progress, session: WVSession | undefined, method: string, arg: any): Promise<void> {
if (!session)
throw new Error('Page is not initialized');
const expression = `window.__pwWebViewInput.${method}(${JSON.stringify(arg)})`;
// Some dispatchers are async — they spread events across event-loop tasks the
// way a real device does. Await the returned promise so the action only
// resolves once every event has been delivered. Stock WebKit's Runtime.evaluate
// has no awaitPromise option, so use the separate Runtime.awaitPromise command.
const { result } = await progress.race(session.send('Runtime.evaluate', { expression, returnByValue: false }));
// `result` is absent if evaluation failed (e.g. the frame navigated away).
// Only promises carry an objectId here — every __pwWebViewInput method returns
// void or a Promise — so this both awaits async dispatch and avoids leaking a
// handle for the synchronous (void) case.
if (result?.className === 'Promise' && result.objectId) {
await progress.race(session.send('Runtime.awaitPromise', { promiseObjectId: result.objectId, returnByValue: true }));
session.sendMayFail('Runtime.releaseObject', { objectId: result.objectId });
const { frame, point } = await this._page.deepestFrameForPoint(progress, x, y);
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.tap(p), { ...point, ...modifierFlags(modifiers) });
}
}
Loading
Loading