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
573 changes: 573 additions & 0 deletions src/webkit/browser-commands.ts

Large diffs are not rendered by default.

803 changes: 80 additions & 723 deletions src/webkit/client.ts

Large diffs are not rendered by default.

183 changes: 183 additions & 0 deletions src/webkit/dom-input-scripts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* dom-input-scripts.ts — DOM script builders for WebKit browser input commands.
*
* Centralizes the inline JS strings used by click, swipe, type, longPress commands.
* All scripts use document.createTouch() / document.createTouchList() — never new Touch().
* All scripts use the prototype-walk value-setter pattern to avoid cross-realm TypeError.
*
* (#706 4/5 — extracted from client.ts)
*/

// ========== Click / Tap ==========

/**
* Build a tap script that dispatches touchstart → touchend → click at (x, y).
* Uses document.createTouch for iOS Safari compatibility.
*/
export function buildTapScript(x: number, y: number): string {
return `
(function(x, y) {
var el = document.elementFromPoint(x, y);
if (!el) return;
var touch = document.createTouch(window, el, 1, x, y, x, y);
var touchList = document.createTouchList(touch);
var emptyList = document.createTouchList();
el.dispatchEvent(new TouchEvent('touchstart', { touches: touchList, changedTouches: touchList, bubbles: true }));
el.dispatchEvent(new TouchEvent('touchend', { touches: emptyList, changedTouches: touchList, bubbles: true }));
el.click();
})(${x}, ${y})
`;
}

// ========== Long Press ==========

/**
* Build a long-press script that holds touchstart for `duration` ms then fires touchend.
*/
export function buildLongPressScript(x: number, y: number, duration: number): string {
return `
(async function(x, y, duration) {
var el = document.elementFromPoint(x, y);
if (!el) return;
var touch = document.createTouch(window, el, 1, x, y, x, y);
var touchList = document.createTouchList(touch);
el.dispatchEvent(new TouchEvent('touchstart', { touches: touchList, changedTouches: touchList, bubbles: true }));
await new Promise(function(r) { setTimeout(r, duration); });
var emptyList = document.createTouchList();
el.dispatchEvent(new TouchEvent('touchend', { touches: emptyList, changedTouches: touchList, bubbles: true }));
})(${x}, ${y}, ${duration})
`;
}

// ========== Swipe ==========

/**
* Build a swipe script that generates touchstart → N touchmove → touchend.
*/
export function buildSwipeScript(sx: number, sy: number, ex: number, ey: number, steps: number): string {
return `
(async function(sx, sy, ex, ey, steps) {
var el = document.elementFromPoint(sx, sy);
if (!el) return;
var makeTouch = function(x, y) { return document.createTouch(window, el, 1, x, y, x, y); };
var startTouch = makeTouch(sx, sy);
var startList = document.createTouchList(startTouch);
el.dispatchEvent(new TouchEvent('touchstart', { touches: startList, changedTouches: startList, bubbles: true }));
for (var i = 1; i <= steps; i++) {
var x = sx + (ex - sx) * (i / steps);
var y = sy + (ey - sy) * (i / steps);
var moveTouch = makeTouch(x, y);
var moveList = document.createTouchList(moveTouch);
el.dispatchEvent(new TouchEvent('touchmove', { touches: moveList, changedTouches: moveList, bubbles: true }));
await new Promise(function(r) { setTimeout(r, 16); });
}
var endTouch = makeTouch(ex, ey);
var endList = document.createTouchList(endTouch);
var emptyList = document.createTouchList();
el.dispatchEvent(new TouchEvent('touchend', { touches: emptyList, changedTouches: endList, bubbles: true }));
})(${sx}, ${sy}, ${ex}, ${ey}, ${steps})
`;
}

// ========== Type / Set Value ==========

/**
* Build a script that focuses a selector element and sets its value directly,
* then dispatches input + change events. Uses prototype-walk value setter to
* avoid cross-realm TypeError with window.HTMLInputElement.prototype.
*/
export function buildSetValueScript(selector: string, text: string): string {
const sel = JSON.stringify(selector);
const val = JSON.stringify(text);
return `
(function() {
var el = document.querySelector(${sel});
if (!el) return;
var p = Object.getPrototypeOf(el);
while (p && !Object.getOwnPropertyDescriptor(p, 'value')) {
p = Object.getPrototypeOf(p);
}
var desc = p ? Object.getOwnPropertyDescriptor(p, 'value') : null;
if (desc && desc.set) {
desc.set.call(el, ${val});
} else {
el.value = ${val};
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
})()
`;
}

/**
* Build a script that appends a single character to an input, dispatching
* keydown / keypress / input / keyup events. Used in character-by-character mode.
*/
export function buildTypeCharScript(selector: string, char: string): string {
const sel = JSON.stringify(selector);
const ch = JSON.stringify(char);
return `
(function() {
var el = document.querySelector(${sel});
if (!el) return;
var ev = new KeyboardEvent('keydown', { key: ${ch}, bubbles: true });
el.dispatchEvent(ev);
el.dispatchEvent(new KeyboardEvent('keypress', { key: ${ch}, bubbles: true }));
var p = Object.getPrototypeOf(el);
while (p && !Object.getOwnPropertyDescriptor(p, 'value')) {
p = Object.getPrototypeOf(p);
}
var desc = p ? Object.getOwnPropertyDescriptor(p, 'value') : null;
if (desc && desc.set) {
desc.set.call(el, el.value + ${ch});
} else {
el.value += ${ch};
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key: ${ch}, bubbles: true }));
})()
`;
}

/**
* Build a script that focuses a selector element.
* preventScroll prevents iOS Safari from auto-scrolling on focus.
*/
export function buildFocusScript(selector: string): string {
const sel = JSON.stringify(selector);
return `
(function() {
var el = document.querySelector(${sel});
if (el && typeof el.focus === 'function') el.focus({ preventScroll: true });
})()
`;
}

// ========== Select ==========

/**
* Build a script that sets a <select> element's value and dispatches input + change.
* Uses prototype-walk setter to avoid cross-realm TypeError.
*/
export function buildSelectOptionScript(selector: string, value: string): string {
const sel = JSON.stringify(selector);
const val = JSON.stringify(value);
return `
(function() {
var el = document.querySelector(${sel});
if (!el || el.tagName !== 'SELECT') return;
var p = Object.getPrototypeOf(el);
while (p && !Object.getOwnPropertyDescriptor(p, 'value')) {
p = Object.getPrototypeOf(p);
}
var desc = p ? Object.getOwnPropertyDescriptor(p, 'value') : null;
if (desc && desc.set) {
desc.set.call(el, ${val});
} else {
el.value = ${val};
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
})()
`;
}
105 changes: 105 additions & 0 deletions src/webkit/evaluate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* evaluate.ts — evaluateValue<T> helper for WebKit Runtime evaluation.
*
* Handles the three-step pattern required by WebKit Inspector:
* 1. Runtime.evaluate with returnByValue:false (preserves objectId for Promises)
* 2. Runtime.awaitPromise (as a separate command) for Promise results
* 3. Runtime.callFunctionOn to serialize non-primitive object results
*
* (#706 4/5 — extracted from client.ts)
*/

import { EvaluationError } from './errors';

/**
* Minimal interface for sending protocol commands needed by evaluateValue.
* Using an interface avoids a circular dependency on WebKitClient.
*/
export interface EvaluateSender {
send<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
}

/**
* Evaluate a JS expression in the page context and return its value.
*
* - Handles Promise results via a separate Runtime.awaitPromise call (WebKit requirement).
* - Serializes non-primitive object results via Runtime.callFunctionOn.
* - Throws EvaluationError if the expression throws or the Promise rejects.
*
* @param sender Object with a send() method (typically WebKitClient or BrowserCommands)
* @param expression JS expression string to evaluate
* @param options.emulateUserGesture Pass true for touch-dispatching scripts
*/
export async function evaluateValue<T = unknown>(
sender: EvaluateSender,
expression: string,
options?: { emulateUserGesture?: boolean },
): Promise<T> {
// Step 1: Evaluate with returnByValue:false to preserve objectId for Promises.
// WebKit serializes Promises as {} when returnByValue:true, losing the objectId
// needed for Runtime.awaitPromise.
const result = await sender.send<{
result: {
type: string;
subtype?: string;
className?: string;
value?: unknown;
objectId?: string;
description?: string;
};
wasThrown: boolean;
}>('Runtime.evaluate', {
expression,
returnByValue: false,
emulateUserGesture: options?.emulateUserGesture ?? false,
});

if (result.wasThrown) {
throw new EvaluationError(result.result?.description ?? 'Evaluation failed');
}

// Step 2: If result is a Promise, use awaitPromise to get the resolved value.
// WebKit Inspector may use subtype:'promise' OR className:'Promise' depending on version.
// Note: awaitPromise blocks until the Promise settles. Never-resolving Promises
// will block for the full send() timeout (DEFAULT_WEBKIT_SEND_TIMEOUT_MS, typically 15s).
const isPromise =
result.result?.type === 'object' &&
result.result?.objectId &&
(result.result?.subtype === 'promise' || result.result?.className === 'Promise');

if (isPromise) {
const awaited = await sender.send<{
result: {
type: string;
value?: unknown;
objectId?: string;
description?: string;
};
wasThrown: boolean;
}>('Runtime.awaitPromise', {
promiseObjectId: result.result.objectId,
returnByValue: true,
});

if (awaited.wasThrown) {
throw new EvaluationError(awaited.result?.description ?? 'Promise rejected');
}
return awaited.result?.value as T;
}

// Step 3: For non-Promise object results, use callFunctionOn to serialize the value
// without re-executing the expression (avoids double side effects).
if (result.result?.objectId && result.result?.value === undefined) {
const valued = await sender.send<{
result: { type: string; value?: unknown; description?: string };
wasThrown: boolean;
}>('Runtime.callFunctionOn', {
objectId: result.result.objectId,
functionDeclaration: 'function() { return this; }',
returnByValue: true,
});
return valued.result?.value as T;
}

return result.result?.value as T;
}
Loading