diff --git a/src/firefox/bidi.ts b/src/firefox/bidi.ts index 2431587a..3a4f2cd4 100644 --- a/src/firefox/bidi.ts +++ b/src/firefox/bidi.ts @@ -7,8 +7,10 @@ import type { EmptyParams, EmptyResult, Network, + Script, } from 'webdriver-bidi-protocol'; import { logDebug } from '../utils/logger.js'; +import { remoteValueToNative } from '../utils/remote-value.js'; // Firefox-specific events type DebuggingPausedEvent = { @@ -154,6 +156,57 @@ export class BiDiFacade extends EventEmitter { }); } + async evaluate( + context: BrowsingContext.BrowsingContext, + expression: string + ): Promise { + return remoteValueToNative(await this.evaluateRaw(expression, context)) as T; + } + + async evaluateRaw( + context: BrowsingContext.BrowsingContext, + expression: string + ): Promise { + const result = await this.sendCommand('script.evaluate', { + expression, + awaitPromise: true, + target: { context }, + }); + if (result.type === 'success') { + return result.result; + } + throw new Error( + `Script evaluation failed: ${result.exceptionDetails?.text ?? 'unknown error'}` + ); + } + + async callFunction( + context: BrowsingContext.BrowsingContext, + functionDeclaration: string, + args: Script.LocalValue[] + ): Promise { + return remoteValueToNative(await this.callFunctionRaw(context, functionDeclaration, args)) as T; + } + + async callFunctionRaw( + context: BrowsingContext.BrowsingContext, + functionDeclaration: string, + args: Script.LocalValue[] + ): Promise { + const result = await this.sendCommand('script.callFunction', { + functionDeclaration, + arguments: args, + awaitPromise: true, + target: { context }, + }); + if (result.type === 'success') { + return result.result; + } + throw new Error( + `Script evaluation failed: ${result.exceptionDetails?.text ?? 'unknown error'}` + ); + } + private listenForEvents(ws: any) { ws.on('message', (data: any) => { let payload: any; diff --git a/src/firefox/dom.ts b/src/firefox/dom.ts index 87029418..6bb31737 100644 --- a/src/firefox/dom.ts +++ b/src/firefox/dom.ts @@ -2,9 +2,9 @@ * DOM interactions: evaluate, element lookup, input actions */ -import { Key, WebDriver, WebElement } from 'selenium-webdriver'; -import type { Driver as FirefoxDriver } from 'selenium-webdriver/firefox.js'; -import type { Actions } from 'selenium-webdriver/lib/input.js'; +import { Key } from 'selenium-webdriver'; +import type { BrowsingContext, Input, Script } from 'webdriver-bidi-protocol'; +import type { BiDiFacade } from './bidi'; /** * Key names accepted by press_key. Names are matched case-insensitively. @@ -133,35 +133,54 @@ export function parseKeyCombo(combo: string): KeyCombo { return { modifiers, key }; } -/** - * Queue a key press on an action sequence: hold the modifiers, tap the key, - * then release the modifiers in reverse order. - */ -function appendKeyPress(actions: Actions, { modifiers, key }: KeyCombo): void { +export function textKeyActions(text: string): Input.KeySourceAction[] { + return [...text].flatMap((ch) => [ + { type: 'keyDown', value: ch }, + { type: 'keyUp', value: ch }, + ]); +} + +function comboKeyActions({ modifiers, key }: KeyCombo): Input.KeySourceAction[] { + const actions: Input.KeySourceAction[] = []; for (const modifier of modifiers) { - actions.keyDown(modifier); + actions.push({ type: 'keyDown', value: modifier }); } - actions.keyDown(key); - actions.keyUp(key); + actions.push({ type: 'keyDown', value: key }); + actions.push({ type: 'keyUp', value: key }); for (const modifier of [...modifiers].reverse()) { - actions.keyUp(modifier); + actions.push({ type: 'keyUp', value: modifier }); } + return actions; } export class DomInteractions { constructor( - private driver: WebDriver, - private resolveUid?: (uid: string) => Promise + private bidi: BiDiFacade, + private resolveUid: ( + context: BrowsingContext.BrowsingContext, + uid: string + ) => Promise = () => { + throw new Error('Not implemented'); + } ) {} /** - * Wait until an element reports isDisplayed(), ignoring failures. + * Wait until an element is visible, ignoring failures. */ - private async waitForVisible(el: WebElement, timeout = 5000): Promise { + private async waitForVisible( + context: BrowsingContext.BrowsingContext, + el: Script.SharedReference, + timeout = 5000 + ): Promise { const deadline = Date.now() + timeout; while (Date.now() < deadline) { try { - if (await el.isDisplayed()) { + const isVisible = await this.bidi.callFunction( + context, + 'el => el.checkVisibility({ opacityProperty: true, visibilityProperty: true })', + [el] + ); + if (isVisible) { return; } } catch { @@ -172,6 +191,50 @@ export class DomInteractions { // Visibility wait is best-effort; don't throw } + private async scrollAndGetInViewCenterPoint( + context: BrowsingContext.BrowsingContext, + el: Script.SharedReference + ): Promise<{ x: number; y: number }> { + return await this.bidi.callFunction( + context, + ` + async (el) => { + let rect = el.getBoundingClientRect(); + if (rect.left >= innerWidth || rect.right <= 0 || rect.top >= innerHeight || rect.bottom <= 0) { + el.scrollIntoView({ behavior: "instant" }); + rect = el.getBoundingClientRect(); + } + const left = Math.min(Math.max(rect.left, 0), innerWidth); + const right = Math.min(Math.max(rect.right, 0), innerWidth); + const top = Math.min(Math.max(rect.top, 0), innerHeight); + const bottom = Math.min(Math.max(rect.bottom, 0), innerHeight); + return { x: (left + right) / 2, y: (top + bottom) / 2 }; + } + `, + [el] + ); + } + + private async sendKeyActions( + context: BrowsingContext.BrowsingContext, + actions: Input.KeySourceAction[] + ): Promise { + await this.bidi.sendCommand('input.performActions', { + context, + actions: [{ type: 'key', id: 'mcp_keyboard', actions }], + }); + } + + private async sendPointerActions( + context: BrowsingContext.BrowsingContext, + actions: Input.PointerSourceAction[] + ): Promise { + await this.bidi.sendCommand('input.performActions', { + context, + actions: [{ type: 'pointer', id: 'mcp_mouse', actions }], + }); + } + // ============================================================================ // UID-based input methods // ============================================================================ @@ -180,109 +243,105 @@ export class DomInteractions { * Click element by UID * Requires resolveUid callback to be set (from SnapshotManager) */ - async clickByUid(uid: string, dblClick = false): Promise { - if (!this.resolveUid) { - throw new Error('clickByUid: resolveUid callback not set. Ensure snapshot is initialized.'); - } - const el = await this.resolveUid(uid); - await this.waitForVisible(el, 5000); - + async clickByUid( + context: BrowsingContext.BrowsingContext, + uid: string, + dblClick = false + ): Promise { + const el = await this.resolveUid(context, uid); + await this.waitForVisible(context, el); + const { x, y } = await this.scrollAndGetInViewCenterPoint(context, el); + const actions: Input.PointerSourceAction[] = [ + { type: 'pointerMove', x, y }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 }, + ]; if (dblClick) { - await this.driver.actions({ async: true }).doubleClick(el).perform(); - } else { - await el.click(); + actions.push({ type: 'pointerDown', button: 0 }); + actions.push({ type: 'pointerUp', button: 0 }); } - - // Wait for events to propagate - await this.waitForEventsAfterAction(); + await this.sendPointerActions(context, actions); } /** * Hover over element by UID */ - async hoverByUid(uid: string): Promise { - if (!this.resolveUid) { - throw new Error('hoverByUid: resolveUid callback not set. Ensure snapshot is initialized.'); - } - const el = await this.resolveUid(uid); - await this.driver.actions({ async: true }).move({ origin: el }).perform(); - - // Wait for events to propagate - await this.waitForEventsAfterAction(); + async hoverByUid(context: BrowsingContext.BrowsingContext, uid: string): Promise { + const el = await this.resolveUid(context, uid); + await this.waitForVisible(context, el); + const { x, y } = await this.scrollAndGetInViewCenterPoint(context, el); + await this.sendPointerActions(context, [{ type: 'pointerMove', x, y }]); } /** * Fill input field by UID */ - async fillByUid(uid: string, value: string): Promise { - if (!this.resolveUid) { - throw new Error('fillByUid: resolveUid callback not set. Ensure snapshot is initialized.'); - } - const el = await this.resolveUid(uid); - - try { - await el.clear(); - } catch { - // Some inputs may not support clear(); fall back to select-all + delete - await el.sendKeys(Key.chord(Key.CONTROL, 'a'), Key.DELETE); - } - - await el.sendKeys(value); - - // Wait for events to propagate - await this.waitForEventsAfterAction(); + async fillByUid( + context: BrowsingContext.BrowsingContext, + uid: string, + value: string + ): Promise { + const el = await this.resolveUid(context, uid); + await this.bidi.callFunction( + context, + ` + (el) => { + if (el.nodeName === 'INPUT' || el.nodeName === 'TEXTAREA') { + el.value = ''; + } else if (el.isContentEditable) { + el.textContent = ''; + } + el.focus(); + } + `, + [el] + ); + await this.sendKeyActions(context, textKeyActions(value)); } /** * Drag & drop by UIDs * Uses JS events fallback for better compatibility */ - async dragByUidToUid(fromUid: string, toUid: string): Promise { - if (!this.resolveUid) { - throw new Error( - 'dragByUidToUid: resolveUid callback not set. Ensure snapshot is initialized.' - ); - } - - const fromEl = await this.resolveUid(fromUid); - const toEl = await this.resolveUid(toUid); - + async dragByUidToUid( + context: BrowsingContext.BrowsingContext, + fromUid: string, + toUid: string + ): Promise { + const [sourceElement, targetElement] = await Promise.all([ + this.resolveUid(context, fromUid), + this.resolveUid(context, toUid), + ]); // Use JS drag events fallback for compatibility (Actions DnD not used) - await this.driver.executeScript( + await this.bidi.callFunction( + context, ` - var srcEl = arguments[0], tgtEl = arguments[1]; - if (!srcEl || !tgtEl) throw new Error('dragAndDrop: element not found'); - function dispatch(type, target, dt) { - var evt = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }); - return target.dispatchEvent(evt); + (src, tgt) => { + function dispatch(type, target, dt) { + var evt = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }); + return target.dispatchEvent(evt); + } + var dt = typeof DataTransfer !== 'undefined' ? new DataTransfer() : undefined; + dispatch('dragstart', src, dt); + dispatch('dragenter', tgt, dt); + dispatch('dragover', tgt, dt); + dispatch('drop', tgt, dt); + dispatch('dragend', src, dt); } - var dt = typeof DataTransfer !== 'undefined' ? new DataTransfer() : undefined; - dispatch('dragstart', srcEl, dt); - dispatch('dragenter', tgtEl, dt); - dispatch('dragover', tgtEl, dt); - dispatch('drop', tgtEl, dt); - dispatch('dragend', srcEl, dt); - `, - fromEl, - toEl + `, + [sourceElement, targetElement] ); - - // Wait for events to propagate - await this.waitForEventsAfterAction(); } /** * Fill multiple form fields by UIDs */ - async fillFormByUid(elements: Array<{ uid: string; value: string }>): Promise { - if (!this.resolveUid) { - throw new Error( - 'fillFormByUid: resolveUid callback not set. Ensure snapshot is initialized.' - ); - } - + async fillFormByUid( + context: BrowsingContext.BrowsingContext, + elements: Array<{ uid: string; value: string }> + ): Promise { for (const { uid, value } of elements) { - await this.fillByUid(uid, value); + await this.fillByUid(context, uid, value); } } @@ -290,37 +349,32 @@ export class DomInteractions { * Upload file by UID * Handles hidden file inputs by making them visible */ - async uploadFileByUid(uid: string, filePath: string): Promise { - if (!this.resolveUid) { - throw new Error( - 'uploadFileByUid: resolveUid callback not set. Ensure snapshot is initialized.' - ); - } - - const el = await this.resolveUid(uid); - + async uploadFileByUid( + context: BrowsingContext.BrowsingContext, + uid: string, + filePath: string + ): Promise { + const el = await this.resolveUid(context, uid); // Ensure it's an ; if hidden, unhide via JS - await this.driver.executeScript( + await this.bidi.callFunction( + context, ` - var element = arguments[0]; - if (!element) throw new Error('uploadFile: element not found'); - if (element.tagName !== 'INPUT' || element.type !== 'file') - throw new Error('uploadFile: element must be '); - var style = window.getComputedStyle(element); - if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { - var s = element.style; - s.display = 'block'; s.visibility = 'visible'; s.opacity = '1'; - s.position = 'fixed'; s.left = '0px'; s.top = '0px'; - s.zIndex = '2147483647'; + (element) => { + if (element.tagName !== 'INPUT' || element.type !== 'file') + throw new Error('uploadFile: element must be '); + var style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + var s = element.style; + s.display = 'block'; s.visibility = 'visible'; s.opacity = '1'; + s.position = 'fixed'; s.left = '0px'; s.top = '0px'; + s.zIndex = '2147483647'; + } + element.focus(); } - `, - el + `, + [el] ); - - await el.sendKeys(filePath); - - // Wait for events to propagate - await this.waitForEventsAfterAction(); + await this.sendKeyActions(context, textKeyActions(filePath)); } /** @@ -328,20 +382,17 @@ export class DomInteractions { * @param key Key name or combination, such as "Escape", "Enter" or "ctrl+shift+t" * @param uid Element UID to focus first. Defaults to the focused element. */ - async pressKey(key: string, uid?: string): Promise { - const combo = parseKeyCombo(key); - + async pressKey( + context: BrowsingContext.BrowsingContext, + key: string, + uid?: string + ): Promise { if (uid) { // If a uid was provided, focus the element first so that actions will be // applied to it. - await this.focusByUid(uid); + await this.focusByUid(context, uid); } - - const actions = this.driver.actions({ async: true }); - appendKeyPress(actions, combo); - await actions.perform(); - - await this.waitForEventsAfterAction(); + await this.sendKeyActions(context, comboKeyActions(parseKeyCombo(key))); } /** @@ -351,6 +402,7 @@ export class DomInteractions { * @param options.submitKey Key to press after the text, such as "Enter" or "Tab" */ async typeText( + context: BrowsingContext.BrowsingContext, text: string, options: { uid?: string | undefined; submitKey?: string | undefined } = {} ): Promise { @@ -358,33 +410,28 @@ export class DomInteractions { const submit = options.submitKey === undefined ? undefined : parseKeyCombo(options.submitKey); if (options.uid) { - await this.focusByUid(options.uid); + await this.focusByUid(context, options.uid); } // One sequence, so that the text and the key land on the same element. - const actions = this.driver.actions({ async: true }); - actions.sendKeys(text); + const actions = textKeyActions(text); if (submit) { - appendKeyPress(actions, submit); + actions.push(...comboKeyActions(submit)); } - await actions.perform(); - - await this.waitForEventsAfterAction(); + await this.sendKeyActions(context, actions); } /** * Focus the element for the provided uid. Throws if the element cannot be * focused. */ - private async focusByUid(uid: string): Promise { - if (!this.resolveUid) { - throw new Error('pressKey: resolveUid callback not set. Ensure snapshot is initialized.'); - } - const el = await this.resolveUid(uid); - await this.waitForVisible(el, 5000); - const focused = await this.driver.executeScript( - 'arguments[0].focus(); return arguments[0].getRootNode().activeElement === arguments[0];', - el + private async focusByUid(context: BrowsingContext.BrowsingContext, uid: string): Promise { + const el = await this.resolveUid(context, uid); + await this.waitForVisible(context, el, 5000); + const focused = await this.bidi.callFunction( + context, + '(el) => { el.focus(); return el.getRootNode().activeElement === el; }', + [el] ); if (!focused) { throw new Error( @@ -393,17 +440,6 @@ export class DomInteractions { } } - /** - * Wait for events to propagate after user action - * Gives the page time to respond to interactions - */ - private async waitForEventsAfterAction(): Promise { - // Wait for microtask/raf to allow event handlers to fire - await this.driver.executeScript('return new Promise(r => requestAnimationFrame(() => r()))'); - // Small additional delay for good measure - await new Promise((resolve) => setTimeout(resolve, 50)); - } - // ============================================================================ // Screenshot // ============================================================================ @@ -413,13 +449,16 @@ export class DomInteractions { * @param fullPage Capture the whole document via the Firefox-only full screenshot endpoint * @returns PNG as base64 string */ - async takeScreenshotPage(fullPage = false): Promise { - if (fullPage) { - // Note: when switching screenshots to WebDriver BiDi (Bug 2071799), we - // can use { origin: "document" } to handle full page screenshots. - return await (this.driver as FirefoxDriver).takeFullPageScreenshot(); - } - return await this.driver.takeScreenshot(); + async takeScreenshotPage( + context: BrowsingContext.BrowsingContext, + fullPage = false + ): Promise { + const result = await this.bidi.sendCommand('browsingContext.captureScreenshot', { + context, + format: { type: 'png' }, + origin: fullPage ? 'document' : 'viewport', + }); + return result.data; } /** @@ -428,25 +467,20 @@ export class DomInteractions { * @param uid Element UID from snapshot * @returns PNG as base64 string */ - async takeScreenshotByUid(uid: string): Promise { - if (!this.resolveUid) { - throw new Error( - 'takeScreenshotByUid: resolveUid callback not set. Ensure snapshot is initialized.' - ); - } - - const el = await this.resolveUid(uid); - - // Scroll element into view - await this.driver.executeScript( - 'arguments[0].scrollIntoView({block: "center", inline: "center"});', - el - ); - - // Wait for scroll to complete - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Take screenshot of element (Selenium automatically crops to element bounds) - return await el.takeScreenshot(); + async takeScreenshotByUid( + context: BrowsingContext.BrowsingContext, + uid: string + ): Promise { + const el = await this.resolveUid(context, uid); + await this.waitForVisible(context, el); + await this.scrollAndGetInViewCenterPoint(context, el); + + // Take screenshot of element (WebDriver BiDi automatically crops to element bounds) + const result = await this.bidi.sendCommand('browsingContext.captureScreenshot', { + context, + format: { type: 'png' }, + clip: { type: 'element', element: el }, + }); + return result.data; } } diff --git a/src/firefox/index.ts b/src/firefox/index.ts index 1ca62278..8ff59078 100644 --- a/src/firefox/index.ts +++ b/src/firefox/index.ts @@ -3,12 +3,10 @@ */ import type { FirefoxLaunchOptions, ConsoleMessage, LogpointResult } from './types.js'; -import { WebElement } from 'selenium-webdriver'; -import type { Browser, BrowsingContext, Network } from 'webdriver-bidi-protocol'; +import type { Browser, BrowsingContext, Network, Script } from 'webdriver-bidi-protocol'; import { FirefoxCore } from './core.js'; import { BiDiFacade } from './bidi.js'; import { logDebug } from '../utils/logger.js'; -import { remoteValueToNative } from '../utils/remote-value.js'; import { ConsoleEvents, NetworkEvents, DebuggingEvents, DownloadEvents } from './events/index.js'; import type { NetworkBodyResult } from './events/network.js'; import { DomInteractions } from './dom.js'; @@ -36,13 +34,35 @@ export class FirefoxClient { this.core = new FirefoxCore(options); } - getBidi(): BiDiFacade { + private getBidi(): BiDiFacade { if (!this.bidi) { throw new Error('Not connected'); } return this.bidi; } + private getDom(): DomInteractions { + if (!this.dom) { + throw new Error('Not connected'); + } + return this.dom; + } + + private getSnapshot(): SnapshotManager { + if (!this.snapshot) { + throw new Error('Not connected'); + } + return this.snapshot; + } + + private getContext(): BrowsingContext.BrowsingContext { + const context = this.core.getCurrentContextId(); + if (!context) { + throw new Error('No active browsing context'); + } + return context; + } + /** * Connect and initialize all modules */ @@ -54,7 +74,7 @@ export class FirefoxClient { this.bidi = new BiDiFacade(driver); // Initialize snapshot manager first - this.snapshot = new SnapshotManager(driver); + this.snapshot = new SnapshotManager(this.bidi); this.consoleEvents = new ConsoleEvents(this.bidi, { autoClearOnNavigate: false, @@ -94,8 +114,10 @@ export class FirefoxClient { } // Initialize DOM with UID resolver callback - this.dom = new DomInteractions(driver, (uid: string) => - this.snapshot!.resolveUidToElement(uid) + this.dom = new DomInteractions( + this.bidi, + (context: BrowsingContext.BrowsingContext, uid: string) => + this.snapshot!.resolveUidToElement(context, uid) ); this.pages = new PageManagement( @@ -122,82 +144,44 @@ export class FirefoxClient { * native value; throws on a script exception. */ async evaluate(expression: string): Promise { - const context = this.core.getCurrentContextId(); - if (!context) { - throw new Error('No active browsing context'); - } - const result = await this.getBidi().sendCommand('script.evaluate', { - expression, - awaitPromise: true, - target: { context }, - }); - if (result.type === 'success') { - return remoteValueToNative(result.result); - } - throw new Error( - `Script evaluation failed: ${result.exceptionDetails?.text ?? 'unknown error'}` - ); + return await this.getBidi().evaluate(expression, this.getContext()); } // UID-based input methods async clickByUid(uid: string, dblClick = false): Promise { - if (!this.dom) { - throw new Error('Not connected'); - } - return await this.dom.clickByUid(uid, dblClick); + return await this.getDom().clickByUid(this.getContext(), uid, dblClick); } async hoverByUid(uid: string): Promise { - if (!this.dom) { - throw new Error('Not connected'); - } - return await this.dom.hoverByUid(uid); + return await this.getDom().hoverByUid(this.getContext(), uid); } async fillByUid(uid: string, value: string): Promise { - if (!this.dom) { - throw new Error('Not connected'); - } - return await this.dom.fillByUid(uid, value); + return await this.getDom().fillByUid(this.getContext(), uid, value); } async dragByUidToUid(fromUid: string, toUid: string): Promise { - if (!this.dom) { - throw new Error('Not connected'); - } - return await this.dom.dragByUidToUid(fromUid, toUid); + return await this.getDom().dragByUidToUid(this.getContext(), fromUid, toUid); } async fillFormByUid(elements: Array<{ uid: string; value: string }>): Promise { - if (!this.dom) { - throw new Error('Not connected'); - } - return await this.dom.fillFormByUid(elements); + return await this.getDom().fillFormByUid(this.getContext(), elements); } async uploadFileByUid(uid: string, filePath: string): Promise { - if (!this.dom) { - throw new Error('Not connected'); - } - return await this.dom.uploadFileByUid(uid, filePath); + return await this.getDom().uploadFileByUid(this.getContext(), uid, filePath); } async pressKey(key: string, uid?: string): Promise { - if (!this.dom) { - throw new Error('Not connected'); - } - return await this.dom.pressKey(key, uid); + return await this.getDom().pressKey(this.getContext(), key, uid); } async typeText( text: string, options?: { uid?: string | undefined; submitKey?: string | undefined } ): Promise { - if (!this.dom) { - throw new Error('Not connected'); - } - return await this.dom.typeText(text, options); + return await this.getDom().typeText(this.getContext(), text, options); } // ============================================================================ @@ -423,31 +407,19 @@ export class FirefoxClient { // ============================================================================ async takeSnapshot(options?: SnapshotOptions): Promise { - if (!this.snapshot) { - throw new Error('Not connected'); - } - return await this.snapshot.takeSnapshot(options); + return await this.getSnapshot().takeSnapshot(this.getContext(), options); } async resolveUidToSelector(uid: string): Promise { - if (!this.snapshot) { - throw new Error('Not connected'); - } - return await this.snapshot.resolveUidToSelector(uid); + return await this.getSnapshot().resolveUidToSelector(this.getContext(), uid); } - async resolveUidToElement(uid: string): Promise { - if (!this.snapshot) { - throw new Error('Not connected'); - } - return await this.snapshot.resolveUidToElement(uid); + async resolveUidToElement(uid: string): Promise { + return await this.getSnapshot().resolveUidToElement(this.getContext(), uid); } async clearSnapshot(): Promise { - if (!this.snapshot) { - throw new Error('Not connected'); - } - await this.snapshot.clear(); + await this.getSnapshot().clear(this.getContext()); } // ============================================================================ @@ -458,17 +430,11 @@ export class FirefoxClient { * @param fullPage Capture the whole scrollable document instead of the viewport */ async takeScreenshotPage(fullPage = false): Promise { - if (!this.dom) { - throw new Error('Not connected'); - } - return await this.dom.takeScreenshotPage(fullPage); + return await this.getDom().takeScreenshotPage(this.getContext(), fullPage); } async takeScreenshotByUid(uid: string): Promise { - if (!this.dom) { - throw new Error('Not connected'); - } - return await this.dom.takeScreenshotByUid(uid); + return await this.getDom().takeScreenshotByUid(this.getContext(), uid); } // ============================================================================ diff --git a/src/firefox/snapshot/manager.ts b/src/firefox/snapshot/manager.ts index 47a9c053..52769e39 100644 --- a/src/firefox/snapshot/manager.ts +++ b/src/firefox/snapshot/manager.ts @@ -3,10 +3,12 @@ * Handles snapshot creation using bundled injected script */ -import { WebDriver, WebElement } from 'selenium-webdriver'; +import { BrowsingContext, Script } from 'webdriver-bidi-protocol'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { BiDiFacade } from '../bidi.js'; +import { nativeToLocalValue } from '../../utils/local-value.js'; import { logDebug } from '../../utils/logger.js'; import type { Snapshot, SnapshotJson, InjectedScriptResult } from './types.js'; import { formatSnapshotTree } from './formatter.js'; @@ -25,15 +27,13 @@ export interface SnapshotOptions { * Uses bundled injected script for snapshot creation */ export class SnapshotManager { - private driver: WebDriver; private resolver: UidResolver; private injectedScript: string | null = null; /** Counter handed to the injected script so UIDs stay unique across snapshots */ private nextElementId = 0; - constructor(driver: WebDriver) { - this.driver = driver; - this.resolver = new UidResolver(driver); + constructor(private bidi: BiDiFacade) { + this.resolver = new UidResolver(bidi); } /** @@ -89,7 +89,10 @@ export class SnapshotManager { * Take a snapshot of the current page * Returns text and JSON, no DOM mutations */ - async takeSnapshot(options?: SnapshotOptions): Promise { + async takeSnapshot( + context: BrowsingContext.BrowsingContext, + options?: SnapshotOptions + ): Promise { if (options?.selector || options?.includeAll) { const optionsOutput: string[] = []; if (options.selector) { @@ -104,7 +107,7 @@ export class SnapshotManager { } // Execute bundled injected script - const result = await this.executeInjectedScript(this.nextElementId, options); + const result = await this.executeInjectedScript(context, this.nextElementId, options); if (typeof result?.nextElementId === 'number') { this.nextElementId = result.nextElementId; @@ -159,28 +162,35 @@ export class SnapshotManager { /** * Resolve UID to a CSS selector generated on demand */ - async resolveUidToSelector(uid: string): Promise { - return await this.resolver.resolveUidToSelector(uid); + async resolveUidToSelector( + context: BrowsingContext.BrowsingContext, + uid: string + ): Promise { + return await this.resolver.resolveUidToSelector(context, uid); } /** * Resolve UID to the WebElement it was assigned to */ - async resolveUidToElement(uid: string): Promise { - return await this.resolver.resolveUidToElement(uid); + async resolveUidToElement( + context: BrowsingContext.BrowsingContext, + uid: string + ): Promise { + return await this.resolver.resolveUidToElement(context, uid); } /** * Clear snapshot UIDs */ - async clear(): Promise { - await this.resolver.clear(); + async clear(context: BrowsingContext.BrowsingContext): Promise { + await this.resolver.clear(context); } /** * Execute bundled injected snapshot script */ private async executeInjectedScript( + context: BrowsingContext.BrowsingContext, nextElementId: number, options?: SnapshotOptions ): Promise { @@ -189,24 +199,26 @@ export class SnapshotManager { // Inject and execute the bundled script // The script exposes window.__createSnapshot via IIFE global // Guard: Only inject once, then reuse - const result = await this.driver.executeScript( + const result = await this.bidi.callFunction( + context, ` - // Only inject the bundle if not already present - if (typeof window.__createSnapshot === 'undefined') { - ${scriptSource} - // Register the snapshot and UID resolution functions globally - if (typeof __SnapshotInjected !== 'undefined' && __SnapshotInjected.createSnapshot) { - window.__createSnapshot = __SnapshotInjected.createSnapshot; - window.__resolveUid = __SnapshotInjected.resolveUid; - window.__uidToSelector = __SnapshotInjected.uidToSelector; - window.__clearUidRegistry = __SnapshotInjected.clearUidRegistry; + (nextElementId, options) => { + // Only inject the bundle if not already present + if (typeof window.__createSnapshot === 'undefined') { + ${scriptSource} + // Register the snapshot and UID resolution functions globally + if (typeof __SnapshotInjected !== 'undefined' && __SnapshotInjected.createSnapshot) { + window.__createSnapshot = __SnapshotInjected.createSnapshot; + window.__resolveUid = __SnapshotInjected.resolveUid; + window.__uidToSelector = __SnapshotInjected.uidToSelector; + window.__clearUidRegistry = __SnapshotInjected.clearUidRegistry; + } } + // Call it with options + return window.__createSnapshot(nextElementId, options); } - // Call it with options - return window.__createSnapshot(arguments[0], arguments[1]); `, - nextElementId, - options || {} + [nativeToLocalValue(nextElementId), nativeToLocalValue(options || {})] ); return result; diff --git a/src/firefox/snapshot/resolver.ts b/src/firefox/snapshot/resolver.ts index 986de68b..bf33a4a9 100644 --- a/src/firefox/snapshot/resolver.ts +++ b/src/firefox/snapshot/resolver.ts @@ -4,12 +4,13 @@ * during the snapshot (see injected/uidRegistry.ts) */ -import { WebDriver, WebElement } from 'selenium-webdriver'; +import type { BrowsingContext, Script } from 'webdriver-bidi-protocol'; +import { BiDiFacade } from '../bidi.js'; +import { nativeToLocalValue } from '../../utils/local-value.js'; import { logDebug } from '../../utils/logger.js'; -const RESOLVE_SCRIPT = 'return window.__resolveUid ? window.__resolveUid(arguments[0]) : null;'; -const SELECTOR_SCRIPT = - 'return window.__uidToSelector ? window.__uidToSelector(arguments[0]) : null;'; +const RESOLVE_SCRIPT = '(el) => window.__resolveUid ? window.__resolveUid(el) : null'; +const SELECTOR_SCRIPT = '(el) => window.__uidToSelector ? window.__uidToSelector(el) : null'; const CLEAR_SCRIPT = 'if (window.__clearUidRegistry) { window.__clearUidRegistry(); }'; /** @@ -17,15 +18,15 @@ const CLEAR_SCRIPT = 'if (window.__clearUidRegistry) { window.__clearUidRegistry * Separated from SnapshotManager for better modularity */ export class UidResolver { - constructor(private driver: WebDriver) {} + constructor(private bidi: BiDiFacade) {} /** * Forget all UID associations in the page, making existing UIDs unresolvable. * Best effort: the registry dies with the page anyway. */ - async clear(): Promise { + async clear(context: BrowsingContext.BrowsingContext): Promise { try { - await this.driver.executeScript(CLEAR_SCRIPT); + await this.bidi.evaluate(CLEAR_SCRIPT, context); logDebug('Snapshot UIDs cleared'); } catch { logDebug('Unable to clear snapshot UIDs (page may be navigating)'); @@ -35,8 +36,13 @@ export class UidResolver { /** * Resolve UID to a CSS selector, generated on demand from the element it points at */ - async resolveUidToSelector(uid: string): Promise { - const selector = await this.driver.executeScript(SELECTOR_SCRIPT, uid); + async resolveUidToSelector( + context: BrowsingContext.BrowsingContext, + uid: string + ): Promise { + const selector = await this.bidi.callFunction(context, SELECTOR_SCRIPT, [ + nativeToLocalValue(uid), + ]); if (!selector) { throw new Error(notFoundMessage(uid)); } @@ -47,14 +53,19 @@ export class UidResolver { /** * Resolve UID to the element it was assigned to during the snapshot */ - async resolveUidToElement(uid: string): Promise { - const element = await this.driver.executeScript(RESOLVE_SCRIPT, uid); - if (!element) { + async resolveUidToElement( + context: BrowsingContext.BrowsingContext, + uid: string + ): Promise { + const element = await this.bidi.callFunctionRaw(context, RESOLVE_SCRIPT, [ + nativeToLocalValue(uid), + ]); + if (element?.type !== 'node' || !element.sharedId) { throw new Error(notFoundMessage(uid)); } logDebug(`Resolved element for UID: ${uid}`); - return element; + return { sharedId: element.sharedId }; } } diff --git a/src/firefox/types.ts b/src/firefox/types.ts index 399b9c95..4d3a320a 100644 --- a/src/firefox/types.ts +++ b/src/firefox/types.ts @@ -2,6 +2,8 @@ * WebDriver BiDi types for Firefox */ +import type { Script } from 'webdriver-bidi-protocol'; + export type BrowsingContextId = string; /** @@ -69,7 +71,7 @@ export interface DownloadRecord { * A single result captured by a logpoint hit */ export interface LogpointResult { - value: unknown; + value: Script.RemoteValue | null; error?: string; timestamp: number; } diff --git a/src/tools/debugging.ts b/src/tools/debugging.ts index ec66cc03..02908e14 100644 --- a/src/tools/debugging.ts +++ b/src/tools/debugging.ts @@ -198,7 +198,7 @@ export const handleGetLogpointResults = defineToolHandler( if (r.error) { return `[${i + 1}] Error: ${r.error}`; } - return `[${i + 1}] ${JSON.stringify(remoteValueToNative(r.value))}`; + return `[${i + 1}] ${JSON.stringify(remoteValueToNative(r.value!))}`; }); return successResponse(lines.join('\n')); } diff --git a/src/tools/script.ts b/src/tools/script.ts index 690c2564..fa2cabcf 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -97,8 +97,7 @@ export const handleEvaluateScript = defineToolHandler( if (fnArgs && fnArgs.length > 0) { for (const arg of fnArgs) { try { - const element = await firefox.resolveUidToElement(arg.uid); - resolvedArgs.push({ sharedId: await element.getId() }); + resolvedArgs.push(await firefox.resolveUidToElement(arg.uid)); } catch (error) { const errorMsg = (error as Error).message; diff --git a/src/utils/local-value.ts b/src/utils/local-value.ts new file mode 100644 index 00000000..01e4201c --- /dev/null +++ b/src/utils/local-value.ts @@ -0,0 +1,49 @@ +import { Script } from 'webdriver-bidi-protocol'; + +export function nativeToLocalValue(value: unknown): Script.LocalValue { + switch (typeof value) { + case 'undefined': + return { type: 'undefined' }; + case 'boolean': + return { type: 'boolean', value }; + case 'number': + if (Object.is(value, NaN)) { + return { type: 'number', value: 'NaN' }; + } else if (Object.is(value, Infinity)) { + return { type: 'number', value: 'Infinity' }; + } else if (Object.is(value, -Infinity)) { + return { type: 'number', value: '-Infinity' }; + } else if (Object.is(value, -0)) { + return { type: 'number', value: '-0' }; + } else { + return { type: 'number', value }; + } + case 'string': + return { type: 'string', value }; + case 'bigint': + return { type: 'bigint', value: `${value}` }; + case 'object': + if (value === null) { + return { type: 'null' }; + } else if (Array.isArray(value)) { + return { type: 'array', value: value.map(nativeToLocalValue) }; + } else if (value instanceof Set) { + return { type: 'set', value: [...value].map(nativeToLocalValue) }; + } else if (value instanceof Map) { + return { + type: 'map', + value: [...value.entries()].map((k, v) => [nativeToLocalValue(k), nativeToLocalValue(v)]), + }; + } else if (value instanceof Date) { + return { type: 'date', value: value.toISOString() }; //TODO + } else if (value instanceof RegExp) { + return { type: 'regexp', value: { pattern: value.source, flags: value.flags } }; + } + return { + type: 'object', + value: Object.entries(value).map(([k, v]) => [k, nativeToLocalValue(v)]), + }; + default: + throw new Error(`Can't convert object of type ${typeof value} to remote value.`); + } +} diff --git a/src/utils/remote-value.ts b/src/utils/remote-value.ts index ed3aa73d..ebba19e9 100644 --- a/src/utils/remote-value.ts +++ b/src/utils/remote-value.ts @@ -1,48 +1,46 @@ +import type { Script } from 'webdriver-bidi-protocol'; + /** * Converts a WebDriver BiDi RemoteValue to a native JavaScript value. * Special number values (NaN, Infinity, -0) are returned as strings since * they cannot be represented in JSON. */ -export function remoteValueToNative(rv: unknown): unknown { +export function remoteValueToNative(rv: Script.RemoteValue): unknown { if (!rv || typeof rv !== 'object') { return rv; } - const { type, value } = rv as { type: string; value?: unknown }; - - switch (type) { + switch (rv.type) { case 'undefined': return undefined; case 'null': return null; case 'string': case 'boolean': - return value; + return rv.value; case 'number': - if (value === 'NaN') { + if (rv.value === 'NaN') { return 'NaN'; } - if (value === 'Infinity') { + if (rv.value === 'Infinity') { return 'Infinity'; } - if (value === '-Infinity') { + if (rv.value === '-Infinity') { return '-Infinity'; } - if (value === '-0') { + if (rv.value === '-0') { return '-0'; } - return value; + return rv.value; case 'bigint': - return `${value as string}n`; + return `${rv.value}n`; case 'array': - return (value as unknown[]).map(remoteValueToNative); + return (rv.value ?? []).map(remoteValueToNative); case 'object': - return Object.fromEntries( - (value as [string, unknown][]).map(([k, v]) => [k, remoteValueToNative(v)]) - ); + return Object.fromEntries((rv.value ?? []).map(([k, v]) => [k, remoteValueToNative(v)])); case 'map': return Object.fromEntries( - (value as [unknown, unknown][]).map(([k, v]) => [ + (rv.value ?? []).map(([k, v]) => [ typeof k === 'object' ? JSON.stringify(remoteValueToNative(k)) : String(k as string | number | boolean), @@ -50,14 +48,14 @@ export function remoteValueToNative(rv: unknown): unknown { ]) ); case 'set': - return (value as unknown[]).map(remoteValueToNative); + return (rv.value ?? []).map(remoteValueToNative); case 'regexp': { - const { pattern, flags } = value as { pattern: string; flags?: string }; + const { pattern, flags } = rv.value; return `/${pattern}/${flags ?? ''}`; } case 'date': - return value; + return rv.value; default: - return `[${type}]`; + return `[${rv.type}]`; } } diff --git a/tests/firefox/press-key.test.ts b/tests/firefox/press-key.test.ts index 92e4988a..13138c14 100644 --- a/tests/firefox/press-key.test.ts +++ b/tests/firefox/press-key.test.ts @@ -4,110 +4,111 @@ import { describe, it, expect, vi } from 'vitest'; import { Key } from 'selenium-webdriver'; +import type { BrowsingContext, Input, Script } from 'webdriver-bidi-protocol'; import { DomInteractions } from '../../src/firefox/dom.js'; +import type { BiDiFacade } from '../../src/firefox/bidi.js'; -interface MockActions { - keyDown: ReturnType; - keyUp: ReturnType; - sendKeys: ReturnType; - perform: ReturnType; +const CONTEXT = 'context-1' as BrowsingContext.BrowsingContext; + +function createBidi(focusResult: unknown = true) { + const sendCommand = vi.fn(async (method: string) => { + if (method === 'input.performActions') { + return {}; + } + throw new Error(`Unexpected command: ${method}`); + }); + const callFunction = vi.fn(async (_, functionDeclaration: string) => { + if (functionDeclaration.includes('checkVisibility')) { + return true; + } + if (functionDeclaration.includes('activeElement')) { + return focusResult; + } + return undefined; + }); + const evaluate = vi.fn(async () => undefined); + const bidi = { sendCommand, callFunction, evaluate } as unknown as BiDiFacade; + return { bidi, sendCommand, callFunction }; } -function createDriver(focusResult: unknown = true) { - const calls: Array<[string, string]> = []; - const actions: MockActions = { - keyDown: vi.fn((k: string) => { - calls.push(['keyDown', k]); - return actions; - }), - keyUp: vi.fn((k: string) => { - calls.push(['keyUp', k]); - return actions; - }), - sendKeys: vi.fn(() => actions), - perform: vi.fn().mockResolvedValue(undefined), - }; - const driver = { - actions: vi.fn(() => actions), - executeScript: vi.fn(async (script: string) => - script.includes('activeElement') ? focusResult : undefined - ), - }; - return { driver, actions, calls }; +/** + * Pull the key actions out of every input.performActions call, since that is + * the only place pressKey's sequencing is observable through the bidi facade. + */ +function keyActions(sendCommand: ReturnType): Input.KeySourceAction[] { + return sendCommand.mock.calls + .filter(([method]) => method === 'input.performActions') + .flatMap(([, params]) => params.actions as Input.SourceActions[]) + .filter((source): source is Input.KeySourceActions => source.type === 'key') + .flatMap((source) => source.actions); } describe('DomInteractions.pressKey', () => { it('should press and release the key on the focused element', async () => { - const { driver, calls } = createDriver(); - const dom = new DomInteractions(driver as never); + const { bidi, sendCommand } = createBidi(); + const dom = new DomInteractions(bidi); - await dom.pressKey('Escape'); + await dom.pressKey(CONTEXT, 'Escape'); - expect(calls).toEqual([ - ['keyDown', Key.ESCAPE], - ['keyUp', Key.ESCAPE], + expect(keyActions(sendCommand)).toEqual([ + { type: 'keyDown', value: Key.ESCAPE }, + { type: 'keyUp', value: Key.ESCAPE }, ]); }); it('should hold modifiers around the key and release them in reverse order', async () => { - const { driver, calls } = createDriver(); - const dom = new DomInteractions(driver as never); - - await dom.pressKey('ctrl+shift+t'); - - expect(calls).toEqual([ - ['keyDown', Key.CONTROL], - ['keyDown', Key.SHIFT], - ['keyDown', 't'], - ['keyUp', 't'], - ['keyUp', Key.SHIFT], - ['keyUp', Key.CONTROL], + const { bidi, sendCommand } = createBidi(); + const dom = new DomInteractions(bidi); + + await dom.pressKey(CONTEXT, 'ctrl+shift+t'); + + expect(keyActions(sendCommand)).toEqual([ + { type: 'keyDown', value: Key.CONTROL }, + { type: 'keyDown', value: Key.SHIFT }, + { type: 'keyDown', value: 't' }, + { type: 'keyUp', value: 't' }, + { type: 'keyUp', value: Key.SHIFT }, + { type: 'keyUp', value: Key.CONTROL }, ]); }); - it('should focus a uid and still use the actions keyboard, not element sendKeys', async () => { - const { driver, actions, calls } = createDriver(); - const sendKeys = vi.fn(); - const resolveUid = vi.fn().mockResolvedValue({ sendKeys, isDisplayed: async () => true }); - const dom = new DomInteractions(driver as never, resolveUid); - - await dom.pressKey('Enter', 'uid-1'); - - expect(resolveUid).toHaveBeenCalledWith('uid-1'); - // Element Send Keys appends a NULL key that the page sees as a second - // keydown, so the uid path must not use it. - expect(sendKeys).not.toHaveBeenCalled(); - expect(actions.sendKeys).not.toHaveBeenCalled(); - expect(calls).toEqual([ - ['keyDown', Key.RETURN], - ['keyUp', Key.RETURN], + it('should focus a uid before pressing the key', async () => { + const { bidi, sendCommand, callFunction } = createBidi(); + const sharedRef: Script.SharedReference = { sharedId: 'shared-1' }; + const resolveUid = vi.fn().mockResolvedValue(sharedRef); + const dom = new DomInteractions(bidi, resolveUid); + + await dom.pressKey(CONTEXT, 'Enter', 'uid-1'); + + expect(resolveUid).toHaveBeenCalledWith(CONTEXT, 'uid-1'); + expect(callFunction).toHaveBeenCalledWith(CONTEXT, expect.stringContaining('activeElement'), [ + sharedRef, + ]); + expect(keyActions(sendCommand)).toEqual([ + { type: 'keyDown', value: Key.RETURN }, + { type: 'keyUp', value: Key.RETURN }, ]); }); it('should reject a uid that cannot take focus instead of pressing elsewhere', async () => { - const { driver, calls } = createDriver(false); - const resolveUid = vi.fn().mockResolvedValue({ isDisplayed: async () => true }); - const dom = new DomInteractions(driver as never, resolveUid); + const { bidi, sendCommand } = createBidi(false); + const resolveUid = vi.fn().mockResolvedValue({ sharedId: 'shared-2' }); + const dom = new DomInteractions(bidi, resolveUid); - await expect(dom.pressKey('Escape', 'uid-2')).rejects.toThrow( + await expect(dom.pressKey(CONTEXT, 'Escape', 'uid-2')).rejects.toThrow( /uid-2 cannot receive keyboard focus/ ); - expect(calls).toEqual([]); - }); - - it('should require a resolveUid callback when a uid is given', async () => { - const { driver } = createDriver(); - const dom = new DomInteractions(driver as never); - - await expect(dom.pressKey('Escape', 'uid-3')).rejects.toThrow(/resolveUid callback not set/); + expect(keyActions(sendCommand)).toEqual([]); }); - it('should reject an invalid combination before touching the driver', async () => { - const { driver, calls } = createDriver(); - const dom = new DomInteractions(driver as never); + it('should reject an invalid combination before touching the bidi session', async () => { + const { bidi, sendCommand, callFunction } = createBidi(); + const dom = new DomInteractions(bidi); - await expect(dom.pressKey('ctrl+k+l')).rejects.toThrow(/more than one non-modifier key/); - expect(calls).toEqual([]); - expect(driver.actions).not.toHaveBeenCalled(); + await expect(dom.pressKey(CONTEXT, 'ctrl+k+l')).rejects.toThrow( + /more than one non-modifier key/ + ); + expect(sendCommand).not.toHaveBeenCalled(); + expect(callFunction).not.toHaveBeenCalled(); }); }); diff --git a/tests/firefox/screenshot-page.test.ts b/tests/firefox/screenshot-page.test.ts index 6a597512..965188da 100644 --- a/tests/firefox/screenshot-page.test.ts +++ b/tests/firefox/screenshot-page.test.ts @@ -3,29 +3,47 @@ */ import { describe, it, expect, vi } from 'vitest'; +import type { BrowsingContext } from 'webdriver-bidi-protocol'; import { DomInteractions } from '../../src/firefox/dom.js'; +import type { BiDiFacade } from '../../src/firefox/bidi.js'; -function createDriver() { - return { - takeScreenshot: vi.fn().mockResolvedValue('viewport-png'), - takeFullPageScreenshot: vi.fn().mockResolvedValue('full-page-png'), - }; +const CONTEXT = 'context-1' as BrowsingContext.BrowsingContext; + +function createBidi() { + const sendCommand = vi.fn(async (method: string) => { + if (method === 'browsingContext.captureScreenshot') { + return { data: 'png-data' }; + } + throw new Error(`Unexpected command: ${method}`); + }); + const callFunction = vi.fn(async () => undefined); + const evaluate = vi.fn(async () => undefined); + const bidi = { sendCommand, callFunction, evaluate } as unknown as BiDiFacade; + return { bidi, sendCommand }; } describe('DomInteractions.takeScreenshotPage', () => { it('should capture the viewport by default', async () => { - const driver = createDriver(); - const dom = new DomInteractions(driver as never); + const { bidi, sendCommand } = createBidi(); + const dom = new DomInteractions(bidi); - expect(await dom.takeScreenshotPage()).toBe('viewport-png'); - expect(driver.takeFullPageScreenshot).not.toHaveBeenCalled(); + expect(await dom.takeScreenshotPage(CONTEXT)).toBe('png-data'); + expect(sendCommand).toHaveBeenCalledWith('browsingContext.captureScreenshot', { + context: CONTEXT, + format: { type: 'png' }, + origin: 'viewport', + }); }); - it('should use the Firefox full page endpoint when fullPage is set', async () => { - const driver = createDriver(); - const dom = new DomInteractions(driver as never); + it('should capture the whole document when fullPage is set', async () => { + const { bidi, sendCommand } = createBidi(); + const dom = new DomInteractions(bidi); - expect(await dom.takeScreenshotPage(true)).toBe('full-page-png'); - expect(driver.takeScreenshot).not.toHaveBeenCalled(); + expect(await dom.takeScreenshotPage(CONTEXT, true)).toBe('png-data'); + expect(sendCommand).toHaveBeenCalledWith('browsingContext.captureScreenshot', { + context: CONTEXT, + format: { type: 'png' }, + origin: 'document', + }); }); }); diff --git a/tests/firefox/snapshot/resolver.test.ts b/tests/firefox/snapshot/resolver.test.ts index 5e85f0bc..067a4b3c 100644 --- a/tests/firefox/snapshot/resolver.test.ts +++ b/tests/firefox/snapshot/resolver.test.ts @@ -3,68 +3,78 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { BrowsingContext, Script } from 'webdriver-bidi-protocol'; import { UidResolver } from '@/firefox/snapshot/resolver.js'; +import { nativeToLocalValue } from '@/utils/local-value'; + +const CONTEXT = 'context-1' as BrowsingContext.BrowsingContext; // Mock WebDriver -const createMockDriver = () => ({ - executeScript: vi.fn(), +const createMockBiDi = () => ({ + evaluate: vi.fn(), + callFunction: vi.fn(), + callFunctionRaw: vi.fn(), }); describe('UidResolver', () => { - let mockDriver: any; + let mockBiDi: any; let resolver: UidResolver; beforeEach(() => { - mockDriver = createMockDriver(); - resolver = new UidResolver(mockDriver); + mockBiDi = createMockBiDi(); + resolver = new UidResolver(mockBiDi); }); describe('resolveUidToElement', () => { it('should return the element looked up in the page', async () => { - const mockElement = { getId: vi.fn() }; - mockDriver.executeScript.mockResolvedValue(mockElement); + const sharedRef: Script.SharedReference = { type: 'node', sharedId: 'shared-1' }; + mockBiDi.callFunctionRaw.mockResolvedValue(sharedRef); - const element = await resolver.resolveUidToElement('e0'); + const element = await resolver.resolveUidToElement(CONTEXT, 'e0'); - expect(element).toBe(mockElement); - expect(mockDriver.executeScript).toHaveBeenCalledWith(expect.any(String), 'e0'); + expect(element).toEqual({ sharedId: 'shared-1' }); + expect(mockBiDi.callFunctionRaw).toHaveBeenCalledWith(CONTEXT, expect.any(String), [ + nativeToLocalValue('e0'), + ]); }); it('should throw when the page has no element for the UID', async () => { - mockDriver.executeScript.mockResolvedValue(null); + mockBiDi.callFunctionRaw.mockResolvedValue(null); - await expect(resolver.resolveUidToElement('e0')).rejects.toThrow(/UID not found/); + await expect(resolver.resolveUidToElement(CONTEXT, 'e0')).rejects.toThrow(/UID not found/); }); }); describe('resolveUidToSelector', () => { it('should return the selector generated in the page', async () => { - mockDriver.executeScript.mockResolvedValue('body > button#submit'); + mockBiDi.callFunction.mockResolvedValue('body > button#submit'); - await expect(resolver.resolveUidToSelector('e0')).resolves.toBe('body > button#submit'); + await expect(resolver.resolveUidToSelector(CONTEXT, 'e0')).resolves.toBe( + 'body > button#submit' + ); }); it('should throw when the page has no element for the UID', async () => { - mockDriver.executeScript.mockResolvedValue(null); + mockBiDi.callFunction.mockResolvedValue(null); - await expect(resolver.resolveUidToSelector('e0')).rejects.toThrow(/UID not found/); + await expect(resolver.resolveUidToSelector(CONTEXT, 'e0')).rejects.toThrow(/UID not found/); }); }); describe('clear', () => { it('should clear the UID registry in the page', async () => { - mockDriver.executeScript.mockResolvedValue(undefined); + mockBiDi.evaluate.mockResolvedValue(undefined); - await resolver.clear(); + await resolver.clear(CONTEXT); - expect(mockDriver.executeScript).toHaveBeenCalledOnce(); - expect(mockDriver.executeScript.mock.calls[0][0]).toContain('__clearUidRegistry'); + expect(mockBiDi.evaluate).toHaveBeenCalledOnce(); + expect(mockBiDi.evaluate.mock.calls[0][0]).toContain('__clearUidRegistry'); }); it('should not throw when the page cannot be reached', async () => { - mockDriver.executeScript.mockRejectedValue(new Error('no such window')); + mockBiDi.evaluate.mockRejectedValue(new Error('no such window')); - await expect(resolver.clear()).resolves.toBeUndefined(); + await expect(resolver.clear(CONTEXT)).resolves.toBeUndefined(); }); }); }); diff --git a/tests/firefox/type-text.test.ts b/tests/firefox/type-text.test.ts index 9345f6a9..ec935b8f 100644 --- a/tests/firefox/type-text.test.ts +++ b/tests/firefox/type-text.test.ts @@ -4,113 +4,117 @@ import { describe, it, expect, vi } from 'vitest'; import { Key } from 'selenium-webdriver'; -import { DomInteractions } from '../../src/firefox/dom.js'; - -interface MockActions { - keyDown: ReturnType; - keyUp: ReturnType; - sendKeys: ReturnType; - perform: ReturnType; +import type { BrowsingContext, Input, Script } from 'webdriver-bidi-protocol'; +import type { BiDiFacade } from '../../src/firefox/bidi.js'; +import { DomInteractions, textKeyActions } from '../../src/firefox/dom.js'; + +const CONTEXT = 'context-1' as BrowsingContext.BrowsingContext; + +function createBidi(focusResult: unknown = true) { + const sendCommand = vi.fn(async (method: string) => { + if (method === 'input.performActions') { + return {}; + } + throw new Error(`Unexpected command: ${method}`); + }); + const callFunction = vi.fn(async (_, functionDeclaration: string) => { + if (functionDeclaration.includes('checkVisibility')) { + return true; + } + if (functionDeclaration.includes('activeElement')) { + return focusResult; + } + return undefined; + }); + const evaluate = vi.fn(async () => undefined); + const bidi = { sendCommand, callFunction, evaluate } as unknown as BiDiFacade; + return { bidi, sendCommand, callFunction }; } -function createDriver(focusResult: unknown = true) { - const calls: Array<[string, string]> = []; - const actions: MockActions = { - keyDown: vi.fn((k: string) => { - calls.push(['keyDown', k]); - return actions; - }), - keyUp: vi.fn((k: string) => { - calls.push(['keyUp', k]); - return actions; - }), - sendKeys: vi.fn((text: string) => { - calls.push(['sendKeys', text]); - return actions; - }), - perform: vi.fn().mockResolvedValue(undefined), - }; - const driver = { - actions: vi.fn(() => actions), - executeScript: vi.fn(async (script: string) => - script.includes('activeElement') ? focusResult : undefined - ), - }; - return { driver, actions, calls }; +/** + * Pull the key actions out of every input.performActions call, since that is + * the only place typeText's sequencing is observable through the bidi facade. + */ +function keyActions(sendCommand: ReturnType): Input.KeySourceAction[] { + return sendCommand.mock.calls + .filter(([method]) => method === 'input.performActions') + .flatMap(([, params]) => params.actions as Input.SourceActions[]) + .filter((source): source is Input.KeySourceActions => source.type === 'key') + .flatMap((source) => source.actions); } describe('DomInteractions.typeText', () => { it('should type the text on the focused element', async () => { - const { driver, actions, calls } = createDriver(); - const dom = new DomInteractions(driver as never); + const { bidi, sendCommand } = createBidi(); + const dom = new DomInteractions(bidi); - await dom.typeText('hello'); + await dom.typeText(CONTEXT, 'hello'); - expect(calls).toEqual([['sendKeys', 'hello']]); - expect(actions.perform).toHaveBeenCalledTimes(1); + expect(keyActions(sendCommand)).toEqual(textKeyActions('hello')); }); it('should press the submit key after the text in the same sequence', async () => { - const { driver, actions, calls } = createDriver(); - const dom = new DomInteractions(driver as never); + const { bidi, sendCommand } = createBidi(); + const dom = new DomInteractions(bidi); - await dom.typeText('hello', { submitKey: 'Enter' }); + await dom.typeText(CONTEXT, 'hello', { submitKey: 'Enter' }); - expect(calls).toEqual([ - ['sendKeys', 'hello'], - ['keyDown', Key.RETURN], - ['keyUp', Key.RETURN], + expect(keyActions(sendCommand)).toEqual([ + ...textKeyActions('hello'), + { type: 'keyDown', value: Key.RETURN }, + { type: 'keyUp', value: Key.RETURN }, ]); - expect(actions.perform).toHaveBeenCalledTimes(1); }); it('should hold modifiers of the submit key around it', async () => { - const { driver, calls } = createDriver(); - const dom = new DomInteractions(driver as never); + const { bidi, sendCommand } = createBidi(); + const dom = new DomInteractions(bidi); - await dom.typeText('hello', { submitKey: 'shift+Enter' }); + await dom.typeText(CONTEXT, 'hello', { submitKey: 'shift+Enter' }); - expect(calls).toEqual([ - ['sendKeys', 'hello'], - ['keyDown', Key.SHIFT], - ['keyDown', Key.RETURN], - ['keyUp', Key.RETURN], - ['keyUp', Key.SHIFT], + expect(keyActions(sendCommand)).toEqual([ + ...textKeyActions('hello'), + { type: 'keyDown', value: Key.SHIFT }, + { type: 'keyDown', value: Key.RETURN }, + { type: 'keyUp', value: Key.RETURN }, + { type: 'keyUp', value: Key.SHIFT }, ]); }); it('should focus a uid first and still type through the actions keyboard', async () => { - const { driver, calls } = createDriver(); - const sendKeys = vi.fn(); - const resolveUid = vi.fn().mockResolvedValue({ sendKeys, isDisplayed: async () => true }); - const dom = new DomInteractions(driver as never, resolveUid); + const { bidi, sendCommand, callFunction } = createBidi(); + const sharedRef: Script.SharedReference = { sharedId: 'shared-1' }; + const resolveUid = vi.fn().mockResolvedValue(sharedRef); + const dom = new DomInteractions(bidi, resolveUid); - await dom.typeText('hello', { uid: 'uid-1' }); + await dom.typeText(CONTEXT, 'hello', { uid: 'uid-1' }); - expect(resolveUid).toHaveBeenCalledWith('uid-1'); - expect(sendKeys).not.toHaveBeenCalled(); - expect(calls).toEqual([['sendKeys', 'hello']]); + expect(resolveUid).toHaveBeenCalledWith(CONTEXT, 'uid-1'); + expect(callFunction).toHaveBeenCalledWith(CONTEXT, expect.stringContaining('activeElement'), [ + sharedRef, + ]); + expect(keyActions(sendCommand)).toEqual(textKeyActions('hello')); }); it('should reject a uid that cannot take focus before typing anything', async () => { - const { driver, calls } = createDriver(false); - const resolveUid = vi.fn().mockResolvedValue({ isDisplayed: async () => true }); - const dom = new DomInteractions(driver as never, resolveUid); + const { bidi, sendCommand } = createBidi(false); + const resolveUid = vi.fn().mockResolvedValue({ sharedId: 'shared-2' }); + const dom = new DomInteractions(bidi, resolveUid); - await expect(dom.typeText('hello', { uid: 'uid-2' })).rejects.toThrow( + await expect(dom.typeText(CONTEXT, 'hello', { uid: 'uid-2' })).rejects.toThrow( /uid-2 cannot receive keyboard focus/ ); - expect(calls).toEqual([]); + expect(keyActions(sendCommand)).toEqual([]); }); - it('should reject an invalid submit key before touching the driver', async () => { - const { driver, calls } = createDriver(); - const dom = new DomInteractions(driver as never); + it('should reject an invalid submit key before touching the bidi session', async () => { + const { bidi, sendCommand, callFunction } = createBidi(); + const dom = new DomInteractions(bidi); - await expect(dom.typeText('hello', { submitKey: 'foobar' })).rejects.toThrow( + await expect(dom.typeText(CONTEXT, 'hello', { submitKey: 'foobar' })).rejects.toThrow( /unknown key "foobar"/ ); - expect(calls).toEqual([]); - expect(driver.actions).not.toHaveBeenCalled(); + expect(sendCommand).not.toHaveBeenCalled(); + expect(callFunction).not.toHaveBeenCalled(); }); });