From 6aac043d6882137e23f44da2ce2773657df3378c Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Sat, 31 Aug 2024 14:23:26 -0700 Subject: [PATCH] feat: add mobile:simctl to run listed simctl subcommands (#2461) * add simctl command * add rough description * remove timeout * update example * add result * add a note * add test * fix lint and update types * tweak the readme * check the given command target * tweak the example * add tests * add simulator check * tweak docs * revert bad commit * add timeout as an option * add tests * bump node-simctl * tweak type * exclude addmedia --- docs/reference/execute-methods.md | 20 ++++++++ lib/commands/index.js | 2 + lib/commands/simctl.js | 75 +++++++++++++++++++++++++++++ lib/driver.js | 1 + lib/execute-method-map.ts | 7 +++ package.json | 2 +- test/unit/commands/simctl-specs.js | 76 ++++++++++++++++++++++++++++++ 7 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 lib/commands/simctl.js create mode 100644 test/unit/commands/simctl-specs.js 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/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/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/driver.js b/lib/driver.js index 4053185c7..66375ecff 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -1874,6 +1874,7 @@ export class XCUITestDriver extends BaseDriver { execute = commands.executeExtensions.execute; executeAsync = commands.executeExtensions.executeAsync; executeMobile = commands.executeExtensions.executeMobile; + mobileSimctl = commands.simctl.mobileSimctl; /*--------------+ | FILEMOVEMENT | 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/package.json b/package.json index 70cf2db25..af4337066 100644 --- a/package.json +++ b/package.json @@ -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", 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; + }); + }); +});