diff --git a/README.md b/README.md index 6d36e1b..7fc0cb8 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ This is an Appium driver for automating macOS applications using Apple's XCTest framework. > [!IMPORTANT] -> Since major version *3.0.0*, this driver is only compatible with Appium 3. +> Since major version *5.0.0*, this driver is only compatible with Appium 4. ## Documentation diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 67fca28..07b0dd0 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -29,7 +29,8 @@ and prerequisites of Appium itself can be found in [the Appium documentation](ht | Mac2 driver version | Supported Appium server version | | --- | --- | -| >= 3.0.0 | Appium 3 | +| >= 5.0.0 | Appium 4 | +| 3.0.0 - 4.6.1 | Appium 3 | | 1.0.0 - 2.2.2 | Appium 2 | ## Installation diff --git a/lib/commands/app-management.ts b/lib/commands/app-management.ts index f9f94a5..9aa14d2 100644 --- a/lib/commands/app-management.ts +++ b/lib/commands/app-management.ts @@ -2,6 +2,15 @@ import type {StringRecord} from '@appium/types'; import type {Mac2Driver} from '../driver.js'; +declare module '../driver.js' { + interface Mac2Driver { + macosLaunchApp(...args: Parameters): ReturnType; + macosActivateApp(...args: Parameters): ReturnType; + macosTerminateApp(...args: Parameters): ReturnType; + macosQueryAppState(...args: Parameters): ReturnType; + } +} + /** * Start an app with given bundle identifier or activates it * if the app is already running. An exception is thrown if the diff --git a/lib/commands/applescript.ts b/lib/commands/applescript.ts index 4fd93b2..176fb0e 100644 --- a/lib/commands/applescript.ts +++ b/lib/commands/applescript.ts @@ -5,6 +5,12 @@ import {exec} from 'teen_process'; import type {Mac2Driver} from '../driver.js'; +declare module '../driver.js' { + interface Mac2Driver { + macosExecAppleScript(...args: Parameters): ReturnType; + } +} + const OSASCRIPT = 'osascript'; const APPLE_SCRIPT_FEATURE = 'apple_script'; diff --git a/lib/commands/audit.ts b/lib/commands/audit.ts index fc9890a..d55b5c5 100644 --- a/lib/commands/audit.ts +++ b/lib/commands/audit.ts @@ -1,5 +1,13 @@ import type {Mac2Driver} from '../driver.js'; +declare module '../driver.js' { + interface Mac2Driver { + macosPerformAccessibilityAudit( + ...args: Parameters + ): ReturnType; + } +} + export interface AccessibilityAuditItem { detailedDescription: string; compactDescription: string; diff --git a/lib/commands/clipboard.ts b/lib/commands/clipboard.ts index 2c69760..7e5175f 100644 --- a/lib/commands/clipboard.ts +++ b/lib/commands/clipboard.ts @@ -1,5 +1,12 @@ import type {Mac2Driver} from '../driver.js'; +declare module '../driver.js' { + interface Mac2Driver { + macosGetClipboard(...args: Parameters): ReturnType; + macosSetClipboard(...args: Parameters): ReturnType; + } +} + /** * Sets the content of the clipboard. * diff --git a/lib/commands/context.ts b/lib/commands/context.ts index e334022..8e828f0 100644 --- a/lib/commands/context.ts +++ b/lib/commands/context.ts @@ -1,5 +1,6 @@ import path from 'node:path'; +import type {TimeoutValue} from '@appium/types'; import {Chromedriver} from 'appium-chromedriver'; import type {ChromedriverOpts} from 'appium-chromedriver'; import {errors} from 'appium/driver.js'; @@ -7,10 +8,20 @@ import axios from 'axios'; import {findAPortNotInUse} from 'portscanner'; import type {Mac2Driver} from '../driver.js'; -import log from '../logger.js'; +import {log} from '../logger.js'; import type {CdpPage, CdpVersionInfo, WebViewDetails} from '../types.js'; import {getModuleRoot} from '../utils.js'; +declare module '../driver.js' { + interface Mac2Driver { + getCurrentContext(...args: Parameters): ReturnType; + getContexts(...args: Parameters): ReturnType; + setContext(...args: Parameters): ReturnType; + getWebViewDetails(...args: Parameters): ReturnType; + macosGetContexts(...args: Parameters): ReturnType; + } +} + export const NATIVE_APP = 'NATIVE_APP'; export const WEBVIEW_PREFIX = 'WEBVIEW_'; export const DEBUGGING_PORT_FLAG = '--remote-debugging-port'; @@ -82,14 +93,7 @@ export async function setContext(this: Mac2Driver, name?: string | null): Promis await cd.start({'goog:chromeOptions': {debuggerAddress}}); await switchToPage(cd, page.id); // Timeouts belong to the session, but a new Chromedriver session starts with its defaults - const timeouts = { - ...(this.implicitWaitMs ? {implicit: this.implicitWaitMs} : {}), - ...(this._pageLoadTimeoutMs !== null ? {pageLoad: this._pageLoadTimeoutMs} : {}), - ...(this._scriptTimeoutMs !== null ? {script: this._scriptTimeoutMs} : {}), - }; - if (Object.keys(timeouts).length) { - await cd.jwproxy.command('/timeouts', 'POST', timeouts); - } + await cd.jwproxy.command('/timeouts', 'POST', getSessionTimeouts.call(this)); } catch (e) { try { await cd.stop(); @@ -100,6 +104,17 @@ export async function setContext(this: Mac2Driver, name?: string | null): Promis this._currentContext = `${WEBVIEW_PREFIX}${page.id}`; } +/** + * The session timeouts, including explicit `null` (no timeout), to apply to a new Chromedriver session. + */ +export function getSessionTimeouts(this: Mac2Driver): { + implicit: TimeoutValue; + pageLoad: TimeoutValue; + script: TimeoutValue; +} { + return {implicit: this.implicitWaitMs, pageLoad: this.pageLoadTimeoutMs, script: this.scriptTimeoutMs}; +} + /** * Chromedriver attaches to the browser, so the requested page must be selected explicitly. * diff --git a/lib/commands/execute.ts b/lib/commands/execute.ts index 4cce2e2..aa52b65 100644 --- a/lib/commands/execute.ts +++ b/lib/commands/execute.ts @@ -2,6 +2,12 @@ import type {StringRecord} from '@appium/types'; import type {Mac2Driver} from '../driver.js'; +declare module '../driver.js' { + interface Mac2Driver { + execute(...args: Parameters): ReturnType; + } +} + const EXECUTE_SCRIPT_PREFIX = 'macos:'; /** diff --git a/lib/commands/find.ts b/lib/commands/find.ts index 9d7663f..82aaea0 100644 --- a/lib/commands/find.ts +++ b/lib/commands/find.ts @@ -1,7 +1,20 @@ +import {errors, isErrorType} from 'appium/driver.js'; import {util} from 'appium/support.js'; +import {ConditionUnmetError} from 'asyncbox'; import type {Mac2Driver} from '../driver.js'; +declare module '../driver.js' { + interface Mac2Driver { + findElOrEls(...args: Parameters): ReturnType; + } +} + +// Older asyncbox (possibly bundled with the base driver) throws a plain Error +function isConditionUnmet(e: unknown): boolean { + return e instanceof ConditionUnmetError || /Condition unmet/.test((e as Error)?.message ?? ''); +} + /** * This is needed to make lookup by image working * @@ -32,8 +45,45 @@ export async function findElOrEls( normalizedStrategy = 'class chain'; } - return await this.wda.proxy.command(endpoint, 'POST', { - using: normalizedStrategy, - value: selector, - }); + const doFind = async (): Promise => + await this.wda.proxy.command(endpoint, 'POST', { + using: normalizedStrategy, + value: selector, + }); + // `null` means no timeout, which the implicit wait helper treats as unlimited + if (this.implicitWaitMs === 0) { + return await doFind(); + } + + // The WDA server ignores the /timeouts API, so the implicit wait is applied here + let result: any; + let lastError: Error | undefined; + // A lookup error other than NoSuchElementError stops the wait and must reach the caller as is + let lookupError: unknown; + try { + await this.implicitWaitForCondition(async () => { + try { + result = await doFind(); + } catch (e) { + if (!isErrorType(e, errors.NoSuchElementError)) { + lookupError = e; + throw e; + } + lastError = e as Error; + return false; + } + return !mult || (Array.isArray(result) && result.length > 0); + }); + } catch (e) { + if (lookupError !== undefined) { + throw lookupError; + } + if (!isConditionUnmet(e)) { + throw e; + } + if (!mult && lastError) { + throw lastError; + } + } + return result; } diff --git a/lib/commands/gestures.ts b/lib/commands/gestures.ts index 0de0bfd..248d0f0 100644 --- a/lib/commands/gestures.ts +++ b/lib/commands/gestures.ts @@ -4,6 +4,30 @@ import {util} from 'appium/support.js'; import type {Mac2Driver} from '../driver.js'; import type {KeyOptions} from '../types.js'; +declare module '../driver.js' { + interface Mac2Driver { + macosSetValue(...args: Parameters): ReturnType; + macosClick(...args: Parameters): ReturnType; + macosScroll(...args: Parameters): ReturnType; + macosSwipe(...args: Parameters): ReturnType; + macosRightClick(...args: Parameters): ReturnType; + macosHover(...args: Parameters): ReturnType; + macosDoubleClick(...args: Parameters): ReturnType; + macosClickAndDrag(...args: Parameters): ReturnType; + macosClickAndDragAndHold( + ...args: Parameters + ): ReturnType; + macosKeys(...args: Parameters): ReturnType; + macosPressAndHold(...args: Parameters): ReturnType; + macosTap(...args: Parameters): ReturnType; + macosDoubleTap(...args: Parameters): ReturnType; + macosPressAndDrag(...args: Parameters): ReturnType; + macosPressAndDragAndHold( + ...args: Parameters + ): ReturnType; + } +} + /** * Set value to the given element. * Note: diff --git a/lib/commands/native-record-screen.ts b/lib/commands/native-record-screen.ts index 644343d..18eb579 100644 --- a/lib/commands/native-record-screen.ts +++ b/lib/commands/native-record-screen.ts @@ -15,6 +15,21 @@ import {BIDI_EVENT_NAME} from './bidi/constants.js'; import {toNativeVideoChunkAddedEvent} from './bidi/models.js'; import {uploadRecordedMedia} from './helpers.js'; +declare module '../driver.js' { + interface Mac2Driver { + macosStartNativeScreenRecording( + ...args: Parameters + ): ReturnType; + macosGetNativeScreenRecordingInfo( + ...args: Parameters + ): ReturnType; + macosStopNativeScreenRecording( + ...args: Parameters + ): ReturnType; + macosListDisplays(...args: Parameters): ReturnType; + } +} + const RECORDING_STARTUP_TIMEOUT_MS = 5000; const BUFFER_SIZE = 0xffff; const MONITORING_INTERVAL_DURATION_MS = 1000; diff --git a/lib/commands/navigation.ts b/lib/commands/navigation.ts index 7e24077..b462231 100644 --- a/lib/commands/navigation.ts +++ b/lib/commands/navigation.ts @@ -1,5 +1,11 @@ import type {Mac2Driver} from '../driver.js'; +declare module '../driver.js' { + interface Mac2Driver { + macosDeepLink(...args: Parameters): ReturnType; + } +} + /** * Opens the given URL with the default or the given application. * Xcode must be at version 14.3+. diff --git a/lib/commands/record-screen.ts b/lib/commands/record-screen.ts index bf5af5a..e1c8f4c 100644 --- a/lib/commands/record-screen.ts +++ b/lib/commands/record-screen.ts @@ -6,6 +6,19 @@ import {SubProcess} from 'teen_process'; import type {Mac2Driver} from '../driver.js'; import {uploadRecordedMedia} from './helpers.js'; +declare module '../driver.js' { + interface Mac2Driver { + startRecordingScreen(...args: Parameters): ReturnType; + stopRecordingScreen(...args: Parameters): ReturnType; + macosStartRecordingScreen( + ...args: Parameters + ): ReturnType; + macosStopRecordingScreen( + ...args: Parameters + ): ReturnType; + } +} + const RETRY_PAUSE = 300; const RETRY_TIMEOUT = 5000; const DEFAULT_TIME_LIMIT = 60 * 10; // 10 minutes diff --git a/lib/commands/screenshots.ts b/lib/commands/screenshots.ts index cdd5a71..2e07f73 100644 --- a/lib/commands/screenshots.ts +++ b/lib/commands/screenshots.ts @@ -1,6 +1,12 @@ import type {Mac2Driver} from '../driver.js'; import type {ScreenshotsInfo} from '../types.js'; +declare module '../driver.js' { + interface Mac2Driver { + macosScreenshots(...args: Parameters): ReturnType; + } +} + /** * Retrieves screenshots of each display available to macOS * diff --git a/lib/commands/source.ts b/lib/commands/source.ts index aca0472..9370014 100644 --- a/lib/commands/source.ts +++ b/lib/commands/source.ts @@ -1,5 +1,11 @@ import type {Mac2Driver} from '../driver.js'; +declare module '../driver.js' { + interface Mac2Driver { + macosSource(...args: Parameters): ReturnType; + } +} + /** * Retrieves the string representation of the current application * diff --git a/lib/commands/windows.ts b/lib/commands/windows.ts index 3b363d5..6763da8 100644 --- a/lib/commands/windows.ts +++ b/lib/commands/windows.ts @@ -1,6 +1,13 @@ import type {Mac2Driver} from '../driver.js'; import type {AdjustedWindowInfo, WindowInfo, WindowState} from '../types.js'; +declare module '../driver.js' { + interface Mac2Driver { + macosListWindows(...args: Parameters): ReturnType; + macosAdjustWindow(...args: Parameters): ReturnType; + } +} + /** * Lists the windows of the current application * diff --git a/lib/constraints.ts b/lib/constraints.ts index 6ccc2c7..b1a486b 100644 --- a/lib/constraints.ts +++ b/lib/constraints.ts @@ -1,6 +1,6 @@ import type {Constraints} from '@appium/types'; -const MAC2_CONSTRAINTS = { +export const MAC2_CONSTRAINTS = { systemPort: { isNumber: true, }, @@ -66,6 +66,4 @@ const MAC2_CONSTRAINTS = { }, } as const satisfies Constraints; -export default MAC2_CONSTRAINTS; - export type Mac2Constraints = typeof MAC2_CONSTRAINTS; diff --git a/lib/driver.ts b/lib/driver.ts index 7f8596e..e12ce37 100644 --- a/lib/driver.ts +++ b/lib/driver.ts @@ -3,34 +3,69 @@ import type { HTTPMethod, HTTPBody, DefaultCreateSessionResult, - DriverData, InitialOpts, StringRecord, ExternalDriver, DriverCaps, DriverOpts, W3CDriverCaps, + TimeoutValue, } from '@appium/types'; import type {Chromedriver} from 'appium-chromedriver'; import {BaseDriver, DeviceSettings, errors} from 'appium/driver.js'; -import * as appManagemenetCommands from './commands/app-management.js'; -import * as appleScriptCommands from './commands/applescript.js'; -import * as auditCommands from './commands/audit.js'; -import * as clipboardCommands from './commands/clipboard.js'; -import * as contextCommands from './commands/context.js'; -import * as executeCommands from './commands/execute.js'; -import * as findCommands from './commands/find.js'; -import * as gesturesCommands from './commands/gestures.js'; -import * as nativeScreenRecordingCommands from './commands/native-record-screen.js'; -import * as navigationCommands from './commands/navigation.js'; -import * as recordScreenCommands from './commands/record-screen.js'; -import * as screenshotCommands from './commands/screenshots.js'; -import * as sourceCommands from './commands/source.js'; -import * as windowCommands from './commands/windows.js'; -import MAC2_CONSTRAINTS, {type Mac2Constraints} from './constraints.js'; +import {macosLaunchApp, macosActivateApp, macosTerminateApp, macosQueryAppState} from './commands/app-management.js'; +import {macosExecAppleScript} from './commands/applescript.js'; +import {macosPerformAccessibilityAudit} from './commands/audit.js'; +import {macosGetClipboard, macosSetClipboard} from './commands/clipboard.js'; +import { + getCurrentContext, + getContexts, + setContext, + getWebViewDetails, + macosGetContexts, + prepareWebviewLaunch, +} from './commands/context.js'; +import {execute} from './commands/execute.js'; +import {findElOrEls} from './commands/find.js'; +import { + macosSetValue, + macosClick, + macosScroll, + macosSwipe, + macosRightClick, + macosHover, + macosDoubleClick, + macosClickAndDrag, + macosClickAndDragAndHold, + macosKeys, + macosPressAndHold, + macosTap, + macosDoubleTap, + macosPressAndDrag, + macosPressAndDragAndHold, +} from './commands/gestures.js'; +import { + macosStartNativeScreenRecording, + macosGetNativeScreenRecordingInfo, + macosStopNativeScreenRecording, + macosListDisplays, + NativeVideoChunksBroadcaster, +} from './commands/native-record-screen.js'; +import {macosDeepLink} from './commands/navigation.js'; +import { + startRecordingScreen, + stopRecordingScreen, + macosStartRecordingScreen, + macosStopRecordingScreen, + type ScreenRecorder, +} from './commands/record-screen.js'; +import {macosScreenshots} from './commands/screenshots.js'; +import {macosSource} from './commands/source.js'; +import {macosListWindows, macosAdjustWindow} from './commands/windows.js'; +import {MAC2_CONSTRAINTS, type Mac2Constraints} from './constraints.js'; import {executeMethodMap} from './execute-method-map.js'; -import log from './logger.js'; +import {log} from './logger.js'; import {newMethodMap} from './method-map.js'; import {sessionClaimHandler} from './session-claim-handler.js'; import {WDA_MAC_SERVER, type WDAMacServer} from './wda-mac.js'; @@ -79,70 +114,12 @@ export class Mac2Driver static newMethodMap = newMethodMap; static executeMethodMap = executeMethodMap; - macosLaunchApp = appManagemenetCommands.macosLaunchApp; - macosActivateApp = appManagemenetCommands.macosActivateApp; - macosTerminateApp = appManagemenetCommands.macosTerminateApp; - macosQueryAppState = appManagemenetCommands.macosQueryAppState; - - macosExecAppleScript = appleScriptCommands.macosExecAppleScript; - - execute = executeCommands.execute; - - getCurrentContext = contextCommands.getCurrentContext; - getContexts = contextCommands.getContexts; - setContext = contextCommands.setContext; - getWebViewDetails = contextCommands.getWebViewDetails; - macosGetContexts = contextCommands.macosGetContexts; - - findElOrEls = findCommands.findElOrEls; - - macosSetValue = gesturesCommands.macosSetValue; - macosClick = gesturesCommands.macosClick; - macosScroll = gesturesCommands.macosScroll; - macosSwipe = gesturesCommands.macosSwipe; - macosRightClick = gesturesCommands.macosRightClick; - macosHover = gesturesCommands.macosHover; - macosDoubleClick = gesturesCommands.macosDoubleClick; - macosClickAndDrag = gesturesCommands.macosClickAndDrag; - macosClickAndDragAndHold = gesturesCommands.macosClickAndDragAndHold; - macosKeys = gesturesCommands.macosKeys; - macosPressAndHold = gesturesCommands.macosPressAndHold; - macosTap = gesturesCommands.macosTap; - macosDoubleTap = gesturesCommands.macosDoubleTap; - macosPressAndDrag = gesturesCommands.macosPressAndDrag; - macosPressAndDragAndHold = gesturesCommands.macosPressAndDragAndHold; - - macosGetClipboard = clipboardCommands.macosGetClipboard; - macosSetClipboard = clipboardCommands.macosSetClipboard; - macosPerformAccessibilityAudit = auditCommands.macosPerformAccessibilityAudit; - - macosDeepLink = navigationCommands.macosDeepLink; - - startRecordingScreen = recordScreenCommands.startRecordingScreen; - stopRecordingScreen = recordScreenCommands.stopRecordingScreen; - macosStartRecordingScreen = recordScreenCommands.macosStartRecordingScreen; - macosStopRecordingScreen = recordScreenCommands.macosStopRecordingScreen; - - macosStartNativeScreenRecording = nativeScreenRecordingCommands.macosStartNativeScreenRecording; - macosGetNativeScreenRecordingInfo = nativeScreenRecordingCommands.macosGetNativeScreenRecordingInfo; - macosStopNativeScreenRecording = nativeScreenRecordingCommands.macosStopNativeScreenRecording; - macosListDisplays = nativeScreenRecordingCommands.macosListDisplays; - - macosScreenshots = screenshotCommands.macosScreenshots; - - macosSource = sourceCommands.macosSource; - - macosListWindows = windowCommands.macosListWindows; - macosAdjustWindow = windowCommands.macosAdjustWindow; - - _videoChunksBroadcaster!: nativeScreenRecordingCommands.NativeVideoChunksBroadcaster; - _screenRecorder: recordScreenCommands.ScreenRecorder | null = null; + _videoChunksBroadcaster!: NativeVideoChunksBroadcaster; + _screenRecorder: ScreenRecorder | null = null; public proxyReqRes!: (...args: any) => any; chromedriver: Chromedriver | null = null; _currentContext: string | null = null; - _pageLoadTimeoutMs: number | null = null; - _scriptTimeoutMs: number | null = null; _webviewDevtoolsPort: number | null = null; private isProxyActive: boolean = false; @@ -217,27 +194,30 @@ export class Mac2Driver } // Timeouts belong to the session: keep them here and mirror to Chromedriver in a web view - override async implicitWaitW3C(ms: number): Promise { - await super.implicitWaitW3C(ms); - await this.chromedriver?.jwproxy.command('/timeouts', 'POST', {implicit: this.implicitWaitMs}); - } - - override async pageLoadTimeoutW3C(ms: number): Promise { - if (!this.chromedriver) { - return await super.pageLoadTimeoutW3C(ms); + override async timeouts( + type?: string, + ms?: number | string, + script?: TimeoutValue, + pageLoad?: TimeoutValue, + implicit?: TimeoutValue, + command?: number, + ): Promise { + if (type && ms !== undefined) { + // The deprecated 'type'/'ms' form is validated by the base driver; only map it for mirroring + const legacyMs = this.parseTimeoutArgument(ms); + script = type === 'script' ? legacyMs : undefined; + pageLoad = type === 'page load' ? legacyMs : undefined; + implicit = type === 'implicit' ? legacyMs : undefined; } - const pageLoad = this.parseTimeoutArgument(ms); - await this.chromedriver.jwproxy.command('/timeouts', 'POST', {pageLoad}); - this._pageLoadTimeoutMs = pageLoad; - } - - override async scriptTimeoutW3C(ms: number): Promise { - if (!this.chromedriver) { - return await super.scriptTimeoutW3C(ms); + if (pageLoad !== undefined && !this.chromedriver) { + throw new errors.NotImplementedError('Not implemented: page load timeout is only supported in web view contexts'); + } + await super.timeouts(type, ms, script, pageLoad, implicit, command); + for (const [name, value] of Object.entries({script, pageLoad, implicit})) { + if (value !== undefined) { + await this.chromedriver?.jwproxy.command('/timeouts', 'POST', {[name]: value}); + } } - const script = this.parseTimeoutArgument(ms); - await this.chromedriver.jwproxy.command('/timeouts', 'POST', {script}); - this._scriptTimeoutMs = script; } isWebviewEnabled(): boolean { @@ -281,12 +261,9 @@ export class Mac2Driver } override async createSession( - w3cCaps1: W3CMac2DriverCaps, - w3cCaps2?: W3CMac2DriverCaps, - w3cCaps3?: W3CMac2DriverCaps, - driverData?: DriverData[], + w3cCapabilities: W3CMac2DriverCaps, ): Promise> { - const [sessionId, caps] = await super.createSession(w3cCaps1, w3cCaps2, w3cCaps3, driverData); + const [sessionId, caps] = await super.createSession(w3cCapabilities); this._wda = WDA_MAC_SERVER; this.caps = caps as Mac2DriverCaps; // oxlint-disable-next-line no-self-assign -- narrows this.opts' type for the rest of the method @@ -312,7 +289,7 @@ export class Mac2Driver log.info(`Prerun script output: ${output}`); } } - const launch = await contextCommands.prepareWebviewLaunch(caps); + const launch = await prepareWebviewLaunch(caps); this._webviewDevtoolsPort = launch.port; this._wdaSessionId = await this.wda.startSession(launch.caps, { reqBasePath: this.basePath, @@ -380,15 +357,68 @@ export class Mac2Driver this._hasClaimedHost = false; this.isProxyActive = false; this.resetWebviewState(); - this._pageLoadTimeoutMs = null; - this._scriptTimeoutMs = null; this._webviewDevtoolsPort = null; - this._videoChunksBroadcaster = new nativeScreenRecordingCommands.NativeVideoChunksBroadcaster( - this.eventEmitter, - this.log, - ); + this._videoChunksBroadcaster = new NativeVideoChunksBroadcaster(this.eventEmitter, this.log); this._screenRecorder = null; } } -export default Mac2Driver; +Object.assign(Mac2Driver.prototype, { + // app-management + macosLaunchApp, + macosActivateApp, + macosTerminateApp, + macosQueryAppState, + // applescript + macosExecAppleScript, + // execute + execute, + // context + getCurrentContext, + getContexts, + setContext, + getWebViewDetails, + macosGetContexts, + // find + findElOrEls, + // gestures + macosSetValue, + macosClick, + macosScroll, + macosSwipe, + macosRightClick, + macosHover, + macosDoubleClick, + macosClickAndDrag, + macosClickAndDragAndHold, + macosKeys, + macosPressAndHold, + macosTap, + macosDoubleTap, + macosPressAndDrag, + macosPressAndDragAndHold, + // clipboard + macosGetClipboard, + macosSetClipboard, + // audit + macosPerformAccessibilityAudit, + // navigation + macosDeepLink, + // record-screen + startRecordingScreen, + stopRecordingScreen, + macosStartRecordingScreen, + macosStopRecordingScreen, + // native-record-screen + macosStartNativeScreenRecording, + macosGetNativeScreenRecordingInfo, + macosStopNativeScreenRecording, + macosListDisplays, + // screenshots + macosScreenshots, + // source + macosSource, + // windows + macosListWindows, + macosAdjustWindow, +}); diff --git a/lib/index.ts b/lib/index.ts index 2faa76b..f920a32 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,4 +1 @@ -import {Mac2Driver} from './driver.js'; - -export default Mac2Driver; -export {Mac2Driver}; +export {Mac2Driver} from './driver.js'; diff --git a/lib/logger.ts b/lib/logger.ts index b7ffcad..1332643 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -1,5 +1,3 @@ import {logger} from 'appium/support.js'; -const log = logger.getLogger('Mac2Driver'); - -export default log; +export const log = logger.getLogger('Mac2Driver'); diff --git a/package.json b/package.json index 78bef1a..3ad9ce8 100644 --- a/package.json +++ b/package.json @@ -72,20 +72,20 @@ "teen_process": "^4.2.0" }, "devDependencies": { - "@appium/docutils": "^3.0.0", - "@appium/oxc-config": "^1.1.0", - "@appium/semantic-release-config": "^1.1.0", - "@appium/tsconfig": "^1.2.0", - "@appium/types": "^1.0.0-rc.1", + "@appium/docutils": "^4.0.0-beta.5", + "@appium/oxc-config": "^2.0.0-beta.2", + "@appium/semantic-release-config": "^2.0.0-beta.1", + "@appium/tsconfig": "^2.0.0-beta.1", + "@appium/types": "^2.0.0-beta.5", "@types/node": "^26.0.0", "@types/portscanner": "^2.1.1", - "webdriverio": "^9.0.0" + "webdriverio": "^10.1.0" }, "peerDependencies": { - "appium": "^3.0.0-rc.2" + "appium": "^4.0.0-beta.6" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "node": "^22.22.2 || ^24.15.0 || >=26.0.0", "npm": ">=10" }, "appium": { diff --git a/test/unit/context.test.ts b/test/unit/context.test.ts index 0bb49ae..efbcc7a 100644 --- a/test/unit/context.test.ts +++ b/test/unit/context.test.ts @@ -4,6 +4,7 @@ import {describe, it} from 'node:test'; import {macosGetClipboard} from '../../lib/commands/clipboard.js'; import { getChromedriverOpts, + getSessionTimeouts, getContexts, prepareWebviewLaunch, setContext, @@ -206,18 +207,30 @@ describe('webview contexts', () => { ]); }); - it('remembers page load and script timeouts for the next Chromedriver', async () => { + it('keeps explicit null timeouts for the next Chromedriver', async () => { const driver = makeDriver(); withChromedriver(driver); - await driver.timeouts(undefined, undefined, 3000, 4000); - assert.equal(driver._scriptTimeoutMs, 3000); - assert.equal(driver._pageLoadTimeoutMs, 4000); + await driver.timeouts(undefined, undefined, null, 4000, 5000); + assert.deepEqual(getSessionTimeouts.call(driver), {implicit: 5000, pageLoad: 4000, script: null}); }); it('does not support page load timeouts natively', async () => { await assert.rejects(makeDriver().timeouts(undefined, undefined, undefined, 4000), /Not implemented/); }); + it('rejects unknown legacy timeout types', async () => { + const driver = makeDriver(); + withChromedriver(driver); + await assert.rejects(driver.timeouts('random timeout', 42), /not supported/); + }); + + it('mirrors legacy timeouts to Chromedriver', async () => { + const driver = makeDriver(); + const calls = withChromedriver(driver); + await driver.timeouts('implicit', 5000); + assert.deepEqual(calls, [['/timeouts', 'POST', {implicit: 5000}]]); + }); + it('routes POST timeouts to the driver in a web view', () => { const driver = makeDriver(); driver.chromedriver = {} as any; diff --git a/test/unit/find.test.ts b/test/unit/find.test.ts new file mode 100644 index 0000000..3596fa4 --- /dev/null +++ b/test/unit/find.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; + +import {errors} from 'appium/driver.js'; + +import {Mac2Driver} from '../../lib/driver.js'; + +describe('findElOrEls', () => { + function makeDriver(responses: (() => unknown)[], implicitWaitMs: number | null) { + const driver = new Mac2Driver({} as any); + const calls: string[] = []; + (driver as any)._wda = { + proxy: { + command: async (url: string) => { + calls.push(url); + const respond = responses[Math.min(calls.length, responses.length) - 1]; + return respond(); + }, + }, + }; + driver.setImplicitWait(implicitWaitMs); + return {driver, calls}; + } + + const notFound = () => { + throw new errors.NoSuchElementError(); + }; + + it('sends a single request if the implicit wait is not set', async () => { + const {driver, calls} = makeDriver([notFound], 0); + await assert.rejects(driver.findElOrEls('accessibility id', 'foo', false), errors.NoSuchElementError); + assert.equal(calls.length, 1); + }); + + it('retries without a deadline if the implicit wait is null', async () => { + const element = {ELEMENT: '1'}; + const {driver, calls} = makeDriver([notFound, () => element], null); + assert.deepEqual(await driver.findElOrEls('accessibility id', 'foo', false), element); + assert.equal(calls.length, 2); + }); + + it('retries a single element lookup within the implicit wait', async () => { + const element = {ELEMENT: '1'}; + const {driver, calls} = makeDriver([notFound, () => element], 2000); + assert.deepEqual(await driver.findElOrEls('accessibility id', 'foo', false), element); + assert.equal(calls.length, 2); + }); + + it('retries a multiple elements lookup within the implicit wait', async () => { + const elements = [{ELEMENT: '1'}]; + const {driver, calls} = makeDriver([() => [], () => elements], 2000); + assert.deepEqual(await driver.findElOrEls('accessibility id', 'foo', true), elements); + assert.equal(calls.length, 2); + }); + + it('throws the lookup error once the implicit wait expires', async () => { + const {driver, calls} = makeDriver([notFound], 600); + await assert.rejects(driver.findElOrEls('accessibility id', 'foo', false), errors.NoSuchElementError); + assert.ok(calls.length > 1); + }); + + it('returns an empty list once the implicit wait expires', async () => { + const {driver, calls} = makeDriver([() => []], 600); + assert.deepEqual(await driver.findElOrEls('accessibility id', 'foo', true), []); + assert.ok(calls.length > 1); + }); + + it('does not retry on other errors', async () => { + const {driver, calls} = makeDriver( + [ + () => { + throw new errors.InvalidSelectorError(); + }, + ], + 2000, + ); + await assert.rejects(driver.findElOrEls('xpath', '//', false), errors.InvalidSelectorError); + assert.equal(calls.length, 1); + }); + + it('throws a lookup error whose message looks like the wait timeout', async () => { + const {driver, calls} = makeDriver( + [ + () => { + throw new errors.InvalidSelectorError('Invalid selector: Condition unmet'); + }, + ], + 2000, + ); + await assert.rejects(driver.findElOrEls('xpath', '//', false), errors.InvalidSelectorError); + await assert.rejects(driver.findElOrEls('xpath', '//', true), errors.InvalidSelectorError); + assert.equal(calls.length, 2); + }); +});