UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

569 lines (508 loc) 16.2 kB
import {fs, logger} from 'appium/support.js'; import {exec} from 'teen_process'; import os from 'node:os'; import path from 'node:path'; import {fileURLToPath, pathToFileURL} from 'node:url'; import {mkdtemp, rm} from 'node:fs/promises'; import {Command} from 'commander'; const scriptFilePath = fileURLToPath(import.meta.url); const SCRIPT_NAME = path.basename(scriptFilePath, path.extname(scriptFilePath)); const RESIGNER_BINARY_NAME = 'resigner'; const MOBILEPROVISION_EXTENSION = '.mobileprovision'; const DEFAULT_PROFILE_DIR_CANDIDATES = [ path.join(os.homedir(), 'Library', 'Developer', 'Xcode', 'UserData', 'Provisioning Profiles'), path.join(os.homedir(), 'Library', 'MobileDevice', 'Provisioning Profiles'), ]; const DEFAULT_WDA_BUNDLE_IDS = [ 'com.facebook.WebDriverAgentRunner', 'com.facebook.WebDriverAgentRunner.xctrunner', 'com.facebook.WebDriverAgentLib', ]; const log = logger.getLogger(SCRIPT_NAME); class Resigner { /** @type {string} */ _wdaPath; /** * @param {string} wdaPath Path to the WebDriverAgent `.app` bundle. */ constructor(wdaPath) { this._wdaPath = wdaPath; } /** * @param {SignOptions} options * @returns {Promise<void>} */ async signWDA(options) { await this._requireBinary(); const args = this._buildSignArgs(options); log.info(`Running resigner to sign ${this._wdaPath}`); await exec(RESIGNER_BINARY_NAME, args, { env: { ...process.env, P12_PASSWORD: options.p12Password, }, }); log.info('WDA signed successfully'); } /** * @returns {Promise<string>} */ async inspectWDA() { await this._requireBinary(); log.info(`Inspecting signed WDA at ${this._wdaPath}`); const {stdout} = await exec(RESIGNER_BINARY_NAME, ['--inspect', this._wdaPath]); return String(stdout || '').trim(); } /** * @param {SignOptions} options * @returns {string[]} */ _buildSignArgs(options) { const args = [ '--p12-file', options.p12File, '--profile', options.profileDir, '--force', ]; if (options.bundleId) { args.push( ...[ // To re-apply the same mapping again for past failure cases for safety. options.bundleId, ...DEFAULT_WDA_BUNDLE_IDS, ].flatMap((bundleId) => [ '--bundle-id-remap', `${bundleId}=${options.bundleId}`, ]) ); } args.push(this._wdaPath); return args; } /** * @returns {Promise<void>} */ async _requireBinary() { try { await fs.which(RESIGNER_BINARY_NAME); } catch { throw new Error('Resigner binary is not available in the PATH.'); } } } class ProvisioningProfilesHelper { /** @type {string | undefined} */ _profileDir; /** * @param {string | undefined} profileDir Explicit directory, or `undefined` to auto-discover. */ constructor(profileDir) { this._profileDir = profileDir; } /** * @returns {Promise<string>} */ async resolveRoot() { const profileDir = this._profileDir; if (profileDir) { return await this._validate(profileDir, 'Provided'); } for (const candidate of DEFAULT_PROFILE_DIR_CANDIDATES) { if (!(await fs.exists(candidate))) { continue; } try { await this._validate(candidate, 'Discovered'); log.info(`Using discovered provisioning profile directory: ${candidate}`); return candidate; } catch { continue; } } throw new Error( `No provisioning profile directory could be discovered. ` + `Please provide --profile-dir explicitly. Checked: ${DEFAULT_PROFILE_DIR_CANDIDATES.join(', ')}` ); } /** * @param {string} dir * @param {string} source * @returns {Promise<string>} */ async _validate(dir, source) { if (!(await fs.exists(dir))) { throw new Error(`${source} provisioning profile directory does not exist: ${dir}`); } let entries; try { entries = await fs.readdir(dir); } catch { throw new Error(`${source} provisioning profile directory is not a readable directory: ${dir}`); } if (!entries.some((name) => name.toLowerCase().endsWith(MOBILEPROVISION_EXTENSION))) { throw new Error( `${source} provisioning profile directory does not contain any ${MOBILEPROVISION_EXTENSION} files: ${dir}` ); } return dir; } } class P12Converter { /** @type {string} */ _certPath; /** @type {string} */ _keyPath; /** @type {string} */ _p12Password; /** * @param {string} certPath * @param {string} keyPath * @param {string} p12Password */ constructor(certPath, keyPath, p12Password) { this._certPath = certPath; this._keyPath = keyPath; this._p12Password = p12Password; } /** * @returns {string} */ static generateRandomPassword() { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; let password = ''; for (let i = 0; i < 12; i++) { password += chars[Math.floor(Math.random() * chars.length)]; } return password; } /** * @returns {Promise<{p12File: string, tempDir: string}>} */ async convert() { await this._assertCertAndKeyExist(); await this._requireOpenSsl(); const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wda-sign-')); const certPemPath = path.join(tempDir, 'certificate.pem'); const p12FilePath = path.join(tempDir, 'certificate.p12'); try { await this._convertCerToPem(certPemPath); await this._exportPkcs12(certPemPath, p12FilePath); log.info(`Successfully created temporary .p12 file: ${p12FilePath}`); return {p12File: p12FilePath, tempDir}; } catch (err) { try { await rm(tempDir, {recursive: true, force: true}); } catch {} throw err; } } /** * @returns {Promise<void>} */ async _assertCertAndKeyExist() { const certPath = this._certPath; const keyPath = this._keyPath; if (!(await fs.exists(certPath))) { throw new Error(`Certificate file does not exist: ${certPath}`); } if (!(await fs.exists(keyPath))) { throw new Error(`Private key file does not exist: ${keyPath}`); } } /** * @returns {Promise<void>} */ async _requireOpenSsl() { try { await fs.which('openssl'); } catch { throw new Error( 'OpenSSL binary is not available in the PATH. ' + 'It is required to convert .cer and .key files to .p12 format.' ); } } /** * @param {string} certPemPath * @returns {Promise<void>} */ async _convertCerToPem(certPemPath) { const certPath = this._certPath; log.info(`Converting certificate from ${certPath} to PEM format`); await exec('openssl', [ 'x509', '-in', certPath, '-inform', 'DER', '-out', certPemPath, ]); } /** * @param {string} certPemPath * @param {string} p12FilePath * @returns {Promise<void>} */ async _exportPkcs12(certPemPath, p12FilePath) { const keyPath = this._keyPath; const p12Password = this._p12Password; log.info(`Creating .p12 file from certificate and key`); await exec('openssl', [ 'pkcs12', '-export', '-in', certPemPath, '-inkey', keyPath, '-out', p12FilePath, '-passout', `pass:${p12Password}`, ]); } } /** * Shared helpers for workflows that operate on a WDA `.app` bundle path. */ class WdaBundleWorkflow { /** * @param {string} wdaPath * @returns {Promise<void>} */ async _assertWdaExists(wdaPath) { if (!(await fs.exists(wdaPath))) { throw new Error(`WDA path does not exist: ${wdaPath}`); } } } class SignWdaWorkflow extends WdaBundleWorkflow { /** * @param {object} [deps] * @param {(wdaPath: string) => Resigner} [deps.createResigner] * @param {(profileDir: string | undefined) => ProvisioningProfilesHelper} [deps.createProvisioning] * @param {(certPath: string, keyPath: string, p12Password: string) => P12Converter} [deps.createP12] */ constructor(deps = {}) { super(); this._createResigner = deps.createResigner ?? ((wdaPath) => new Resigner(wdaPath)); this._createProvisioning = deps.createProvisioning ?? ((profileDir) => new ProvisioningProfilesHelper(profileDir)); this._createP12 = deps.createP12 ?? ((certPath, keyPath, p12Password) => new P12Converter(certPath, keyPath, p12Password)); } /** * @param {SignWDAOptions} options * @returns {Promise<void>} */ async run(options) { await this._assertWdaExists(options.wdaPath); const resolvedProfileDir = await this._createProvisioning(options.profileDir).resolveRoot(); let p12File = options.p12File; let tempDir; let p12Password = options.p12Password; try { if (options.p12Cert && options.p12Key) { const generatedPassword = P12Converter.generateRandomPassword(); const result = await this._createP12( options.p12Cert, options.p12Key, generatedPassword ).convert(); p12File = result.p12File; tempDir = result.tempDir; p12Password = generatedPassword; } if (!p12File) { throw new Error('No p12 file available for signing'); } await this._createResigner(options.wdaPath).signWDA({ p12File, p12Password, profileDir: resolvedProfileDir, bundleId: options.bundleId, }); } finally { await this._cleanupTempDir(tempDir); } } /** * @param {string | undefined} tempDir * @returns {Promise<void>} */ async _cleanupTempDir(tempDir) { if (!tempDir) { return; } try { await rm(tempDir, {recursive: true, force: true}); log.info(`Cleaned up temporary directory: ${tempDir}`); } catch (err) { log.warn(`Failed to clean up temporary directory ${tempDir}: ${err.message}`); } } } class InspectWdaWorkflow extends WdaBundleWorkflow { /** * @param {object} [deps] * @param {(wdaPath: string) => Resigner} [deps.createResigner] */ constructor(deps = {}) { super(); this._createResigner = deps.createResigner ?? ((wdaPath) => new Resigner(wdaPath)); } /** * @param {InspectWDAOptions} options * @returns {Promise<void>} */ async run(options) { await this._assertWdaExists(options.wdaPath); const inspectResult = await this._createResigner(options.wdaPath).inspectWDA(); if (inspectResult) { log.info(`Resigner inspect result:\n---\n${inspectResult}`); } else { log.info('Resigner inspect finished, but no output was returned.'); } } } class SignWdaCli { /** * @param {object} [deps] * @param {SignWdaWorkflow} [deps.signWorkflow] * @param {InspectWdaWorkflow} [deps.inspectWorkflow] */ constructor(deps = {}) { this._signWorkflow = deps.signWorkflow ?? new SignWdaWorkflow(); this._inspectWorkflow = deps.inspectWorkflow ?? new InspectWdaWorkflow(); } /** * @param {string[]} argv * @returns {Promise<void>} */ async run(argv) { const program = this._createProgram(); await program.parseAsync(argv); } /** * @returns {Command} */ _createProgram() { const program = new Command(); program .name('appium driver run xcuitest sign-wda') .description('Sign a WebDriverAgentRunner app bundle with code signing certificate') .requiredOption('--wda-path <path>', 'Path to the WebDriverAgentRunner.app bundle to sign') .option('--inspect', 'Run resigner inspect only (no signing)') .option( '--p12-file <path>', 'Path to the .p12 signing certificate file (requires P12_PASSWORD env var; mutually exclusive with --p12-cert/--p12-key)' ) .option( '--p12-cert <path>', 'Path to the .cer certificate file from Apple Developer portal (auto-converted to .p12 with generated password; mutually exclusive with --p12-file; must use with --p12-key)' ) .option( '--p12-key <path>', 'Path to the .key private key file from Apple Developer portal (auto-converted to .p12 with generated password; mutually exclusive with --p12-file; must use with --p12-cert)' ) .option('--profile-dir <path>', 'Directory containing provisioning profiles (auto-discovered if omitted)') .option('--bundle-id <id>', 'Target bundle ID for remapping (e.g., com.example.wda)') .addHelpText( 'after', ` EXAMPLES: # Sign downloaded WDA with .p12 certificate (requires P12_PASSWORD) P12_PASSWORD=mypassword appium driver run xcuitest sign-wda -- --wda-path ./wda-real/WebDriverAgentRunner-Runner.app \ --p12-file ~/sign.p12 # Sign WDA with .cer and .key files (auto-converted, no password needed!) appium driver run xcuitest sign-wda -- --wda-path ./wda-real/WebDriverAgentRunner-Runner.app \ --p12-cert ~/certificate.cer \ --p12-key ~/private.key # Sign WDA and remap bundle ID with .p12 certificate (requires P12_PASSWORD) P12_PASSWORD=mypassword appium driver run xcuitest sign-wda -- --wda-path ./wda-real/WebDriverAgentRunner-Runner.app \ --p12-file ~/sign.p12 \ --bundle-id com.example.wda # Sign WDA with specified provisioning profile directory (cert+key approach) appium driver run xcuitest sign-wda -- --wda-path ./wda-real/WebDriverAgentRunner-Runner.app \ --p12-cert ~/certificate.cer \ --p12-key ~/private.key \ --profile-dir /path/to/your/provisioning/profiles # Inspect a WDA app without signing appium driver run xcuitest sign-wda -- --wda-path ./wda-real/WebDriverAgentRunner-Runner.app --inspect`, ) .action(async (options) => { await this._handleParsedOptions(options); }); return program; } /** * @param {object} options * @returns {Promise<void>} */ async _handleParsedOptions(options) { if (options.inspect) { await this._inspectWorkflow.run({ wdaPath: options.wdaPath, }); return; } const p12Password = process.env.P12_PASSWORD; this._checkSigningOptions(options, p12Password); await this._signWorkflow.run({ wdaPath: options.wdaPath, p12File: options.p12File, p12Cert: options.p12Cert, p12Key: options.p12Key, p12Password: p12Password || '', profileDir: options.profileDir, bundleId: options.bundleId, }); } /** * @param {object} options * @param {string | undefined} p12Password * @returns {void} */ _checkSigningOptions(options, p12Password) { const hasP12File = !!options.p12File; const hasCertAndKey = !!(options.p12Cert && options.p12Key); if (!hasP12File && !hasCertAndKey) { throw new Error( `Must provide either --p12-file or both --p12-cert and --p12-key for signing mode` ); } if (hasP12File && hasCertAndKey) { throw new Error( `Cannot provide both --p12-file and --p12-cert/--p12-key; use one approach` ); } if ((options.p12Cert && !options.p12Key) || (!options.p12Cert && options.p12Key)) { throw new Error( `Both --p12-cert and --p12-key must be provided together` ); } if (hasP12File && !p12Password) { throw new Error( `Missing required option for signing mode: P12_PASSWORD env var (required when using --p12-file)` ); } } } const isMainModule = Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMainModule) { await new SignWdaCli().run(process.argv); } /** * @typedef {Object} SignOptions * @property {string} p12File * @property {string} p12Password * @property {string} profileDir * @property {string | undefined} [bundleId] */ /** * @typedef {Object} SignWDAOptions * @property {string} wdaPath * @property {string | undefined} [p12File] * @property {string | undefined} [p12Cert] * @property {string | undefined} [p12Key] * @property {string} p12Password * @property {string | undefined} [profileDir] * @property {string | undefined} [bundleId] */ /** * @typedef {Object} InspectWDAOptions * @property {string} wdaPath */