diff --git a/src/commands/android/constants.ts b/src/commands/android/constants.ts index 0fe09f4..55d923e 100644 --- a/src/commands/android/constants.ts +++ b/src/commands/android/constants.ts @@ -2,7 +2,8 @@ import inquirer from 'inquirer'; import path from 'path'; import os from 'os'; -import {AvailableOptions, AvailableSubcommands, SdkBinary} from './interfaces'; +import {AvailableOptions, SdkBinary} from './interfaces'; +import {AvailableSubcommands} from './subcommands/interfaces'; export const AVAILABLE_OPTIONS: AvailableOptions = { help: { @@ -34,12 +35,56 @@ export const AVAILABLE_OPTIONS: AvailableOptions = { export const AVAILABLE_SUBCOMMANDS: AvailableSubcommands = { connect: { description: 'Connect to a device', - options: [ + flags: [ { name: 'wireless', description: 'Connect a real device wirelessly' } ] + }, + list: { + description: 'List connected devices or installed AVDs', + flags: [{ + name: 'device', + description: 'List connected devices (real devices and AVDs)' + }, + { + name: 'avd', + description: 'List installed AVDs' + }] + }, + install: { + description: 'Install APK or AVD on a device', + flags: [ + { + name: 'avd', + description: 'Create an Android Virtual Device' + }, + { + name: 'app', + description: 'Install an APK on the device', + cliConfigs: [ + { + name: 'path', + alias: ['p'], + description: 'Path to the APK file', + usageHelp: 'path_to_apk' + }, + { + name: 'deviceId', + alias: ['s'], + description: 'Id of the device to install the APK', + usageHelp: 'device_id' + } + ] + } + ] + }, + uninstall: { + description: 'todo item', + flags: [ + {name: 'avd', description: 'todo item'}, + ] } }; diff --git a/src/commands/android/dotcommands.ts b/src/commands/android/dotcommands.ts new file mode 100644 index 0000000..e9378f0 --- /dev/null +++ b/src/commands/android/dotcommands.ts @@ -0,0 +1,96 @@ +import colors from 'ansi-colors'; +import {spawnSync} from 'child_process'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +import {ANDROID_DOTCOMMANDS} from '../../constants'; +import Logger from '../../logger'; +import {getPlatformName} from '../../utils'; +import {Platform, SdkBinary} from './interfaces'; +import {checkJavaInstallation, getBinaryLocation, getBinaryNameForOS, getSdkRootFromEnv} from './utils/common'; + +export class AndroidDotCommand { + dotcmd: string; + args: string[]; + sdkRoot: string; + rootDir: string; + platform: Platform; + androidHomeInGlobalEnv: boolean; + + constructor(dotcmd: string, argv: string[], rootDir = process.cwd()) { + this.dotcmd = dotcmd; + this.args = argv.slice(1); + this.sdkRoot = ''; + this.rootDir = rootDir; + this.platform = getPlatformName(); + this.androidHomeInGlobalEnv = false; + } + + async run(): Promise { + if (!ANDROID_DOTCOMMANDS.includes(this.dotcmd)) { + Logger.log(colors.red(`Unknown dot command passed: ${this.dotcmd}\n`)); + + Logger.log('Run Android SDK command line tools using the following command:'); + Logger.log(colors.cyan('npx @nightwatch/mobile-helper [options|args]\n')); + + Logger.log(`Available Dot Commands: ${colors.magenta(ANDROID_DOTCOMMANDS.join(', '))}`); + Logger.log(`(Example command: ${colors.gray('npx @nightwatch/mobile-helper android.emulator @nightwatch-android-11')})\n`); + + return false; + } + + const javaInstalled = checkJavaInstallation(this.rootDir); + if (!javaInstalled) { + return false; + } + + this.loadEnvFromDotEnv(); + + const sdkRootEnv = getSdkRootFromEnv(this.rootDir, this.androidHomeInGlobalEnv); + if (!sdkRootEnv) { + Logger.log(`Run: ${colors.cyan('npx @nightwatch/mobile-helper android --standalone')} to fix this issue.`); + Logger.log(`(Remove the ${colors.gray('--standalone')} flag from the above command if using the tool for testing.)\n`); + + return false; + } + this.sdkRoot = sdkRootEnv; + + return this.executeDotCommand(); + } + + loadEnvFromDotEnv(): void { + this.androidHomeInGlobalEnv = 'ANDROID_HOME' in process.env; + dotenv.config({path: path.join(this.rootDir, '.env')}); + } + + buildCommand(): string { + const binaryName = this.dotcmd.split('.')[1] as SdkBinary; + const binaryLocation = getBinaryLocation(this.sdkRoot, this.platform, binaryName, true); + + let cmd: string; + if (binaryLocation === 'PATH') { + const binaryFullName = getBinaryNameForOS(this.platform, binaryName); + cmd = `${binaryFullName}`; + } else { + const binaryFullName = path.basename(binaryLocation); + const binaryDirPath = path.dirname(binaryLocation); + cmd = path.join(binaryDirPath, binaryFullName); + } + + return cmd; + } + + executeDotCommand(): boolean { + const cmd = this.buildCommand(); + const result = spawnSync(cmd, this.args, {stdio: 'inherit'}); + + if (result.error) { + console.error(result.error); + + return false; + } + + return result.status === 0; + } +} + diff --git a/src/commands/android/index.ts b/src/commands/android/index.ts new file mode 100644 index 0000000..f224c3b --- /dev/null +++ b/src/commands/android/index.ts @@ -0,0 +1,28 @@ +import colors from 'ansi-colors'; + +import {AndroidSetup} from './androidSetup'; +import {Options} from './interfaces'; +import {AndroidSubcommand} from './subcommands'; +import {getSubcommandHelp} from './utils/common'; +import {AndroidDotCommand} from './dotcommands'; + +export function handleAndroidCommand(args: string[], options: Options, argv: string[]): void { + if (args[0].includes('.')) { + // Here args[0] represents the android dot command + const androidDotCommand = new AndroidDotCommand(args[0], argv, process.cwd()); + androidDotCommand.run(); + } else if (args.length === 1) { + const androidSetup = new AndroidSetup(options); + androidSetup.run(); + } else if (args.length === 2) { + // Here args[1] represents the android subcommand. + const androidSubcommand = new AndroidSubcommand(args[1], options); + androidSubcommand.run(); + } else { + // android command doesn't accept more than one argument. + console.log(`${colors.red(`Too many arguments passed for 'android' command: ${args.slice(1).join(', ')}`)}\n`); + + console.log(getSubcommandHelp()); + } +} + diff --git a/src/commands/android/interfaces.ts b/src/commands/android/interfaces.ts index 3e5223c..a64b66c 100644 --- a/src/commands/android/interfaces.ts +++ b/src/commands/android/interfaces.ts @@ -15,16 +15,6 @@ export interface Options { [key: string]: string | string[] | boolean; } -export interface AvailableSubcommands { - [key: string]: { - description: string; - options: { - name: string; - description: string; - }[]; - } -} - export type Platform = 'windows' | 'linux' | 'mac'; export interface OtherInfo { diff --git a/src/commands/android/subcommands/common.ts b/src/commands/android/subcommands/common.ts index 3e38619..a0c0b50 100644 --- a/src/commands/android/subcommands/common.ts +++ b/src/commands/android/subcommands/common.ts @@ -1,7 +1,13 @@ import colors from 'ansi-colors'; import Logger from '../../../logger'; +import {symbols} from '../../../utils'; +import {AVAILABLE_SUBCOMMANDS} from '../constants'; +import {Platform, Options, SdkBinary} from '../interfaces'; import ADB from '../utils/appium-adb'; +import {execBinarySync} from '../utils/sdk'; +import {CliConfig, SubcommandOptionsVerificationResult} from './interfaces'; +import {showHelp} from './help'; const deviceStateWithColor = (state: string) => { switch (state) { @@ -66,3 +72,146 @@ export async function showConnectedEmulators() { return false; } } + +export async function getInstalledSystemImages(sdkmanagerLocation: string, platform: Platform): Promise<{ + result: boolean, + systemImages: string[] +}> { + const stdout = execBinarySync(sdkmanagerLocation, 'sdkmanager', platform, '--list'); + if (!stdout) { + Logger.log(`\n${colors.red('Failed to fetch system images!')} Please try again.`); + + return { + result: false, + systemImages: [] + }; + } + const lines = stdout.split('\n'); + const installedImages: string[] = []; + + for (const line of lines) { + if (line.includes('Available Packages:')) { + break; + } + if (line.includes('system-images')) { + installedImages.push(line.split('|')[0].trim()); + } + } + + return { + result: true, + systemImages: installedImages + }; +} + +export function showMissingBinaryHelp(binaryName: SdkBinary) { + Logger.log(` ${colors.red(symbols().fail)} ${colors.cyan(binaryName)} binary not found.\n`); + Logger.log(`Run: ${colors.cyan('npx @nightwatch/mobile-helper android --standalone')} to setup missing requirements.`); + Logger.log(`(Remove the ${colors.gray('--standalone')} flag from the above command if setting up for testing.)\n`); +} + +export function verifyOptions(subcommand: string, options: Options): SubcommandOptionsVerificationResult | false { + const optionsPassed = Object.keys(options).filter(option => options[option] !== false); + + const allowedFlags = AVAILABLE_SUBCOMMANDS[subcommand].flags; + const allowedFlagNames = allowedFlags.map(flag => flag.name); + + // Divide the optionsPassed array in two arrays: flagsPassed and configsPassed. + // flagsPassed contains the flags that are available for the subcommand. + // configsPassed contains the config options with string or boolean values corresponding to the flag. + const flagsPassed = optionsPassed.filter(option => allowedFlagNames.includes(option)); + const configsPassed = optionsPassed.filter(option => !allowedFlagNames.includes(option)); + + // CHECK THE VALIDITY OF FLAG(s) PASSED + + if (flagsPassed.length > 1) { + // A subcommand can only take one flag at a time. + Logger.log(`${colors.red(`Too many flags passed for '${subcommand}' subcommand:`)} ${flagsPassed.join(', ')} ${colors.gray('(only one expected)')}`); + showHelp(subcommand); + + return false; + } + + if (allowedFlags.length && flagsPassed.length === 0) { + // If the subcommand expects a flag but it is not passed: + // - if instead some other options are passed, throw error (we don't know if the options passed are configs and for which flag). + // - if no other options are passed, then we can prompt them for the flag and related configs. + if (configsPassed.length > 0) { + Logger.log(`${colors.red(`Unknown flag(s) passed for '${subcommand}' subcommand:`)} ${configsPassed.join(', ')}`); + showHelp(subcommand); + + return false; + } + + return { + subcommandFlag: '', + configs: [] + }; + } + + // CHECK THE VALIDITY OF CONFIGS PASSED + + const subcommandFlag = flagsPassed[0] || ''; // '' if no flag is allowed for the subcommand. + + if (configsPassed.length === 0) { + // If no configs are passed, then we simply return and continue with the default subcommand flow. + return { + subcommandFlag, + configs: [] + }; + } + + let allowedConfigs: CliConfig[] = []; + let configsFor = ''; + if (!allowedFlags.length) { + allowedConfigs = AVAILABLE_SUBCOMMANDS[subcommand].cliConfigs || []; + configsFor = ` for '${subcommand}' subcommand`; + } else { + allowedConfigs = allowedFlags.find(flag => flag.name === subcommandFlag)?.cliConfigs || []; + configsFor = ` for '--${subcommandFlag}' flag`; + } + + if (allowedConfigs.length) { + // Check if the passed configs are valid. + const configNames: string[] = allowedConfigs.map(config => config.name); + + const configAliases: string[] = []; + allowedConfigs.forEach(config => configAliases.push(...config.alias)); + configNames.push(...configAliases); + + const unknownConfigs = configsPassed.filter(option => !configNames.includes(option)); + if (unknownConfigs.length) { + Logger.log(`${colors.red(`Unknown config(s) passed${configsFor}:`)} ${unknownConfigs.join(', ')}`); + showHelp(subcommand); + + return false; + } + + // set main config in `options` if config aliases are passed. + const aliasToMainConfig: {[key: string]: string} = {}; + allowedConfigs.forEach(config => { + config.alias.forEach(alias => { + aliasToMainConfig[alias] = config.name; + }); + }); + + configsPassed.forEach((configName) => { + if (aliasToMainConfig[configName]) { + // `configName` is an alias + const mainConfig = aliasToMainConfig[configName]; + options[mainConfig] = options[configName]; + } + }); + } else { + // if no configs are allowed for the flag but still some options are passed, then throw error. + Logger.log(`${colors.red(`Unknown config(s) passed${configsFor}:`)} ${configsPassed.join(', ')} ${colors.gray('(none expected)')}`); + showHelp(subcommand); + + return false; + } + + return { + subcommandFlag, + configs: configsPassed + }; +} diff --git a/src/commands/android/subcommands/connect/index.ts b/src/commands/android/subcommands/connect/index.ts index f53e206..8d6d307 100644 --- a/src/commands/android/subcommands/connect/index.ts +++ b/src/commands/android/subcommands/connect/index.ts @@ -1,9 +1,19 @@ import {Options, Platform} from '../../interfaces'; -import {showConnectedRealDevices} from '../common'; +import {verifyOptions, showConnectedRealDevices} from '../common'; import {connectWirelessAdb} from './wireless'; export async function connect(options: Options, sdkRoot: string, platform: Platform): Promise { - if (options.wireless) { + const verifyResult = verifyOptions('connect', options); + if (!verifyResult) { + return false; + } + + const subcommandFlag = verifyResult.subcommandFlag; + if (subcommandFlag === '') { + // flag not passed by the user -- prompt user for the flag + } + + if (subcommandFlag === 'wireless') { await showConnectedRealDevices(); return await connectWirelessAdb(sdkRoot, platform); diff --git a/src/commands/android/subcommands/help.ts b/src/commands/android/subcommands/help.ts new file mode 100644 index 0000000..40048a9 --- /dev/null +++ b/src/commands/android/subcommands/help.ts @@ -0,0 +1,34 @@ +import colors from 'ansi-colors'; + +import Logger from '../../../logger'; +import {AVAILABLE_SUBCOMMANDS} from '../constants'; +import {Subcommand} from './interfaces'; + +export function showHelp(subcommand: string) { + const subcmd = AVAILABLE_SUBCOMMANDS[subcommand]; + + const subcmdFlagUsage = subcmd.flags?.length ? ' [flag]' : ''; + Logger.log(`Usage: ${colors.cyan(`npx @nightwatch/mobile-helper android ${subcommand}${subcmdFlagUsage} [configs]`)}\n`); + + const subcmdFlagsHelp = getSubcommandFlagsHelp(subcmd); + if (subcmdFlagsHelp) { + Logger.log(colors.yellow('Available flags:')); + Logger.log(subcmdFlagsHelp); + } +} + +export const getSubcommandFlagsHelp = (subcmd: Subcommand) => { + let output = ''; + const longest = (xs: string[]) => Math.max.apply(null, xs.map(x => x.length)); + + if (subcmd.flags && subcmd.flags.length > 0) { + const optionLongest = longest(subcmd.flags.map(flag => `--${flag.name}`)); + subcmd.flags.forEach(flag => { + const flagStr = `--${flag.name}`; + const optionPadding = new Array(Math.max(optionLongest - flagStr.length + 3, 0)).join('.'); + output += ` ${flagStr} ${colors.grey(optionPadding)} ${colors.gray(flag.description)}\n`; + }); + } + + return output; +}; diff --git a/src/commands/android/subcommands/index.ts b/src/commands/android/subcommands/index.ts index 80c03ff..b16e20f 100644 --- a/src/commands/android/subcommands/index.ts +++ b/src/commands/android/subcommands/index.ts @@ -4,9 +4,13 @@ import path from 'path'; import Logger from '../../../logger'; import {getPlatformName} from '../../../utils'; +import {AVAILABLE_SUBCOMMANDS} from '../constants'; import {Options, Platform} from '../interfaces'; -import {checkJavaInstallation, getSdkRootFromEnv} from '../utils/common'; +import {checkJavaInstallation, getSdkRootFromEnv, getSubcommandHelp} from '../utils/common'; import {connect} from './connect'; +import {showHelp} from './help'; +import {install} from './install'; +import {list} from './list'; import {uninstall} from './uninstall'; export class AndroidSubcommand { @@ -27,6 +31,21 @@ export class AndroidSubcommand { } async run(): Promise { + if (!Object.keys(AVAILABLE_SUBCOMMANDS).includes(this.subcommand)) { + Logger.log(`${colors.red(`unknown subcommand passed: ${this.subcommand}`)}\n`); + Logger.log(getSubcommandHelp()); + Logger.log(`For individual subcommand help, run: ${colors.cyan('npx @nightwatch/mobile-helper android SUBCOMMAND --help')}`); + Logger.log(`For complete Android help, run: ${colors.cyan('npx @nightwatch/mobile-helper android --help')}\n`); + + return false; + } + + if (this.options.help) { + showHelp(this.subcommand); + + return true; + } + this.loadEnvFromDotEnv(); const javaInstalled = checkJavaInstallation(this.rootDir); @@ -43,9 +62,7 @@ export class AndroidSubcommand { } this.sdkRoot = sdkRootEnv; - this.executeSubcommand(); - - return false; + return await this.executeSubcommand(); } loadEnvFromDotEnv(): void { @@ -57,6 +74,10 @@ export class AndroidSubcommand { async executeSubcommand(): Promise { if (this.subcommand === 'connect') { return await connect(this.options, this.sdkRoot, this.platform); + } else if (this.subcommand === 'install') { + return await install(this.options, this.sdkRoot, this.platform); + } else if (this.subcommand === 'list') { + return await list(this.options, this.sdkRoot, this.platform); } else if (this.subcommand === 'uninstall') { return await uninstall(this.options, this.sdkRoot, this.platform); } diff --git a/src/commands/android/subcommands/install/app.ts b/src/commands/android/subcommands/install/app.ts new file mode 100644 index 0000000..607d90d --- /dev/null +++ b/src/commands/android/subcommands/install/app.ts @@ -0,0 +1,105 @@ +import colors from 'ansi-colors'; +import {existsSync} from 'fs'; +import inquirer from 'inquirer'; +import path from 'path'; + +import Logger from '../../../../logger'; +import {Options, Platform} from '../../interfaces'; +import ADB from '../../utils/appium-adb'; +import {getBinaryLocation} from '../../utils/common'; +import {execBinaryAsync} from '../../utils/sdk'; +import {showMissingBinaryHelp} from '../common'; + +export async function installApp(options: Options, sdkRoot: string, platform: Platform): Promise { + try { + const adbLocation = getBinaryLocation(sdkRoot, platform, 'adb', true); + if (!adbLocation) { + showMissingBinaryHelp('adb'); + + return false; + } + + const adb = await ADB.createADB({allowOfflineDevices: true}); + const devices = await adb.getConnectedDevices(); + + if (!devices.length) { + Logger.log(`${colors.red('No device found running.')} Please connect a device to install the APK.`); + Logger.log(`Use ${colors.cyan('npx @nightwatch/mobile-helper android connect')} to connect to a device.\n`); + + return true; + } + + if (options.deviceId) { + // If device id is passed then check if the id is valid. If not then prompt user to select a device. + const deviceConnected = devices.find(device => device.udid === options.deviceId); + if (!deviceConnected) { + Logger.log(colors.yellow(`No connected device found with deviceId '${options.deviceId}'.\n`)); + + options.deviceId = ''; + } + } + + if (!options.deviceId) { + // if device id not found, or invalid device id is found, then prompt the user + // to select a device from the list of running devices. + const deviceAnswer = await inquirer.prompt({ + type: 'list', + name: 'device', + message: 'Select the device to install the APK:', + choices: devices.map(device => device.udid) + }); + options.deviceId = deviceAnswer.device; + } + + if (!options.path) { + // if path to APK is not provided, then prompt the user to enter the path. + const apkPathAnswer = await inquirer.prompt({ + type: 'input', + name: 'apkPath', + message: 'Enter the path to the APK file:' + }); + options.path = apkPathAnswer.apkPath; + } + + Logger.log(); + + options.path = path.resolve(process.cwd(), options.path as string); + if (!existsSync(options.path)) { + Logger.log(`${colors.red('No APK file found at: ' + options.path)}\nPlease provide a valid path to the APK file.\n`); + + return false; + } + + Logger.log('Installing APK...'); + + const installationStatus = await execBinaryAsync(adbLocation, 'adb', platform, `-s ${options.deviceId} install ${options.path}`); + if (installationStatus?.includes('Success')) { + Logger.log(colors.green('APK installed successfully!\n')); + + return true; + } + + handleError(installationStatus); + + return false; + } catch (err) { + handleError(err); + + return false; + } +} + +const handleError = (consoleOutput: any) => { + Logger.log(colors.red('\nError while installing APK:')); + + let errorMessage = consoleOutput; + if (consoleOutput.includes('INSTALL_FAILED_ALREADY_EXISTS')) { + errorMessage = 'APK with the same package name already exists on the device.\n'; + errorMessage += colors.reset('\nPlease uninstall the app first from the device and then install again.\n'); + errorMessage += colors.reset(`To uninstall, use: ${colors.cyan('npx @nightwatch/mobile-helper android uninstall --app')}\n`); + } else if (consoleOutput.includes('INSTALL_FAILED_OLDER_SDK')) { + errorMessage = 'Target installation location (AVD/Real device) has older SDK version than the minimum requirement of the APK.\n'; + } + + Logger.log(colors.red(errorMessage)); +}; diff --git a/src/commands/android/subcommands/install/avd.ts b/src/commands/android/subcommands/install/avd.ts new file mode 100644 index 0000000..a3ab64b --- /dev/null +++ b/src/commands/android/subcommands/install/avd.ts @@ -0,0 +1,145 @@ +import colors from 'ansi-colors'; +import inquirer from 'inquirer'; + +import Logger from '../../../../logger'; +import {Platform} from '../../interfaces'; +import {getBinaryLocation} from '../../utils/common'; +import {execBinaryAsync, execBinarySync} from '../../utils/sdk'; +import {getInstalledSystemImages, showMissingBinaryHelp} from '../common'; + +type DeviceType = 'Nexus' | 'Pixel' | 'Wear OS' | 'Android TV' | 'Desktop' | 'Others'; + +const deviceTypesToGrepCommand: Record = { + 'Nexus': 'Nexus', + 'Pixel': 'pixel', + 'Wear OS': 'wear', + 'Android TV': 'tv', + 'Desktop': 'desktop', + 'Others': '-Ev "wear|Nexus|pixel|tv|desktop"' +}; + +export async function createAvd(sdkRoot: string, platform: Platform): Promise { + try { + const avdmanagerLocation = getBinaryLocation(sdkRoot, platform, 'avdmanager', true); + if (!avdmanagerLocation) { + showMissingBinaryHelp('avdmanager'); + + return false; + } + + const sdkmanagerLocation = getBinaryLocation(sdkRoot, platform, 'sdkmanager', true); + if (!sdkmanagerLocation) { + showMissingBinaryHelp('sdkmanager'); + + return false; + } + + const avdNameAnswer = await inquirer.prompt({ + type: 'input', + name: 'avdName', + message: 'Enter a name for the AVD:' + }); + const avdName = avdNameAnswer.avdName || 'my_avd'; + + const installedSystemImages = await getInstalledSystemImages(sdkmanagerLocation, platform); + if (!installedSystemImages.result) { + return false; + } + if (!installedSystemImages.systemImages.length) { + Logger.log(colors.red('\nNo installed system images were found!')); + Logger.log(`Run: ${colors.cyan('npx @nightwatch/mobile-helper android install --system-image')} to install a new system image.`); + + return false; + } + + const systemImageAnswer = await inquirer.prompt({ + type: 'list', + name: 'systemImage', + message: 'Select the system image to use for AVD:', + choices: installedSystemImages.systemImages + }); + const systemImage = systemImageAnswer.systemImage; + + const deviceTypeAnswer = await inquirer.prompt({ + type: 'list', + name: 'deviceType', + message: 'Select the device type for AVD:', + choices: Object.keys(deviceTypesToGrepCommand) + }); + const deviceType = deviceTypeAnswer.deviceType; + + let cmd = `list devices -c | grep ${deviceTypesToGrepCommand[deviceType as DeviceType]}`; + const availableDeviceProfiles = execBinarySync(avdmanagerLocation, 'avdmanager', platform, cmd); + + if (!availableDeviceProfiles) { + Logger.log(`${colors.red(`No potential device profile found for device type ${deviceType}.`)} Please try again.`); + + return false; + } + const availableDeviceProfilesList = availableDeviceProfiles.split('\n').filter(deviceProfile => deviceProfile !== ''); + const deviceAnswer = await inquirer.prompt({ + type: 'list', + name: 'deviceProfile', + message: 'Select the device profile for AVD:', + choices: availableDeviceProfilesList + }); + const deviceProfile = deviceAnswer.deviceProfile; + + Logger.log(); + Logger.log('Creating AVD...\n'); + + cmd = `create avd -n '${avdName}' -k '${systemImage}' -d '${deviceProfile}'`; + let createAVDStatus = false; + + try { + createAVDStatus = await executeCreateAvdCommand(cmd, avdmanagerLocation, platform, avdName); + } catch (err) { + if (typeof err === 'string' && err.includes('already exists')) { + // AVD with the same name already exists. Ask user if they want to overwrite it. + Logger.log(`\n${colors.yellow('AVD with the same name already exists!')}\n`); + const overwriteAnswer = await inquirer.prompt({ + type: 'confirm', + name: 'overwrite', + message: 'Overwrite the existing AVD?' + }); + Logger.log(); + + if (overwriteAnswer.overwrite) { + cmd += ' --force'; + createAVDStatus = await executeCreateAvdCommand(cmd, avdmanagerLocation, platform, avdName); + } + } else { + handleError(err); + } + } + + return createAVDStatus; + } catch (err) { + handleError(err); + + return false; + } +} + +async function executeCreateAvdCommand(cmd: string, avdmanagerLocation: string, platform: Platform, avdName: string): Promise { + const output = await execBinaryAsync(avdmanagerLocation, 'avdmanager', platform, cmd); + + if (output?.includes('100% Fetch remote repository')) { + Logger.log(colors.green('AVD created successfully!\n')); + Logger.log(`Run ${colors.cyan(`npx @nightwatch/mobile-helper android connect --emulator --avd ${avdName}`)} to launch the AVD.\n`); + + return true; + } + + Logger.log(colors.red('Something went wrong while creating AVD!')); + Logger.log(`Please run ${colors.cyan(`npx @nightwatch/mobile-helper android connect --emulator --avd ${avdName}`)} to verify AVD creation.`); + Logger.log('If AVD does not launch, please try creating the AVD again.\n'); + + return false; +} + +function handleError(err: any) { + Logger.log(colors.red('\nError occurred while creating AVD!')); + console.error(err); +} + diff --git a/src/commands/android/subcommands/install/index.ts b/src/commands/android/subcommands/install/index.ts new file mode 100644 index 0000000..6387629 --- /dev/null +++ b/src/commands/android/subcommands/install/index.ts @@ -0,0 +1,45 @@ +import inquirer from 'inquirer'; + +import Logger from '../../../../logger'; +import {Options, Platform} from '../../interfaces'; +import {verifyOptions} from '../common'; +import {installApp} from './app'; +import {createAvd} from './avd'; + +export async function install(options: Options, sdkRoot: string, platform: Platform): Promise { + const optionsVerified = verifyOptions('install', options); + if (!optionsVerified) { + return false; + } + + let subcommandFlag = optionsVerified.subcommandFlag; + if (subcommandFlag === '') { + subcommandFlag = await promptForFlag(); + } + + if (subcommandFlag === 'app') { + return await installApp(options, sdkRoot, platform); + } else if (subcommandFlag === 'avd') { + return await createAvd(sdkRoot, platform); + } + + return false; +} + +async function promptForFlag(): Promise { + const flagAnswer = await inquirer.prompt({ + type: 'list', + name: 'flag', + message: 'Select what do you want to install:', + choices: ['APK', 'AVD'] + }); + Logger.log(); + + const flag = flagAnswer.flag; + if (flag === 'APK') { + return 'app'; + } + + return 'avd'; +} + diff --git a/src/commands/android/subcommands/interfaces.ts b/src/commands/android/subcommands/interfaces.ts new file mode 100644 index 0000000..eae016d --- /dev/null +++ b/src/commands/android/subcommands/interfaces.ts @@ -0,0 +1,26 @@ +// configs for subcommand options +export interface CliConfig { + name: string, + alias: string[], + description: string, + usageHelp: string, +} + +export interface Subcommand { + description: string; + cliConfigs?: CliConfig[]; + flags: { + name: string; + description: string; + cliConfigs?: CliConfig[]; + }[]; +} + +export interface AvailableSubcommands { + [key: string]: Subcommand; +} + +export interface SubcommandOptionsVerificationResult { + subcommandFlag: string; + configs: string[]; +} diff --git a/src/commands/android/subcommands/list/avd.ts b/src/commands/android/subcommands/list/avd.ts new file mode 100644 index 0000000..93f3f04 --- /dev/null +++ b/src/commands/android/subcommands/list/avd.ts @@ -0,0 +1,38 @@ +import colors from 'ansi-colors'; + +import Logger from '../../../../logger'; +import {Platform} from '../../interfaces'; +import {getBinaryLocation} from '../../utils/common'; +import {execBinarySync} from '../../utils/sdk'; +import {showMissingBinaryHelp} from '../common'; + +export async function listInstalledAVDs(sdkRoot: string, platform: Platform): Promise { + try { + const avdmanagerLocation = getBinaryLocation(sdkRoot, platform, 'avdmanager', true); + if (!avdmanagerLocation) { + showMissingBinaryHelp('avdmanager'); + + return false; + } + + const installedAVDs = execBinarySync(avdmanagerLocation, 'avd', platform, 'list avd'); + if (!installedAVDs) { + Logger.log(`\n${colors.red('Failed to list installed AVDs!')} Please try again.`); + + return false; + } + + if (installedAVDs.split('\n').length < 3) { + Logger.log(colors.red('No installed AVDs found!')); + } else { + Logger.log(installedAVDs); + } + + return true; + } catch (err) { + Logger.log(colors.red('Error occurred while listing installed AVDs.')); + console.error(err); + + return false; + } +} diff --git a/src/commands/android/subcommands/list/device.ts b/src/commands/android/subcommands/list/device.ts new file mode 100644 index 0000000..35c50b9 --- /dev/null +++ b/src/commands/android/subcommands/list/device.ts @@ -0,0 +1,30 @@ +import colors from 'ansi-colors'; + +import Logger from '../../../../logger'; +import {Platform} from '../../interfaces'; +import ADB from '../../utils/appium-adb'; +import {getBinaryLocation} from '../../utils/common'; +import {showConnectedEmulators, showConnectedRealDevices, showMissingBinaryHelp} from '../common'; + +export async function listConnectedDevices(sdkRoot: string, platform: Platform): Promise { + const adbLocation = getBinaryLocation(sdkRoot, platform, 'adb', true); + if (adbLocation === '') { + showMissingBinaryHelp('adb'); + + return false; + } + + const adb = await ADB.createADB({allowOfflineDevices: true}); + const devices = await adb.getConnectedDevices(); + + if (!devices.length) { + Logger.log(colors.yellow('No connected devices found.\n')); + + return true; + } + + await showConnectedRealDevices(); + await showConnectedEmulators(); + + return true; +} diff --git a/src/commands/android/subcommands/list/index.ts b/src/commands/android/subcommands/list/index.ts new file mode 100644 index 0000000..0bf7cd1 --- /dev/null +++ b/src/commands/android/subcommands/list/index.ts @@ -0,0 +1,44 @@ +import inquirer from 'inquirer'; + +import Logger from '../../../../logger'; +import {Options, Platform} from '../../interfaces'; +import {verifyOptions} from '../common'; +import {listInstalledAVDs} from './avd'; +import {listConnectedDevices} from './device'; + +export async function list(options: Options, sdkRoot: string, platform: Platform): Promise { + const optionsVerified = verifyOptions('list', options); + if (!optionsVerified) { + return false; + } + + let subcommandFlag = optionsVerified.subcommandFlag; + if (subcommandFlag === '') { + subcommandFlag = await promptForFlag(); + } + + if (subcommandFlag === 'avd') { + return await listInstalledAVDs(sdkRoot, platform); + } else if (subcommandFlag === 'device') { + return await listConnectedDevices(sdkRoot, platform); + } + + return false; +} + +async function promptForFlag(): Promise { + const flagAnswer = await inquirer.prompt({ + type: 'list', + name: 'flag', + message: 'Select what do you want to list:', + choices: ['Connected devices', 'Installed AVDs'] + }); + Logger.log(); + + const flag = flagAnswer.flag; + if (flag === 'Connected devices') { + return 'device'; + } + + return 'avd'; +} diff --git a/src/commands/android/subcommands/uninstall/avd.ts b/src/commands/android/subcommands/uninstall/avd.ts new file mode 100644 index 0000000..7368611 --- /dev/null +++ b/src/commands/android/subcommands/uninstall/avd.ts @@ -0,0 +1,63 @@ +import colors from 'ansi-colors'; +import inquirer from 'inquirer'; + +import Logger from '../../../../logger'; +import {Platform} from '../../interfaces'; +import {getBinaryLocation} from '../../utils/common'; +import {execBinarySync, execBinaryAsync} from '../../utils/sdk'; +import {showMissingBinaryHelp} from '../common'; + +export async function deleteAvd(sdkRoot: string, platform: Platform): Promise { + try { + const avdmanagerLocation = getBinaryLocation(sdkRoot, platform, 'avdmanager', true); + if (!avdmanagerLocation) { + showMissingBinaryHelp('avdmanager'); + + return false; + } + + const installedAvds = execBinarySync(avdmanagerLocation, 'avdmanager', platform, 'list avd -c'); + if (installedAvds === null) { + Logger.log(`${colors.red('\nFailed to fetch installed AVDs.')} Please try again.\n`); + + return false; + } else if (installedAvds === '') { + Logger.log(`${colors.yellow('No installed AVD found.')}\n`); + Logger.log('To see the list of installed AVDs, run the following command:'); + Logger.log(colors.cyan(' npx @nightwatch/mobile-helper android list --avd\n')); + + return false; + } + + const avdAnswer = await inquirer.prompt({ + type: 'list', + name: 'avdName', + message: 'Select the AVD to delete:', + choices: installedAvds.split('\n').filter(avd => avd !== '') + }); + const avdName = avdAnswer.avdName; + + Logger.log(); + Logger.log(`Deleting ${colors.cyan(avdName)}...\n`); + + const deleteStatus = await execBinaryAsync(avdmanagerLocation, 'avdmanager', platform, `delete avd --name '${avdName}'`); + + if (deleteStatus?.includes('deleted')) { + Logger.log(colors.green('AVD deleted successfully!\n')); + + return true; + } + + Logger.log(colors.red('Something went wrong while deleting AVD.')); + Logger.log('Command output:', deleteStatus); + Logger.log(`To verify if the AVD was deleted, run: ${colors.cyan('npx @nightwatch/mobile-helper android list --avd')}`); + Logger.log('If the AVD is still present, try deleting it again.\n'); + + return false; + } catch (error) { + Logger.log(colors.red('\nError occurred while deleting AVD.')); + console.error(error); + + return false; + } +} diff --git a/src/commands/android/subcommands/uninstall/index.ts b/src/commands/android/subcommands/uninstall/index.ts index 6c187f1..0c60e52 100644 --- a/src/commands/android/subcommands/uninstall/index.ts +++ b/src/commands/android/subcommands/uninstall/index.ts @@ -1,9 +1,12 @@ import {Options, Platform} from '../../interfaces'; import {uninstallApp} from './app'; +import {deleteAvd} from './avd'; export async function uninstall(options: Options, sdkRoot: string, platform: Platform): Promise { if (options.app) { return await uninstallApp(options, sdkRoot, platform); + } else if (options.avd) { + return await deleteAvd(sdkRoot, platform); } return false; diff --git a/src/commands/android/utils/common.ts b/src/commands/android/utils/common.ts index 6e4e492..0ae2b28 100644 --- a/src/commands/android/utils/common.ts +++ b/src/commands/android/utils/common.ts @@ -9,10 +9,14 @@ import path from 'path'; import untildify from 'untildify'; import which from 'which'; +import Logger from '../../../logger'; import {symbols} from '../../../utils'; -import {ABI, AVAILABLE_OPTIONS, AVAILABLE_SUBCOMMANDS, DEFAULT_CHROME_VERSIONS, DEFAULT_FIREFOX_VERSION, SDK_BINARY_LOCATIONS} from '../constants'; +import { + ABI, AVAILABLE_OPTIONS, AVAILABLE_SUBCOMMANDS, + DEFAULT_CHROME_VERSIONS, DEFAULT_FIREFOX_VERSION, SDK_BINARY_LOCATIONS +} from '../constants'; import {Platform, SdkBinary} from '../interfaces'; -import Logger from '../../../logger'; +import {getSubcommandFlagsHelp} from '../subcommands/help'; export const getAllAvailableOptions = () => { const mainOptions = Object.keys(AVAILABLE_OPTIONS); @@ -212,27 +216,18 @@ export const checkJavaInstallation = (cwd: string): boolean => { export const getSubcommandHelp = (): string => { let output = ''; - output += `Usage: ${colors.cyan('npx @nightwatch/mobile-helper android [subcmd] [subcmd-options]')}\n`; - output += ' The following subcommands are used for different operations on Android SDK:\n\n'; - output += `${colors.yellow('Subcommands and Subcommand-Options:')}\n`; - - const longest = (xs: string[]) => Math.max.apply(null, xs.map(x => x.length)); + output += `Usage: ${colors.cyan('npx @nightwatch/mobile-helper android SUBCOMMAND [flag] [configs]')}\n`; + output += ' Perform common Android SDK operations using subcommands.\n\n'; + output += `${colors.yellow('Subcommands (with available flags and configs):')}\n`; Object.keys(AVAILABLE_SUBCOMMANDS).forEach(subcommand => { const subcmd = AVAILABLE_SUBCOMMANDS[subcommand]; - const subcmdOptions = subcmd.options?.map(option => `[--${option.name}]`).join(' ') || ''; + const subcmdOptions = subcmd.flags?.map(flag => `[--${flag.name}]`).join(' ') || ''; output += ` ${colors.cyan(subcommand)} ${subcmdOptions}\n`; output += ` ${colors.gray(subcmd.description)}\n`; - 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('.'); - output += ` ${optionStr} ${colors.grey(optionPadding)} ${colors.gray(option.description)}\n`; - }); - } + output += getSubcommandFlagsHelp(subcmd); }); return output; diff --git a/src/commands/android/utils/sdk.ts b/src/commands/android/utils/sdk.ts index 88dbfcc..7b7a971 100644 --- a/src/commands/android/utils/sdk.ts +++ b/src/commands/android/utils/sdk.ts @@ -1,13 +1,13 @@ import colors from 'ansi-colors'; +import {exec, execSync} from 'child_process'; import fs from 'fs'; -import path from 'path'; import {homedir} from 'os'; -import {execSync} from 'child_process'; +import path from 'path'; import {copySync, rmDirSync, symbols} from '../../../utils'; -import {downloadWithProgressBar, getBinaryNameForOS} from './common'; -import {Platform} from '../interfaces'; import DOWNLOADS from '../downloads.json'; +import {Platform} from '../interfaces'; +import {downloadWithProgressBar, getBinaryNameForOS} from './common'; export const getDefaultAndroidSdkRoot = (platform: Platform) => { @@ -187,6 +187,43 @@ export const execBinarySync = ( } }; +export const execBinaryAsync = ( + binaryLocation: string, + binaryName: string, + platform: Platform, + args: string +): Promise => { + return new Promise((resolve, reject) => { + let cmd: string; + if (binaryLocation === 'PATH') { + const binaryFullName = getBinaryNameForOS(platform, binaryName); + cmd = `${binaryFullName} ${args}`; + } else { + const binaryFullName = path.basename(binaryLocation); + const binaryDirPath = path.dirname(binaryLocation); + + if (platform === 'windows') { + cmd = `${binaryFullName} ${args}`; + } else { + cmd = `./${binaryFullName} ${args}`; + } + + cmd = `cd ${binaryDirPath} && ${cmd}`; + } + + exec(cmd, (error, stdout, stderr) => { + if (error) { + console.log( + ` ${colors.red(symbols().fail)} Failed to run ${colors.cyan(cmd)}` + ); + reject(stderr); + } else { + resolve(stdout.toString()); + } + }); + }); +}; + export const getBuildToolsAvailableVersions = (buildToolsPath: string): string[] => { if (!fs.existsSync(buildToolsPath)) { return []; diff --git a/src/constants.ts b/src/constants.ts index 89bb1b4..86c6ca1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1 +1,2 @@ export const AVAILABLE_COMMANDS = ['android', 'ios']; +export const ANDROID_DOTCOMMANDS = ['android.emulator', 'android.avdmanager', 'android.sdkmanager', 'android.adb']; diff --git a/src/index.ts b/src/index.ts index 8a5aace..b99e756 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,9 @@ import colors from 'ansi-colors'; import minimist from 'minimist'; import {AndroidSetup} from './commands/android/androidSetup'; -import {AndroidSubcommand} from './commands/android/subcommands'; import {AVAILABLE_COMMANDS} from './constants'; import {IosSetup} from './commands/ios'; +import {handleAndroidCommand} from './commands/android'; export const run = () => { try { @@ -25,28 +25,20 @@ export const run = () => { console.log(`${colors.red('No command passed.')}\n`); } showHelp(); - } else if (!AVAILABLE_COMMANDS.includes(args[0])) { - console.log(`${colors.red(`Unknown command passed: ${args[0]}`)}\n`); - showHelp(); - } else if (args.length > 2 || (args[0] === 'ios' && args.length > 1)) { - // android command can accept only one subcommand. - // ios command does not accept subcommands. - console.log(`${colors.red(`Too many arguments passed: ${args.slice(1).join(', ')}`)}\n`); - showHelp(); - } else if (args[0] === 'android') { - if (args[1]) { - // args[1] represents the android subcommand. - // If subcommand is present then proceed to run the subcommand. - const androidSubcommand = new AndroidSubcommand(args[1], options); - androidSubcommand.run(); + } else if (args[0].split('.')[0] === 'android') { + handleAndroidCommand(args, options, argv); + } else if (args[0] === 'ios') { + if (args.length > 1) { + // ios command does not accept subcommands. + console.log(`${colors.red(`Too many arguments passed for 'ios' command: ${args.slice(1).join(', ')}`)}\n`); + console.log(`Run: ${colors.cyan('npx @nightwatch/mobile-helper ios --help')} to get help for 'ios' command.`); } else { - // If no subcommand is present then proceed to run the main android setup. - const androidSetup = new AndroidSetup(options); - androidSetup.run(); + const iOSSetup = new IosSetup(options); + iOSSetup.run(); } - } else if (args[0] === 'ios') { - const iOSSetup = new IosSetup(options); - iOSSetup.run(); + } else { + console.log(`${colors.red(`Unknown command passed: ${args[0]}`)}\n`); + showHelp(); } } catch (err) { console.error(err as string);