Skip to content

Commit

Permalink
feat: Update application bundles processing (#1421)
Browse files Browse the repository at this point in the history
  • Loading branch information
mykola-mokhnach authored Aug 1, 2022
1 parent 17ec005 commit 0a3debb
Show file tree
Hide file tree
Showing 2 changed files with 165 additions and 22 deletions.
85 changes: 71 additions & 14 deletions lib/app-utils.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import _ from 'lodash';
import path from 'path';
import { plist, fs, util } from 'appium/support';
import { plist, fs, util, tempDir, zip } from 'appium/support';
import log from './logger.js';

const STRINGSDICT_RESOURCE = '.stringsdict';
const STRINGS_RESOURCE = '.strings';
const SAFARI_BUNDLE_ID = 'com.apple.mobilesafari';

const APP_EXT = '.app';
const IPA_EXT = '.ipa';

async function extractPlistEntry (app, entryName) {
const plistPath = path.resolve(app, 'Info.plist');
Expand All @@ -23,6 +24,22 @@ async function extractBundleId (app) {
return bundleId;
}

async function fetchSupportedAppPlatforms (app) {
try {
const result = await extractPlistEntry(app, 'CFBundleSupportedPlatforms');
if (!_.isArray(result)) {
log.warn(`${path.basename(app)}': CFBundleSupportedPlatforms is not a valid list`);
return [];
}
return result;
} catch (err) {
log.warn(
`Cannot extract the list of supported platforms from '${path.basename(app)}': ${err.message}`
);
return [];
}
}

/**
* @typedef {Object} PlatformOpts
*
Expand All @@ -41,18 +58,8 @@ async function extractBundleId (app) {
async function verifyApplicationPlatform (app, expectedPlatform) {
log.debug('Verifying application platform');

let supportedPlatforms;
try {
supportedPlatforms = await extractPlistEntry(app, 'CFBundleSupportedPlatforms');
} catch (err) {
log.debug(err.message);
return;
}
const supportedPlatforms = await fetchSupportedAppPlatforms(app);
log.debug(`CFBundleSupportedPlatforms: ${JSON.stringify(supportedPlatforms)}`);
if (!_.isArray(supportedPlatforms)) {
log.debug(`CFBundleSupportedPlatforms key does not exist in '${path.basename(app)}'`);
return;
}

const {
isSimulator,
Expand Down Expand Up @@ -157,7 +164,57 @@ async function parseLocalizableStrings (opts) {
return resultStrings;
}

/**
* Check whether the given path on the file system points to the .app bundle root
*
* @param {string} appPath Possible .app bundle root
* @returns {boolean} Whether the given path points to an .app bundle
*/
async function isAppBundle (appPath) {
return _.endsWith(_.toLower(appPath), APP_EXT)
&& (await fs.stat(appPath)).isDirectory()
&& await fs.exists(path.join(appPath, 'Info.plist'));
}

/**
* Extract the given archive and looks for items with given extensions in it
*
* @param {string} archivePath Full path to a .zip archive
* @param {Array<string>} appExtensions List of matching item extensions
* @returns {[string, Array<String>]} Tuple, where the first element points to
* a temporary folder root where the archive has been extracted and the second item
* contains a list of relative paths to matched items
*/
async function findApps (archivePath, appExtensions) {
const useSystemUnzipEnv = process.env.APPIUM_PREFER_SYSTEM_UNZIP;
const useSystemUnzip = _.isEmpty(useSystemUnzipEnv)
|| !['0', 'false'].includes(_.toLower(useSystemUnzipEnv));
const tmpRoot = await tempDir.openDir();
await zip.extractAllTo(archivePath, tmpRoot, {useSystemUnzip});
const globPattern = `**/*.+(${appExtensions.map((ext) => ext.replace(/^\./, '')).join('|')})`;
const sortedBundleItems = (await fs.glob(globPattern, {
cwd: tmpRoot,
strict: false,
})).sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
return [tmpRoot, sortedBundleItems];
}

/**
* Moves the application bundle to a newly created temporary folder
*
* @param {string} appRoot Full path to the .app bundle
* @returns {string} The new path to the app bundle.
* The name of the app bundle remains though
*/
async function isolateAppBundle (appRoot) {
const tmpRoot = await tempDir.openDir();
const dstRoot = path.join(tmpRoot, path.basename(appRoot));
await fs.mv(appRoot, dstRoot, {mkdirp: true});
return dstRoot;
}

export {
extractBundleId, verifyApplicationPlatform, parseLocalizableStrings,
SAFARI_BUNDLE_ID
SAFARI_BUNDLE_ID, fetchSupportedAppPlatforms, APP_EXT, IPA_EXT,
isAppBundle, findApps, isolateAppBundle,
};
102 changes: 94 additions & 8 deletions lib/driver.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BaseDriver, DeviceSettings } from 'appium/driver';
import { util, mjpeg } from 'appium/support';
import { util, mjpeg, fs } from 'appium/support';
import _ from 'lodash';
import url from 'url';
import { WebDriverAgent } from 'appium-webdriveragent';
Expand All @@ -15,7 +15,9 @@ import {
} from './cert-utils';
import { retryInterval, retry } from 'asyncbox';
import {
verifyApplicationPlatform, extractBundleId, SAFARI_BUNDLE_ID
verifyApplicationPlatform, extractBundleId, SAFARI_BUNDLE_ID,
fetchSupportedAppPlatforms, APP_EXT, IPA_EXT,
isAppBundle, findApps, isolateAppBundle,
} from './app-utils';
import {
desiredCapConstraints, PLATFORM_NAME_IOS, PLATFORM_NAME_TVOS
Expand Down Expand Up @@ -44,6 +46,8 @@ import Pyidevice from './py-ios-device-client';
const SHUTDOWN_OTHER_FEAT_NAME = 'shutdown_other_sims';
const CUSTOMIZE_RESULT_BUNDPE_PATH = 'customize_result_bundle_path';

const SUPPORTED_EXTENSIONS = [IPA_EXT, APP_EXT];
const MAX_ARCHIVE_SCAN_DEPTH = 1;
const defaultServerCaps = {
webStorageEnabled: false,
locationContextEnabled: false,
Expand Down Expand Up @@ -146,6 +150,7 @@ const MEMOIZED_FUNCTIONS = [
'getScreenInfo',
];


class XCUITestDriver extends BaseDriver {
constructor (opts = {}, shouldValidateCaps = true) {
super(opts, shouldValidateCaps);
Expand Down Expand Up @@ -817,13 +822,94 @@ class XCUITestDriver extends BaseDriver {
return;
}

this.opts.app = await this.helpers.configureApp(this.opts.app, {
onPostProcess: this.onPostConfigureApp.bind(this),
supportedExtensions: SUPPORTED_EXTENSIONS
});
}

/**
* Unzip the given archive and find a matching .app bundle in it
*
* @param {string} appPath The path to the archive.
* @param {number} depth [0] the current nesting depth. App bundles whose nesting level
* is greater than 1 are not supported.
* @returns {string} Full path to the first matching .app bundle..
* @throws If no matching .app bundles were found in the provided archive.
*/
async unzipApp (appPath, depth = 0) {
if (depth > MAX_ARCHIVE_SCAN_DEPTH) {
throw new Error('Nesting of package bundles is not supported');
}
const [rootDir, matchedPaths] = await findApps(appPath, SUPPORTED_EXTENSIONS);
if (_.isEmpty(matchedPaths)) {
this.log.debug(`'${path.basename(appPath)}' has no bundles`);
} else {
this.log.debug(
`Found ${util.pluralize('bundle', matchedPaths.length, true)} in ` +
`'${path.basename(appPath)}': ${matchedPaths}`
);
}
try {
// download if necessary
this.opts.app = await this.helpers.configureApp(this.opts.app, '.app');
} catch (err) {
this.log.error(err);
throw new Error(`Bad app: ${this.opts.app}. ` +
`App paths need to be absolute or an URL to a compressed app file: ${err.message}`);
for (const matchedPath of matchedPaths) {
const fullPath = path.join(rootDir, matchedPath);
if (await isAppBundle(fullPath)) {
const supportedPlatforms = await fetchSupportedAppPlatforms(fullPath);
if (this.isSimulator() && !supportedPlatforms.some((p) => _.includes(p, 'Simulator'))) {
this.log.info(`'${matchedPath}' does not have Simulator devices in the list of supported platforms ` +
`(${supportedPlatforms.join(',')}). Skipping it`);;
continue;
}
if (this.isRealDevice() && !supportedPlatforms.some((p) => _.includes(p, 'OS'))) {
this.log.info(`'${matchedPath}' does not have real devices in the list of supported platforms ` +
`(${supportedPlatforms.join(',')}). Skipping it`);;
continue;
}
this.log.info(`'${matchedPath}' is the resulting application bundle selected from '${appPath}'`);
return await isolateAppBundle(fullPath);
} else if (_.endsWith(_.toLower(fullPath), IPA_EXT) && (await fs.stat(fullPath)).isFile()) {
try {
return await this.unzipApp(fullPath, depth + 1);
} catch (e) {
this.log.warn(`Skipping processing of '${matchedPath}': ${e.message}`);
}
}
}
} finally {
await fs.rimraf(rootDir);
}
throw new Error(`${this.opts.app} did not have any matching ${APP_EXT} or ${IPA_EXT} ` +
`bundles. Please make sure the provided package is valid and contains at least one matching ` +
`application bundle which is not nested.`
);
}

async onPostConfigureApp ({cachedAppInfo, isUrl, appPath}) {
// Pick the previously cached entry if its integrity has been preserved
if (_.isPlainObject(cachedAppInfo)
&& (await fs.stat(appPath)).isFile()
&& await fs.hash(appPath) === cachedAppInfo.packageHash
&& await fs.exists(cachedAppInfo.fullPath)
&& (await fs.glob('**/*', {
cwd: cachedAppInfo.fullPath, strict: false, nosort: true
})).length === cachedAppInfo.integrity.folder) {
this.log.info(`Using '${cachedAppInfo.fullPath}' which was cached from '${appPath}'`);
return {appPath: cachedAppInfo.fullPath};
}

// Only local .app bundles that are available in-place should not be cached
if (await isAppBundle(appPath)) {
return false;
}

// Extract the app bundle and cache it
try {
return {appPath: await this.unzipApp(appPath)};
} finally {
// Cleanup previously downloaded archive
if (isUrl) {
await fs.rimraf(appPath);
}
}
}

Expand Down

0 comments on commit 0a3debb

Please sign in to comment.