diff --git a/lib/commands/certificate.js b/lib/commands/certificate.js index 1c18b85ab..8e0d824d9 100644 --- a/lib/commands/certificate.js +++ b/lib/commands/certificate.js @@ -7,7 +7,7 @@ import path from 'path'; import http from 'http'; import {exec} from 'teen_process'; import {findAPortNotInUse, checkPortStatus} from 'portscanner'; -import Pyidevice from '../py-ios-device-client'; +import {Pyidevice} from '../real-device-clients/py-ios-device-client'; import {errors} from 'appium/driver'; const CONFIG_EXTENSION = 'mobileconfig'; diff --git a/lib/commands/log.js b/lib/commands/log.js index 8d05fb1d0..86f921ba3 100644 --- a/lib/commands/log.js +++ b/lib/commands/log.js @@ -108,8 +108,9 @@ export default { } if (_.isUndefined(this.logs.syslog)) { this.logs.crashlog = new IOSCrashLog({ - sim: this.device, + sim: /** @type {import('appium-ios-simulator').Simulator} */ (this.device), udid: this.isRealDevice() ? this.opts.udid : undefined, + log: this.log, }); this.logs.syslog = this.isRealDevice() ? new IOSDeviceLog({ diff --git a/lib/commands/memory.js b/lib/commands/memory.js index 3faa0c242..297281bda 100644 --- a/lib/commands/memory.js +++ b/lib/commands/memory.js @@ -17,7 +17,7 @@ export default { const device = /** @type {import('../real-device').RealDevice} */ (this.device); - /** @type {import('../devicectl').AppInfo[]} */ + /** @type {import('../real-device-clients/devicectl').AppInfo[]} */ const appInfos = await device.devicectl.listApps(bundleId); if (_.isEmpty(appInfos)) { throw new errors.InvalidArgumentError( diff --git a/lib/commands/pcap.js b/lib/commands/pcap.js index 044f1215c..f92f86f36 100644 --- a/lib/commands/pcap.js +++ b/lib/commands/pcap.js @@ -1,11 +1,10 @@ -import Pyidevice from '../py-ios-device-client'; -import {fs, tempDir, logger, util} from 'appium/support'; +import { Pyidevice } from '../real-device-clients/py-ios-device-client'; +import {fs, tempDir, util} from 'appium/support'; import {encodeBase64OrUpload} from '../utils'; import {errors} from 'appium/driver'; const MAX_CAPTURE_TIME_SEC = 60 * 60 * 12; const DEFAULT_EXT = '.pcap'; -const pcapLogger = logger.getLogger('pcapd'); export class TrafficCapture { /** @type {import('teen_process').SubProcess|null} */ @@ -21,7 +20,7 @@ export class TrafficCapture { this.mainProcess = /** @type {import('teen_process').SubProcess} */ ( await new Pyidevice(this.udid).collectPcap(this.resultPath) ); - this.mainProcess.on('line-stderr', (line) => pcapLogger.info(line)); + this.mainProcess.on('line-stderr', (line) => this.log.info(`[Pcap] ${line}`)); this.log.info( `Starting network traffic capture session on the device '${this.udid}'. ` + `Will timeout in ${timeoutSeconds}s`, diff --git a/lib/device-log/helpers.ts b/lib/device-log/helpers.ts index 73903e23b..b53dc8b89 100644 --- a/lib/device-log/helpers.ts +++ b/lib/device-log/helpers.ts @@ -1,4 +1,7 @@ import type { LogEntry } from '../commands/types'; +import { fs } from 'appium/support'; +import { createInterface } from 'node:readline'; +import _ from 'lodash'; export const DEFAULT_LOG_LEVEL = 'ALL'; export const MAX_JSON_LOG_LENGTH = 200; @@ -11,3 +14,27 @@ export function toLogEntry(message: string, timestamp: number, level: string = D message, }; } + +export interface GrepOptions { + caseInsensitive?: boolean; +} + +export async function grepFile( + fullPath: string, + str: string, + opts: GrepOptions = {} +): Promise { + const input = fs.createReadStream(fullPath); + const rl = createInterface({input}); + return await new Promise((resolve, reject) => { + input.once('error', reject); + rl.on('line', (line) => { + if (opts.caseInsensitive && _.toLower(line).includes(_.toLower(str)) + || !opts.caseInsensitive && line.includes(str)) { + resolve(true); + input.close(); + } + }); + input.once('end', () => resolve(false)); + }); +} diff --git a/lib/device-log/ios-crash-log.js b/lib/device-log/ios-crash-log.js deleted file mode 100644 index b5d3898a7..000000000 --- a/lib/device-log/ios-crash-log.js +++ /dev/null @@ -1,146 +0,0 @@ -import {fs, tempDir} from 'appium/support'; -import B from 'bluebird'; -import log from '../logger'; -import {utilities} from 'appium-ios-device'; -import path from 'path'; -import _ from 'lodash'; -import Pyidevice from '../py-ios-device-client'; - -const REAL_DEVICE_MAGIC = '3620bbb0-fb9f-4b62-a668-896f2edc4d88'; -const MAGIC_SEP = '/'; -// The file format has been changed from '.crash' to '.ips' since Monterey. -const CRASH_REPORTS_GLOB_PATTERN = '**/*.@(crash|ips)'; - -class IOSCrashLog { - constructor(opts = {}) { - this.udid = opts.udid; - this.pyideviceClient = this.udid ? new Pyidevice(this.udid) : null; - const root = process.env.HOME || '/'; - const logDir = opts.udid - ? path.resolve(root, 'Library', 'Logs', 'CrashReporter', 'MobileDevice') - : path.resolve(root, 'Library', 'Logs', 'DiagnosticReports'); - this.logDir = logDir || path.resolve(root, 'Library', 'Logs', 'DiagnosticReports'); - this.prevLogs = []; - this.logsSinceLastRequest = []; - this.phoneName = null; - this.sim = opts.sim; - } - - /** - * @returns {Promise} - */ - async _gatherFromRealDevice() { - if (await this.pyideviceClient?.assertExists(false)) { - return (await /** @type {Pyidevice} */ (this.pyideviceClient).listCrashes()).map( - (x) => `${REAL_DEVICE_MAGIC}${MAGIC_SEP}${x}`, - ); - } - - let crashLogsRoot = this.logDir; - if (this.udid) { - this.phoneName = this.phoneName || (await utilities.getDeviceName(this.udid)); - crashLogsRoot = path.resolve(crashLogsRoot, this.phoneName); - } - if (!(await fs.exists(crashLogsRoot))) { - log.debug(`Crash reports root '${crashLogsRoot}' does not exist. Got nothing to gather.`); - return []; - } - return await fs.glob(CRASH_REPORTS_GLOB_PATTERN, { - cwd: crashLogsRoot, - absolute: true, - }); - } - - /** - * @returns {Promise} - */ - async _gatherFromSimulator() { - if (!(await fs.exists(this.logDir))) { - log.debug(`Crash reports root '${this.logDir}' does not exist. Got nothing to gather.`); - return []; - } - const foundFiles = await fs.glob(CRASH_REPORTS_GLOB_PATTERN, { - cwd: this.logDir, - absolute: true, - }); - // For Simulator only include files, that contain current UDID - return await B.filter(foundFiles, async (x) => { - try { - const content = await fs.readFile(x, 'utf8'); - return content.toUpperCase().includes(this.sim.udid.toUpperCase()); - } catch (err) { - return false; - } - }); - } - - /** - * @returns {Promise} - */ - async getCrashes() { - return this.udid ? await this._gatherFromRealDevice() : await this._gatherFromSimulator(); - } - - /** - * @returns {Promise} - */ - async startCapture() { - this.prevLogs = await this.getCrashes(); - } - - /** - * @returns {Promise} - */ - async stopCapture() { - // needed for consistent API with other logs - } - - /** - * @returns {Promise} - */ - async getLogs() { - let crashFiles = await this.getCrashes(); - let diff = _.difference(crashFiles, this.prevLogs, this.logsSinceLastRequest); - this.logsSinceLastRequest = _.union(this.logsSinceLastRequest, diff); - return await this.filesToJSON(diff); - } - - /** - * @param {string[]} paths - * @returns {Promise} - */ - async filesToJSON(paths) { - const tmpRoot = await tempDir.openDir(); - try { - return /** @type {import('../commands/types').LogEntry[]} */ (( - await B.map(paths, async (fullPath) => { - if (_.includes(fullPath, REAL_DEVICE_MAGIC)) { - const fileName = /** @type {string} */ (_.last(fullPath.split(MAGIC_SEP))); - try { - // @ts-expect-error If pyideviceClient is not defined, then the exception will be caught below - await this.pyideviceClient.exportCrash(fileName, tmpRoot); - } catch (e) { - log.warn( - `Cannot export the crash report '${fileName}'. Skipping it. ` + - `Original error: ${e.message}`, - ); - return; - } - fullPath = path.join(tmpRoot, fileName); - } - const stat = await fs.stat(fullPath); - return { - timestamp: stat.ctime.getTime(), - level: 'ALL', - message: await fs.readFile(fullPath, 'utf8'), - }; - }) - ).filter(Boolean)); - } finally { - await fs.rimraf(tmpRoot); - } - } -} - -export {IOSCrashLog}; -export default IOSCrashLog; diff --git a/lib/device-log/ios-crash-log.ts b/lib/device-log/ios-crash-log.ts new file mode 100644 index 000000000..cdbcede9b --- /dev/null +++ b/lib/device-log/ios-crash-log.ts @@ -0,0 +1,167 @@ +import {fs, tempDir, util} from 'appium/support'; +import B from 'bluebird'; +import path from 'path'; +import _ from 'lodash'; +import {Pyidevice} from '../real-device-clients/py-ios-device-client'; +import IOSLog from './ios-log'; +import { toLogEntry, grepFile } from './helpers'; +import type { AppiumLogger } from '@appium/types'; +import type { BaseDeviceClient } from '../real-device-clients/base-device-client'; +import type { Simulator } from 'appium-ios-simulator'; +import type { LogEntry } from '../commands/types'; + +// The file format has been changed from '.crash' to '.ips' since Monterey. +const CRASH_REPORTS_GLOB_PATTERN = '**/*.@(crash|ips)'; +// The size of a single diagnostic report might be hundreds of kilobytes. +// Thus we do not want to store too many items in the memory at once. +const MAX_RECENT_ITEMS = 20; + +type TSerializedEntry = [string, number]; + +export interface IOSCrashLogOptions { + /** UDID of a real device */ + udid?: string; + /** Simulator instance */ + sim?: Simulator; + log: AppiumLogger; +} + +export class IOSCrashLog extends IOSLog { + private readonly _udid: string | undefined; + private readonly _realDeviceClient: BaseDeviceClient | null; + private readonly _logDir: string | null; + private readonly _sim: Simulator | undefined; + private _recentCrashFiles: string[]; + private _started: boolean; + + constructor(opts: IOSCrashLogOptions) { + super({ + log: opts.log, + maxBufferSize: MAX_RECENT_ITEMS, + }); + this._udid = opts.udid; + this._sim = opts.sim; + this._realDeviceClient = this._isRealDevice() + ? new Pyidevice({ + udid: this._udid as string, + log: opts.log, + }) + : null; + this._logDir = this._isRealDevice() + ? null + : path.resolve(process.env.HOME || '/', 'Library', 'Logs', 'DiagnosticReports'); + this._recentCrashFiles = []; + this._started = false; + } + + override async startCapture(): Promise { + this._recentCrashFiles = await this._listCrashFiles(false); + this._started = true; + } + + // eslint-disable-next-line require-await + override async stopCapture(): Promise { + this._started = false; + } + + override get isCapturing(): boolean { + return this._started; + } + + override async getLogs(): Promise { + const crashFiles = (await this._listCrashFiles(true)).slice(-MAX_RECENT_ITEMS); + const diffFiles = _.difference(crashFiles, this._recentCrashFiles); + if (_.isEmpty(diffFiles)) { + return []; + } + + this.log.debug(`Found ${util.pluralize('fresh crash report', diffFiles.length, true)}`); + await this._serializeCrashes(diffFiles); + this._recentCrashFiles = crashFiles; + return super.getLogs(); + } + + protected override _serializeEntry(value: TSerializedEntry): TSerializedEntry { + return value; + } + + protected override _deserializeEntry(value: TSerializedEntry): LogEntry { + const [message, timestamp] = value; + return toLogEntry(message, timestamp); + } + + private async _serializeCrashes(paths: string[]): Promise { + const tmpRoot = await tempDir.openDir(); + try { + for (const filePath of paths) { + let fullPath = filePath; + if (this._isRealDevice()) { + const fileName = filePath; + try { + await (this._realDeviceClient as BaseDeviceClient).exportCrash(fileName, tmpRoot); + } catch (e) { + this.log.warn( + `Cannot export the crash report '${fileName}'. Skipping it. ` + + `Original error: ${e.message}`, + ); + return; + } + fullPath = path.join(tmpRoot, fileName); + } + const {ctime} = await fs.stat(fullPath); + this.broadcast([await fs.readFile(fullPath, 'utf8'), ctime.getTime()]); + } + } finally { + await fs.rimraf(tmpRoot); + } + } + + private async _gatherFromRealDevice(strict: boolean): Promise { + if (!this._realDeviceClient) { + return []; + } + if (!await this._realDeviceClient.assertExists(strict)) { + this.log.info( + `The ${_.toLower(this._realDeviceClient.constructor.name)} tool is not present in PATH. ` + + `Skipping crash logs collection for real devices.` + ); + return []; + } + + return await this._realDeviceClient.listCrashes(); + } + + private async _gatherFromSimulator(): Promise { + if (!this._logDir || !this._sim || !(await fs.exists(this._logDir))) { + this.log.debug(`Crash reports root '${this._logDir}' does not exist. Got nothing to gather.`); + return []; + } + + const foundFiles = await fs.glob(CRASH_REPORTS_GLOB_PATTERN, { + cwd: this._logDir, + absolute: true, + }); + const simUdid = (this._sim as Simulator).udid; + // For Simulator only include files, that contain current UDID + return await B.filter(foundFiles, async (filePath) => { + try { + return await grepFile(filePath, simUdid, {caseInsensitive: true}); + } catch (err) { + this.log.warn(err); + return false; + } + }); + } + + private async _listCrashFiles(strict: boolean): Promise { + return this._isRealDevice() + ? await this._gatherFromRealDevice(strict) + : await this._gatherFromSimulator(); + } + + private _isRealDevice(): boolean { + return Boolean(this._udid); + } +} + +export default IOSCrashLog; diff --git a/lib/device-log/ios-log.ts b/lib/device-log/ios-log.ts index 41910c118..27a012571 100644 --- a/lib/device-log/ios-log.ts +++ b/lib/device-log/ios-log.ts @@ -37,7 +37,8 @@ export abstract class IOSLog< return this._log; } - getLogs(): LogEntry[] { + // eslint-disable-next-line require-await + async getLogs(): Promise { const result: LogEntry[] = []; for (const value of this.logs.rvalues()) { result.push(this._deserializeEntry(value as TSerializedEntry)); diff --git a/lib/driver.js b/lib/driver.js index ff1523f6c..43a22ad94 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -23,7 +23,7 @@ import {desiredCapConstraints} from './desired-caps'; import DEVICE_CONNECTIONS_FACTORY from './device-connections-factory'; import {executeMethodMap} from './execute-method-map'; import {newMethodMap} from './method-map'; -import Pyidevice from './py-ios-device-client'; +import { Pyidevice } from './real-device-clients/py-ios-device-client'; import { installToRealDevice, runRealDeviceReset, diff --git a/lib/py-ios-device-client.js b/lib/py-ios-device-client.js deleted file mode 100644 index 1bb63c0e4..000000000 --- a/lib/py-ios-device-client.js +++ /dev/null @@ -1,167 +0,0 @@ -import {exec, SubProcess} from 'teen_process'; -import {fs, util, tempDir} from 'appium/support'; -import log from './logger'; -import path from 'path'; - -// https://github.com/YueChen-C/py-ios-device - -const BINARY_NAME = 'pyidevice'; - -class Pyidevice { - /** - * @param {string} udid - */ - constructor(udid) { - this.udid = udid; - this.binaryPath = null; - } - - /** - * @param {boolean} isStrict - * @return {Promise} - */ - async assertExists(isStrict = true) { - if (this.binaryPath) { - return true; - } - - try { - this.binaryPath = await fs.which(BINARY_NAME); - return true; - } catch (e) { - if (isStrict) { - throw new Error( - `${BINARY_NAME} binary cannot be found in PATH. ` + - `Please make sure it is installed. Visit https://github.com/YueChen-C/py-ios-device for ` + - `more details.`, - ); - } - return false; - } - } - - /** - * @typedef {Object} ExecuteOptions - * @property {string} cwd - * @property {string?} format [json] - * @property {boolean} logStdout [false] - * @property {boolean} asynchronous [false] - */ - - /** - * @param {string[]} args - * @param {Partial} opts - * @return {Promise} - */ - async execute(args, opts = {}) { - await this.assertExists(); - const {cwd, format = 'json', logStdout = false, asynchronous = false} = opts; - - const finalArgs = [...args, '--udid', this.udid, '--network']; - if (format) { - finalArgs.push('--format', format); - } - const binaryPath = /** @type {string} */ (this.binaryPath); - const cmdStr = util.quote([binaryPath, ...finalArgs]); - log.debug(`Executing ${cmdStr}`); - try { - if (asynchronous) { - const result = new SubProcess(binaryPath, finalArgs, {cwd}); - await result.start(0); - return result; - } - const result = await exec(binaryPath, finalArgs, {cwd}); - if (logStdout) { - log.debug(`Command output: ${result.stdout}`); - } - return result; - } catch (e) { - throw new Error(`'${cmdStr}' failed. Original error: ${e.stderr || e.stdout || e.message}`); - } - } - - /** - * @return {Promise} - */ - async listProfiles() { - const {stdout} = /** @type {import('teen_process').TeenProcessExecResult} */ ( - await this.execute(['profiles', 'list']) - ); - return JSON.parse(stdout); - } - - /** - * - * @param { {profilePath?: string, payload: string|Buffer} } opts - * @privateRemarks The error below seems to suggest that `payload` can be undefined, but the code suggests otherwise - */ - async installProfile(opts) { - const {profilePath, payload} = opts ?? {}; - if (!profilePath && !payload) { - throw new Error('Either the full path to the profile or its payload must be provided'); - } - - let tmpRoot; - let srcPath = profilePath; - try { - if (!srcPath) { - tmpRoot = await tempDir.openDir(); - srcPath = path.join(tmpRoot, 'cert.pem'); - await fs.writeFile(srcPath, payload, 'utf8'); - } - await this.execute(['profiles', 'install', '--path', srcPath], { - logStdout: true, - }); - } finally { - if (tmpRoot) { - await fs.rimraf(tmpRoot); - } - } - } - - /** - * - * @param {string} name - * @returns {Promise} - */ - async removeProfile(name) { - return /** @type {import('teen_process').TeenProcessExecResult} */ ( - await this.execute(['profiles', 'remove', '--name', name], {logStdout: true}) - ).stdout; - } - - /** - * @returns {Promise} - */ - async listCrashes() { - const {stdout} = /** @type {import('teen_process').TeenProcessExecResult} */ ( - await this.execute(['crash', 'list']) - ); - return JSON.parse(stdout.replace(/'/g, '"')).filter((x) => !['.', '..'].includes(x)); - } - - /** - * @param {string} name - * @param {string} dstFolder - * @returns {Promise} - */ - async exportCrash(name, dstFolder) { - await this.execute(['crash', 'export', '--name', name], { - logStdout: true, - // The tool exports crash reports to the current working dir - cwd: dstFolder, - }); - } - /** - * @param {string} dstFile - */ - async collectPcap(dstFile) { - return await this.execute(['pcapd', dstFile], { - format: null, - asynchronous: true, - }); - } -} - -export {Pyidevice}; -export default Pyidevice; diff --git a/lib/real-device-clients/base-device-client.ts b/lib/real-device-clients/base-device-client.ts new file mode 100644 index 000000000..84031cf90 --- /dev/null +++ b/lib/real-device-clients/base-device-client.ts @@ -0,0 +1,34 @@ +import type { AppiumLogger } from '@appium/types'; +import type { SubProcess } from 'teen_process'; + +export interface BaseDeviceClientOptions { + log: AppiumLogger; +} + +export interface InstallProfileArgs { + profilePath?: string; + payload?: string|Buffer; +} + +export abstract class BaseDeviceClient { + private readonly _log: AppiumLogger; + + constructor (opts: BaseDeviceClientOptions) { + this._log = opts.log; + } + + get log(): AppiumLogger { + return this._log; + } + + abstract assertExists(isStrict: boolean): Promise; + + abstract listProfiles(): Promise; + abstract installProfile(args: InstallProfileArgs): Promise; + abstract removeProfile(name: string): Promise; + + abstract listCrashes(): Promise; + abstract exportCrash(name: string, dstFolder: string): Promise; + + abstract collectPcap(dstFile: string): Promise; +} diff --git a/lib/devicectl.js b/lib/real-device-clients/devicectl.js similarity index 100% rename from lib/devicectl.js rename to lib/real-device-clients/devicectl.js diff --git a/lib/real-device-clients/py-ios-device-client.ts b/lib/real-device-clients/py-ios-device-client.ts new file mode 100644 index 000000000..8c1f03ca9 --- /dev/null +++ b/lib/real-device-clients/py-ios-device-client.ts @@ -0,0 +1,149 @@ +import {exec, SubProcess} from 'teen_process'; +import {fs, util, tempDir} from 'appium/support'; +import path from 'path'; +import { BaseDeviceClient } from './base-device-client'; +import type { BaseDeviceClientOptions, InstallProfileArgs } from './base-device-client'; +import type { TeenProcessExecResult } from 'teen_process'; +import type { CertificateList } from '../commands/types'; + +// https://github.com/YueChen-C/py-ios-device + +const BINARY_NAME = 'pyidevice'; +const CRASH_REPORT_EXT = '.ips'; + +export interface PyideviceOptions extends BaseDeviceClientOptions { + udid: string; +} + +interface ExecuteOptions { + cwd?: string; + format?: string | null; + logStdout?: boolean; + asynchronous?: boolean; +} + +export class Pyidevice extends BaseDeviceClient { + private readonly _udid: string; + private _binaryPath: string | null; + + constructor(opts: PyideviceOptions) { + super({log: opts.log}); + this._udid = opts.udid; + this._binaryPath = null; + } + + override async assertExists(isStrict = true): Promise { + if (this._binaryPath) { + return true; + } + + try { + this._binaryPath = await fs.which(BINARY_NAME); + return true; + } catch (e) { + if (isStrict) { + throw new Error( + `${BINARY_NAME} binary cannot be found in PATH. ` + + `Please make sure it is installed. Visit https://github.com/YueChen-C/py-ios-device for ` + + `more details.`, + ); + } + return false; + } + } + + override async listProfiles(): Promise { + const {stdout} = await this.execute(['profiles', 'list']) as TeenProcessExecResult; + return JSON.parse(stdout); + } + + override async installProfile(args: InstallProfileArgs): Promise { + const {profilePath, payload} = args; + if (!profilePath && !payload) { + throw new Error('Either the full path to the profile or its payload must be provided'); + } + + let tmpRoot: string | undefined; + let srcPath = profilePath; + try { + if (!srcPath) { + tmpRoot = await tempDir.openDir(); + srcPath = path.join(tmpRoot, 'cert.pem'); + if (Buffer.isBuffer(payload)) { + await fs.writeFile(srcPath, payload); + } else { + await fs.writeFile(srcPath, payload as string, 'utf8'); + } + } + await this.execute(['profiles', 'install', '--path', srcPath], { + logStdout: true, + }); + } finally { + if (tmpRoot) { + await fs.rimraf(tmpRoot); + } + } + } + + override async removeProfile(name: string): Promise { + return ( + await this.execute(['profiles', 'remove', '--name', name], {logStdout: true}) as TeenProcessExecResult + ).stdout; + } + + override async listCrashes(): Promise { + const {stdout} = await this.execute(['crash', 'list']) as TeenProcessExecResult; + // Example output: + // ['.', '..', 'SiriSearchFeedback-2023-12-06-144043.ips', ' + // SiriSearchFeedback-2024-05-22-194219.ips', 'JetsamEvent-2024-05-23-225056.ips', + // 'JetsamEvent-2023-09-18-090920.ips', 'JetsamEvent-2024-05-16-054529.ips', + // 'Assistant'] + return JSON.parse(stdout.replace(/'/g, '"')) + .filter((x: string) => x.endsWith(CRASH_REPORT_EXT)); + } + + override async exportCrash(name: string, dstFolder: string): Promise { + await this.execute(['crash', 'export', '--name', name], { + logStdout: true, + // The tool exports crash reports to the current working dir + cwd: dstFolder, + }); + } + + override async collectPcap(dstFile: string): Promise { + return await this.execute(['pcapd', dstFile], { + format: null, + asynchronous: true, + }) as SubProcess; + } + + private async execute( + args: string[], + opts: ExecuteOptions = {} + ): Promise | SubProcess> { + await this.assertExists(); + const {cwd, format = 'json', logStdout = false, asynchronous = false} = opts; + + const finalArgs = [...args, '--udid', this._udid, '--network']; + if (format) { + finalArgs.push('--format', format); + } + const binaryPath = this._binaryPath as string; + const cmdStr = util.quote([binaryPath, ...finalArgs]); + this.log.debug(`Executing ${cmdStr}`); + try { + if (asynchronous) { + const result = new SubProcess(binaryPath, finalArgs, {cwd}); + await result.start(0); + return result; + } + const result = await exec(binaryPath, finalArgs, {cwd}); + if (logStdout) { + this.log.debug(`Command output: ${result.stdout}`); + } + return result; + } catch (e) { + throw new Error(`'${cmdStr}' failed. Original error: ${e.stderr || e.stdout || e.message}`); + } + } +} diff --git a/lib/real-device.js b/lib/real-device.js index c87da56ce..9d84b696b 100644 --- a/lib/real-device.js +++ b/lib/real-device.js @@ -6,7 +6,7 @@ import defaultLogger from './logger'; import _ from 'lodash'; import {SAFARI_BUNDLE_ID} from './app-utils'; import {pushFile, pushFolder, IO_TIMEOUT_MS} from './ios-fs-helpers'; -import { Devicectl } from './devicectl'; +import { Devicectl } from './real-device-clients/devicectl'; const APPLICATION_INSTALLED_NOTIFICATION = 'com.apple.mobile.application_installed'; const APPLICATION_NOTIFICATION_TIMEOUT_MS = 30 * 1000; diff --git a/test/unit/logs-helpers-specs.js b/test/unit/logs-helpers-specs.js new file mode 100644 index 000000000..5482cc530 --- /dev/null +++ b/test/unit/logs-helpers-specs.js @@ -0,0 +1,46 @@ +import { grepFile } from '../../lib/device-log/helpers'; +import {fs, tempDir} from 'appium/support'; +import path from 'node:path'; + + +describe('log-helpers', function () { + let chai; + + before(async function () { + chai = await import('chai'); + const chaiAsPromised = await import('chai-as-promised'); + + chai.should(); + chai.use(chaiAsPromised.default); + }); + + describe('grepFile', function () { + let tmpRoot; + + beforeEach(async function () { + tmpRoot = await tempDir.openDir(); + }); + + afterEach(async function () { + await fs.rimraf(tmpRoot); + }); + + it('should grep file content case sensitive', async function () { + const filePath = path.join(tmpRoot, 'grep.test'); + await fs.writeFile(filePath, `123\n45\nab`, 'utf8'); + await grepFile(filePath, 'ab').should.eventually.be.true; + }); + + it('should grep file content case insensitive', async function () { + const filePath = path.join(tmpRoot, 'grep.test'); + await fs.writeFile(filePath, `123\n45\nAB\ncd`, 'utf8'); + await grepFile(filePath, 'ab', {caseInsensitive: true}).should.eventually.be.true; + }); + + it('should return false if no match', async function () { + const filePath = path.join(tmpRoot, 'grep.test'); + await fs.writeFile(filePath, `123\n45\nAB`, 'utf8'); + await grepFile(filePath, 'cd', {caseInsensitive: true}).should.eventually.be.false; + }); + }); +});