diff --git a/src/commands/android/constants.ts b/src/commands/android/constants.ts index 761c04f..4f1ff96 100644 --- a/src/commands/android/constants.ts +++ b/src/commands/android/constants.ts @@ -2,7 +2,7 @@ import inquirer from 'inquirer'; import path from 'path'; import os from 'os'; -import {AvailableOptions, SdkBinary} from './interfaces'; +import {AvailableOptions, AvailableSubcommands, SdkBinary} from './interfaces'; export const AVAILABLE_OPTIONS: AvailableOptions = { help: { @@ -29,12 +29,31 @@ export const AVAILABLE_OPTIONS: AvailableOptions = { alias: [], description: 'Do standalone setup for Android Emulator (no Nightwatch-related requirements will be downloaded).' }, - wireless: { - alias: [], - description: 'Connect ADB with wireless connection.' - } }; +export const AVAILABLE_SUBCOMMANDS: AvailableSubcommands = { + connect: { + description: 'Connect to a device', + options: [ + { + name: "wireless", + description: "Connect a real device wirelessly", + }, + { + name: "avd", + description: "Connect to an Android Virtual Device", + }, + { + name: "list", + description: "List all connected devices", + } + ] + }, + disconnect: { + description: 'Disconnect an AVD or a real device', + } +} + export const NIGHTWATCH_AVD = 'nightwatch-android-11'; export const DEFAULT_FIREFOX_VERSION = '105.1.0'; export const DEFAULT_CHROME_VERSIONS = ['83', '91']; diff --git a/src/commands/android/index.ts b/src/commands/android/index.ts index 97c1faa..e3a1596 100644 --- a/src/commands/android/index.ts +++ b/src/commands/android/index.ts @@ -12,13 +12,14 @@ import Logger from '../../logger'; import {getPlatformName, symbols} from '../../utils'; import {getAlreadyRunningAvd, killEmulatorWithoutWait, launchAVD} from './adb'; import { - ABI, AVAILABLE_OPTIONS, BINARY_TO_PACKAGE_NAME, DEFAULT_CHROME_VERSIONS, + ABI, AVAILABLE_OPTIONS, AVAILABLE_SUBCOMMANDS, BINARY_TO_PACKAGE_NAME, DEFAULT_CHROME_VERSIONS, DEFAULT_FIREFOX_VERSION, NIGHTWATCH_AVD, SETUP_CONFIG_QUES } from './constants'; -import {AndroidSetupResult, Options, OtherInfo, Platform, SdkBinary, SetupConfigs} from './interfaces'; +import {AndroidSetupResult, Options, OtherInfo, Platform, SdkBinary, SetupConfigs } from './interfaces'; import { - downloadFirefoxAndroid, downloadWithProgressBar, getAllAvailableOptions, - getBinaryLocation, getBinaryNameForOS, getFirefoxApkName, getLatestVersion + downloadFirefoxAndroid, downloadWithProgressBar, + getBinaryLocation, getBinaryNameForOS, getFirefoxApkName, getLatestVersion, + getUnknownOptions, } from './utils/common'; import { downloadAndSetupAndroidSdk, downloadSdkBuildTools, execBinarySync, @@ -26,21 +27,23 @@ import { } from './utils/sdk'; import DOWNLOADS from './downloads.json'; -import {connectWirelessAdb} from './utils/adbWirelessConnect'; +import {connectAvd, connectWirelessAdb, defaultConnectFlow, disconnectDevice, listRunningDevices} from './utils/connectDevice'; export class AndroidSetup { sdkRoot: string; javaHome: string; options: Options; + subcommand: string; rootDir: string; platform: Platform; otherInfo: OtherInfo; - constructor(options: Options = {}, rootDir = process.cwd()) { + constructor(options: Options = {}, subcommand: string, rootDir = process.cwd()) { this.sdkRoot = ''; this.javaHome = ''; this.options = options; + this.subcommand = subcommand; this.rootDir = rootDir; this.platform = getPlatformName(); this.otherInfo = { @@ -50,11 +53,15 @@ export class AndroidSetup { } async run(): Promise { - const allAvailableOptions = getAllAvailableOptions(); - const unknownOptions = Object.keys(this.options).filter((option) => !allAvailableOptions.includes(option)); + const unknownOptions = getUnknownOptions(this.options, this.subcommand); - if (this.options.help || unknownOptions.length) { - this.showHelp(unknownOptions); + let unknownSubcommand; + if (this.subcommand && !Object.keys(AVAILABLE_SUBCOMMANDS).includes(this.subcommand)) { + unknownSubcommand = this.subcommand; + } + + if (this.options.help || unknownOptions.length || unknownSubcommand) { + this.showHelp(unknownOptions, unknownSubcommand); return this.options.help === true; } @@ -108,8 +115,8 @@ export class AndroidSetup { this.sdkRoot = sdkRootEnv || await this.getSdkRootFromUser(); process.env.ANDROID_HOME = this.sdkRoot; - if (this.options.wireless) { - return await connectWirelessAdb(this.sdkRoot, this.platform); + if (this.subcommand) { + return this.executeSdkScript(this.subcommand, this.options); } let result = true; @@ -155,12 +162,14 @@ export class AndroidSetup { }; } - showHelp(unknownOptions: string[]) { - if (unknownOptions.length) { + showHelp(unknownOptions: string[], unknownSubcommand: string | undefined) { + if (unknownSubcommand) { + Logger.log(colors.red(`unknown subcommand passed: ${unknownSubcommand}\n`)); + } else if (unknownOptions.length) { Logger.log(colors.red(`unknown option(s) passed: ${unknownOptions.join(', ')}\n`)); } - Logger.log(`Usage: ${colors.cyan('npx @nightwatch/mobile-helper android [options]')}`); + Logger.log(`Usage: ${colors.cyan('npx @nightwatch/mobile-helper android [options] [subcommand] [subcommand-options]')}`); Logger.log(' Verify if all the requirements are met to run tests on an Android device/emulator.\n'); Logger.log(`${colors.yellow('Options:')}`); @@ -199,6 +208,26 @@ export class AndroidSetup { Logger.log(prelude + ' ' + colors.grey(desc)); }); + + Logger.log(`\n${colors.yellow('Subcommands and Subcommand-Options:')}`); + + Object.keys(AVAILABLE_SUBCOMMANDS).forEach(subcommand => { + const subcmd = AVAILABLE_SUBCOMMANDS[subcommand]; + const subcmdOptions = subcmd.options?.map(option => `[--${option.name}]`).join(' ') || ''; + + Logger.log(` ${colors.cyan(subcommand)} ${subcmdOptions}`); + Logger.log(` ${colors.gray(subcmd.description)}`); + + if (subcmd.options && subcmd.options.length > 0) { + const optionLongest = longest(subcmd.options.map(option => `--${option.name}`)); + subcmd.options.forEach(option => { + const optionStr = `--${option.name}`; + const optionPadding = new Array(Math.max(optionLongest - optionStr.length + 3, 0)).join('.'); + Logger.log(` ${optionStr} ${colors.grey(optionPadding)} ${colors.gray(option.description)}`); + }); + } + Logger.log(); + }); } checkJavaInstallation(): boolean { @@ -1071,4 +1100,22 @@ export class AndroidSetup { envSetHelp() { // Add platform-wise help or link a doc to help users set env variable. } + + async executeSdkScript (subcommand: string, options: Options) { + if (subcommand === 'connect') { + if (options.wireless) { + return await connectWirelessAdb(this.sdkRoot, this.platform); + } else if (options.avd) { + return await connectAvd(this.sdkRoot, this.platform); + } else if (options.list) { + return listRunningDevices(); + } else { + return await defaultConnectFlow(this.sdkRoot, this.platform); + } + } + if (subcommand === 'disconnect') { + return await disconnectDevice(this.sdkRoot, this.platform); + } + return false; + } } diff --git a/src/commands/android/interfaces.ts b/src/commands/android/interfaces.ts index a64b66c..f4929ee 100644 --- a/src/commands/android/interfaces.ts +++ b/src/commands/android/interfaces.ts @@ -15,6 +15,18 @@ export interface Options { [key: string]: string | string[] | boolean; } +export interface AvailableSubcommands { + [key: string]: { + description: string; + options?: { + name: string; + description: string; + }[] + } +} + +export type Subcommand = 'connect' | 'disconnect' | undefined; + export type Platform = 'windows' | 'linux' | 'mac'; export interface OtherInfo { diff --git a/src/commands/android/utils/common.ts b/src/commands/android/utils/common.ts index 5f6c2fc..12f9c23 100644 --- a/src/commands/android/utils/common.ts +++ b/src/commands/android/utils/common.ts @@ -8,17 +8,25 @@ import path from 'path'; import which from 'which'; import {symbols} from '../../../utils'; -import {ABI, AVAILABLE_OPTIONS, DEFAULT_CHROME_VERSIONS, DEFAULT_FIREFOX_VERSION, SDK_BINARY_LOCATIONS} from '../constants'; -import {Platform, SdkBinary} from '../interfaces'; +import {ABI, AVAILABLE_OPTIONS, AVAILABLE_SUBCOMMANDS, DEFAULT_CHROME_VERSIONS, DEFAULT_FIREFOX_VERSION, SDK_BINARY_LOCATIONS} from '../constants'; +import { Options, Platform, SdkBinary} from '../interfaces'; -export const getAllAvailableOptions = () => { +export const getUnknownOptions = (options: Options, subcommand: string) => { const mainOptions = Object.keys(AVAILABLE_OPTIONS); const allOptions: string[] = []; + mainOptions.forEach((option) => allOptions.push(option, ...AVAILABLE_OPTIONS[option].alias)); + let unknownOptions = Object.keys(options).filter((option) => !allOptions.includes(option)); - return allOptions; -}; + if (subcommand) { + // if a subcommand is provided then remove corresponding subcommand's options from `unknownOptions`. + const subcommandOptions = AVAILABLE_SUBCOMMANDS[subcommand]?.options?.map((option) => option.name); + unknownOptions = unknownOptions.filter((option) => !subcommandOptions?.includes(option)); + } + + return unknownOptions; +} export const getBinaryNameForOS = (platform: Platform, binaryName: string) => { if (platform !== 'windows') { diff --git a/src/commands/android/utils/adbWirelessConnect.ts b/src/commands/android/utils/connectDevice.ts similarity index 52% rename from src/commands/android/utils/adbWirelessConnect.ts rename to src/commands/android/utils/connectDevice.ts index 5b103d8..893ea06 100644 --- a/src/commands/android/utils/adbWirelessConnect.ts +++ b/src/commands/android/utils/connectDevice.ts @@ -4,6 +4,8 @@ import Logger from '../../../logger'; import inquirer from 'inquirer'; import colors from 'ansi-colors'; import { getBinaryLocation } from './common'; +import { launchAVD } from '../adb'; +import ADB from 'appium-adb'; export async function connectWirelessAdb(sdkRoot: string, platform: Platform): Promise { try { @@ -77,7 +79,7 @@ export async function connectWirelessAdb(sdkRoot: string, platform: Platform): P } const connecting = execBinarySync(adbLocation, 'adb', platform, `connect ${deviceIP}:${port}`); - if (connecting) { + if (connecting?.includes('connected')) { Logger.log(colors.green('Connected to device wirelessly.')); } else { Logger.log(`${colors.red('Failed to connect!')} Please try again.`); @@ -93,3 +95,114 @@ export async function connectWirelessAdb(sdkRoot: string, platform: Platform): P return false; } } + +export async function connectAvd (sdkRoot: string, platform: Platform): Promise { + const avdmanagerLocation = getBinaryLocation(sdkRoot, platform, 'avdmanager', true); + const availableAVDsDetails = execBinarySync(avdmanagerLocation, 'avdmanager', platform, 'list avd'); + + if (availableAVDsDetails) { + Logger.log('Available AVDs:'); + Logger.log(availableAVDsDetails); + } + + const availableAVDs = execBinarySync(avdmanagerLocation, 'avdmanager', platform, 'list avd -c'); + + if (availableAVDs) { + const avdAnswer = await inquirer.prompt({ + type: 'list', + name: 'avdName', + message: 'Select the AVD to connect:', + choices: availableAVDs.split('\n').filter(avd => avd !== '') + }); + const avdName = avdAnswer.avdName; + + Logger.log(`Connecting to ${avdName}...`); + const connectingAvd = await launchAVD(sdkRoot, platform, avdName); + + if (connectingAvd) { + return true; + } + + return false; + } else { + Logger.log(`${colors.red('No AVDs found!')} Use ${colors.magenta('--setup')} flag with the main command to setup missing requirements.`); + } + return false; +} + +export async function disconnectDevice (sdkRoot: string, platform: Platform) { + const adbLocation = getBinaryLocation(sdkRoot, platform, 'adb', true); + const adb = await ADB.createADB({allowOfflineDevices: true}); + const devices = await adb.getConnectedDevices(); + + if (devices.length === 0) { + Logger.log(`${colors.yellow('No device found running.')}`); + return true; + } + + const deviceAnswer = await inquirer.prompt({ + type: 'list', + name: 'device', + message: 'Select the device to disconnect:', + choices: devices.map(device => device.udid) + }); + const deviceId = deviceAnswer.device; + + let disconnecting; + if (deviceId.includes('emulator')) { + disconnecting = execBinarySync(adbLocation, 'adb', platform, `-s ${deviceId} emu kill`); + } else { + disconnecting = execBinarySync(adbLocation, 'adb', platform, `disconnect ${deviceId}`); + } + console.log(disconnecting); + + return false; +} + +export async function listRunningDevices() { + const adb = await ADB.createADB({ allowOfflineDevices: true }); + const devices = await adb.getConnectedDevices(); + + if (devices.length === 0) { + Logger.log(`No device connected.`); + return true; + } + + Logger.log(colors.bold('Connected Devices:')); + + const maxUdidLength = devices.reduce((max, device) => Math.max(max, device.udid.length), 0); + const paddedLength = maxUdidLength + 2; + + devices.forEach((device) => { + const paddedUdid = device.udid.padEnd(paddedLength); + Logger.log(`${paddedUdid}${device.state}`); + }); + + return true; +} + +export async function defaultConnectFlow(sdkRoot: string, platform: Platform) { + await listRunningDevices(); + + Logger.log(); + + const connectAnswer = await inquirer.prompt({ + type: 'list', + name: 'connectOption', + message: 'Select the type of device to connect:', + choices: ['Real Device', 'AVD'] + }); + const connectOption = connectAnswer.connectOption; + + Logger.log(); + + switch (connectOption) { + case 'Real Device': + return await connectWirelessAdb(sdkRoot, platform); + case 'AVD': + return await connectAvd(sdkRoot, platform); + default: + Logger.log('Invalid option selected.'); + return false; + } +} diff --git a/src/index.ts b/src/index.ts index ff5abd7..3418228 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ export const run = () => { try { const argv = process.argv.slice(2); const {_: args, ...options} = minimist(argv, { - boolean: ['install', 'setup', 'help', 'appium', 'standalone', 'wireless'], + boolean: ['install', 'setup', 'help', 'appium', 'standalone'], alias: { help: 'h', mode: 'm', @@ -18,12 +18,12 @@ export const run = () => { } }); - if (!args[0] || !AVAILABLE_COMMANDS.includes(args[0])) { - showHelp(args[0], options.help); + if (!args[0] || !AVAILABLE_COMMANDS.includes(args[0]) || args.length > 2) { + showHelp(args, options.help); } else if (args[0] === 'android') { - const androidSetup = new AndroidSetup(options); + const androidSetup = new AndroidSetup(options, args[1]); androidSetup.run(); - } else { + } else if (args[0] === 'ios') { const iOSSetup = new IosSetup(options); iOSSetup.run(); } @@ -33,14 +33,16 @@ export const run = () => { } }; -const showHelp = (cmdPassed: string, helpFlag: boolean) => { - if (cmdPassed) { - console.log(colors.red(`unknown command: ${cmdPassed}`), '\n'); - } else if (!helpFlag) { +const showHelp = (args: string[], helpFlag: boolean) => { + if (args.length > 2) { + console.log(colors.red('Too many arguments passed.'), '\n'); + } else if (args[0]) { + console.log(colors.red(`unknown command: ${args[0]}`), '\n'); + } else if (!args[0] && !helpFlag) { console.log(colors.red('No command passed.'), '\n'); } - console.log(`Usage: ${colors.cyan('npx @nightwatch/mobile-helper COMMAND [options]')}\n`); + console.log(`Usage: ${colors.cyan('npx @nightwatch/mobile-helper COMMAND [options] [subcommand] [subcommand-options]')}\n`); console.log(`Available commands: ${colors.green(AVAILABLE_COMMANDS.join(', '))}\n`); console.log(`To know more about each command, run: