diff --git a/CHANGELOG.md b/CHANGELOG.md index bef6a3532..6741a36c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,51 @@ +## [7.26.4](https://github.com/appium/appium-xcuitest-driver/compare/v7.26.3...v7.26.4) (2024-09-17) + +### Miscellaneous Chores + +* **deps-dev:** bump sinon from 18.0.1 to 19.0.2 ([#2469](https://github.com/appium/appium-xcuitest-driver/issues/2469)) ([e737dde](https://github.com/appium/appium-xcuitest-driver/commit/e737dde53880b70536440cec84037ad58ed7f4ab)) + +## [7.26.3](https://github.com/appium/appium-xcuitest-driver/compare/v7.26.2...v7.26.3) (2024-09-13) + +### Bug Fixes + +* Strip colors from server logs ([#2466](https://github.com/appium/appium-xcuitest-driver/issues/2466)) ([661f9d3](https://github.com/appium/appium-xcuitest-driver/commit/661f9d3f0fcb0d483de137fc542de141f077b1dc)) + +## [7.26.2](https://github.com/appium/appium-xcuitest-driver/compare/v7.26.1...v7.26.2) (2024-09-07) + +### Miscellaneous Chores + +* Set static event realm ([a08ce4f](https://github.com/appium/appium-xcuitest-driver/commit/a08ce4ffb3e73e21fed4d7a28d0004bcaa899bde)) + +## [7.26.1](https://github.com/appium/appium-xcuitest-driver/compare/v7.26.0...v7.26.1) (2024-09-06) + +### Bug Fixes + +* safari process management will be done by WDA for real devices ([#2464](https://github.com/appium/appium-xcuitest-driver/issues/2464)) ([18eddc3](https://github.com/appium/appium-xcuitest-driver/commit/18eddc3c48a8361182511a14e46915d6db71cda2)) + +## [7.26.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.25.0...v7.26.0) (2024-09-05) + +### Features + +* Publish different log types over BiDi ([#2458](https://github.com/appium/appium-xcuitest-driver/issues/2458)) ([29e9d5e](https://github.com/appium/appium-xcuitest-driver/commit/29e9d5e18c9fe2523dd586d7a32fdce870a81ff0)) + +## [7.25.0](https://github.com/appium/appium-xcuitest-driver/compare/v7.24.18...v7.25.0) (2024-08-31) + +### Features + +* add mobile:simctl to run listed simctl subcommands ([#2461](https://github.com/appium/appium-xcuitest-driver/issues/2461)) ([6aac043](https://github.com/appium/appium-xcuitest-driver/commit/6aac043d6882137e23f44da2ce2773657df3378c)) + +## [7.24.18](https://github.com/appium/appium-xcuitest-driver/compare/v7.24.17...v7.24.18) (2024-08-26) + +### Miscellaneous Chores + +* Bump appium-ios-device ([d67869e](https://github.com/appium/appium-xcuitest-driver/commit/d67869ef2243d1a7f0bd4780062e9370ab87d6c8)) + +## [7.24.17](https://github.com/appium/appium-xcuitest-driver/compare/v7.24.16...v7.24.17) (2024-08-22) + +### Miscellaneous Chores + +* **deps-dev:** bump webdriverio from 8.40.3 to 9.0.6 ([#2457](https://github.com/appium/appium-xcuitest-driver/issues/2457)) ([4d9ba89](https://github.com/appium/appium-xcuitest-driver/commit/4d9ba8937746b8fa354fc019af3ebfe439e72dee)) + ## [7.24.16](https://github.com/appium/appium-xcuitest-driver/compare/v7.24.15...v7.24.16) (2024-08-16) ### Miscellaneous Chores diff --git a/docs/reference/bidi.md b/docs/reference/bidi.md new file mode 100644 index 000000000..dbfd76915 --- /dev/null +++ b/docs/reference/bidi.md @@ -0,0 +1,47 @@ +--- +title: BiDi Protocol Support +--- + +XCUITest driver has partial support of the [BiDi Protocol](https://w3c.github.io/webdriver-bidi/) since version 7.26.0. +Only events and commands mentioned below are supported. +All other entities described in the spec throw not implemented errors. + +# Supported Events + +## log.entryAdded + +This event is emitted if the driver retrieves a new entry for any of the below log types. Logs collection might be disabled by the `appium:skipLogCapture` capability. + +### crashlog + +Events are emitted for both emulator and real devices. The latter only works if [py-ios-device](https://github.com/YueChen-C/py-ios-device) is installed on the server host. Each event contains a particular device crash report entry. +Events are always emitted with the `NATIVE_APP` context. + +### syslog + +Events are emitted for both emulator and real devices. Each event contains a single device system log line. +Events are always emitted with the `NATIVE_APP` context. + +### safariConsole + +Events are emitted for both emulator and real devices. Each event contains a single Safari console log line. +Events are always emitted with the appropriate web context name from which they were generated. +Events are only emitted if the `appium:showSafariConsoleLog` capability value is provided. + +### safariNetwork + +Events are emitted for both emulator and real devices. Each event contains a single Safari network log line. +Events are always emitted with the appropriate web context name from which they were generated. +Events are only emitted if the `appium:showSafariNetworkLog` capability value is provided. + +### performance + +Events are emitted for both emulator and real devices. Each event contains a single Safari performance log line. +Events are always emitted with the appropriate web context name from which they were generated. +Events are only emitted if the `appium:enablePerformanceLogging` capability value is provided. + +### server + +Events are emitted for both emulator and real devices. Each event contains a single Appium server log line. +Events are always emitted with the `NATIVE_APP` context. +Events are only emitted if the `get_server_logs` server security feature is enabled. diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 90fc63ff3..b9548c62e 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -80,6 +80,7 @@ about capabilities, refer to the [Appium documentation](https://appium.io/docs/e |`appium:simpleIsVisibleCheck`|Use native methods for determining visibility of elements. In some cases this takes a long time. Setting this capability to `false` will cause the system to use the position and size of elements to make sure they are visible on the screen. This can, however, lead to false results in some situations. Defaults to `false`. | `true`, `false`| | **Deprecated** `appium:waitForQuiescence`| It allows to turn on/off waiting for application quiescence in `WebDriverAgent`, while performing queries. The default value is `true`. You can avoid [this kind of issues](https://github.com/appium/appium/issues/11132) if you turn it off. Consider using `waitForIdleTimeout` capability instead for this purpose since Appium 1.20.0 | `false` | |`appium:mjpegServerPort`|The port number on which WDA broadcasts screenshots stream encoded into MJPEG format from the device under test. It might be necessary to change this value if the default port is busy because of other tests running in parallel. Default value: `9100`|`12000`| +|`appium:mjpegScreenshotUrl` | The URL of a service that provides realtime device screenshots in MJPEG format. If provided then the actual command to retrieve a screenshot will be requesting pictures from this service rather than directly from the server. Appium does not handle port forward etc to the URL. | `http://:9100` | |`appium:screenshotQuality`| Changes the initial quality of display screenshots. This capability affects the screenshoting speed and the actual quality of resulting screenshots. Before version 5.4.0 of WebDriverAgent possible values were: `0`, `1` (default), `2`, where `0` abbreviates lossless PNG, `1` is a high-quality JPEG and `2` is a low-quality JPEG. In the version 5.4.0 one more mode has been added (`3`), which is now the default one. It abbreviates lossless HEIC with fallback to PNG if the device does not support hardware-accelerated HEIC encoding. You can also change the value of screenshotQuality in [settings](settings.md). | `2` | |`appium:autoAcceptAlerts`| Accept all iOS alerts automatically if they pop up. This includes privacy access permission alerts (location, contacts, photos). Default is `false`. |`true` or `false`| |`appium:autoDismissAlerts`| Dismiss all iOS alerts automatically if they pop up. This includes privacy access permission alerts (location, contacts, photos). Default is `false`. |`true` or `false`| diff --git a/docs/reference/element-attributes.md b/docs/reference/element-attributes.md index f0002d51b..83331192a 100644 --- a/docs/reference/element-attributes.md +++ b/docs/reference/element-attributes.md @@ -9,7 +9,7 @@ The XCUITest driver supports the following element attributes: |
Name
| Description |
Example
| | --- | --- | --- | -| `name` | Could contain either element's [identifier](https://developer.apple.com/documentation/xctest/xcuielementattributes/1500981-identifier?language=objc) or its [label](https://developer.apple.com/documentation/xctest/xcuielementattributes/1500692-label?language=objc), depending on which one is available first. Could also be `null`. It is recommended to prefer the usage of [accessibilityIdentifier](https://developer.apple.com/documentation/uikit/uiaccessibilityidentification/1623132-accessibilityidentifier) over [accessibilityLabel](https://developer.apple.com/documentation/objectivec/nsobject/1615181-accessibilitylabel) for automation purposes, since the `identifier` property is supposed to stay constant under different locales and does not affect accessibility services such as VoiceOver. | `hello` | +| `name` | Could contain either element's [identifier](https://developer.apple.com/documentation/xctest/xcuielementattributes/1500981-identifier?language=objc) or its [label](https://developer.apple.com/documentation/xctest/xcuielementattributes/1500692-label?language=objc), depending on which one is available first. Could also be `null`. It is recommended to prefer the usage of [accessibilityIdentifier](https://developer.apple.com/documentation/uikit/uiaccessibilityidentification/1623132-accessibilityidentifier) over [accessibilityLabel](https://developer.apple.com/documentation/objectivec/nsobject/1615181-accessibilitylabel) for automation purposes, since the `identifier` property is supposed to stay constant under different locales and does not affect accessibility services such as VoiceOver. In applications written using [ReactNative](https://reactnative.dev/) framework this attribute reflects the value of the `testID` property. | `hello` | | `label` | Element's [label](https://developer.apple.com/documentation/xctest/xcuielementattributes/1500692-label?language=objc) value. Could be `null`. Since XCUITest driver 4.7.3 (WebDriverAgent 4.8.0), the behavior of this value was better aligned with XCTest, so it could include line breaks (`\n`). Before this version, line breaks were replaced by spaces. | `hello`, `hello\nworld` | | `type` | Element's [type](https://developer.apple.com/documentation/xctest/xcuielementattributes/1500614-elementtype?language=objc) name | `XCUIElementTypeButton` | | `visible` | Whether the element is visible. This value is not available in the "vanilla" XCTest and is read directly from the accessibility layer | `false` | diff --git a/docs/reference/execute-methods.md b/docs/reference/execute-methods.md index 6c05840e0..96ea84dd8 100644 --- a/docs/reference/execute-methods.md +++ b/docs/reference/execute-methods.md @@ -2086,3 +2086,23 @@ Same as for [mobile: startXCTestScreenRecording](#mobile-startxctestscreenrecord Name | Type | Description | Example --- | --- | --- | --- payload | string | Base64-encoded content of the recorded media file if `remotePath` parameter is empty/null or an empty string otherwise. The resulting media is expected to a be a valid QuickTime movie (.mov). | `YXBwaXVt....` + +### mobile: simctl + +Runs the given command as a subcommand of `xcrun simctl` against the device under test. +Does not work for real devices. + +#### Arguments +Name | Type | Required | Description | Example +--- | --- | --- | --- | --- +command | string | yes | a subcommand for the `simctl`. Available commands are boot, get_app_container, getenv, icloud_sync, install, install_app_data, io, keychain, launch, location, logverbose, openurl, pbcopy, pbpaste, privacy, push, shutdown, spawn, status_bar, terminate, ui, and uninstall. Please check each usage details with `xcrun simctl help`. | `'getenv'` +args | array | no | array of string as arguments for the command after ``. For example `getenv` subcommand accept `simctl getenv `. The `` will be filled out automatically. This `args` should be the ` ` part only. | `['HOME']` +timeout | number | no | Command timeout in milliseconds. If the command blocks for longer than this timeout then an exception is going to be thrown. The default timeout is `600000` ms. | `10000` + +#### Returned Result + +Name | Type | Description | Example +--- | --- | --- | --- +stdout | string | The standard output of the command. | `'/Users/user/Library/Developer/CoreSimulator/Devices/60EB8FDB-92E0-4895-B466-0153C6DE7BAE/data\n'` +stderr | string | The standard error of the command. | `''` (an empty string) +code | string | The status code of the command. | `0` diff --git a/lib/commands/context.js b/lib/commands/context.js index b2ba44286..f7f0fda2d 100644 --- a/lib/commands/context.js +++ b/lib/commands/context.js @@ -3,8 +3,8 @@ import {errors, isErrorType} from 'appium/driver'; import {util, timing} from 'appium/support'; import IOSPerformanceLog from '../device-log/ios-performance-log'; import _ from 'lodash'; +import { NATIVE_WIN } from '../utils'; -const NATIVE_WIN = 'NATIVE_APP'; const WEBVIEW_WIN = 'WEBVIEW'; const WEBVIEW_BASE = `${WEBVIEW_WIN}_`; const DEFAULT_REMOTE_DEBUGGER_CONNECT_TIMEOUT_MS = 5000; @@ -556,12 +556,18 @@ const commands = { // attempt to start performance logging, if requested if (this.opts.enablePerformanceLogging && this.remote) { - this.log.debug(`Starting performance log on '${this.curContext}'`); - this.logs.performance = new IOSPerformanceLog({ - remoteDebugger: this.remote, - log: this.log, - }); - await this.logs.performance.startCapture(); + const context = this.curContext; + this.log.debug(`Starting performance log on '${context}'`); + [this.logs.performance,] = this.assignBiDiLogListener( + new IOSPerformanceLog({ + remoteDebugger: this.remote, + log: this.log, + }), { + type: 'performance', + context, + } + ); + await this.logs.performance?.startCapture(); } // start safari logging if the logs handlers are active diff --git a/lib/commands/index.js b/lib/commands/index.js index ff180a9d1..8e643c358 100644 --- a/lib/commands/index.js +++ b/lib/commands/index.js @@ -37,6 +37,7 @@ import recordAudioExtensions from './record-audio'; import recordScreenExtensions from './recordscreen'; import screenshotExtensions from './screenshots'; import sourceExtensions from './source'; +import simctl from './simctl'; import timeoutExtensions from './timeouts'; import webExtensions from './web'; import xctestExtensions from './xctest'; @@ -80,6 +81,7 @@ export default { proxyHelperExtensions, recordAudioExtensions, recordScreenExtensions, + simctl, screenshotExtensions, sourceExtensions, timeoutExtensions, diff --git a/lib/commands/log.js b/lib/commands/log.js index 87a1ad622..439e0adf6 100644 --- a/lib/commands/log.js +++ b/lib/commands/log.js @@ -4,11 +4,11 @@ import {DEFAULT_WS_PATHNAME_PREFIX} from 'appium/driver'; import {IOSCrashLog} from '../device-log/ios-crash-log'; import {IOSSimulatorLog} from '../device-log/ios-simulator-log'; import {IOSDeviceLog} from '../device-log/ios-device-log'; -import log from '../logger'; import WebSocket from 'ws'; import SafariConsoleLog from '../device-log/safari-console-log'; import SafariNetworkLog from '../device-log/safari-network-log'; import { toLogEntry } from '../device-log/helpers'; +import { NATIVE_WIN, BIDI_EVENT_NAME } from '../utils'; /** * Determines the websocket endpoint based on the `sessionId` @@ -17,9 +17,22 @@ import { toLogEntry } from '../device-log/helpers'; */ const WEBSOCKET_ENDPOINT = (sessionId) => `${DEFAULT_WS_PATHNAME_PREFIX}/session/${sessionId}/appium/device/syslog`; - +const COLOR_CODE_PATTERN = /\u001b\[(\d+(;\d+)*)?m/g; // eslint-disable-line no-control-regex const GET_SERVER_LOGS_FEATURE = 'get_server_logs'; +/** + * + * @param {Object} x + * @returns {import('./types').LogEntry} + */ +function nativeLogEntryToSeleniumEntry (x) { + const msg = _.isEmpty(x.prefix) ? x.message : `[${x.prefix}] ${x.message}`; + return toLogEntry( + _.replace(msg, COLOR_CODE_PATTERN, ''), + /** @type {any} */ (x).timestamp ?? Date.now() + ); +} + /** * @type {import('@appium/types').LogDefRecord} * @privateRemarks The return types for these getters should be specified @@ -52,10 +65,7 @@ const SUPPORTED_LOG_TYPES = { */ getter: (self) => { self.assertFeatureEnabled(GET_SERVER_LOGS_FEATURE); - return log.unwrap().record.map((x) => toLogEntry( - _.isEmpty(x.prefix) ? x.message : `[${x.prefix}] ${x.message}`, - /** @type {any} */ (x).timestamp ?? Date.now() - )); + return self.log.unwrap().record.map(nativeLogEntryToSeleniumEntry); }, }, }; @@ -97,44 +107,107 @@ export default { ); }, + /** + * https://w3c.github.io/webdriver-bidi/#event-log-entryAdded + * + * @template {import('node:events').EventEmitter} EE + * @this {XCUITestDriver} + * @param {EE} logEmitter + * @param {BiDiListenerProperties} properties + * @returns {[EE, import('./types').LogListener]} + */ + assignBiDiLogListener (logEmitter, properties) { + const { + type, + context = NATIVE_WIN, + srcEventName = 'output', + entryTransformer, + } = properties; + const listener = (/** @type {import('./types').LogEntry} */ logEntry) => { + const finalEntry = entryTransformer ? entryTransformer(logEntry) : logEntry; + this.eventEmitter.emit(BIDI_EVENT_NAME, { + context, + method: 'log.entryAdded', + params: { + type, + level: finalEntry.level, + source: { + realm: '', + }, + text: finalEntry.message, + timestamp: finalEntry.timestamp, + }, + }); + }; + logEmitter.on(srcEventName, listener); + return [logEmitter, listener]; + }, + /** * @this {XCUITestDriver} */ async startLogCapture() { this.logs = this.logs || {}; if (!_.isUndefined(this.logs.syslog) && this.logs.syslog.isCapturing) { - log.warn('Trying to start iOS log capture but it has already started!'); + this.log.warn('Trying to start iOS log capture but it has already started!'); return true; } + if (_.isUndefined(this.logs.syslog)) { - this.logs.crashlog = new IOSCrashLog({ - 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({ - udid: this.opts.udid, - showLogs: this.opts.showIOSLog, - log: this.log, - }) - : new IOSSimulatorLog({ + [this.logs.crashlog,] = this.assignBiDiLogListener( + new IOSCrashLog({ sim: /** @type {import('appium-ios-simulator').Simulator} */ (this.device), - showLogs: this.opts.showIOSLog, - iosSimulatorLogsPredicate: this.opts.iosSimulatorLogsPredicate, + udid: this.isRealDevice() ? this.opts.udid : undefined, log: this.log, - }); + }), { + type: 'crashlog', + } + ); + [this.logs.syslog,] = this.assignBiDiLogListener( + this.isRealDevice() + ? new IOSDeviceLog({ + udid: this.opts.udid, + showLogs: this.opts.showIOSLog, + log: this.log, + }) + : new IOSSimulatorLog({ + sim: /** @type {import('appium-ios-simulator').Simulator} */ (this.device), + showLogs: this.opts.showIOSLog, + iosSimulatorLogsPredicate: this.opts.iosSimulatorLogsPredicate, + log: this.log, + }), + { + type: 'syslog', + } + ); if (_.isBoolean(this.opts.showSafariConsoleLog)) { - this.logs.safariConsole = new SafariConsoleLog({ - showLogs: this.opts.showSafariConsoleLog, - log: this.log, - }); + [this.logs.safariConsole,] = this.assignBiDiLogListener( + new SafariConsoleLog({ + showLogs: this.opts.showSafariConsoleLog, + log: this.log, + }), { + type: 'safariConsole', + } + ); } if (_.isBoolean(this.opts.showSafariNetworkLog)) { - this.logs.safariNetwork = new SafariNetworkLog({ - showLogs: this.opts.showSafariNetworkLog, - log: this.log, - }); + [this.logs.safariNetwork,] = this.assignBiDiLogListener( + new SafariNetworkLog({ + showLogs: this.opts.showSafariNetworkLog, + log: this.log, + }), { + type: 'safariNetwork', + } + ); + } + if (this.isFeatureEnabled(GET_SERVER_LOGS_FEATURE)) { + [, this._bidiServerLogListener] = this.assignBiDiLogListener( + this.log.unwrap(), { + type: 'server', + srcEventName: 'log', + entryTransformer: nativeLogEntryToSeleniumEntry, + } + ); } } @@ -143,15 +216,15 @@ export default { const promises = [ (async () => { try { - await this.logs.syslog.startCapture(); + await this.logs.syslog?.startCapture(); didStartSyslog = true; this.eventEmitter.emit('syslogStarted', this.logs.syslog); } catch (err) { - log.debug(err.stack); - log.warn(`Continuing without capturing device logs: ${err.message}`); + this.log.debug(err.stack); + this.log.warn(`Continuing without capturing device logs: ${err.message}`); } })(), - this.logs.crashlog.startCapture(), + this.logs.crashlog?.startCapture() ?? B.resolve(), ]; await B.all(promises); @@ -179,13 +252,13 @@ export default { ).getWebSocketHandlers(pathname), ) ) { - log.debug( + this.log.debug( `The system logs broadcasting web socket server is already listening at ${pathname}`, ); return; } - log.info(`Assigning system logs broadcasting web socket server to ${pathname}`); + this.log.info(`Assigning system logs broadcasting web socket server to ${pathname}`); // https://github.com/websockets/ws/blob/master/doc/ws.md const wss = new WebSocket.Server({ noServer: true, @@ -195,9 +268,9 @@ export default { const remoteIp = _.isEmpty(req.headers['x-forwarded-for']) ? req.connection?.remoteAddress : req.headers['x-forwarded-for']; - log.debug(`Established a new system logs listener web socket connection from ${remoteIp}`); + this.log.debug(`Established a new system logs listener web socket connection from ${remoteIp}`); } else { - log.debug('Established a new system logs listener web socket connection'); + this.log.debug('Established a new system logs listener web socket connection'); } if (_.isEmpty(this._syslogWebsocketListener)) { @@ -207,11 +280,11 @@ export default { } }; } - this.logs.syslog.on('output', this._syslogWebsocketListener); + this.logs.syslog?.on('output', this._syslogWebsocketListener); ws.on('close', (code, reason) => { if (!_.isEmpty(this._syslogWebsocketListener)) { - this.logs.syslog.removeListener('output', this._syslogWebsocketListener); + this.logs.syslog?.removeListener('output', this._syslogWebsocketListener); this._syslogWebsocketListener = null; } @@ -222,7 +295,7 @@ export default { if (!_.isEmpty(reason)) { closeMsg += ` Reason: ${reason.toString()}.`; } - log.debug(closeMsg); + this.log.debug(closeMsg); }); }); await /** @type {AppiumServer} */ (this.server).addWebSocketHandler( @@ -243,7 +316,7 @@ export default { return; } - log.debug('Stopping the system logs broadcasting web socket server'); + this.log.debug('Stopping the system logs broadcasting web socket server'); await /** @type {AppiumServer} */ (this.server).removeWebSocketHandler(pathname); }, }; @@ -259,3 +332,11 @@ export default { /** * @typedef {import('@appium/types').AppiumServer} AppiumServer */ + +/** + * @typedef {Object} BiDiListenerProperties + * @property {string} type + * @property {string} [srcEventName='output'] + * @property {string} [context=NATIVE_WIN] + * @property {(x: Object) => import('./types').LogEntry} [entryTransformer] + */ diff --git a/lib/commands/simctl.js b/lib/commands/simctl.js new file mode 100644 index 000000000..228bf1584 --- /dev/null +++ b/lib/commands/simctl.js @@ -0,0 +1,75 @@ +import { errors } from 'appium/driver'; + +/** + * List of subcommands for `simctl` we provide as mobile simctl command. + * They accept 'device' target. + */ +const SUBCOMMANDS_HAS_DEVICE = [ + 'boot', + 'get_app_container', + 'getenv', + 'icloud_sync', + 'install', + 'install_app_data', + 'io', + 'keychain', + 'launch', + 'location', + 'logverbose', + 'openurl', + 'pbcopy', + 'pbpaste', + 'privacy', + 'push', + 'shutdown', + 'spawn', + 'status_bar', + 'terminate', + 'ui', + 'uninstall' +]; + +const commands = { + /** + * Run the given command with arguments as `xcrun simctl` subcommand. + * This method works behind the 'simctl' security flag. + * @this {XCUITestDriver} + * @param {string} command Subcommand to run with `xcrun simctl` + * @param {string[]} [args=[]] arguments for the subcommand. The arguments should be after in the help. + * @param {number|undefined} timeout - The maximum number of milliseconds + * @returns {Promise} + * @throws {Error} If the simctl subcommand command returns non-zero return code, or the given subcommand was invalid. + */ + async mobileSimctl(command, args = [], timeout = undefined) { + if (!this.isSimulator()) { + throw new errors.UnsupportedOperationError(`Only simulator is supported.`); + }; + + if (!this.opts.udid) { + throw new errors.InvalidArgumentError(`Unknown device or simulator UDID: '${this.opts.udid}'`); + } + + if (!SUBCOMMANDS_HAS_DEVICE.includes(command)) { + throw new errors.InvalidArgumentError(`The given command '${command}' is not supported. ` + + `Available subcommands are ${SUBCOMMANDS_HAS_DEVICE.join(',')}`); + } + + return await /** @type {import('./../driver').Simulator} */ (this.device).simctl.exec( + command, + {args: [this.opts.udid, ...args], timeout} + ); + } +}; + +export default {...commands}; + +/** + * @typedef {Object} SimctlExecResponse + * @property {string} stdout The output of standard out. + * @property {string} stderr The output of standard error. + * @property {number} code Return code. + */ + +/** + * @typedef {import('../driver').XCUITestDriver} XCUITestDriver + */ diff --git a/lib/commands/types.ts b/lib/commands/types.ts index 2b7ec7544..acb255596 100644 --- a/lib/commands/types.ts +++ b/lib/commands/types.ts @@ -572,3 +572,5 @@ export interface LogEntry { level: string, message: string; } + +export type LogListener = (logEntry: LogEntry) => any; diff --git a/lib/doctor/optional-checks.js b/lib/doctor/optional-checks.js index 3b0850a3e..03f7ee29d 100644 --- a/lib/doctor/optional-checks.js +++ b/lib/doctor/optional-checks.js @@ -39,7 +39,7 @@ export const optionalIdbCheck = new OptionalIdbCommandCheck(); /** @satisfies {import('@appium/types').IDoctorCheck} */ export class OptionalApplesimutilsCommandCheck { - README_LINK = 'https://github.com/appium/appium-xcuitest-driver/blob/master/docs/execute-methods.md#mobile-setpermission'; + README_LINK = 'https://github.com/appium/appium-xcuitest-driver/blob/master/docs/reference/execute-methods.md#mobile-setpermission'; async diagnose() { const applesimutilsPath = await resolveExecutablePath('applesimutils'); diff --git a/lib/driver.js b/lib/driver.js index 5727b518d..0b36601bb 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -269,6 +269,12 @@ export class XCUITestDriver extends BaseDriver { /** @type {import('appium-remote-debugger').RemoteDebugger|null} */ remote; + /** @type {DriverLogs} */ + logs; + + /** @type {import('./commands/types').LogListener|undefined} */ + _bidiServerLogListener; + /** * * @param {XCUITestDriverOpts} opts @@ -318,6 +324,7 @@ export class XCUITestDriver extends BaseDriver { this._audioRecorder = null; this.appInfosCache = new AppInfosCache(this.log); this.remote = null; + this.doesSupportBidi = true; // Whether to skip the device check in a new session request. // This is a workaround for network-connected devices such as an Apple TV 4k tvOS 17+ real device. @@ -325,7 +332,6 @@ export class XCUITestDriver extends BaseDriver { // over appium-ios-device for example. // Please remove this capability when we no longer need this workaround. this.skipDeviceCheck = this.opts.skipDeviceCheck || false; - } async onSettingsUpdate(key, value) { @@ -990,10 +996,12 @@ export class XCUITestDriver extends BaseDriver { } } - if (!_.isEmpty(this.logs)) { - await this.logs.syslog.stopCapture(); - this.logs = {}; + await this.logs.syslog?.stopCapture(); + _.values(this.logs).forEach((x) => x.removeAllListeners()); + if (this._bidiServerLogListener) { + this.log.unwrap().off('log', this._bidiServerLogListener); } + this.logs = {}; if (this.mjpegStream) { this.log.info('Closing MJPEG stream'); @@ -1892,6 +1900,7 @@ export class XCUITestDriver extends BaseDriver { execute = commands.executeExtensions.execute; executeAsync = commands.executeExtensions.executeAsync; executeMobile = commands.executeExtensions.executeMobile; + mobileSimctl = commands.simctl.mobileSimctl; /*--------------+ | FILEMOVEMENT | @@ -2025,6 +2034,7 @@ export class XCUITestDriver extends BaseDriver { extractLogs = commands.logExtensions.extractLogs; supportedLogTypes = commands.logExtensions.supportedLogTypes; startLogCapture = commands.logExtensions.startLogCapture; + assignBiDiLogListener = commands.logExtensions.assignBiDiLogListener; mobileStartLogsBroadcast = commands.logExtensions.mobileStartLogsBroadcast; mobileStopLogsBroadcast = commands.logExtensions.mobileStopLogsBroadcast; @@ -2198,3 +2208,12 @@ export default XCUITestDriver; * @typedef {import('appium-xcode').XcodeVersion} XcodeVersion * @typedef {import('appium-ios-simulator').Simulator} Simulator */ + +/** + * @typedef {Object} DriverLogs + * @property {import('./device-log/ios-device-log').IOSDeviceLog|import('./device-log/ios-simulator-log').IOSSimulatorLog} [syslog] + * @property {import('./device-log/ios-crash-log').IOSCrashLog} [crashlog] + * @property {import('./device-log/safari-console-log').SafariConsoleLog} [safariConsole] + * @property {import('./device-log/safari-network-log').SafariNetworkLog} [safariNetwork] + * @property {import('./device-log/ios-performance-log').IOSPerformanceLog} [performance] + */ diff --git a/lib/execute-method-map.ts b/lib/execute-method-map.ts index f40fcb39c..519149d3f 100644 --- a/lib/execute-method-map.ts +++ b/lib/execute-method-map.ts @@ -548,4 +548,11 @@ export const executeMethodMap = { command: 'background', params: {optional: ['seconds']}, }, + 'mobile: simctl': { + command: 'mobileSimctl', + params: { + required: ['command'], + optional: ['args', 'timeout'], + }, + }, } as const satisfies ExecuteMethodMap; diff --git a/lib/real-device.js b/lib/real-device.js index 9d84b696b..af1998bfa 100644 --- a/lib/real-device.js +++ b/lib/real-device.js @@ -314,18 +314,11 @@ export class RealDevice { * @param {import('./driver').XCUITestDriverOpts} opts * @returns {Promise} */ - async reset({bundleId, fullReset, platformVersion}) { - if (!bundleId) { - return; - } - - if (bundleId === SAFARI_BUNDLE_ID) { - this.log.debug('Reset requested. About to terminate Safari'); - await this.terminateApp(bundleId, String(platformVersion)); - return; - } - - if (!fullReset) { + async reset({bundleId, fullReset}) { + if (!bundleId || !fullReset || bundleId === SAFARI_BUNDLE_ID) { + // Safari cannot be removed as system app. + // Safari process handling will be managed by WDA + // with noReset, forceAppLaunch or shouldTerminateApp capabilities. return; } diff --git a/lib/utils.js b/lib/utils.js index 501b7c19d..918eee3a1 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -20,6 +20,8 @@ const XCTEST_LOG_FILES_PATTERNS = [ /^StandardOutputAndStandardError\.txt$/i, ]; const XCTEST_LOGS_CACHE_FOLDER_PREFIX = 'com.apple.dt.XCTest'; +export const NATIVE_WIN = 'NATIVE_APP'; +export const BIDI_EVENT_NAME = 'bidiEvent'; /** * @privateRemarks Is the minimum version really Xcode 7.3? diff --git a/mkdocs.yml b/mkdocs.yml index 54731fc30..b10b0a140 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ nav: - preparation/prov-profile-generic-manual.md - Reference: - reference/scripts.md + - reference/bidi.md - Server Configuration: - reference/server-args.md - reference/security-flags.md diff --git a/package.json b/package.json index 784b32b6e..64c8b628b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "xcuitest", "xctest" ], - "version": "7.24.16", + "version": "7.26.4", "author": "Appium Contributors", "license": "Apache-2.0", "repository": { @@ -78,7 +78,7 @@ "dependencies": { "@colors/colors": "^1.6.0", "appium-idb": "^1.6.13", - "appium-ios-device": "^2.5.4", + "appium-ios-device": "^2.7.23", "appium-ios-simulator": "^6.1.7", "appium-remote-debugger": "^12.1.1", "appium-webdriveragent": "^8.9.1", @@ -92,7 +92,7 @@ "lru-cache": "^10.0.0", "moment": "^2.29.4", "moment-timezone": "^0.x", - "node-simctl": "^7.1.17", + "node-simctl": "^7.6.0", "portscanner": "^2.2.0", "semver": "^7.5.4", "source-map-support": "^0.x", @@ -158,11 +158,11 @@ "rimraf": "^5.0.1", "semantic-release": "^24.0.0", "sharp": "^0.x", - "sinon": "^18.0.0", + "sinon": "^19.0.2", "ts-node": "^10.9.1", "type-fest": "^4.1.0", "typescript": "^5.4.2", - "webdriverio": "^8.14.3" + "webdriverio": "^9.0.6" }, "overrides": { "http-cache-semantics": "4.1.1" diff --git a/test/unit/commands/simctl-specs.js b/test/unit/commands/simctl-specs.js new file mode 100644 index 000000000..e1acf8c37 --- /dev/null +++ b/test/unit/commands/simctl-specs.js @@ -0,0 +1,76 @@ +import sinon from 'sinon'; +import XCUITestDriver from '../../../lib/driver'; +import Simctl from 'node-simctl'; + + +describe('general commands', function () { + const driver = new XCUITestDriver(); + const simctl = new Simctl(); + driver._device = { simctl }; + + let chai; + let mockSimctl; + + before(async function () { + chai = await import('chai'); + chai.should(); + }); + + beforeEach(function () { + mockSimctl = sinon.mock(driver.device.simctl); + }); + + afterEach(function () { + mockSimctl.verify(); + }); + + describe('simctl', function () { + it('should call xcrun simctl', async function () { + driver.opts.udid = '60EB8FDB-92E0-4895-B466-0153C6DE7BAE'; + driver.isSimulator = () => true; + mockSimctl.expects('exec').once().withExactArgs( + 'getenv', + {args: ['60EB8FDB-92E0-4895-B466-0153C6DE7BAE', 'HOME'], timeout: undefined} + ); + await driver.mobileSimctl('getenv', ['HOME']); + }); + + it('should call xcrun simctl with timeout', async function () { + driver.opts.udid = '60EB8FDB-92E0-4895-B466-0153C6DE7BAE'; + driver.isSimulator = () => true; + mockSimctl.expects('exec').once().withExactArgs( + 'getenv', + {args: ['60EB8FDB-92E0-4895-B466-0153C6DE7BAE', 'HOME'], timeout: 10000} + ); + await driver.mobileSimctl('getenv', ['HOME'], 10000); + }); + + it('should raise an error as not supported command', async function () { + driver.opts.udid = '60EB8FDB-92E0-4895-B466-0153C6DE7BAE'; + driver.isSimulator = () => true; + mockSimctl.expects('exec').never(); + await driver.mobileSimctl( + 'list', + ['devices', 'booted', '--json'] + ).should.eventually.be.rejected; + }); + + it('should raise an error as no udid', async function () { + driver.opts.udid = null; + driver.isSimulator = () => true; + mockSimctl.expects('exec').never(); + await driver.mobileSimctl( + 'getenv', ['HOME'] + ).should.eventually.be.rejected; + }); + + it('should raise an error for non-simulator', async function () { + driver.opts.udid = '60EB8FDB-92E0-4895-B466-0153C6DE7BAE'; + driver.isSimulator = () => false; + mockSimctl.expects('exec').never(); + await driver.mobileSimctl( + 'getenv', ['HOME'] + ).should.eventually.be.rejected; + }); + }); +});