diff --git a/README.md b/README.md index 2c853b1..ba0581d 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,11 @@ Get the latest version from `https://pub.dev/packages/appium_flutter_server/inst flutter build ipa --release integration_test/appium_test.dart ``` +7. Build the MacOS app: + ```bash + flutter build macos --release integration_test/appium_test.dart + ``` + Bingo! You are ready to run your tests using Appium Flutter Integration Driver. Check if your Flutter app is running on the device or emulator. diff --git a/package.json b/package.json index 7e68dee..d80d43a 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "automationName": "FlutterIntegration", "platformNames": [ "Android", - "iOS" + "iOS", + "Mac" ], "mainClass": "AppiumFlutterDriver", "flutterServerVersion": ">=0.0.18 <1.0.0" @@ -98,6 +99,7 @@ "appium-ios-device": "^3.0.0", "appium-uiautomator2-driver": "^5.0.0", "appium-xcuitest-driver": "^10.0.0", + "appium-mac2-driver": "^3.0.0", "async-retry": "^1.3.3", "asyncbox": "^3.0.0", "bluebird": "^3.7.2", diff --git a/src/commands/element.ts b/src/commands/element.ts index 4b1a237..2d2a4ba 100644 --- a/src/commands/element.ts +++ b/src/commands/element.ts @@ -1,7 +1,11 @@ import _ from 'lodash'; -import { getProxyDriver } from '../utils'; +import { getProxyDriver, FLUTTER_LOCATORS } from '../utils'; import { JWProxy } from 'appium/driver'; import { AndroidUiautomator2Driver } from 'appium-uiautomator2-driver'; +// @ts-ignore +import { XCUITestDriver } from 'appium-xcuitest-driver'; +// @ts-ignore +import { Mac2Driver } from 'appium-mac2-driver'; import { W3C_ELEMENT_KEY } from 'appium/driver'; import type { AppiumFlutterDriver } from '../driver'; @@ -15,28 +19,53 @@ export async function findElOrEls( ): Promise { const driver = await getProxyDriver.bind(this)(strategy); let elementBody; - if ( - !(driver instanceof JWProxy) && - !(this.proxydriver instanceof AndroidUiautomator2Driver) + function constructFindElementPayload( + strategy: string, + selector: string, + proxyDriver: XCUITestDriver | AndroidUiautomator2Driver | Mac2Driver, ) { - elementBody = { - using: strategy, - value: selector, - context, //this needs be validated - }; - } else { - elementBody = { - strategy, - selector: ['-flutter descendant', '-flutter ancestor'].includes( - strategy, - ) - ? _.isString(selector) - ? JSON.parse(selector) - : selector - : selector, - context, - }; + const isFlutterLocator = + strategy.startsWith('-flutter') || FLUTTER_LOCATORS.includes(strategy); + + let parsedSelector; + if (['-flutter descendant', '-flutter ancestor'].includes(strategy)) { + // Handle descendant/ancestor special case + parsedSelector = _.isString(selector) + ? JSON.parse(selector) + : selector; + + // For Mac2Driver and XCUITestDriver, format selector differently + if ( + proxyDriver instanceof XCUITestDriver || + proxyDriver instanceof Mac2Driver + ) { + return { + using: strategy, + value: JSON.stringify(parsedSelector), + context, + }; + } + } else { + parsedSelector = selector; + } + + // If user is looking for Native IOS/Mac locator + if ( + !isFlutterLocator && + (proxyDriver instanceof XCUITestDriver || + proxyDriver instanceof Mac2Driver) + ) { + return { using: strategy, value: parsedSelector, context }; + } else { + return { strategy, selector: parsedSelector, context }; + } } + + elementBody = constructFindElementPayload( + strategy, + selector, + this.proxydriver, + ); if (mult) { const response = await driver.command('/elements', 'POST', elementBody); response.forEach((element: any) => { @@ -52,9 +81,42 @@ export async function findElOrEls( export async function click(this: AppiumFlutterDriver, element: string) { const driver = ELEMENT_CACHE.get(element); - return await driver.command(`/element/${element}/click`, 'POST', { - element, - }); + + if (this.proxydriver instanceof Mac2Driver) { + this.log.debug('Mac2Driver detected, using non-blocking click'); + + try { + // Working around Mac2Driver issues which is blocking click request when clicking on Flutter elements opens native dialog + // For Flutter elements, we just verify the element is in our cache + if (!ELEMENT_CACHE.has(element)) { + throw new Error('Element not found in cache'); + } + + // Element exists, send click command + driver + .command(`/element/${element}/click`, 'POST', { + element, + }) + .catch((err: Error) => { + // Log error but don't block + this.log.debug( + `Click command sent (non-blocking). Any error: ${err.message}`, + ); + }); + + // Return success since element check passed + return true; + } catch (err) { + // Element check failed - this is a legitimate error we should report + this.log.error('Element validation failed before click:', err); + throw new Error(`Element validation failed: ${err.message}`); + } + } else { + // For other drivers, proceed with normal click behavior + return await driver.command(`/element/${element}/click`, 'POST', { + element, + }); + } } export async function getText(this: AppiumFlutterDriver, elementId: string) { diff --git a/src/desiredCaps.ts b/src/desiredCaps.ts index b5d5430..c1d3880 100644 --- a/src/desiredCaps.ts +++ b/src/desiredCaps.ts @@ -7,7 +7,7 @@ export const desiredCapConstraints = { presence: true, }, platformName: { - inclusionCaseInsensitive: ['iOS', 'Android'], + inclusionCaseInsensitive: ['iOS', 'Android', 'Mac'], isString: true, presence: true, }, diff --git a/src/driver.ts b/src/driver.ts index 336e9fe..5647215 100644 --- a/src/driver.ts +++ b/src/driver.ts @@ -10,6 +10,8 @@ type FlutterDriverConstraints = typeof desiredCapConstraints; // @ts-ignore import { XCUITestDriver } from 'appium-xcuitest-driver'; import { AndroidUiautomator2Driver } from 'appium-uiautomator2-driver'; +// @ts-ignore +import { Mac2Driver } from 'appium-mac2-driver'; import { createSession as createSessionMixin } from './session'; import { findElOrEls, @@ -55,7 +57,7 @@ const WEBVIEW_NO_PROXY = [ export class AppiumFlutterDriver extends BaseDriver { // @ts-ignore - public proxydriver: XCUITestDriver | AndroidUiautomator2Driver; + public proxydriver: XCUITestDriver | AndroidUiautomator2Driver | Mac2Driver; public flutterPort: number | null | undefined; private internalCaps: DriverCaps | undefined; public proxy: JWProxy | undefined; @@ -220,6 +222,9 @@ export class AppiumFlutterDriver extends BaseDriver { this.currentContext === this.NATIVE_CONTEXT_NAME && isFlutterDriverCommand(command) ) { + this.log.debug( + `executeCommand: command ${command} is flutter command using flutter driver`, + ); return await super.executeCommand(command, ...args); } else { this.log.info( diff --git a/src/macOS.ts b/src/macOS.ts new file mode 100644 index 0000000..41f2e18 --- /dev/null +++ b/src/macOS.ts @@ -0,0 +1,30 @@ +import type { AppiumFlutterDriver } from './driver'; +// @ts-ignore +import { Mac2Driver } from 'appium-mac2-driver'; +import type { InitialOpts } from '@appium/types'; +import { DEVICE_CONNECTIONS_FACTORY } from './iProxy'; + +export async function startMacOsSession( + this: AppiumFlutterDriver, + ...args: any[] +): Promise { + this.log.info(`Starting an MacOs proxy session`); + const macOsDriver = new Mac2Driver({} as InitialOpts); + await macOsDriver.createSession(...args); + return macOsDriver; +} + +export async function macOsPortForward( + udid: string, + systemPort: number, + devicePort: number, +) { + await DEVICE_CONNECTIONS_FACTORY.requestConnection(udid, systemPort, { + usePortForwarding: true, + devicePort: devicePort, + }); +} + +export function macOsRemovePortForward(udid: string, systemPort: number) { + DEVICE_CONNECTIONS_FACTORY.releaseConnection(udid, systemPort); +} diff --git a/src/platform.ts b/src/platform.ts index cd907cc..852ac74 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -1,4 +1,5 @@ export const PLATFORM = { IOS: 'ios', ANDROID: 'android', + MAC: 'mac', } as const; diff --git a/src/session.ts b/src/session.ts index 2e85cc4..8934706 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,6 +3,7 @@ import _ from 'lodash'; import { PLATFORM } from './platform'; import { startAndroidSession } from './android'; import { startIOSSession } from './iOS'; +import { startMacOsSession } from './macOS'; import type { DefaultCreateSessionResult } from '@appium/types'; export async function createSession( @@ -28,6 +29,13 @@ export async function createSession( this.proxydriver.denyInsecure = this.denyInsecure; this.proxydriver.allowInsecure = this.allowInsecure; break; + case PLATFORM.MAC: + this.proxydriver = await startMacOsSession.bind(this)(...args); + this.proxydriver.relaxedSecurityEnabled = + this.relaxedSecurityEnabled; + this.proxydriver.denyInsecure = this.denyInsecure; + this.proxydriver.allowInsecure = this.allowInsecure; + break; default: this.log.errorWithException( `Unsupported platformName: ${caps.platformName}. ` + diff --git a/src/utils.ts b/src/utils.ts index aeb0f09..89d3899 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,8 @@ import { AndroidUiautomator2Driver } from 'appium-uiautomator2-driver'; +// @ts-ignore +import { XCUITestDriver } from 'appium-xcuitest-driver'; +// @ts-ignore +import { Mac2Driver } from 'appium-mac2-driver'; import { findAPortNotInUse } from 'portscanner'; import { waitForCondition } from 'asyncbox'; import { JWProxy } from '@appium/base-driver'; @@ -23,19 +27,36 @@ export const FLUTTER_LOCATORS = [ 'text', 'type', 'text containing', + 'descendant', + 'ancestor', ]; export async function getProxyDriver( this: AppiumFlutterDriver, strategy: string, ): Promise { if (strategy.startsWith('-flutter') || FLUTTER_LOCATORS.includes(strategy)) { + this.log.debug( + `getProxyDriver: using flutter driver, strategy: ${strategy}`, + ); return this.proxy; } else if (this.proxydriver instanceof AndroidUiautomator2Driver) { + this.log.debug( + 'getProxyDriver: using AndroidUiautomator2Driver driver for Android', + ); // @ts-ignore Proxy instance is OK return this.proxydriver.uiautomator2.jwproxy; - } else { + } else if (this.proxydriver instanceof XCUITestDriver) { + this.log.debug('getProxyDriver: using XCUITestDriver driver for iOS'); // @ts-ignore Proxy instance is OK return this.proxydriver.wda.jwproxy; + } else if (this.proxydriver instanceof Mac2Driver) { + this.log.debug('getProxyDriver: using Mac2Driver driver for mac'); + // @ts-ignore Proxy instance is OK + return this.proxydriver.wda.proxy; + } else { + throw new Error( + `proxydriver is unknown type (${typeof this.proxydriver})`, + ); } } diff --git a/test/unit/element.specs.ts b/test/unit/element.specs.ts index 04c7da9..72225e3 100644 --- a/test/unit/element.specs.ts +++ b/test/unit/element.specs.ts @@ -2,6 +2,10 @@ import sinon from 'sinon'; import * as utils from '../../src/utils'; import { AndroidUiautomator2Driver } from 'appium-uiautomator2-driver'; +// @ts-ignore +import { XCUITestDriver } from 'appium-xcuitest-driver'; +// @ts-ignore +import { Mac2Driver } from 'appium-mac2-driver'; import { W3C_ELEMENT_KEY } from 'appium/driver'; import { ELEMENT_CACHE, @@ -66,10 +70,11 @@ describe('Element Interaction Functions', () => { expect(result).to.deep.equal(element); expect(ELEMENT_CACHE.get('elem1')).to.equal(mockDriver); + // Since proxydriver is not Mac2Driver, XCUITestDriver, or AndroidUiautomator2Driver expect( mockDriver.command.calledWith('/element', 'POST', { - using: 'strategy', - value: 'selector', + strategy: 'strategy', + selector: 'selector', context: 'context', }), ).to.be.true; @@ -96,8 +101,8 @@ describe('Element Interaction Functions', () => { expect(ELEMENT_CACHE.get('elem2')).to.equal(mockDriver); expect( mockDriver.command.calledWith('/elements', 'POST', { - using: 'strategy', - value: 'selector', + strategy: 'strategy', + selector: 'selector', context: 'context', }), ).to.be.true; @@ -137,6 +142,45 @@ describe('Element Interaction Functions', () => { }), ).to.be.true; }); + + it('should use different element body for XCUITestDriver', async () => { + mockAppiumFlutterDriver.proxydriver = new XCUITestDriver(); + + await findElOrEls.call( + mockAppiumFlutterDriver, + 'strategy', + 'selector', + false, + 'context', + ); + + expect( + mockDriver.command.calledWith('/element', 'POST', { + using: 'strategy', + value: 'selector', + context: 'context', + }), + ).to.be.true; + }); + + it('should use different element body for Mac2Driver', async () => { + mockAppiumFlutterDriver.proxydriver = new Mac2Driver(); + + await findElOrEls.call( + mockAppiumFlutterDriver, + 'strategy', + 'selector', + false, + 'context', + ); + expect( + mockDriver.command.calledWith('/element', 'POST', { + using: 'strategy', + value: 'selector', + context: 'context', + }), + ).to.be.true; + }); }); describe('click', () => { @@ -160,8 +204,8 @@ describe('Element Interaction Functions', () => { expect(ELEMENT_CACHE.get('elem1')).to.equal(mockDriver); expect( mockDriver.command.calledWith('/element', 'POST', { - using: 'strategy', - value: 'selector', + strategy: 'strategy', + selector: 'selector', context: 'context', }), ).to.be.true;