From 78bcebca625f562298b88c1b8a90f85d787eb676 Mon Sep 17 00:00:00 2001 From: shaun0927 <70629228+shaun0927@users.noreply.github.com> Date: Thu, 7 May 2026 23:35:01 +0900 Subject: [PATCH 01/17] refactor(webkit): extract error classes into dedicated module (#706 1/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving extraction; same class names, constructor signatures, and instanceof semantics. No public API change. - New: src/webkit/errors.ts — ConnectionError, TimeoutError, ProtocolError, EvaluationError - client.ts now imports from ./errors and re-exports for backward compat - index.ts exports errors directly from ./errors (same external surface) - New: tests/unit/webkit-errors.test.ts — asserts name/code/message/instanceof + re-export identity Co-Authored-By: Claude Sonnet 4.6 --- src/webkit/client.ts | 35 +------------ src/webkit/errors.ts | 38 ++++++++++++++ src/webkit/index.ts | 9 +--- tests/unit/webkit-errors.test.ts | 88 ++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 40 deletions(-) create mode 100644 src/webkit/errors.ts create mode 100644 tests/unit/webkit-errors.test.ts diff --git a/src/webkit/client.ts b/src/webkit/client.ts index 4b3d71d0..b168c8cb 100644 --- a/src/webkit/client.ts +++ b/src/webkit/client.ts @@ -1,6 +1,8 @@ import WebSocket from 'ws'; import http from 'http'; import { EventEmitter } from 'events'; +import { ConnectionError, TimeoutError, ProtocolError, EvaluationError } from './errors'; +export { ConnectionError, TimeoutError, ProtocolError, EvaluationError } from './errors'; import { BrowserBackend, NavigateOptions, @@ -1279,36 +1281,3 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { }); } } - -// ========== Error Classes ========== - -export class ConnectionError extends Error { - constructor(message: string) { - super(message); - this.name = 'ConnectionError'; - } -} - -export class TimeoutError extends Error { - constructor(message: string) { - super(message); - this.name = 'TimeoutError'; - } -} - -export class ProtocolError extends Error { - constructor( - message: string, - public readonly code?: number, - ) { - super(message); - this.name = 'ProtocolError'; - } -} - -export class EvaluationError extends Error { - constructor(message: string) { - super(message); - this.name = 'EvaluationError'; - } -} diff --git a/src/webkit/errors.ts b/src/webkit/errors.ts new file mode 100644 index 00000000..c4e29bf2 --- /dev/null +++ b/src/webkit/errors.ts @@ -0,0 +1,38 @@ +// ========== Error Classes ========== +// Behavior-preserving extraction from client.ts. +// Same class names, constructor signatures, and instanceof semantics. + +/** Thrown when a WebSocket connection cannot be established or is lost. */ +export class ConnectionError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConnectionError'; + } +} + +/** Thrown when a protocol command or navigation exceeds the allowed time. */ +export class TimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'TimeoutError'; + } +} + +/** Thrown when the WebKit Remote Debugging Protocol returns an error response. */ +export class ProtocolError extends Error { + constructor( + message: string, + public readonly code?: number, + ) { + super(message); + this.name = 'ProtocolError'; + } +} + +/** Thrown when a Runtime.evaluate call throws or rejects inside the page. */ +export class EvaluationError extends Error { + constructor(message: string) { + super(message); + this.name = 'EvaluationError'; + } +} diff --git a/src/webkit/index.ts b/src/webkit/index.ts index f12e8e1d..72ad99ad 100644 --- a/src/webkit/index.ts +++ b/src/webkit/index.ts @@ -1,8 +1,3 @@ -export { - WebKitClient, - ConnectionError, - TimeoutError, - ProtocolError, - EvaluationError, -} from './client'; +export { WebKitClient } from './client'; export type { WebKitClientOptions, WebKitTarget } from './client'; +export { ConnectionError, TimeoutError, ProtocolError, EvaluationError } from './errors'; diff --git a/tests/unit/webkit-errors.test.ts b/tests/unit/webkit-errors.test.ts new file mode 100644 index 00000000..262be054 --- /dev/null +++ b/tests/unit/webkit-errors.test.ts @@ -0,0 +1,88 @@ +import { + ConnectionError, + TimeoutError, + ProtocolError, + EvaluationError, +} from '../../src/webkit/errors'; + +describe('webkit error classes', () => { + describe('ConnectionError', () => { + it('is an instance of Error', () => { + const err = new ConnectionError('test'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ConnectionError); + }); + + it('preserves message and name', () => { + const err = new ConnectionError('conn failed'); + expect(err.message).toBe('conn failed'); + expect(err.name).toBe('ConnectionError'); + }); + }); + + describe('TimeoutError', () => { + it('is an instance of Error', () => { + const err = new TimeoutError('timed out'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(TimeoutError); + }); + + it('preserves message and name', () => { + const err = new TimeoutError('op timed out after 5000ms'); + expect(err.message).toBe('op timed out after 5000ms'); + expect(err.name).toBe('TimeoutError'); + }); + }); + + describe('ProtocolError', () => { + it('is an instance of Error', () => { + const err = new ProtocolError('method not found', -32601); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ProtocolError); + }); + + it('preserves message, name, and code', () => { + const err = new ProtocolError('method not found', -32601); + expect(err.message).toBe('method not found'); + expect(err.name).toBe('ProtocolError'); + expect(err.code).toBe(-32601); + }); + + it('accepts undefined code', () => { + const err = new ProtocolError('no code'); + expect(err.code).toBeUndefined(); + }); + }); + + describe('EvaluationError', () => { + it('is an instance of Error', () => { + const err = new EvaluationError('eval failed'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(EvaluationError); + }); + + it('preserves message and name', () => { + const err = new EvaluationError('Promise rejected'); + expect(err.message).toBe('Promise rejected'); + expect(err.name).toBe('EvaluationError'); + }); + }); + + describe('re-exports from client and index', () => { + it('errors are accessible from src/webkit/index', async () => { + const mod = await import('../../src/webkit/index'); + expect(mod.ConnectionError).toBe(ConnectionError); + expect(mod.TimeoutError).toBe(TimeoutError); + expect(mod.ProtocolError).toBe(ProtocolError); + expect(mod.EvaluationError).toBe(EvaluationError); + }); + + it('errors are accessible from src/webkit/client', async () => { + const mod = await import('../../src/webkit/client'); + expect(mod.ConnectionError).toBe(ConnectionError); + expect(mod.TimeoutError).toBe(TimeoutError); + expect(mod.ProtocolError).toBe(ProtocolError); + expect(mod.EvaluationError).toBe(EvaluationError); + }); + }); +}); From f5b0492fc0f0f08881cf7cccc05161e6fef51d20 Mon Sep 17 00:00:00 2001 From: shaun0927 <70629228+shaun0927@users.noreply.github.com> Date: Thu, 7 May 2026 23:41:05 +0900 Subject: [PATCH 02/17] refactor(webkit): extract protocol transport (#706 2/5) Behavior-preserving; same wire protocol; same error semantics. Extracts WebSocket / RDP message-passing layer from client.ts into src/webkit/protocol-transport.ts: - ProtocolTransport interface (adapter) used by WebKitClient to avoid circular dependencies and enable test doubles - WebSocketProtocolTransport concrete class owns the WebSocket, outer/inner pending-request maps, message-id generation, and Target.dispatchMessageFromTarget response routing - WebKitClient delegates send/connect/disconnect/isConnected to the transport; all public method signatures unchanged (facade preserved) - New tests/unit/protocol-transport.test.ts: happy path, out-of-order routing, TimeoutError, ConnectionError, ProtocolError propagation (outer + inner), domain event emission, disconnect rejection Co-Authored-By: Claude Sonnet 4.6 --- src/webkit/client.ts | 335 ++++++++------------------ src/webkit/protocol-transport.ts | 281 +++++++++++++++++++++ tests/unit/protocol-transport.test.ts | 280 +++++++++++++++++++++ 3 files changed, 656 insertions(+), 240 deletions(-) create mode 100644 src/webkit/protocol-transport.ts create mode 100644 tests/unit/protocol-transport.test.ts diff --git a/src/webkit/client.ts b/src/webkit/client.ts index b168c8cb..4c950e9d 100644 --- a/src/webkit/client.ts +++ b/src/webkit/client.ts @@ -1,8 +1,9 @@ -import WebSocket from 'ws'; import http from 'http'; import { EventEmitter } from 'events'; import { ConnectionError, TimeoutError, ProtocolError, EvaluationError } from './errors'; export { ConnectionError, TimeoutError, ProtocolError, EvaluationError } from './errors'; +import { ProtocolTransport, WebSocketProtocolTransport } from './protocol-transport'; +export type { ProtocolTransport } from './protocol-transport'; import { BrowserBackend, NavigateOptions, @@ -38,16 +39,8 @@ export interface WebKitTarget { } export class WebKitClient extends EventEmitter implements BrowserBackend { - private ws: WebSocket | null = null; + private transport: ProtocolTransport; private messageId = 0; - private pendingRequests: Map< - number, - { - resolve: (value: any) => void; - reject: (error: Error) => void; - timer: ReturnType; - } - > = new Map(); private enabledDomains: Set = new Set(); private enabledDomainsPerTarget: Map> = new Map(); private connected = false; @@ -60,24 +53,99 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { private activeTargetId: string | null = null; private knownTargets: Set = new Set(); private innerMessageId = 0; - private innerPendingRequests: Map< - number, - { - resolve: (value: any) => void; - reject: (error: Error) => void; - timer: ReturnType; - } - > = new Map(); private targetReady: Promise | null = null; private targetReadyResolve: (() => void) | null = null; constructor(private options: WebKitClientOptions) { super(); + this.transport = new WebSocketProtocolTransport({ + connectTimeout: options.connectTimeout, + sendTimeout: options.sendTimeout, + }); + this.bindTransportEvents(); } getHost(): string { return this.options.host; } getPort(): number { return this.options.port; } + // ========== Transport Event Binding ========== + + /** + * Forward all transport-emitted events (domain events, target lifecycle events) to this + * EventEmitter so that callers using `client.on('Page.loadEventFired', ...)` continue to work. + */ + private bindTransportEvents(): void { + // Wildcard forwarding is not natively available on EventEmitter, so we intercept emit. + // We replace the transport's emit to also forward to client's emit. + const originalEmit = this.transport.emit.bind(this.transport); + (this.transport as any).emit = (event: string, ...args: any[]): boolean => { + const result = originalEmit(event, ...args); + if (event !== 'transport:close' && event !== 'transport:error' && event !== 'newListener' && event !== 'removeListener') { + (EventEmitter.prototype.emit as any).call(this, event, ...args); + } + return result; + }; + + this.transport.on('transport:close', () => { + if (this.connected && !this.reconnecting) { + this.connected = false; + this.handleDisconnect(); + } + }); + + this.transport.on('transport:error', (_err: Error) => { + // Error already handled — connection state tracked via transport:close + }); + + // Target lifecycle events emitted by the transport + this.transport.on('Target.targetCreated', (params: any) => { + this.handleTargetCreated(params); + }); + + this.transport.on('Target.targetDestroyed', (params: any) => { + this.handleTargetDestroyed(params); + }); + } + + // ========== Target Lifecycle Handlers (moved from handleMessage) ========== + + private handleTargetCreated(params: any): void { + const info = params?.targetInfo; + if (info?.type !== 'page') return; + + this.knownTargets.add(info.targetId); + this.emit('target:created', { targetId: info.targetId, url: info.url }); + + if (!this.activeTargetId) { + this.activeTargetId = info.targetId; + } + + const globalDomains = [...this.enabledDomains]; + const perTargetDomains = this.enabledDomainsPerTarget.get(info.targetId); + const domainsToEnable = new Set([...globalDomains, ...(perTargetDomains ?? [])]); + Promise.all( + [...domainsToEnable].map(domain => + this.sendToTarget(`${domain}.enable`, undefined, info.targetId).catch(err => { + console.error(`[WebKitClient] Failed to re-enable ${domain} on new target: ${(err as Error).message}`); + }) + ) + ).then(() => { + this.targetReadyResolve?.(); + }); + } + + private handleTargetDestroyed(params: any): void { + const destroyedId = params?.targetId; + this.knownTargets.delete(destroyedId); + this.enabledDomainsPerTarget.delete(destroyedId); + this.emit('target:destroyed', { targetId: destroyedId }); + if (destroyedId === this.activeTargetId) { + this.activeTargetId = this.knownTargets.size > 0 + ? this.knownTargets.values().next().value ?? null + : null; + } + } + /** * Connect directly to a specific WebSocket URL (e.g., per-tab endpoint). */ @@ -134,14 +202,7 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { async disconnect(): Promise { this.stopHeartbeat(); - this.clearPendingRequests(); - if (this.ws) { - this.ws.removeAllListeners(); - if (this.ws.readyState === WebSocket.OPEN) { - this.ws.close(); - } - this.ws = null; - } + await this.transport.disconnect(); this.connected = false; this.enabledDomains.clear(); this.enabledDomainsPerTarget.clear(); @@ -152,7 +213,7 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { } isConnected(): boolean { - return this.connected && this.ws?.readyState === WebSocket.OPEN; + return this.transport.isConnected(); } // ========== Target Discovery ========== @@ -216,9 +277,6 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { params?: Record, targetId?: string | null, ): Promise { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - throw new ConnectionError('WebSocket not connected'); - } const resolvedTargetId = targetId ?? this.activeTargetId; if (!resolvedTargetId) { throw new ConnectionError( @@ -228,157 +286,9 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { const innerId = ++this.innerMessageId; const outerId = ++this.messageId; - const timeout = - this.options.sendTimeout ?? DEFAULT_WEBKIT_SEND_TIMEOUT_MS; - - const innerPromise = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.innerPendingRequests.delete(innerId); - reject(new TimeoutError(`${method} timed out after ${timeout}ms`)); - }, timeout); - this.innerPendingRequests.set(innerId, { resolve, reject, timer }); - }); - - // Track outer message for error propagation (e.g., invalid targetId) - const outerTimer = setTimeout(() => { - this.pendingRequests.delete(outerId); - }, timeout); - this.pendingRequests.set(outerId, { - resolve: () => { /* outer ack ignored — real response via dispatchMessageFromTarget */ }, - reject: (err: Error) => { - // Outer error means inner will never resolve — reject inner too - const innerPending = this.innerPendingRequests.get(innerId); - if (innerPending) { - clearTimeout(innerPending.timer); - this.innerPendingRequests.delete(innerId); - innerPending.reject(err); - } - }, - timer: outerTimer, - }); - - // Wrap in Target.sendMessageToTarget - const innerMessage = JSON.stringify({ id: innerId, method, params }); - this.ws.send( - JSON.stringify({ - id: outerId, - method: 'Target.sendMessageToTarget', - params: { targetId: resolvedTargetId, message: innerMessage }, - }), - ); - - return innerPromise; - } - - private handleMessage(data: string): void { - let msg: any; - try { - msg = JSON.parse(data); - } catch { - return; - } - - // Handle Target events (multiplexing protocol) - if (msg.method === 'Target.targetCreated') { - const info = msg.params?.targetInfo; - if (info?.type === 'page') { - this.knownTargets.add(info.targetId); - this.emit('target:created', { targetId: info.targetId, url: info.url }); - - // Set as active target only if none is active yet (single-tab compat) - // In multi-tab mode, callers manage targets explicitly via TabPool - if (!this.activeTargetId) { - this.activeTargetId = info.targetId; - } - - // Re-enable domains on new target (e.g., after navigation destroys old target) - // Merge global domains + any per-target domains that were tracked for this target - const globalDomains = [...this.enabledDomains]; - const perTargetDomains = this.enabledDomainsPerTarget.get(info.targetId); - const domainsToEnable = new Set([...globalDomains, ...(perTargetDomains ?? [])]); - // Re-enable domains then signal target readiness - Promise.all( - [...domainsToEnable].map(domain => - this.sendToTarget(`${domain}.enable`, undefined, info.targetId).catch(err => { - console.error(`[WebKitClient] Failed to re-enable ${domain} on new target: ${(err as Error).message}`); - }) - ) - ).then(() => { - this.targetReadyResolve?.(); - }); - return; - } - return; - } - - if (msg.method === 'Target.targetDestroyed') { - const destroyedId = msg.params?.targetId; - this.knownTargets.delete(destroyedId); - this.enabledDomainsPerTarget.delete(destroyedId); - this.emit('target:destroyed', { targetId: destroyedId }); - if (destroyedId === this.activeTargetId) { - // Fallback to another known target, or null - this.activeTargetId = this.knownTargets.size > 0 - ? this.knownTargets.values().next().value ?? null - : null; - } - return; - } - - if (msg.method === 'Target.dispatchMessageFromTarget') { - // This contains the REAL response to our domain commands - let innerMsg: any; - try { - innerMsg = JSON.parse(msg.params.message); - } catch { - return; - } - if (innerMsg.id !== undefined) { - const pending = this.innerPendingRequests.get(innerMsg.id); - if (pending) { - clearTimeout(pending.timer); - this.innerPendingRequests.delete(innerMsg.id); - if (innerMsg.error) { - pending.reject( - new ProtocolError( - innerMsg.error.message ?? JSON.stringify(innerMsg.error), - innerMsg.error.code, - ), - ); - } else { - pending.resolve(innerMsg.result); - } - } - } else if (innerMsg.method) { - // Inner event (e.g., Page.loadEventFired, Runtime.consoleAPICalled) - // Include targetId so multi-tab consumers can filter by target - const sourceTargetId = msg.params.targetId; - this.emit(innerMsg.method, innerMsg.params, { targetId: sourceTargetId }); - } - return; - } + const timeout = this.options.sendTimeout ?? DEFAULT_WEBKIT_SEND_TIMEOUT_MS; - if (msg.id !== undefined) { - // Outer ack response to Target.sendMessageToTarget — just clean up - const pending = this.pendingRequests.get(msg.id); - if (pending) { - clearTimeout(pending.timer); - this.pendingRequests.delete(msg.id); - // Don't resolve/reject caller — real response comes via dispatchMessageFromTarget - // But if there's an outer error (e.g., invalid targetId), propagate it - if (msg.error) { - pending.reject( - new ProtocolError( - msg.error.message ?? JSON.stringify(msg.error), - msg.error.code, - ), - ); - } - } - } else if (msg.method) { - // Other event notifications not handled above - this.emit(msg.method, msg.params); - } + return this.transport.sendToTarget(method, params, resolvedTargetId, innerId, outerId, timeout); } // ========== Domain Management ========== @@ -451,12 +361,8 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { this.reconnecting = true; this.connected = false; - // Clear stale inner requests before reconnect - for (const [, pending] of this.innerPendingRequests) { - clearTimeout(pending.timer); - pending.reject(new ConnectionError('Connection lost during reconnect')); - } - this.innerPendingRequests.clear(); + // Clear stale pending requests before reconnect + (this.transport as WebSocketProtocolTransport).clearPendingRequests(); this.activeTargetId = null; this.knownTargets.clear(); @@ -1197,46 +1103,9 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { this.targetReadyResolve = resolve; }); - await new Promise((resolve, reject) => { - const connectTimeout = - this.options.connectTimeout ?? DEFAULT_WEBKIT_CONNECT_TIMEOUT_MS; - - const timeout = setTimeout(() => { - reject( - new ConnectionError( - `Connection timeout after ${connectTimeout}ms`, - ), - ); - }, connectTimeout); - - const ws = new WebSocket(wsUrl); - - ws.on('open', () => { - clearTimeout(timeout); - this.ws = ws; - this.connected = true; - this.startHeartbeat(); - resolve(); - }); - - ws.on('message', (data: WebSocket.Data) => { - this.handleMessage(data.toString()); - }); - - ws.on('close', () => { - if (this.connected && !this.reconnecting) { - this.connected = false; - this.handleDisconnect(); - } - }); - - ws.on('error', (err: Error) => { - clearTimeout(timeout); - if (!this.connected) { - reject(new ConnectionError(`WebSocket error: ${err.message}`)); - } - }); - }); + await this.transport.connect(wsUrl); + this.connected = true; + this.startHeartbeat(); // Wait for first page target to be discovered const connectTimeout = @@ -1255,20 +1124,6 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { } } - private clearPendingRequests(): void { - for (const [, req] of this.pendingRequests) { - clearTimeout(req.timer); - req.reject(new ConnectionError('Connection closed')); - } - this.pendingRequests.clear(); - - for (const [, req] of this.innerPendingRequests) { - clearTimeout(req.timer); - req.reject(new ConnectionError('Connection closed')); - } - this.innerPendingRequests.clear(); - } - private httpGet(url: string): Promise { return new Promise((resolve, reject) => { http diff --git a/src/webkit/protocol-transport.ts b/src/webkit/protocol-transport.ts new file mode 100644 index 00000000..8d4aa3fb --- /dev/null +++ b/src/webkit/protocol-transport.ts @@ -0,0 +1,281 @@ +/** + * ProtocolTransport — WebSocket / RDP message-passing layer for WebKit Remote Debugging Protocol. + * + * Extracted from client.ts (#706 2/5). Behavior-preserving; same wire protocol; same error semantics. + * + * Owns: + * - WebSocket connection lifecycle (connect, disconnect, isConnected) + * - Outer pending-request map (Target.sendMessageToTarget acks) + * - Inner pending-request map (dispatchMessageFromTarget responses) + * - Message-ID generation (outer + inner) + * - Response routing / event emission + * + * Does NOT own: target lifecycle, domain management, heartbeat, browser commands. + */ + +import WebSocket from 'ws'; +import { EventEmitter } from 'events'; +import { ConnectionError, TimeoutError, ProtocolError } from './errors'; +import { DEFAULT_WEBKIT_CONNECT_TIMEOUT_MS } from '../config/defaults'; + +// ========== Interface ========== + +/** + * Adapter interface used by WebKitClient to interact with the transport layer. + * Using an interface avoids circular dependencies and allows test doubles. + */ +export interface ProtocolTransport extends NodeJS.EventEmitter { + /** Connect to a specific WebSocket URL. Resolves when the socket is open. */ + connect(wsUrl: string): Promise; + + /** Close the WebSocket and reject all pending requests. */ + disconnect(): Promise; + + /** True when the WebSocket is in OPEN state. */ + isConnected(): boolean; + + /** + * Send a protocol command wrapped in Target.sendMessageToTarget. + * @param method RDP method name (e.g. "Page.navigate") + * @param params Optional method params + * @param targetId The page target to address + * @param innerMessageId Caller-allocated inner-message ID (avoids ID-space collision) + * @param outerMessageId Caller-allocated outer-message ID + * @param timeout Milliseconds before TimeoutError is thrown + */ + sendToTarget( + method: string, + params: Record | undefined, + targetId: string, + innerMessageId: number, + outerMessageId: number, + timeout: number, + ): Promise; +} + +// ========== Pending Request Slot ========== + +interface PendingSlot { + resolve: (value: any) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +// ========== Concrete Implementation ========== + +export interface WebSocketProtocolTransportOptions { + connectTimeout?: number; + sendTimeout?: number; +} + +/** + * Concrete ProtocolTransport backed by a WebSocket. + * Translates WebKit RDP wire-protocol messages into Promise resolution / EventEmitter events. + */ +export class WebSocketProtocolTransport extends EventEmitter implements ProtocolTransport { + private ws: WebSocket | null = null; + private _connected = false; + + /** Outer pending requests: Target.sendMessageToTarget ack tracking */ + private readonly outerPending: Map = new Map(); + + /** Inner pending requests: actual domain-command response tracking */ + private readonly innerPending: Map = new Map(); + + constructor(private readonly options: WebSocketProtocolTransportOptions = {}) { + super(); + } + + // ========== Lifecycle ========== + + async connect(wsUrl: string): Promise { + const connectTimeout = + this.options.connectTimeout ?? DEFAULT_WEBKIT_CONNECT_TIMEOUT_MS; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new ConnectionError(`Connection timeout after ${connectTimeout}ms`)); + }, connectTimeout); + + const ws = new WebSocket(wsUrl); + + ws.on('open', () => { + clearTimeout(timeout); + this.ws = ws; + this._connected = true; + resolve(); + }); + + ws.on('message', (data: WebSocket.Data) => { + this.handleMessage(data.toString()); + }); + + ws.on('close', () => { + this._connected = false; + this.emit('transport:close'); + }); + + ws.on('error', (err: Error) => { + clearTimeout(timeout); + if (!this._connected) { + reject(new ConnectionError(`WebSocket error: ${err.message}`)); + } else { + this.emit('transport:error', err); + } + }); + }); + } + + async disconnect(): Promise { + this.clearPendingRequests(); + if (this.ws) { + this.ws.removeAllListeners(); + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.close(); + } + this.ws = null; + } + this._connected = false; + } + + isConnected(): boolean { + return this._connected && this.ws?.readyState === WebSocket.OPEN; + } + + // ========== Send ========== + + sendToTarget( + method: string, + params: Record | undefined, + targetId: string, + innerMessageId: number, + outerMessageId: number, + timeout: number, + ): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new ConnectionError('WebSocket not connected')); + } + + const innerPromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.innerPending.delete(innerMessageId); + reject(new TimeoutError(`${method} timed out after ${timeout}ms`)); + }, timeout); + this.innerPending.set(innerMessageId, { resolve, reject, timer }); + }); + + // Outer slot: tracks ack from Target.sendMessageToTarget. + // On outer error (e.g., invalid targetId), propagate to inner so the caller rejects. + const outerTimer = setTimeout(() => { + this.outerPending.delete(outerMessageId); + }, timeout); + + this.outerPending.set(outerMessageId, { + resolve: () => { /* ack only — real response comes via dispatchMessageFromTarget */ }, + reject: (err: Error) => { + const inner = this.innerPending.get(innerMessageId); + if (inner) { + clearTimeout(inner.timer); + this.innerPending.delete(innerMessageId); + inner.reject(err); + } + }, + timer: outerTimer, + }); + + const innerMessage = JSON.stringify({ id: innerMessageId, method, params }); + this.ws.send( + JSON.stringify({ + id: outerMessageId, + method: 'Target.sendMessageToTarget', + params: { targetId, message: innerMessage }, + }), + ); + + return innerPromise; + } + + // ========== Message Routing ========== + + private handleMessage(data: string): void { + let msg: any; + try { + msg = JSON.parse(data); + } catch { + return; + } + + // Multiplexed inner response from a page target + if (msg.method === 'Target.dispatchMessageFromTarget') { + let innerMsg: any; + try { + innerMsg = JSON.parse(msg.params.message); + } catch { + return; + } + + if (innerMsg.id !== undefined) { + const pending = this.innerPending.get(innerMsg.id); + if (pending) { + clearTimeout(pending.timer); + this.innerPending.delete(innerMsg.id); + if (innerMsg.error) { + pending.reject( + new ProtocolError( + innerMsg.error.message ?? JSON.stringify(innerMsg.error), + innerMsg.error.code, + ), + ); + } else { + pending.resolve(innerMsg.result); + } + } + } else if (innerMsg.method) { + // Inner event (e.g., Page.loadEventFired) — include targetId for multi-tab filtering + const sourceTargetId = msg.params.targetId; + this.emit(innerMsg.method, innerMsg.params, { targetId: sourceTargetId }); + } + return; + } + + // Outer ack for Target.sendMessageToTarget + if (msg.id !== undefined) { + const pending = this.outerPending.get(msg.id); + if (pending) { + clearTimeout(pending.timer); + this.outerPending.delete(msg.id); + if (msg.error) { + pending.reject( + new ProtocolError( + msg.error.message ?? JSON.stringify(msg.error), + msg.error.code, + ), + ); + } + // No resolve path — outer ack does not carry the real response + } + return; + } + + // Non-multiplexed event (Target.targetCreated, Target.targetDestroyed, etc.) + if (msg.method) { + this.emit(msg.method, msg.params); + } + } + + // ========== Internal Helpers ========== + + clearPendingRequests(): void { + for (const [, req] of this.outerPending) { + clearTimeout(req.timer); + req.reject(new ConnectionError('Connection closed')); + } + this.outerPending.clear(); + + for (const [, req] of this.innerPending) { + clearTimeout(req.timer); + req.reject(new ConnectionError('Connection closed')); + } + this.innerPending.clear(); + } +} diff --git a/tests/unit/protocol-transport.test.ts b/tests/unit/protocol-transport.test.ts new file mode 100644 index 00000000..32715ff1 --- /dev/null +++ b/tests/unit/protocol-transport.test.ts @@ -0,0 +1,280 @@ +/** + * Tests for WebSocketProtocolTransport (#706 2/5). + * + * Covers: + * - send happy path (inner response resolves) + * - pending-request resolution for out-of-order messages + * - TimeoutError on stalled inner request + * - ConnectionError on connection failure + * - ProtocolError propagated from outer ack error (invalid targetId) + * - ProtocolError propagated from inner response error + * - Events emitted for non-multiplexed protocol events + * - Inner events (domain events) forwarded with targetId metadata + * - disconnect rejects all pending requests + */ + +import { EventEmitter } from 'events'; +import { WebSocketProtocolTransport } from '../../src/webkit/protocol-transport'; +import { ConnectionError, TimeoutError, ProtocolError } from '../../src/webkit/errors'; + +// ─── Minimal WebSocket stub ─────────────────────────────────────────────────── + +class FakeWebSocket extends EventEmitter { + static OPEN = 1; + static CLOSED = 3; + + readyState = FakeWebSocket.OPEN; + sent: string[] = []; + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.readyState = FakeWebSocket.CLOSED; + this.emit('close'); + } + + /** Helper: simulate an inbound raw message from the WebKit proxy. */ + receive(payload: object): void { + this.emit('message', JSON.stringify(payload)); + } +} + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +/** Build a transport that is already "connected" to a FakeWebSocket. */ +function makeConnectedTransport(opts: { sendTimeout?: number } = {}): { + transport: WebSocketProtocolTransport; + ws: FakeWebSocket; +} { + const transport = new WebSocketProtocolTransport({ sendTimeout: opts.sendTimeout ?? 200 }); + const ws = new FakeWebSocket(); + + // Inject ws without going through real WebSocket constructor + (transport as any).ws = ws; + (transport as any)._connected = true; + + // Wire message routing: transport.handleMessage is private; invoke via ws 'message' event + ws.on('message', (data: string) => { + (transport as any).handleMessage(data); + }); + + // Wire close event + ws.on('close', () => { + (transport as any)._connected = false; + transport.emit('transport:close'); + }); + + return { transport, ws }; +} + +/** Parse the most recent message sent over the fake WebSocket. */ +function lastSent(ws: FakeWebSocket): any { + return JSON.parse(ws.sent[ws.sent.length - 1]); +} + +/** Simulate a successful dispatchMessageFromTarget response. */ +function dispatchResponse(ws: FakeWebSocket, innerId: number, result: unknown): void { + ws.receive({ + method: 'Target.dispatchMessageFromTarget', + params: { + targetId: 'target-1', + message: JSON.stringify({ id: innerId, result }), + }, + }); +} + +/** Simulate a dispatchMessageFromTarget error response. */ +function dispatchError(ws: FakeWebSocket, innerId: number, message: string, code?: number): void { + ws.receive({ + method: 'Target.dispatchMessageFromTarget', + params: { + targetId: 'target-1', + message: JSON.stringify({ id: innerId, error: { message, code } }), + }, + }); +} + +/** Simulate an outer ack error (e.g., invalid targetId). */ +function outerError(ws: FakeWebSocket, outerId: number, message: string, code?: number): void { + ws.receive({ id: outerId, error: { message, code } }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('WebSocketProtocolTransport', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // ── Happy path ───────────────────────────────────────────────────────────── + + it('resolves with result when dispatchMessageFromTarget carries matching innerId', async () => { + const { transport, ws } = makeConnectedTransport(); + + const promise = transport.sendToTarget('Runtime.evaluate', { expression: '1+1' }, 'target-1', 1, 101, 500); + + // Verify wire format + const sent = lastSent(ws); + expect(sent.method).toBe('Target.sendMessageToTarget'); + expect(sent.params.targetId).toBe('target-1'); + const inner = JSON.parse(sent.params.message); + expect(inner.id).toBe(1); + expect(inner.method).toBe('Runtime.evaluate'); + + // Deliver response + dispatchResponse(ws, 1, { result: { value: 2 } }); + + const result = await promise; + expect(result).toEqual({ result: { value: 2 } }); + }); + + // ── Out-of-order resolution ──────────────────────────────────────────────── + + it('routes responses to the correct pending request when out of order', async () => { + const { transport, ws } = makeConnectedTransport(); + + const p1 = transport.sendToTarget('Page.navigate', { url: 'https://a.com' }, 'target-1', 10, 201, 500); + const p2 = transport.sendToTarget('Page.navigate', { url: 'https://b.com' }, 'target-1', 11, 202, 500); + + // Deliver in reverse order + dispatchResponse(ws, 11, { frameId: 'b' }); + dispatchResponse(ws, 10, { frameId: 'a' }); + + const [r1, r2] = await Promise.all([p1, p2]); + expect((r1 as any).frameId).toBe('a'); + expect((r2 as any).frameId).toBe('b'); + }); + + // ── Timeout ─────────────────────────────────────────────────────────────── + + it('rejects with TimeoutError when inner response never arrives', async () => { + const { transport } = makeConnectedTransport({ sendTimeout: 100 }); + + const promise = transport.sendToTarget('Page.navigate', {}, 'target-1', 20, 301, 100); + + jest.advanceTimersByTime(101); + + await expect(promise).rejects.toBeInstanceOf(TimeoutError); + await expect(promise).rejects.toMatchObject({ message: expect.stringContaining('timed out after 100ms') }); + }); + + // ── Connection failure ──────────────────────────────────────────────────── + + it('rejects immediately with ConnectionError when WebSocket is not open', async () => { + const transport = new WebSocketProtocolTransport(); + // No ws set — transport not connected + + await expect( + transport.sendToTarget('Runtime.evaluate', {}, 'target-1', 1, 1, 500), + ).rejects.toBeInstanceOf(ConnectionError); + }); + + it('rejects with ConnectionError when connect() times out', async () => { + const transport = new WebSocketProtocolTransport({ connectTimeout: 50 }); + + // connect() opens a real WebSocket — we short-circuit by advancing timers + // We need a URL that never connects; the timeout fires before 'open' + const connectPromise = transport.connect('ws://127.0.0.1:19999/devtools/page/never'); + + jest.advanceTimersByTime(51); + + await expect(connectPromise).rejects.toBeInstanceOf(ConnectionError); + await expect(connectPromise).rejects.toMatchObject({ message: expect.stringContaining('timeout') }); + }); + + // ── Outer ack error (invalid targetId) ─────────────────────────────────── + + it('propagates outer ack ProtocolError to the inner pending promise', async () => { + const { transport, ws } = makeConnectedTransport(); + + const promise = transport.sendToTarget('Runtime.evaluate', {}, 'bad-target', 30, 401, 500); + + outerError(ws, 401, 'No target with given id found', -32000); + + await expect(promise).rejects.toBeInstanceOf(ProtocolError); + await expect(promise).rejects.toMatchObject({ message: 'No target with given id found', code: -32000 }); + }); + + // ── Inner response error ────────────────────────────────────────────────── + + it('rejects with ProtocolError when inner response carries an error', async () => { + const { transport, ws } = makeConnectedTransport(); + + const promise = transport.sendToTarget('Page.unknown', {}, 'target-1', 40, 501, 500); + + dispatchError(ws, 40, "'unknown' was not found", -32601); + + await expect(promise).rejects.toBeInstanceOf(ProtocolError); + await expect(promise).rejects.toMatchObject({ message: "'unknown' was not found", code: -32601 }); + }); + + // ── Non-multiplexed event emission ─────────────────────────────────────── + + it('emits non-multiplexed protocol events directly', async () => { + const { transport, ws } = makeConnectedTransport(); + + const received: any[] = []; + transport.on('Target.targetCreated', (params: any) => received.push(params)); + + ws.receive({ + method: 'Target.targetCreated', + params: { targetInfo: { targetId: 'new-target', type: 'page', url: 'about:blank' } }, + }); + + expect(received).toHaveLength(1); + expect(received[0].targetInfo.targetId).toBe('new-target'); + }); + + // ── Inner domain event emission ─────────────────────────────────────────── + + it('emits inner domain events with targetId metadata', async () => { + const { transport, ws } = makeConnectedTransport(); + + const received: Array<{ params: any; meta: any }> = []; + transport.on('Page.loadEventFired', (params: any, meta: any) => { + received.push({ params, meta }); + }); + + ws.receive({ + method: 'Target.dispatchMessageFromTarget', + params: { + targetId: 'target-1', + message: JSON.stringify({ method: 'Page.loadEventFired', params: { timestamp: 1234 } }), + }, + }); + + expect(received).toHaveLength(1); + expect(received[0].params).toEqual({ timestamp: 1234 }); + expect(received[0].meta).toEqual({ targetId: 'target-1' }); + }); + + // ── disconnect rejects pending ──────────────────────────────────────────── + + it('rejects all pending requests with ConnectionError on disconnect()', async () => { + const { transport } = makeConnectedTransport(); + + const p1 = transport.sendToTarget('Runtime.evaluate', {}, 'target-1', 50, 601, 5000); + const p2 = transport.sendToTarget('Page.navigate', {}, 'target-1', 51, 602, 5000); + + await transport.disconnect(); + + await expect(p1).rejects.toBeInstanceOf(ConnectionError); + await expect(p2).rejects.toBeInstanceOf(ConnectionError); + }); + + // ── isConnected ─────────────────────────────────────────────────────────── + + it('returns true when ws is OPEN, false after disconnect', async () => { + const { transport } = makeConnectedTransport(); + expect(transport.isConnected()).toBe(true); + + await transport.disconnect(); + expect(transport.isConnected()).toBe(false); + }); +}); From eb2d8334e1b40aadc3afbb82f85d2148c60b2913 Mon Sep 17 00:00:00 2001 From: shaun0927 <70629228+shaun0927@users.noreply.github.com> Date: Thu, 7 May 2026 23:46:16 +0900 Subject: [PATCH 03/17] refactor(webkit): extract target session manager (#706 3/5) Behavior-preserving; same target/active-target semantics; per-target enabled-domain dedup preserved. TargetSessionManager owns knownTargets, activeTargetId, enabledDomainsPerTarget and Target.* event subscriptions. WebKitClient delegates all target state operations to the manager via the TargetCommandSender adapter interface (no circular deps). Co-Authored-By: Claude Sonnet 4.6 --- src/webkit/client.ts | 110 +++--------- src/webkit/target-session.ts | 229 ++++++++++++++++++++++++ tests/unit/target-session.test.ts | 281 ++++++++++++++++++++++++++++++ 3 files changed, 537 insertions(+), 83 deletions(-) create mode 100644 src/webkit/target-session.ts create mode 100644 tests/unit/target-session.test.ts diff --git a/src/webkit/client.ts b/src/webkit/client.ts index 4c950e9d..e97c4eaa 100644 --- a/src/webkit/client.ts +++ b/src/webkit/client.ts @@ -4,6 +4,8 @@ import { ConnectionError, TimeoutError, ProtocolError, EvaluationError } from '. export { ConnectionError, TimeoutError, ProtocolError, EvaluationError } from './errors'; import { ProtocolTransport, WebSocketProtocolTransport } from './protocol-transport'; export type { ProtocolTransport } from './protocol-transport'; +import { TargetSessionManager } from './target-session'; +export type { TargetCommandSender } from './target-session'; import { BrowserBackend, NavigateOptions, @@ -41,20 +43,14 @@ export interface WebKitTarget { export class WebKitClient extends EventEmitter implements BrowserBackend { private transport: ProtocolTransport; private messageId = 0; - private enabledDomains: Set = new Set(); - private enabledDomainsPerTarget: Map> = new Map(); + private innerMessageId = 0; private connected = false; private heartbeatTimer: ReturnType | null = null; private lastUrl: string = ''; private reconnecting = false; readonly backendType = 'safari' as const; - // Target-multiplexed protocol state - private activeTargetId: string | null = null; - private knownTargets: Set = new Set(); - private innerMessageId = 0; - private targetReady: Promise | null = null; - private targetReadyResolve: (() => void) | null = null; + private targetSession: TargetSessionManager; constructor(private options: WebKitClientOptions) { super(); @@ -62,6 +58,7 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { connectTimeout: options.connectTimeout, sendTimeout: options.sendTimeout, }); + this.targetSession = new TargetSessionManager(this.transport, this); this.bindTransportEvents(); } @@ -97,55 +94,16 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { // Error already handled — connection state tracked via transport:close }); - // Target lifecycle events emitted by the transport - this.transport.on('Target.targetCreated', (params: any) => { - this.handleTargetCreated(params); + // Forward target lifecycle events emitted by TargetSessionManager to this EventEmitter. + this.targetSession.on('target:created', (payload: any) => { + this.emit('target:created', payload); }); - this.transport.on('Target.targetDestroyed', (params: any) => { - this.handleTargetDestroyed(params); + this.targetSession.on('target:destroyed', (payload: any) => { + this.emit('target:destroyed', payload); }); } - // ========== Target Lifecycle Handlers (moved from handleMessage) ========== - - private handleTargetCreated(params: any): void { - const info = params?.targetInfo; - if (info?.type !== 'page') return; - - this.knownTargets.add(info.targetId); - this.emit('target:created', { targetId: info.targetId, url: info.url }); - - if (!this.activeTargetId) { - this.activeTargetId = info.targetId; - } - - const globalDomains = [...this.enabledDomains]; - const perTargetDomains = this.enabledDomainsPerTarget.get(info.targetId); - const domainsToEnable = new Set([...globalDomains, ...(perTargetDomains ?? [])]); - Promise.all( - [...domainsToEnable].map(domain => - this.sendToTarget(`${domain}.enable`, undefined, info.targetId).catch(err => { - console.error(`[WebKitClient] Failed to re-enable ${domain} on new target: ${(err as Error).message}`); - }) - ) - ).then(() => { - this.targetReadyResolve?.(); - }); - } - - private handleTargetDestroyed(params: any): void { - const destroyedId = params?.targetId; - this.knownTargets.delete(destroyedId); - this.enabledDomainsPerTarget.delete(destroyedId); - this.emit('target:destroyed', { targetId: destroyedId }); - if (destroyedId === this.activeTargetId) { - this.activeTargetId = this.knownTargets.size > 0 - ? this.knownTargets.values().next().value ?? null - : null; - } - } - /** * Connect directly to a specific WebSocket URL (e.g., per-tab endpoint). */ @@ -204,12 +162,8 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { this.stopHeartbeat(); await this.transport.disconnect(); this.connected = false; - this.enabledDomains.clear(); - this.enabledDomainsPerTarget.clear(); - this.activeTargetId = null; - this.knownTargets.clear(); - this.targetReady = null; - this.targetReadyResolve = null; + this.targetSession.reset(); + this.targetSession.resetGlobalDomains(); } isConnected(): boolean { @@ -265,7 +219,7 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { method: string, params?: Record, ): Promise { - return this.sendToTarget(method, params, this.activeTargetId); + return this.sendToTarget(method, params, this.targetSession.getActiveTargetId()); } /** @@ -277,7 +231,7 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { params?: Record, targetId?: string | null, ): Promise { - const resolvedTargetId = targetId ?? this.activeTargetId; + const resolvedTargetId = targetId ?? this.targetSession.getActiveTargetId(); if (!resolvedTargetId) { throw new ConnectionError( 'No active target. Is Safari open in the simulator?', @@ -294,9 +248,9 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { // ========== Domain Management ========== async enableDomain(domain: string): Promise { - if (!this.enabledDomains.has(domain)) { + if (!this.targetSession.hasGlobalEnabledDomain(domain)) { await this.send(`${domain}.enable`); - this.enabledDomains.add(domain); + this.targetSession.addGlobalEnabledDomain(domain); } } @@ -306,31 +260,25 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { */ async enableDomainForTarget(domain: string, targetId: string): Promise { await this.sendToTarget(`${domain}.enable`, undefined, targetId); - if (!this.enabledDomainsPerTarget.has(targetId)) { - this.enabledDomainsPerTarget.set(targetId, new Set()); - } - this.enabledDomainsPerTarget.get(targetId)!.add(domain); + this.targetSession.addEnabledDomainForTarget(domain, targetId); } // ========== Multi-Tab Target Management ========== getActiveTargetId(): string | null { - return this.activeTargetId; + return this.targetSession.getActiveTargetId(); } setActiveTargetId(targetId: string): void { - if (!this.knownTargets.has(targetId)) { - throw new ConnectionError(`Target ${targetId} not found in known targets`); - } - this.activeTargetId = targetId; + this.targetSession.setActiveTargetId(targetId); } getKnownTargets(): Set { - return new Set(this.knownTargets); + return this.targetSession.getKnownTargets(); } getEnabledDomainsForTarget(targetId: string): Set { - return new Set(this.enabledDomainsPerTarget.get(targetId) ?? []); + return this.targetSession.getEnabledDomainsForTarget(targetId); } // ========== Heartbeat ========== @@ -363,8 +311,7 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { // Clear stale pending requests before reconnect (this.transport as WebSocketProtocolTransport).clearPendingRequests(); - this.activeTargetId = null; - this.knownTargets.clear(); + this.targetSession.resetTargets(); this.stopHeartbeat(); @@ -390,9 +337,9 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { console.error(`[WebKitClient] Reconnected successfully`); // Re-enable domains (global + per-target state is rebuilt via enableDomain/enableDomainForTarget) - const domains = [...this.enabledDomains]; - this.enabledDomains.clear(); - this.enabledDomainsPerTarget.clear(); + const domains = this.targetSession.snapshotGlobalDomains(); + this.targetSession.resetGlobalDomains(); + this.targetSession.resetPerTargetDomains(); for (const domain of domains) { await this.enableDomain(domain); } @@ -1098,10 +1045,7 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { private async connectToTarget(wsUrl: string): Promise { // Set up target discovery promise before connecting - this.activeTargetId = null; - this.targetReady = new Promise((resolve) => { - this.targetReadyResolve = resolve; - }); + const targetReady = this.targetSession.prepareForConnect(); await this.transport.connect(wsUrl); this.connected = true; @@ -1118,7 +1062,7 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { ); }); try { - await Promise.race([this.targetReady, targetTimeout]); + await Promise.race([targetReady, targetTimeout]); } finally { clearTimeout(targetTimer!); } diff --git a/src/webkit/target-session.ts b/src/webkit/target-session.ts new file mode 100644 index 00000000..e6b7bed1 --- /dev/null +++ b/src/webkit/target-session.ts @@ -0,0 +1,229 @@ +/** + * TargetSessionManager — per-target session state for WebKit Remote Debugging Protocol. + * + * Extracted from client.ts (#706 3/5). Behavior-preserving; same target/active-target + * semantics; per-target enabled-domain dedup preserved. + * + * Owns: + * - knownTargets Set + * - activeTargetId + * - per-target enabled-domain Sets + * - Target.targetCreated / Target.targetDestroyed event subscriptions + * - Re-enable logic for domains on new targets (delegated back via onNewTarget callback) + * + * Does NOT own: transport, WebSocket, message IDs, heartbeat, browser commands. + */ + +import { EventEmitter } from 'events'; +import { ConnectionError } from './errors'; +import type { ProtocolTransport } from './protocol-transport'; + +// ========== Adapter interface ========== + +/** + * Minimal adapter interface used by TargetSessionManager to send protocol commands + * back to the client without creating a circular dependency on WebKitClient. + */ +export interface TargetCommandSender { + /** + * Send a protocol command to a specific target. + * Mirrors WebKitClient.sendToTarget signature (subset sufficient for domain re-enable). + */ + sendToTarget( + method: string, + params?: Record, + targetId?: string | null, + ): Promise; +} + +// ========== TargetSessionManager ========== + +export class TargetSessionManager extends EventEmitter { + private activeTargetId: string | null = null; + private readonly knownTargets: Set = new Set(); + private readonly enabledDomainsPerTarget: Map> = new Map(); + + // Global enabled-domain set (set by WebKitClient.enableDomain) for re-enable on new targets + private globalEnabledDomains: Set = new Set(); + + // Promise used by connectToTarget to wait for the first page target + private targetReady: Promise | null = null; + private targetReadyResolve: (() => void) | null = null; + + constructor( + private readonly transport: ProtocolTransport, + private readonly sender: TargetCommandSender, + ) { + super(); + this.bindTransportEvents(); + } + + // ========== Transport event binding ========== + + private bindTransportEvents(): void { + this.transport.on('Target.targetCreated', (params: any) => { + this.handleTargetCreated(params); + }); + + this.transport.on('Target.targetDestroyed', (params: any) => { + this.handleTargetDestroyed(params); + }); + } + + // ========== Target lifecycle ========== + + private handleTargetCreated(params: any): void { + const info = params?.targetInfo; + if (info?.type !== 'page') return; + + this.knownTargets.add(info.targetId); + this.emit('target:created', { targetId: info.targetId, url: info.url }); + + if (!this.activeTargetId) { + this.activeTargetId = info.targetId; + } + + const globalDomains = [...this.globalEnabledDomains]; + const perTargetDomains = this.enabledDomainsPerTarget.get(info.targetId); + const domainsToEnable = new Set([...globalDomains, ...(perTargetDomains ?? [])]); + + Promise.all( + [...domainsToEnable].map(domain => + this.sender.sendToTarget(`${domain}.enable`, undefined, info.targetId).catch(err => { + console.error(`[TargetSessionManager] Failed to re-enable ${domain} on new target: ${(err as Error).message}`); + }) + ) + ).then(() => { + this.targetReadyResolve?.(); + }); + } + + private handleTargetDestroyed(params: any): void { + const destroyedId = params?.targetId; + this.knownTargets.delete(destroyedId); + this.enabledDomainsPerTarget.delete(destroyedId); + this.emit('target:destroyed', { targetId: destroyedId }); + if (destroyedId === this.activeTargetId) { + this.activeTargetId = this.knownTargets.size > 0 + ? this.knownTargets.values().next().value ?? null + : null; + } + } + + // ========== Target ready promise (used by connectToTarget) ========== + + /** + * Reset state and create a new target-ready promise. + * Called by WebKitClient just before transport.connect(). + */ + prepareForConnect(): Promise { + this.activeTargetId = null; + this.targetReady = new Promise((resolve) => { + this.targetReadyResolve = resolve; + }); + return this.targetReady; + } + + /** + * The current target-ready promise (null if prepareForConnect has not been called). + */ + getTargetReadyPromise(): Promise | null { + return this.targetReady; + } + + // ========== Active target ========== + + getActiveTargetId(): string | null { + return this.activeTargetId; + } + + setActiveTargetId(targetId: string): void { + if (!this.knownTargets.has(targetId)) { + throw new ConnectionError(`Target ${targetId} not found in known targets`); + } + this.activeTargetId = targetId; + } + + // ========== Known targets ========== + + getKnownTargets(): Set { + return new Set(this.knownTargets); + } + + // ========== Enabled-domain tracking ========== + + /** + * Update the global enabled-domain set so new targets get the same domains re-enabled. + * Called by WebKitClient.enableDomain after successful enable. + */ + addGlobalEnabledDomain(domain: string): void { + this.globalEnabledDomains.add(domain); + } + + /** + * Check whether a domain is already in the global enabled set (for dedup). + */ + hasGlobalEnabledDomain(domain: string): boolean { + return this.globalEnabledDomains.has(domain); + } + + /** + * Track that a domain was enabled for a specific target. + */ + addEnabledDomainForTarget(domain: string, targetId: string): void { + if (!this.enabledDomainsPerTarget.has(targetId)) { + this.enabledDomainsPerTarget.set(targetId, new Set()); + } + this.enabledDomainsPerTarget.get(targetId)!.add(domain); + } + + /** + * Return a snapshot of the enabled domains for a target. + */ + getEnabledDomainsForTarget(targetId: string): Set { + return new Set(this.enabledDomainsPerTarget.get(targetId) ?? []); + } + + // ========== Reset (on disconnect) ========== + + /** + * Clear all target state. Called by WebKitClient.disconnect() and during reconnection. + */ + reset(): void { + this.activeTargetId = null; + this.knownTargets.clear(); + this.enabledDomainsPerTarget.clear(); + this.targetReady = null; + this.targetReadyResolve = null; + } + + /** + * Clear only the per-target state (active + known), preserving global domains. + * Used during reconnection before re-enabling domains. + */ + resetTargets(): void { + this.activeTargetId = null; + this.knownTargets.clear(); + } + + /** + * Clear per-target enabled-domain tracking. Used during reconnection cleanup. + */ + resetPerTargetDomains(): void { + this.enabledDomainsPerTarget.clear(); + } + + /** + * Clear the global enabled-domain set. Called during reconnection cleanup. + */ + resetGlobalDomains(): void { + this.globalEnabledDomains.clear(); + } + + /** + * Snapshot the global enabled-domain set (for reconnection re-enable loop). + */ + snapshotGlobalDomains(): string[] { + return [...this.globalEnabledDomains]; + } +} diff --git a/tests/unit/target-session.test.ts b/tests/unit/target-session.test.ts new file mode 100644 index 00000000..914328ef --- /dev/null +++ b/tests/unit/target-session.test.ts @@ -0,0 +1,281 @@ +/** + * Tests for TargetSessionManager (#706 3/5). + * + * Covers: + * - target add on Target.targetCreated + * - target remove on Target.targetDestroyed + * - listTargets() snapshot (getKnownTargets) + * - setActiveTargetId happy path + invalid id error + * - per-target enabled-domain set is independent across targets + * - per-target enabled-domain set clears on target destroyed + */ + +import { EventEmitter } from 'events'; +import { TargetSessionManager, TargetCommandSender } from '../../src/webkit/target-session'; +import { ConnectionError } from '../../src/webkit/errors'; +import type { ProtocolTransport } from '../../src/webkit/protocol-transport'; + +// ─── Minimal transport stub ─────────────────────────────────────────────────── + +class FakeTransport extends EventEmitter implements ProtocolTransport { + connect(_wsUrl: string): Promise { return Promise.resolve(); } + disconnect(): Promise { return Promise.resolve(); } + isConnected(): boolean { return true; } + sendToTarget(): Promise { return Promise.resolve({} as T); } +} + +// ─── Minimal sender stub ───────────────────────────────────────────────────── + +class FakeSender implements TargetCommandSender { + calls: Array<{ method: string; targetId?: string | null }> = []; + + async sendToTarget(method: string, _params?: Record, targetId?: string | null): Promise { + this.calls.push({ method, targetId }); + return {} as T; + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeManager(): { + manager: TargetSessionManager; + transport: FakeTransport; + sender: FakeSender; +} { + const transport = new FakeTransport(); + const sender = new FakeSender(); + const manager = new TargetSessionManager(transport, sender); + return { manager, transport, sender }; +} + +function emitTargetCreated(transport: FakeTransport, targetId: string, url = 'about:blank', type = 'page'): void { + transport.emit('Target.targetCreated', { targetInfo: { targetId, url, type } }); +} + +function emitTargetDestroyed(transport: FakeTransport, targetId: string): void { + transport.emit('Target.targetDestroyed', { targetId }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('TargetSessionManager', () => { + + // ── Target.targetCreated ─────────────────────────────────────────────────── + + it('adds target to known set on Target.targetCreated', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + expect(manager.getKnownTargets().has('page-1')).toBe(true); + }); + + it('ignores non-page targets on Target.targetCreated', () => { + const { manager, transport } = makeManager(); + transport.emit('Target.targetCreated', { targetInfo: { targetId: 'worker-1', url: 'about:blank', type: 'worker' } }); + expect(manager.getKnownTargets().size).toBe(0); + }); + + it('sets activeTargetId to first page target created', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + expect(manager.getActiveTargetId()).toBe('page-1'); + }); + + it('does not override activeTargetId when a second target arrives', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + emitTargetCreated(transport, 'page-2'); + expect(manager.getActiveTargetId()).toBe('page-1'); + }); + + it('emits target:created event', () => { + const { manager, transport } = makeManager(); + const events: any[] = []; + manager.on('target:created', (payload) => events.push(payload)); + emitTargetCreated(transport, 'page-1', 'https://example.com'); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ targetId: 'page-1', url: 'https://example.com' }); + }); + + // ── Target.targetDestroyed ───────────────────────────────────────────────── + + it('removes target from known set on Target.targetDestroyed', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + emitTargetDestroyed(transport, 'page-1'); + expect(manager.getKnownTargets().has('page-1')).toBe(false); + }); + + it('emits target:destroyed event', () => { + const { manager, transport } = makeManager(); + const events: any[] = []; + manager.on('target:destroyed', (payload) => events.push(payload)); + emitTargetCreated(transport, 'page-1'); + emitTargetDestroyed(transport, 'page-1'); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ targetId: 'page-1' }); + }); + + it('falls back activeTargetId to another known target when active is destroyed', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + emitTargetCreated(transport, 'page-2'); + emitTargetDestroyed(transport, 'page-1'); + expect(manager.getActiveTargetId()).toBe('page-2'); + }); + + it('sets activeTargetId to null when last target is destroyed', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + emitTargetDestroyed(transport, 'page-1'); + expect(manager.getActiveTargetId()).toBeNull(); + }); + + it('does not change activeTargetId when a non-active target is destroyed', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + emitTargetCreated(transport, 'page-2'); + emitTargetDestroyed(transport, 'page-2'); + expect(manager.getActiveTargetId()).toBe('page-1'); + }); + + // ── getKnownTargets snapshot ─────────────────────────────────────────────── + + it('getKnownTargets returns a snapshot not the internal set', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + const snapshot = manager.getKnownTargets(); + emitTargetCreated(transport, 'page-2'); + // snapshot should still be size 1 — it's a copy + expect(snapshot.size).toBe(1); + expect(manager.getKnownTargets().size).toBe(2); + }); + + it('listTargets via getKnownTargets reflects all known page targets', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + emitTargetCreated(transport, 'page-2'); + emitTargetCreated(transport, 'page-3'); + const ids = [...manager.getKnownTargets()].sort(); + expect(ids).toEqual(['page-1', 'page-2', 'page-3']); + }); + + // ── setActiveTargetId ────────────────────────────────────────────────────── + + it('setActiveTargetId happy path switches active target', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + emitTargetCreated(transport, 'page-2'); + manager.setActiveTargetId('page-2'); + expect(manager.getActiveTargetId()).toBe('page-2'); + }); + + it('setActiveTargetId throws ConnectionError for unknown id', () => { + const { manager } = makeManager(); + expect(() => manager.setActiveTargetId('nonexistent')).toThrow(ConnectionError); + expect(() => manager.setActiveTargetId('nonexistent')).toThrow('not found'); + }); + + // ── Per-target enabled-domain Sets ──────────────────────────────────────── + + it('per-target domain sets are independent across targets', () => { + const { manager } = makeManager(); + manager.addEnabledDomainForTarget('Page', 'target-1'); + manager.addEnabledDomainForTarget('Runtime', 'target-2'); + + expect(manager.getEnabledDomainsForTarget('target-1').has('Page')).toBe(true); + expect(manager.getEnabledDomainsForTarget('target-1').has('Runtime')).toBe(false); + expect(manager.getEnabledDomainsForTarget('target-2').has('Runtime')).toBe(true); + expect(manager.getEnabledDomainsForTarget('target-2').has('Page')).toBe(false); + }); + + it('getEnabledDomainsForTarget returns empty set for unknown target', () => { + const { manager } = makeManager(); + expect(manager.getEnabledDomainsForTarget('unknown').size).toBe(0); + }); + + it('per-target domain set clears on target destroyed', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + manager.addEnabledDomainForTarget('Page', 'page-1'); + manager.addEnabledDomainForTarget('Runtime', 'page-1'); + expect(manager.getEnabledDomainsForTarget('page-1').size).toBe(2); + + emitTargetDestroyed(transport, 'page-1'); + expect(manager.getEnabledDomainsForTarget('page-1').size).toBe(0); + }); + + it('destroying one target does not clear domains of another', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + emitTargetCreated(transport, 'page-2'); + manager.addEnabledDomainForTarget('Page', 'page-1'); + manager.addEnabledDomainForTarget('Runtime', 'page-2'); + + emitTargetDestroyed(transport, 'page-1'); + expect(manager.getEnabledDomainsForTarget('page-2').has('Runtime')).toBe(true); + }); + + // ── Global enabled-domain set ───────────────────────────────────────────── + + it('hasGlobalEnabledDomain returns false before adding', () => { + const { manager } = makeManager(); + expect(manager.hasGlobalEnabledDomain('Page')).toBe(false); + }); + + it('hasGlobalEnabledDomain returns true after addGlobalEnabledDomain', () => { + const { manager } = makeManager(); + manager.addGlobalEnabledDomain('Page'); + expect(manager.hasGlobalEnabledDomain('Page')).toBe(true); + }); + + it('snapshotGlobalDomains returns a copy of the global set', () => { + const { manager } = makeManager(); + manager.addGlobalEnabledDomain('Page'); + manager.addGlobalEnabledDomain('Runtime'); + const snap = manager.snapshotGlobalDomains(); + expect(snap.sort()).toEqual(['Page', 'Runtime']); + }); + + it('resetGlobalDomains clears the global set', () => { + const { manager } = makeManager(); + manager.addGlobalEnabledDomain('Page'); + manager.resetGlobalDomains(); + expect(manager.hasGlobalEnabledDomain('Page')).toBe(false); + }); + + // ── prepareForConnect / reset ────────────────────────────────────────────── + + it('prepareForConnect returns a promise that resolves when first page target arrives', async () => { + const { manager, transport } = makeManager(); + const ready = manager.prepareForConnect(); + + let resolved = false; + ready.then(() => { resolved = true; }); + + // Not resolved yet + await Promise.resolve(); + expect(resolved).toBe(false); + + // Emit target — triggers re-enable chain which calls resolve + emitTargetCreated(transport, 'page-1'); + + // Allow microtasks to settle + await ready; + expect(resolved).toBe(true); + }); + + it('reset clears all state', () => { + const { manager, transport } = makeManager(); + emitTargetCreated(transport, 'page-1'); + manager.addEnabledDomainForTarget('Page', 'page-1'); + manager.addGlobalEnabledDomain('Runtime'); + + manager.reset(); + + expect(manager.getKnownTargets().size).toBe(0); + expect(manager.getActiveTargetId()).toBeNull(); + expect(manager.getEnabledDomainsForTarget('page-1').size).toBe(0); + // global domains are NOT cleared by reset() — use resetGlobalDomains() for that + expect(manager.hasGlobalEnabledDomain('Runtime')).toBe(true); + }); +}); From 5004964109ac46c07afa5d43e6f9d3a22894c4ba Mon Sep 17 00:00:00 2001 From: shaun0927 <70629228+shaun0927@users.noreply.github.com> Date: Thu, 7 May 2026 23:54:56 +0900 Subject: [PATCH 04/17] refactor(webkit): extract browser command implementations (#706 4/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving extraction of high-level browser commands from client.ts into three new modules: - src/webkit/dom-input-scripts.ts: DOM script builders (tap, longPress, swipe, setValue, typeChar, focus, selectOption). Uses document.createTouch() / document.createTouchList() — never new Touch(). Uses prototype-walk value setter to avoid cross-realm TypeError. - src/webkit/evaluate.ts: evaluateValue helper implementing the three-step WebKit eval pattern (Runtime.evaluate → Runtime.awaitPromise as separate command → Runtime.callFunctionOn for object serialization). - src/webkit/browser-commands.ts: BrowserCommands class taking a BrowserCommandSender adapter. Implements navigate, screenshot (Page.snapshotRect only — never captureScreenshot), click, longPress, swipe, type, scroll, press, dismissKeyboard, selectOption, readPage, querySelector/All, inspect, waitFor, getCookies/setCookies/clearCookies. WebKitClient becomes a thin facade: same public API, delegates every browser command to BrowserCommands. Same protocol calls, screenshot path, touch APIs, and per-target enabled-domain dedup preserved. Co-Authored-By: Claude Sonnet 4.6 --- src/webkit/browser-commands.ts | 542 ++++++++++++++++++++++++++ src/webkit/client.ts | 582 ++-------------------------- src/webkit/dom-input-scripts.ts | 183 +++++++++ src/webkit/evaluate.ts | 105 +++++ tests/unit/browser-commands.test.ts | 497 ++++++++++++++++++++++++ 5 files changed, 1352 insertions(+), 557 deletions(-) create mode 100644 src/webkit/browser-commands.ts create mode 100644 src/webkit/dom-input-scripts.ts create mode 100644 src/webkit/evaluate.ts create mode 100644 tests/unit/browser-commands.test.ts diff --git a/src/webkit/browser-commands.ts b/src/webkit/browser-commands.ts new file mode 100644 index 00000000..ed32dc17 --- /dev/null +++ b/src/webkit/browser-commands.ts @@ -0,0 +1,542 @@ +/** + * browser-commands.ts — High-level browser command implementations for WebKit. + * + * Extracted from client.ts (#706 4/5). Behavior-preserving: same protocol calls, + * same screenshot API (Page.snapshotRect), same touch APIs (document.createTouch), + * same per-target domain dedup (via TargetSessionManager), same evaluate semantics. + * + * Owns: + * - navigate, screenshot, click, longPress, swipe, type, scroll, press, + * dismissKeyboard, selectOption, readPage, querySelector, querySelectorAll, + * inspect, waitFor, getCookies, setCookies, clearCookies + * + * Does NOT own: transport lifecycle, target discovery, heartbeat, reconnection, + * event forwarding, domain management. + */ + +import { ProtocolError, TimeoutError } from './errors'; +import { evaluateValue, EvaluateSender } from './evaluate'; +import { + buildTapScript, + buildLongPressScript, + buildSwipeScript, + buildSetValueScript, + buildTypeCharScript, + buildFocusScript, + buildSelectOptionScript, +} from './dom-input-scripts'; +import { + NavigateOptions, + NavigateResult, + ScreenshotOptions, + ElementInfo, + Cookie, +} from '../types/browser-backend'; + +// ========== Adapter interfaces ========== + +/** + * Minimal adapter interface for BrowserCommands to send protocol commands. + * Avoids circular dependency on WebKitClient. + */ +export interface BrowserCommandSender { + send(method: string, params?: Record): Promise; + enableDomain(domain: string): Promise; +} + +// ========== BrowserCommands ========== + +export class BrowserCommands { + constructor(private readonly sender: BrowserCommandSender) {} + + // ========== Evaluate ========== + + /** + * Evaluate a JS expression in the page context. + * Handles Promises via a separate Runtime.awaitPromise call (WebKit requirement). + */ + async evaluate( + expression: string, + options?: { emulateUserGesture?: boolean }, + ): Promise { + return evaluateValue(this.sender as EvaluateSender, expression, options); + } + + // ========== Navigate ========== + + async navigate(options: NavigateOptions, lastUrlSetter?: (url: string) => void): Promise { + const startTime = Date.now(); + + if (lastUrlSetter) lastUrlSetter(options.url); + + await this.sender.enableDomain('Page'); + await this.sender.enableDomain('Network'); + + // Try Page.navigate first; fall back to JS navigation if unsupported + try { + await this.sender.send('Page.navigate', { url: options.url }); + } catch (e) { + if (e instanceof ProtocolError && e.code === -32601) { + // Page.navigate not supported — use JS fallback + await this.evaluate(`window.location.href = ${JSON.stringify(options.url)}`); + } else { + throw e; + } + } + + // Poll document.readyState instead of relying on Page.loadEventFired + const waitUntil = options.waitUntil; + const waitStart = Date.now(); + const navTimeout = options.timeout ?? 30000; + while (Date.now() - waitStart < navTimeout) { + await new Promise(r => setTimeout(r, 300)); + try { + const readyState = await this.evaluate('document.readyState'); + if (waitUntil === 'networkidle') { + // networkidle not directly detectable via polling + // Fall back to 'complete' readyState + extra delay + if (readyState === 'complete') { + await new Promise(r => setTimeout(r, 500)); // extra settle time + break; + } + } else if (waitUntil === 'domcontentloaded') { + if (readyState === 'interactive' || readyState === 'complete') break; + } else if (waitUntil === 'load') { + if (readyState === 'complete') break; + } else { + if (readyState === 'complete') break; + } + } catch { + // Target may be transitioning during navigation, keep polling + continue; + } + } + + // P0-1: Check if we actually broke out or timed out + const finalReadyState = await this.evaluate('document.readyState').catch(() => ''); + const expectedState = waitUntil === 'domcontentloaded' ? 'interactive' : 'complete'; + if (finalReadyState !== 'complete' && finalReadyState !== expectedState) { + throw new TimeoutError(`Navigation timeout after ${navTimeout}ms (readyState: ${finalReadyState})`); + } + + // P0-2: Try to get real HTTP status from Performance API + const currentUrl = await this.evaluate('document.URL').catch(() => options.url); + const status = await this.evaluate( + `(function() { try { var e = performance.getEntriesByType('navigation')[0]; return e ? e.responseStatus || 200 : 200; } catch(ex) { return 200; } })()` + ).catch(() => 200); + + return { + url: currentUrl, + status, + loadTime: Date.now() - startTime, + }; + } + + // ========== Screenshot ========== + + async screenshot(options?: ScreenshotOptions): Promise { + try { + // Try WebKit Protocol: Page.snapshotRect (NEVER Page.captureScreenshot) + // Get viewport dimensions first + const viewport = await this.evaluate<{ w: number; h: number }>( + '({w: window.innerWidth, h: window.innerHeight})', + ); + + const clip = options?.clip ?? { x: 0, y: 0, width: viewport.w, height: viewport.h }; + + const result = await this.sender.send<{ dataURL: string }>('Page.snapshotRect', { + x: clip.x, + y: clip.y, + width: clip.width, + height: clip.height, + coordinateSystem: 'Viewport', + }); + + // dataURL format: "data:image/png;base64,..." + const base64Data = result.dataURL.split(',')[1]; + if (!base64Data) { + throw new Error('Invalid dataURL from Page.snapshotRect'); + } + return Buffer.from(base64Data, 'base64'); + } catch { + // Fallback: return empty buffer (simctl screenshot handled at higher level) + throw new Error('Screenshot failed — use SimulatorManager.screenshot() as fallback'); + } + } + + // ========== Click / Tap ========== + + async click(target: string | { x: number; y: number }): Promise { + let x: number, y: number; + + if (typeof target === 'string') { + const center = await this.getElementCenter(target); + if (!center) throw new Error(`Element not found: ${target}`); + x = center.x; + y = center.y; + } else { + x = target.x; + y = target.y; + } + + // Dispatch touch tap: touchstart → touchend → click + // Uses document.createTouch for iOS Safari compatibility (new Touch() not supported) + await this.evaluate(buildTapScript(x, y), { emulateUserGesture: true }); + } + + // ========== Long Press ========== + + async longPress(selector: string, duration?: number): Promise { + const center = await this.getElementCenter(selector); + if (!center) throw new Error(`Element not found: ${selector}`); + const dur = duration ?? 500; + await this.evaluate(buildLongPressScript(center.x, center.y, dur), { emulateUserGesture: true }); + } + + // ========== Swipe ========== + + async swipe(direction: 'up' | 'down' | 'left' | 'right', speed?: number): Promise { + const viewport = await this.getViewportSize(); + const cx = viewport.width / 2; + const cy = viewport.height / 2; + const distance = viewport.height * 0.4; + const steps = speed ?? 10; + + const coords = { + up: { sx: cx, sy: cy + distance / 2, ex: cx, ey: cy - distance / 2 }, + down: { sx: cx, sy: cy - distance / 2, ex: cx, ey: cy + distance / 2 }, + left: { sx: cx + distance / 2, sy: cy, ex: cx - distance / 2, ey: cy }, + right: { sx: cx - distance / 2, sy: cy, ex: cx + distance / 2, ey: cy }, + }; + const { sx, sy, ex, ey } = coords[direction]; + + await this.evaluate(buildSwipeScript(sx, sy, ex, ey, steps), { emulateUserGesture: true }); + } + + // ========== Type ========== + + async type(selector: string, text: string, options?: { delay?: number }): Promise { + // Focus the element explicitly — touch-based click() doesn't reliably trigger focus + // preventScroll prevents iOS Safari from auto-scrolling the element into view on focus + await this.evaluate(buildFocusScript(selector), { emulateUserGesture: true }); + + if (options?.delay) { + // Character-by-character mode with delay + for (const char of text) { + await this.evaluate(buildTypeCharScript(selector, char), { emulateUserGesture: true }); + await new Promise(r => setTimeout(r, options.delay)); + } + } else { + // Fast mode: set value directly + dispatch events + await this.evaluate(buildSetValueScript(selector, text), { emulateUserGesture: true }); + } + } + + // ========== Scroll ========== + + async scroll(direction: 'up' | 'down' | 'left' | 'right', amount: number): Promise { + const scrollMap: Record = { + up: `window.scrollBy(0, -${amount})`, + down: `window.scrollBy(0, ${amount})`, + left: `window.scrollBy(-${amount}, 0)`, + right: `window.scrollBy(${amount}, 0)`, + }; + await this.evaluate(scrollMap[direction]); + } + + // ========== Press ========== + + async press(key: string): Promise { + const keyMap: Record = { + 'Enter': { key: 'Enter', code: 'Enter', keyCode: 13 }, + 'Tab': { key: 'Tab', code: 'Tab', keyCode: 9 }, + 'Escape': { key: 'Escape', code: 'Escape', keyCode: 27 }, + 'Backspace': { key: 'Backspace', code: 'Backspace', keyCode: 8 }, + 'ArrowUp': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 }, + 'ArrowDown': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 }, + 'ArrowLeft': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 }, + 'ArrowRight': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 }, + 'Space': { key: ' ', code: 'Space', keyCode: 32 }, + }; + + const mapped = keyMap[key] ?? { key, code: 'Key' + key.toUpperCase(), keyCode: key.charCodeAt(0) }; + const keyJson = JSON.stringify(mapped.key); + const codeJson = JSON.stringify(mapped.code); + + await this.evaluate(` + (function() { + var el = document.activeElement || document.body; + el.dispatchEvent(new KeyboardEvent('keydown', { key: ${keyJson}, code: ${codeJson}, keyCode: ${mapped.keyCode}, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keypress', { key: ${keyJson}, code: ${codeJson}, keyCode: ${mapped.keyCode}, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key: ${keyJson}, code: ${codeJson}, keyCode: ${mapped.keyCode}, bubbles: true })); + })() + `); + } + + // ========== Dismiss Keyboard ========== + + async dismissKeyboard(): Promise { + await this.evaluate('document.activeElement && document.activeElement.blur()'); + } + + // ========== Select Option ========== + + async selectOption(selector: string, value: string): Promise { + await this.evaluate(buildSelectOptionScript(selector, value)); + } + + // ========== Read Page ========== + + async readPage(): Promise { + return this.evaluate(` + (function() { + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + { + acceptNode(node) { + return node.textContent && node.textContent.trim() + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + } + } + ); + const parts = []; + let node; + while (node = walker.nextNode()) { + parts.push(node.textContent.trim()); + } + return parts.join('\\n'); + })() + `); + } + + // ========== Cookies ========== + + async getCookies(domain?: string): Promise { + // Try Page.getCookies first (returns full metadata including httpOnly). + // Falls back to document.cookie if the proxy doesn't support the command. + try { + const result = await this.sender.send<{ cookies: Array> }>('Page.getCookies'); + if (result?.cookies) { + return result.cookies + .filter(c => !domain || (c.domain as string || '').includes(domain)) + .map(c => ({ + name: (c.name as string) || '', + value: (c.value as string) || '', + domain: (c.domain as string) || '', + path: (c.path as string) || '/', + expires: typeof c.expires === 'number' ? c.expires : -1, + httpOnly: !!(c.httpOnly), + secure: !!(c.secure), + ...(c.sameSite ? { sameSite: c.sameSite as Cookie['sameSite'] } : {}), + })); + } + } catch { + // Page.getCookies not supported or proxy crashed — fall back to document.cookie + } + const raw = await this.evaluate('document.cookie'); + if (!raw) return []; + return raw.split(';').map(pair => { + const [name, ...rest] = pair.trim().split('='); + return { + name: name.trim(), + value: rest.join('='), + domain: domain ?? '', + path: '/', + expires: -1, + httpOnly: false, + secure: false, + }; + }).filter(c => c.name); + } + + async setCookies(cookies: Cookie[]): Promise { + // Try Page.setCookie first (supports httpOnly, sameSite). + // Falls back to document.cookie if the proxy doesn't support the command. + for (const cookie of cookies) { + try { + await this.sender.send('Page.setCookie', { + name: cookie.name, + value: cookie.value, + domain: cookie.domain || undefined, + path: cookie.path || '/', + expires: cookie.expires > 0 ? cookie.expires : undefined, + httpOnly: cookie.httpOnly || undefined, + secure: cookie.secure || undefined, + sameSite: cookie.sameSite || undefined, + }); + } catch { + // Page.setCookie not supported — fall back to document.cookie + const parts = [`${cookie.name}=${cookie.value}`]; + if (cookie.path) parts.push(`path=${cookie.path}`); + if (cookie.domain) parts.push(`domain=${cookie.domain}`); + if (cookie.secure) parts.push('secure'); + if (cookie.expires && cookie.expires > 0) { + parts.push(`expires=${new Date(cookie.expires * 1000).toUTCString()}`); + } + await this.evaluate(`document.cookie = ${JSON.stringify(parts.join('; '))}`); + } + } + } + + async clearCookies(): Promise { + // Try Page.deleteCookie for each cookie (handles httpOnly). + // Falls back to document.cookie clearing if not supported. + try { + const cookies = await this.getCookies(); + for (const cookie of cookies) { + await this.sender.send('Page.deleteCookie', { + cookieName: cookie.name, + url: `https://${cookie.domain}${cookie.path}`, + }); + } + return; + } catch { + // Page.deleteCookie not supported — fall back + } + await this.evaluate(` + document.cookie.split(';').forEach(function(c) { + var name = c.trim().split('=')[0]; + document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; + }); + `); + } + + // ========== DOM Queries ========== + + async querySelector(selector: string): Promise { + return this.evaluate(` + (function() { + var el = document.querySelector(${JSON.stringify(selector)}); + if (!el) return null; + var rect = el.getBoundingClientRect(); + var style = window.getComputedStyle(el); + return { + selector: ${JSON.stringify(selector)}, + tag: el.tagName.toLowerCase(), + text: (el.textContent || '').trim().substring(0, 200), + attributes: Object.fromEntries(Array.from(el.attributes).map(function(a) { return [a.name, a.value]; })), + boundingBox: rect.width > 0 && rect.height > 0 + ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + : null, + computedStyles: { + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + fontSize: style.fontSize, + color: style.color, + backgroundColor: style.backgroundColor, + position: style.position, + zIndex: style.zIndex, + overflow: style.overflow + }, + isVisible: rect.width > 0 && rect.height > 0 + && style.display !== 'none' + && style.visibility !== 'hidden' + && parseFloat(style.opacity) > 0 + }; + })() + `); + } + + async querySelectorAll(selector: string): Promise { + return this.evaluate(` + (function() { + var elements = document.querySelectorAll(${JSON.stringify(selector)}); + return Array.from(elements).slice(0, 100).map(function(el) { + var rect = el.getBoundingClientRect(); + var style = window.getComputedStyle(el); + return { + selector: ${JSON.stringify(selector)}, + tag: el.tagName.toLowerCase(), + text: (el.textContent || '').trim().substring(0, 200), + attributes: Object.fromEntries(Array.from(el.attributes).map(function(a) { return [a.name, a.value]; })), + boundingBox: rect.width > 0 && rect.height > 0 + ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + : null, + computedStyles: { + display: style.display, visibility: style.visibility, opacity: style.opacity, + fontSize: style.fontSize, position: style.position + }, + isVisible: rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity) > 0 + }; + }); + })() + `); + } + + async inspect(selector: string): Promise> { + return this.evaluate>(` + (function() { + var el = document.querySelector(${JSON.stringify(selector)}); + if (!el) return null; + var rect = el.getBoundingClientRect(); + var style = window.getComputedStyle(el); + return { + tag: el.tagName.toLowerCase(), + id: el.id, + className: el.className, + text: (el.textContent || '').trim().substring(0, 500), + innerHTML: el.innerHTML.substring(0, 1000), + boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + styles: { + display: style.display, position: style.position, + width: style.width, height: style.height, + margin: style.margin, padding: style.padding, + fontSize: style.fontSize, fontWeight: style.fontWeight, + color: style.color, backgroundColor: style.backgroundColor, + border: style.border, borderRadius: style.borderRadius, + overflow: style.overflow, zIndex: style.zIndex, + opacity: style.opacity, visibility: style.visibility + }, + accessibility: { + role: el.getAttribute('role'), + ariaLabel: el.getAttribute('aria-label'), + ariaHidden: el.getAttribute('aria-hidden'), + tabIndex: el.tabIndex + }, + childCount: el.children.length, + children: Array.from(el.children).slice(0, 10).map(function(c) { + return { tag: c.tagName.toLowerCase(), text: (c.textContent || '').trim().substring(0, 50) }; + }) + }; + })() + `); + } + + // ========== Wait For ========== + + async waitFor(selector: string, options?: { visible?: boolean; timeout?: number }): Promise { + const timeout = options?.timeout ?? 10000; + const interval = 200; + const start = Date.now(); + + while (Date.now() - start < timeout) { + const el = await this.querySelector(selector); + if (el && (!options?.visible || el.isVisible)) return; + await new Promise(r => setTimeout(r, interval)); + } + + throw new TimeoutError(`waitFor("${selector}") timed out after ${timeout}ms`); + } + + // ========== Private helpers ========== + + private async getElementCenter(selector: string): Promise<{ x: number; y: number } | null> { + return this.evaluate<{ x: number; y: number } | null>(` + (function() { + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) return null; + const rect = el.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + })() + `); + } + + private async getViewportSize(): Promise<{ width: number; height: number }> { + return this.evaluate<{ width: number; height: number }>( + '({width: window.innerWidth, height: window.innerHeight})', + ); + } +} diff --git a/src/webkit/client.ts b/src/webkit/client.ts index e97c4eaa..72dbe5a6 100644 --- a/src/webkit/client.ts +++ b/src/webkit/client.ts @@ -1,11 +1,12 @@ import http from 'http'; import { EventEmitter } from 'events'; -import { ConnectionError, TimeoutError, ProtocolError, EvaluationError } from './errors'; +import { ConnectionError } from './errors'; export { ConnectionError, TimeoutError, ProtocolError, EvaluationError } from './errors'; import { ProtocolTransport, WebSocketProtocolTransport } from './protocol-transport'; export type { ProtocolTransport } from './protocol-transport'; import { TargetSessionManager } from './target-session'; export type { TargetCommandSender } from './target-session'; +import { BrowserCommands } from './browser-commands'; import { BrowserBackend, NavigateOptions, @@ -51,6 +52,7 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { readonly backendType = 'safari' as const; private targetSession: TargetSessionManager; + private browserCommands: BrowserCommands; constructor(private options: WebKitClientOptions) { super(); @@ -59,6 +61,7 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { sendTimeout: options.sendTimeout, }); this.targetSession = new TargetSessionManager(this.transport, this); + this.browserCommands = new BrowserCommands(this); this.bindTransportEvents(); } @@ -364,602 +367,82 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { ); } - // ========== BrowserBackend Implementation (stubs for Epic 1B.2-1B.5) ========== + // ========== BrowserBackend — thin facade delegating to BrowserCommands ========== async navigate(options: NavigateOptions): Promise { - const startTime = Date.now(); - this.lastUrl = options.url; - - await this.enableDomain('Page'); - await this.enableDomain('Network'); - - // Try Page.navigate first; fall back to JS navigation if unsupported - try { - await this.send('Page.navigate', { url: options.url }); - } catch (e) { - if (e instanceof ProtocolError && e.code === -32601) { - // Page.navigate not supported — use JS fallback - await this.evaluate(`window.location.href = ${JSON.stringify(options.url)}`); - } else { - throw e; - } - } - - // Poll document.readyState instead of relying on Page.loadEventFired - const waitUntil = options.waitUntil; - const waitStart = Date.now(); - const navTimeout = options.timeout ?? 30000; - while (Date.now() - waitStart < navTimeout) { - await new Promise(r => setTimeout(r, 300)); - try { - const readyState = await this.evaluate('document.readyState'); - if (waitUntil === 'networkidle') { - // networkidle not directly detectable via polling - // Fall back to 'complete' readyState + extra delay - if (readyState === 'complete') { - await new Promise(r => setTimeout(r, 500)); // extra settle time - break; - } - } else if (waitUntil === 'domcontentloaded') { - if (readyState === 'interactive' || readyState === 'complete') break; - } else if (waitUntil === 'load') { - if (readyState === 'complete') break; - } else { - if (readyState === 'complete') break; - } - } catch { - // Target may be transitioning during navigation, keep polling - continue; - } - } - - // P0-1: Check if we actually broke out or timed out - const finalReadyState = await this.evaluate('document.readyState').catch(() => ''); - const expectedState = waitUntil === 'domcontentloaded' ? 'interactive' : 'complete'; - if (finalReadyState !== 'complete' && finalReadyState !== expectedState) { - throw new TimeoutError(`Navigation timeout after ${navTimeout}ms (readyState: ${finalReadyState})`); - } - - // P0-2: Try to get real HTTP status from Performance API - const currentUrl = await this.evaluate('document.URL').catch(() => options.url); - const status = await this.evaluate( - `(function() { try { var e = performance.getEntriesByType('navigation')[0]; return e ? e.responseStatus || 200 : 200; } catch(ex) { return 200; } })()` - ).catch(() => 200); - - return { - url: currentUrl, - status, - loadTime: Date.now() - startTime, - }; + return this.browserCommands.navigate(options, (url) => { this.lastUrl = url; }); } async screenshot(options?: ScreenshotOptions): Promise { - try { - // Try WebKit Protocol: Page.snapshotRect - // Get viewport dimensions first - const viewport = await this.evaluate<{ w: number; h: number }>( - '({w: window.innerWidth, h: window.innerHeight})', - ); - - const clip = options?.clip ?? { x: 0, y: 0, width: viewport.w, height: viewport.h }; - - const result = await this.send<{ dataURL: string }>('Page.snapshotRect', { - x: clip.x, - y: clip.y, - width: clip.width, - height: clip.height, - coordinateSystem: 'Viewport', - }); - - // dataURL format: "data:image/png;base64,..." - const base64Data = result.dataURL.split(',')[1]; - if (!base64Data) { - throw new Error('Invalid dataURL from Page.snapshotRect'); - } - return Buffer.from(base64Data, 'base64'); - } catch { - // Fallback: return empty buffer (simctl screenshot handled at higher level) - throw new Error('Screenshot failed — use SimulatorManager.screenshot() as fallback'); - } + return this.browserCommands.screenshot(options); } async evaluate(expression: string, options?: { emulateUserGesture?: boolean }): Promise { - // 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 this.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 - const isPromise = result.result?.type === 'object' && result.result?.objectId && - (result.result?.subtype === 'promise' || result.result?.className === 'Promise'); - if (isPromise) { - // 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 awaited = await this.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 this.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; + return this.browserCommands.evaluate(expression, options); } async readPage(): Promise { - return this.evaluate(` - (function() { - const walker = document.createTreeWalker( - document.body, - NodeFilter.SHOW_TEXT, - { - acceptNode(node) { - return node.textContent && node.textContent.trim() - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_REJECT; - } - } - ); - const parts = []; - let node; - while (node = walker.nextNode()) { - parts.push(node.textContent.trim()); - } - return parts.join('\\n'); - })() - `); + return this.browserCommands.readPage(); } async getCookies(domain?: string): Promise { - // Try Page.getCookies first (returns full metadata including httpOnly). - // Falls back to document.cookie if the proxy doesn't support the command. - try { - const result = await this.send<{ cookies: Array> }>('Page.getCookies'); - if (result?.cookies) { - return result.cookies - .filter(c => !domain || (c.domain as string || '').includes(domain)) - .map(c => ({ - name: (c.name as string) || '', - value: (c.value as string) || '', - domain: (c.domain as string) || '', - path: (c.path as string) || '/', - expires: typeof c.expires === 'number' ? c.expires : -1, - httpOnly: !!(c.httpOnly), - secure: !!(c.secure), - ...(c.sameSite ? { sameSite: c.sameSite as Cookie['sameSite'] } : {}), - })); - } - } catch { - // Page.getCookies not supported or proxy crashed — fall back to document.cookie - } - const raw = await this.evaluate('document.cookie'); - if (!raw) return []; - return raw.split(';').map(pair => { - const [name, ...rest] = pair.trim().split('='); - return { - name: name.trim(), - value: rest.join('='), - domain: domain ?? '', - path: '/', - expires: -1, - httpOnly: false, - secure: false, - }; - }).filter(c => c.name); + return this.browserCommands.getCookies(domain); } async setCookies(cookies: Cookie[]): Promise { - // Try Page.setCookie first (supports httpOnly, sameSite). - // Falls back to document.cookie if the proxy doesn't support the command. - for (const cookie of cookies) { - try { - await this.send('Page.setCookie', { - name: cookie.name, - value: cookie.value, - domain: cookie.domain || undefined, - path: cookie.path || '/', - expires: cookie.expires > 0 ? cookie.expires : undefined, - httpOnly: cookie.httpOnly || undefined, - secure: cookie.secure || undefined, - sameSite: cookie.sameSite || undefined, - }); - } catch { - // Page.setCookie not supported — fall back to document.cookie - const parts = [`${cookie.name}=${cookie.value}`]; - if (cookie.path) parts.push(`path=${cookie.path}`); - if (cookie.domain) parts.push(`domain=${cookie.domain}`); - if (cookie.secure) parts.push('secure'); - if (cookie.expires && cookie.expires > 0) { - parts.push(`expires=${new Date(cookie.expires * 1000).toUTCString()}`); - } - await this.evaluate(`document.cookie = ${JSON.stringify(parts.join('; '))}`); - } - } + return this.browserCommands.setCookies(cookies); } async clearCookies(): Promise { - // Try Page.deleteCookie for each cookie (handles httpOnly). - // Falls back to document.cookie clearing if not supported. - try { - const cookies = await this.getCookies(); - for (const cookie of cookies) { - await this.send('Page.deleteCookie', { - cookieName: cookie.name, - url: `https://${cookie.domain}${cookie.path}`, - }); - } - return; - } catch { - // Page.deleteCookie not supported — fall back - } - await this.evaluate(` - document.cookie.split(';').forEach(function(c) { - var name = c.trim().split('=')[0]; - document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; - }); - `); + return this.browserCommands.clearCookies(); } async click(target: string | { x: number; y: number }): Promise { - let x: number, y: number; - - if (typeof target === 'string') { - const center = await this.getElementCenter(target); - if (!center) throw new Error(`Element not found: ${target}`); - x = center.x; - y = center.y; - } else { - x = target.x; - y = target.y; - } - - // Dispatch touch tap: touchstart → touchend → click - // Uses document.createTouch for iOS Safari compatibility (new Touch() not supported) - await this.evaluate(` - (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}) - `, { emulateUserGesture: true }); + return this.browserCommands.click(target); } async type(selector: string, text: string, options?: { delay?: number }): Promise { - // Focus the element explicitly — touch-based click() doesn't reliably trigger focus - // preventScroll prevents iOS Safari from auto-scrolling the element into view on focus - await this.evaluate(` - (function() { - var el = document.querySelector(${JSON.stringify(selector)}); - if (el && typeof el.focus === 'function') el.focus({ preventScroll: true }); - })() - `, { emulateUserGesture: true }); - - if (options?.delay) { - // Character-by-character mode with delay - for (const char of text) { - await this.evaluate(` - (function() { - var el = document.querySelector(${JSON.stringify(selector)}); - if (!el) return; - var ev = new KeyboardEvent('keydown', { key: ${JSON.stringify(char)}, bubbles: true }); - el.dispatchEvent(ev); - el.dispatchEvent(new KeyboardEvent('keypress', { key: ${JSON.stringify(char)}, bubbles: true })); - // Walk the element's own prototype chain to find the value setter. - // Using window.HTMLInputElement.prototype would resolve to the - // inspector realm's prototype, causing a cross-realm TypeError. - 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 + ${JSON.stringify(char)}); - } else { - el.value += ${JSON.stringify(char)}; - } - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(char)}, bubbles: true })); - })() - `, { emulateUserGesture: true }); - await new Promise(r => setTimeout(r, options.delay)); - } - } else { - // Fast mode: set value directly + dispatch events - await this.evaluate(` - (function() { - var el = document.querySelector(${JSON.stringify(selector)}); - if (!el) return; - // Walk the element's own prototype chain to find the value setter. - // Using window.HTMLInputElement.prototype would resolve to the - // inspector realm's prototype, causing a cross-realm TypeError. - 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, ${JSON.stringify(text)}); - } else { - el.value = ${JSON.stringify(text)}; - } - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - })() - `, { emulateUserGesture: true }); - } + return this.browserCommands.type(selector, text, options); } async scroll(direction: 'up' | 'down' | 'left' | 'right', amount: number): Promise { - const scrollMap: Record = { - up: `window.scrollBy(0, -${amount})`, - down: `window.scrollBy(0, ${amount})`, - left: `window.scrollBy(-${amount}, 0)`, - right: `window.scrollBy(${amount}, 0)`, - }; - await this.evaluate(scrollMap[direction]); + return this.browserCommands.scroll(direction, amount); } async longPress(selector: string, duration?: number): Promise { - const center = await this.getElementCenter(selector); - if (!center) throw new Error(`Element not found: ${selector}`); - const dur = duration ?? 500; - - await this.evaluate(` - (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 })); - })(${center.x}, ${center.y}, ${dur}) - `, { emulateUserGesture: true }); + return this.browserCommands.longPress(selector, duration); } async swipe(direction: 'up' | 'down' | 'left' | 'right', speed?: number): Promise { - const viewport = await this.getViewportSize(); - const cx = viewport.width / 2; - const cy = viewport.height / 2; - const distance = viewport.height * 0.4; - const steps = speed ?? 10; - - const coords = { - up: { sx: cx, sy: cy + distance / 2, ex: cx, ey: cy - distance / 2 }, - down: { sx: cx, sy: cy - distance / 2, ex: cx, ey: cy + distance / 2 }, - left: { sx: cx + distance / 2, sy: cy, ex: cx - distance / 2, ey: cy }, - right: { sx: cx - distance / 2, sy: cy, ex: cx + distance / 2, ey: cy }, - }; - const { sx, sy, ex, ey } = coords[direction]; - - await this.evaluate(` - (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}) - `, { emulateUserGesture: true }); + return this.browserCommands.swipe(direction, speed); } async press(key: string): Promise { - const keyMap: Record = { - 'Enter': { key: 'Enter', code: 'Enter', keyCode: 13 }, - 'Tab': { key: 'Tab', code: 'Tab', keyCode: 9 }, - 'Escape': { key: 'Escape', code: 'Escape', keyCode: 27 }, - 'Backspace': { key: 'Backspace', code: 'Backspace', keyCode: 8 }, - 'ArrowUp': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 }, - 'ArrowDown': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 }, - 'ArrowLeft': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 }, - 'ArrowRight': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 }, - 'Space': { key: ' ', code: 'Space', keyCode: 32 }, - }; - - const mapped = keyMap[key] ?? { key, code: 'Key' + key.toUpperCase(), keyCode: key.charCodeAt(0) }; - const keyJson = JSON.stringify(mapped.key); - const codeJson = JSON.stringify(mapped.code); - - await this.evaluate(` - (function() { - var el = document.activeElement || document.body; - el.dispatchEvent(new KeyboardEvent('keydown', { key: ${keyJson}, code: ${codeJson}, keyCode: ${mapped.keyCode}, bubbles: true })); - el.dispatchEvent(new KeyboardEvent('keypress', { key: ${keyJson}, code: ${codeJson}, keyCode: ${mapped.keyCode}, bubbles: true })); - el.dispatchEvent(new KeyboardEvent('keyup', { key: ${keyJson}, code: ${codeJson}, keyCode: ${mapped.keyCode}, bubbles: true })); - })() - `); + return this.browserCommands.press(key); } async dismissKeyboard(): Promise { - await this.evaluate('document.activeElement && document.activeElement.blur()'); + return this.browserCommands.dismissKeyboard(); } async selectOption(selector: string, value: string): Promise { - await this.evaluate(` - (function() { - var el = document.querySelector(${JSON.stringify(selector)}); - if (!el || el.tagName !== 'SELECT') return; - // Walk the element's own prototype chain to find the value setter, - // avoiding cross-realm TypeError with window.HTMLSelectElement.prototype. - 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, ${JSON.stringify(value)}); - } else { - el.value = ${JSON.stringify(value)}; - } - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - })() - `); + return this.browserCommands.selectOption(selector, value); } async querySelector(selector: string): Promise { - return this.evaluate(` - (function() { - var el = document.querySelector(${JSON.stringify(selector)}); - if (!el) return null; - var rect = el.getBoundingClientRect(); - var style = window.getComputedStyle(el); - return { - selector: ${JSON.stringify(selector)}, - tag: el.tagName.toLowerCase(), - text: (el.textContent || '').trim().substring(0, 200), - attributes: Object.fromEntries(Array.from(el.attributes).map(function(a) { return [a.name, a.value]; })), - boundingBox: rect.width > 0 && rect.height > 0 - ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } - : null, - computedStyles: { - display: style.display, - visibility: style.visibility, - opacity: style.opacity, - fontSize: style.fontSize, - color: style.color, - backgroundColor: style.backgroundColor, - position: style.position, - zIndex: style.zIndex, - overflow: style.overflow - }, - isVisible: rect.width > 0 && rect.height > 0 - && style.display !== 'none' - && style.visibility !== 'hidden' - && parseFloat(style.opacity) > 0 - }; - })() - `); + return this.browserCommands.querySelector(selector); } async querySelectorAll(selector: string): Promise { - return this.evaluate(` - (function() { - var elements = document.querySelectorAll(${JSON.stringify(selector)}); - return Array.from(elements).slice(0, 100).map(function(el) { - var rect = el.getBoundingClientRect(); - var style = window.getComputedStyle(el); - return { - selector: ${JSON.stringify(selector)}, - tag: el.tagName.toLowerCase(), - text: (el.textContent || '').trim().substring(0, 200), - attributes: Object.fromEntries(Array.from(el.attributes).map(function(a) { return [a.name, a.value]; })), - boundingBox: rect.width > 0 && rect.height > 0 - ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } - : null, - computedStyles: { - display: style.display, visibility: style.visibility, opacity: style.opacity, - fontSize: style.fontSize, position: style.position - }, - isVisible: rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity) > 0 - }; - }); - })() - `); + return this.browserCommands.querySelectorAll(selector); } async inspect(selector: string): Promise> { - return this.evaluate>(` - (function() { - var el = document.querySelector(${JSON.stringify(selector)}); - if (!el) return null; - var rect = el.getBoundingClientRect(); - var style = window.getComputedStyle(el); - return { - tag: el.tagName.toLowerCase(), - id: el.id, - className: el.className, - text: (el.textContent || '').trim().substring(0, 500), - innerHTML: el.innerHTML.substring(0, 1000), - boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, - styles: { - display: style.display, position: style.position, - width: style.width, height: style.height, - margin: style.margin, padding: style.padding, - fontSize: style.fontSize, fontWeight: style.fontWeight, - color: style.color, backgroundColor: style.backgroundColor, - border: style.border, borderRadius: style.borderRadius, - overflow: style.overflow, zIndex: style.zIndex, - opacity: style.opacity, visibility: style.visibility - }, - accessibility: { - role: el.getAttribute('role'), - ariaLabel: el.getAttribute('aria-label'), - ariaHidden: el.getAttribute('aria-hidden'), - tabIndex: el.tabIndex - }, - childCount: el.children.length, - children: Array.from(el.children).slice(0, 10).map(function(c) { - return { tag: c.tagName.toLowerCase(), text: (c.textContent || '').trim().substring(0, 50) }; - }) - }; - })() - `); + return this.browserCommands.inspect(selector); } async waitFor(selector: string, options?: { visible?: boolean; timeout?: number }): Promise { - const timeout = options?.timeout ?? 10000; - const interval = 200; - const start = Date.now(); - - while (Date.now() - start < timeout) { - const el = await this.querySelector(selector); - if (el && (!options?.visible || el.isVisible)) return; - await new Promise(r => setTimeout(r, interval)); - } - - throw new TimeoutError(`waitFor("${selector}") timed out after ${timeout}ms`); + return this.browserCommands.waitFor(selector, options); } // ========== Event Convenience Methods ========== @@ -1003,7 +486,6 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { }); } - onError(handler: (error: { message: string; stack?: string; source?: string; line?: number; column?: number }) => void): void { // WebKit reports unhandled JS errors via Console.messageAdded with level "error" // (Runtime.exceptionThrown is Chrome-specific and not available in WebKit) @@ -1026,22 +508,8 @@ export class WebKitClient extends EventEmitter implements BrowserBackend { }); }); } - // ========== Private Helpers ========== - - private async getElementCenter(selector: string): Promise<{ x: number; y: number } | null> { - return this.evaluate<{ x: number; y: number } | null>(` - (function() { - const el = document.querySelector(${JSON.stringify(selector)}); - if (!el) return null; - const rect = el.getBoundingClientRect(); - return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; - })() - `); - } - private async getViewportSize(): Promise<{ width: number; height: number }> { - return this.evaluate<{ width: number; height: number }>('({width: window.innerWidth, height: window.innerHeight})'); - } + // ========== Private Helpers ========== private async connectToTarget(wsUrl: string): Promise { // Set up target discovery promise before connecting diff --git a/src/webkit/dom-input-scripts.ts b/src/webkit/dom-input-scripts.ts new file mode 100644 index 00000000..0ab085c5 --- /dev/null +++ b/src/webkit/dom-input-scripts.ts @@ -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