From 082f1487e366342961d1dd2aac738a77f37f0c0f Mon Sep 17 00:00:00 2001 From: Sudharsan Selvaraj Date: Fri, 14 Jun 2024 06:35:58 +0530 Subject: [PATCH] improve port discovery of flutter server (#6) * Improve the port finding logic for flutter server * reduce connectionRetryCount * Fix port remove in delete session --------- Co-authored-by: Sai Krishna --- .github/workflows/main.yml | 110 +++++++++++++++++++----------------- .gitignore | 3 +- src/android.ts | 39 +++++-------- src/driver.ts | 85 ++++++++++++++++++---------- src/iOS.ts | 51 ++++++----------- src/iProxy.ts | 6 +- src/session.ts | 12 +--- src/utils.ts | 113 +++++++++++++++++++++++++++---------- test/specs/sample.e2e.js | 19 +++++++ wdio.conf.ts | 32 +++++++---- 10 files changed, 277 insertions(+), 193 deletions(-) create mode 100644 test/specs/sample.e2e.js diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5760966..f062b39 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -37,7 +37,7 @@ jobs: dart pub global activate rps --version 0.8.0 flutter build apk --debug cd android && ./gradlew app:assembleDebug -Ptarget=`pwd`/../integration_test/appium.dart - - name: "List files" + - name: 'List files' continue-on-error: true run: | ls -l ${{ github.workspace }}/server/demo-app/build/app/outputs/apk/debug/ @@ -48,54 +48,60 @@ jobs: name: Android-build path: ${{ github.workspace }}/server/demo-app/build/app/outputs/apk/debug/app-debug.apk Run_Tests: - needs: Build_Server - runs-on: ubuntu-latest - strategy: - matrix: - api-level: [ 29 ] - target: [ google_apis ] - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'adopt' - - uses: dawidd6/action-download-artifact@v3 - with: - name: Android-build - - name: Display structure of downloaded files - run: ls -R - - uses: subosito/flutter-action@v2 - with: - flutter-version: 3.19.6 - channel: 'stable' - - name: Enable KVM group perms - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - name: run tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - target: ${{ matrix.target }} - arch: x86_64 - profile: Nexus 6 - script: | - adb devices - node --version - npm install -g wait-on - npm install -g appium - appium driver install uiautomator2 - npm install - npm run build - appium driver list - npm run wdio-android -# appium server -pa=/wd/hub & wait-on http://127.0.0.1:4723/wd/hub/status && - - - + needs: Build_Server + runs-on: ubuntu-latest + strategy: + matrix: + api-level: [29] + target: [google_apis] + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'adopt' + - uses: dawidd6/action-download-artifact@v3 + with: + name: Android-build + - name: Display structure of downloaded files + run: ls -R + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.19.6 + channel: 'stable' + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + arch: x86_64 + profile: Nexus 6 + script: | + adb devices + node --version + npm install -g wait-on + npm install -g appium + appium driver install uiautomator2 + npm install + npm run build + appium driver list + mkdir ${{ github.workspace }}/appium-logs + adb logcat > ${{ github.workspace }}/appium-logs/flutter.txt & + npm run wdio-android + adb logcat -t 1000 | grep "flutter" + # appium server -pa=/wd/hub & wait-on http://127.0.0.1:4723/wd/hub/status && + - name: upload appium logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: appium-logs + path: ${{ github.workspace }}/appium-logs diff --git a/.gitignore b/.gitignore index 3eb7d3e..52e3769 100644 --- a/.gitignore +++ b/.gitignore @@ -132,4 +132,5 @@ dist .yarn/install-state.gz .pnp.* .history -.npmrc \ No newline at end of file +.npmrc +appium-logs \ No newline at end of file diff --git a/src/android.ts b/src/android.ts index 9bb9d6e..18ecf2d 100644 --- a/src/android.ts +++ b/src/android.ts @@ -3,8 +3,6 @@ import { log } from './logger'; import type { InitialOpts } from '@appium/types'; import { AppiumFlutterDriver } from './driver'; import ADB from 'appium-adb'; -import { sleep } from 'asyncbox'; -import { fetchFlutterServerPort, getFreePort } from './utils'; const setupNewAndroidDriver = async ( ...args: any[] @@ -19,34 +17,27 @@ export const startAndroidSession = async ( flutterDriver: AppiumFlutterDriver, caps: Record, ...args: any[] -): Promise<[AndroidUiautomator2Driver, null]> => { +): Promise => { log.info(`Starting an Android proxy session`); const androiddriver = await setupNewAndroidDriver(...args); - log.info('Looking for the port in where Flutter server is listening too...'); - await sleep(2000); - const flutterServerPort = fetchFlutterServerPort( - (androiddriver.adb.logcat?.logs as [{ message: string }]) || [], - ); - caps.flutterServerPort = await portForward( - caps.udid, - flutterServerPort, - caps.flutterServerPort, - ); - return [androiddriver, caps.flutterServerPort]; + return androiddriver; }; -const portForward = async ( +export async function androidPortForward( udid: string, + systemPort: number, devicePort: number, - systemPort?: number, -) => { - if (!systemPort) { - systemPort = await getFreePort(); - } +) { let adb = new ADB(); if (udid) adb.setDeviceId(udid); await adb.forwardPort(systemPort!, devicePort); - const adbForwardList = await adb.getForwardList(); - log.info(`Port forwarding: ${JSON.stringify(adbForwardList)}`); - return systemPort; -}; +} + +export async function androidRemovePortForward( + udid: string, + systemPort: number, +) { + let adb = new ADB(); + if (udid) adb.setDeviceId(udid); + await adb.removePortForward(systemPort); +} diff --git a/src/driver.ts b/src/driver.ts index 78bccd1..b365586 100644 --- a/src/driver.ts +++ b/src/driver.ts @@ -23,12 +23,15 @@ import { ELEMENT_CACHE, } from './commands/element'; -import { isFlutterDriverCommand } from './utils'; +import { + fetchFlutterServerPort, + getFreePort, + isFlutterDriverCommand, +} from './utils'; import { W3C_WEB_ELEMENT_IDENTIFIER } from '@appium/support/build/lib/util'; -import { sleep, waitForCondition } from 'asyncbox'; -import { log } from './logger'; - -const DEFAULT_FLUTTER_SERVER_PORT = 8888; +import { androidPortForward, androidRemovePortForward } from './android'; +import { iosPortForward, iosRemovePortForward } from './iOS'; +import { sleep } from 'asyncbox'; export class AppiumFlutterDriver extends BaseDriver { // @ts-ignore @@ -60,7 +63,7 @@ export class AppiumFlutterDriver extends BaseDriver { 'class name', 'semantics label', 'text', - 'type' + 'type', ]; } @@ -152,7 +155,7 @@ export class AppiumFlutterDriver extends BaseDriver { 'POST', { origin, - offset + offset, }, ); } @@ -220,35 +223,52 @@ export class AppiumFlutterDriver extends BaseDriver { caps, ...JSON.parse(JSON.stringify(args)), ); + const packageName = + this.proxydriver instanceof AndroidUiautomator2Driver + ? this.proxydriver.opts.appPackage! + : this.proxydriver.opts.bundleId!; + + let portcallbacks = {}; + if (this.proxydriver instanceof AndroidUiautomator2Driver) { + portcallbacks = { + portForwardCallback: androidPortForward, + portReleaseCallback: androidRemovePortForward, + }; + } else if (this.proxydriver.isRealDevice()) { + portcallbacks = { + portForwardCallback: iosPortForward, + portReleaseCallback: iosRemovePortForward, + }; + } + + const systemPort = + this.proxydriver instanceof XCUITestDriver && + !this.proxydriver.isRealDevice() + ? null + : await getFreePort(); + + const udid = this.proxydriver.opts.udid!; + + this.flutterPort = await fetchFlutterServerPort({ + udid, + packageName, + ...portcallbacks, + systemPort, + }); + + if (!this.flutterPort) { + throw new Error( + `Flutter server is not started. ` + + `Please make sure the application under test is configured properly according to ` + + `https://github.com/AppiumTestDistribution/appium-flutter-server and that it does not crash on startup.`, + ); + } - // HACK for eliminatin socket hang up by waiting 1 sec - await sleep(1000); - // @ts-ignore - console.log('PageSource', await this.proxydriver.getPageSource()); // @ts-ignore this.proxy = new JWProxy({ server: '127.0.0.1', port: this.flutterPort, }); - try { - await waitForCondition(async () => { - try { - // @ts-ignore - await this.proxy.command('/status', 'GET'); - return true; - } catch(err: any) { - log.info('FlutterServer not reachable, Trying..', err); - return false; - } - }, { - waitMs: 15000, - intervalMs: 1000, - }) - } catch(err: any) { - log.error('FlutterServer not reachable', err); - throw new Error(err); - } - await this.proxy.command('/session', 'POST', { capabilities: caps }); return sessionCreated; @@ -259,7 +279,10 @@ export class AppiumFlutterDriver extends BaseDriver { } async deleteSession() { - if (this.proxydriver instanceof AndroidUiautomator2Driver) { + if ( + this.proxydriver instanceof AndroidUiautomator2Driver && + this.flutterPort + ) { // @ts-ignore await this.proxydriver.adb.removePortForward(this.flutterPort); } diff --git a/src/iOS.ts b/src/iOS.ts index f5ac40e..a574229 100644 --- a/src/iOS.ts +++ b/src/iOS.ts @@ -4,8 +4,6 @@ import XCUITestDriver from 'appium-xcuitest-driver'; import type { InitialOpts } from '@appium/types'; import { log } from './logger'; import { DEVICE_CONNECTIONS_FACTORY } from './iProxy'; -import { sleep } from 'asyncbox'; -import { fetchFlutterServerPort, getFreePort } from './utils'; const setupNewIOSDriver = async (...args: any[]): Promise => { const iosdriver = new XCUITestDriver({} as InitialOpts); @@ -13,14 +11,22 @@ const setupNewIOSDriver = async (...args: any[]): Promise => { return iosdriver; }; -const portForward = async ( +export const startIOSSession = async ( + flutterDriver: AppiumFlutterDriver, + caps: Record, + ...args: any[] +): Promise => { + log.info(`Starting an IOS proxy session`); + const iOSDriver = await setupNewIOSDriver(...args); + log.info('iOS session started', iOSDriver); + return iOSDriver; +}; + +export async function iosPortForward( udid: string, systemPort: number, - devicePort: any, -) => { - if (!systemPort) { - systemPort = await getFreePort(); - } + devicePort: number, +) { log.info( `Forwarding port ${systemPort} to device port ${devicePort} ${udid}`, ); @@ -28,29 +34,8 @@ const portForward = async ( usePortForwarding: true, devicePort: devicePort, }); - return systemPort; -}; +} -export const startIOSSession = async ( - flutterDriver: AppiumFlutterDriver, - caps: Record, - ...args: any[] -): Promise<[XCUITestDriver, number]> => { - log.info(`Starting an IOS proxy session`); - const iOSDriver = await setupNewIOSDriver(...args); - log.info('Looking for port Flutter server is listening too...'); - await sleep(2000); - const flutterServerPort = fetchFlutterServerPort(iOSDriver.logs.syslog.logs); - if (iOSDriver.isRealDevice()) { - caps.flutterServerPort = await portForward( - iOSDriver.udid, - caps.flutterServerPort, - flutterServerPort, - ); - } else { - //incase of emulator use the same port where the flutter server is running - caps.flutterServerPort = flutterServerPort; - } - log.info('iOS session started', iOSDriver); - return [iOSDriver, caps.flutterServerPort]; -}; +export function iosRemovePortForward(udid: string, systemPort: number) { + DEVICE_CONNECTIONS_FACTORY.releaseConnection(udid, systemPort); +} diff --git a/src/iProxy.ts b/src/iProxy.ts index ee85ac9..bbc6741 100644 --- a/src/iProxy.ts +++ b/src/iProxy.ts @@ -125,7 +125,7 @@ class DeviceConnectionsFactory { return `${SPLITTER}${util.hasValue(port) ? port : ''}`; } - _toKey(udid = null, port = null) { + _toKey(udid: string, port: number) { return `${util.hasValue(udid) ? udid : ''}${SPLITTER}${util.hasValue(port) ? port : ''}`; } @@ -144,7 +144,7 @@ class DeviceConnectionsFactory { return keys; } - listConnections(udid = null, port = null, strict = false) { + listConnections(udid: string | null, port: number, strict = false) { if (!udid && !port) { return []; } @@ -259,7 +259,7 @@ class DeviceConnectionsFactory { log.info(`Successfully requested the connection for ${currentKey}`); } - releaseConnection(udid = null, port = null) { + releaseConnection(udid: string, port: number) { if (!udid && !port) { log.warn( 'Neither device UDID nor local port is set. ' + diff --git a/src/session.ts b/src/session.ts index 64a42ac..ffcaf01 100644 --- a/src/session.ts +++ b/src/session.ts @@ -12,22 +12,14 @@ export const createSession: any = async function ( try { switch (_.toLower(caps.platformName)) { case PLATFORM.IOS: - [this.proxydriver, this.flutterPort] = await startIOSSession( - this, - caps, - ...args, - ); + this.proxydriver = await startIOSSession(this, caps, ...args); this.proxydriver.relaxedSecurityEnabled = this.relaxedSecurityEnabled; this.proxydriver.denyInsecure = this.denyInsecure; this.proxydriver.allowInsecure = this.allowInsecure; break; case PLATFORM.ANDROID: - [this.proxydriver, this.flutterPort] = await startAndroidSession( - this, - caps, - ...args, - ); + this.proxydriver = await startAndroidSession(this, caps, ...args); this.proxydriver.relaxedSecurityEnabled = this.relaxedSecurityEnabled; this.proxydriver.denyInsecure = this.denyInsecure; this.proxydriver.allowInsecure = this.allowInsecure; diff --git a/src/utils.ts b/src/utils.ts index 69f638c..6b703f2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,11 +2,11 @@ import AndroidUiautomator2Driver from 'appium-uiautomator2-driver'; import XCUITestDriver from 'appium-xcuitest-driver/build/lib/driver'; import { log } from './logger'; import { findAPortNotInUse } from 'portscanner'; +import { waitForCondition } from 'asyncbox'; +import { JWProxy } from '@appium/base-driver'; -const FLUTTER_SERVER_START_MESSAGE = new RegExp( - /Appium flutter server is listening on port (\d+)/, -); -const DEVICE_PORT_RANGE = [9000, 9299]; +const DEVICE_PORT_RANGE = [9000, 9020]; +const SYSTEM_PORT_RANGE = [10000, 11000]; export async function getProxyDriver( strategy: string, @@ -50,31 +50,86 @@ export function isFlutterDriverCommand(command: string) { ); } -export function fetchFlutterServerPort( - deviceLogs: [{ message: string }], -): number { - let port: number | undefined; - for (const line of deviceLogs.map((e) => e.message).reverse()) { - const match = line.match(FLUTTER_SERVER_START_MESSAGE); - if (match) { - log.info(`Found the server started log from device: ${line}`); - port = Number(match[1]); - break; - } - } - if (!port) { - throw new Error( - `Flutter server started message '${FLUTTER_SERVER_START_MESSAGE}' was not found in the device log. ` + - `Please make sure the application under test is configured properly according to ` + - `https://github.com/AppiumTestDistribution/appium-flutter-server and that it does not crash on startup. - Also make sure "appium:skipLogcatCapture" is not set to true in the desired capabilities. `, - ); - } - log.info(`Extracted the port from the device logs: ${port}`); - return port; -} - export async function getFreePort() { - const [start, end] = DEVICE_PORT_RANGE; + const [start, end] = SYSTEM_PORT_RANGE; return await findAPortNotInUse(start, end); } + +async function waitForFlutterServer(port: number, packageName: string) { + const proxy = new JWProxy({ + server: '127.0.0.1', + port: port, + }); + await waitForCondition( + async () => { + try { + const response: any = await proxy.command('/status', 'GET'); + if (!response) { + return false; + } + if (response?.appInfo?.packageName === packageName) { + return true; + } else { + throw new Error( + `Looking for flutter server with package ${packageName}. But found ${response.appInfo?.packageName}`, + ); + } + } catch (err: any) { + log.info(`FlutterServer not reachable on port ${port}, Retrying..`); + return false; + } + }, + { + waitMs: 5000, + intervalMs: 500, + }, + ); +} + +export async function fetchFlutterServerPort({ + udid, + systemPort, + portForwardCallback, + portReleaseCallback, + packageName, +}: { + udid: string; + systemPort?: number; + portForwardCallback?: ( + udid: string, + systemPort: number, + devicePort: number, + ) => any; + portReleaseCallback?: (udid: string, systemPort: number) => any; + packageName: string; +}): Promise { + const [startPort, endPort] = DEVICE_PORT_RANGE as [number, number]; + const isSimulator = !systemPort; + let devicePort = startPort; + let forwardedPort = systemPort; + + while (devicePort <= endPort) { + /** + * For ios simulators, we dont need a dedicated system port and no port forwarding is required + * We need to use the same port range used by flutter server to check if the server is running + */ + if (isSimulator) { + forwardedPort = devicePort; + } + if (portForwardCallback) { + await portForwardCallback(udid, systemPort!, devicePort); + } + try { + log.info(`Checking if flutter server is running on port ${devicePort}`); + await waitForFlutterServer(forwardedPort!, packageName); + log.info(`Flutter server is successfully running on port ${devicePort}`); + return forwardedPort!; + } catch (e) { + if (portReleaseCallback) { + await portReleaseCallback(udid, systemPort!); + } + } + devicePort++; + } + return null; +} diff --git a/test/specs/sample.e2e.js b/test/specs/sample.e2e.js new file mode 100644 index 0000000..2bec350 --- /dev/null +++ b/test/specs/sample.e2e.js @@ -0,0 +1,19 @@ +import { browser, expect } from '@wdio/globals'; + +async function performLogin(userName = 'admin', password = '1234') { + await browser.takeScreenshot(); + await browser.flutterBySemanticsLabel$('username_text_field').clearValue(); + await browser + .flutterBySemanticsLabel$('username_text_field') + .addValue(userName); + + await browser.flutterBySemanticsLabel$('password_text_field').clearValue(); + await browser.flutterByValueKey$('password').addValue(password); + await browser.flutterBySemanticsLabel$('login_button').click(); +} + +describe('My Login application', () => { + it('Create Session with Flutter Integration Driver', async () => { + await performLogin(); + }); +}); diff --git a/wdio.conf.ts b/wdio.conf.ts index 8d2eacf..4086ede 100644 --- a/wdio.conf.ts +++ b/wdio.conf.ts @@ -1,8 +1,14 @@ // @ts-ignore import type { Options } from '@wdio/types'; import { join } from 'node:path'; -const appiumServerPath = join(process.cwd(), 'node_modules', 'appium', 'index.js'); +const appiumServerPath = join( + process.cwd(), + 'node_modules', + 'appium', + 'index.js', +); console.log(appiumServerPath); +console.log(join(process.cwd(), 'appium-log.txt')); export const config: Options.Testrunner = { // // ==================== @@ -20,7 +26,6 @@ export const config: Options.Testrunner = { transpileOnly: true, }, }, - // // ================== // Specify Test Files @@ -36,7 +41,7 @@ export const config: Options.Testrunner = { // The path of the spec files will be resolved relative from the directory of // of the config file unless it's absolute. // - specs: ['./test/specs/**/*.js'], + specs: ['./test/specs/**/sample*.js'], // Patterns to exclude. exclude: [ // 'path/to/excluded/files' @@ -106,18 +111,25 @@ export const config: Options.Testrunner = { // connectionRetryTimeout: 120000, // // Default request retries count - // connectionRetryCount: 3, + connectionRetryCount: 0, // // Test runner services // Services take over a specific job you don't want to take care of. They enhance // your test setup with almost no effort. Unlike plugins, they don't add new // commands. Instead, they hook themselves up into the test process. - services: [['flutter-by', {}], ['appium', { - args: { - basePath: '/wd/hub', - port: 4723, - } - }]], + services: [ + ['flutter-by', {}], + [ + 'appium', + { + args: { + basePath: '/wd/hub', + port: 4723, + log: join(process.cwd(), 'appium-logs', 'logs.txt'), + }, + }, + ], + ], // // Framework you want to run your specs with. // The following are supported: Mocha, Jasmine, and Cucumber