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 1/9] 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 2/9] 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