diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml
index 13c54823..3348f070 100644
--- a/.azure-pipelines.yml
+++ b/.azure-pipelines.yml
@@ -19,8 +19,8 @@ parameters:
xcodeVersion: 10.3
iosDeviceName: iPhone X
vmImage: macOS-10.14
- - iosVersion: 13
- xcodeVersion: 11
+ - iosVersion: 13.2
+ xcodeVersion: 11.2
iosDeviceName: iPhone 11
vmImage: macOS-10.15
- iosVersion: 13.7
diff --git a/azure-templates/base_unit_test_job.yml b/azure-templates/base_unit_test_job.yml
index 5aec8e20..e67c01a8 100644
--- a/azure-templates/base_unit_test_job.yml
+++ b/azure-templates/base_unit_test_job.yml
@@ -14,6 +14,8 @@ jobs:
- task: NodeTool@0
inputs:
versionSpec: "$(DEFAULT_NODE_VERSION)"
+ - script: ls /Applications
+ displayName: List Apps
- script: sudo xcode-select --switch "/Applications/Xcode_$(XCODE_VERSION).app/Contents/Developer"
displayName: Prepare Env
- script: xcrun simctl list
diff --git a/lib/defaults-utils.js b/lib/defaults-utils.js
new file mode 100644
index 00000000..0d20744a
--- /dev/null
+++ b/lib/defaults-utils.js
@@ -0,0 +1,154 @@
+import _ from 'lodash';
+import { DOMParser, XMLSerializer } from 'xmldom';
+import { exec } from 'teen_process';
+import B from 'bluebird';
+import log from './logger';
+
+/**
+ * Serializes the given value to plist-compatible
+ * XML representation, which is ready for further usage
+ * with `defaults` command line tool arguments
+ *
+ * @param {*} value The value to be serialized
+ * @param {boolean} serialize [true] Whether to serialize the resulting
+ * XML to string or to return raw HTMLElement instance
+ * @returns {HTMLElement|string} Either string or raw node representation of
+ * the given value
+ * @throws {TypeError} If it is not known how to serialize the given value
+ */
+function toXmlArg (value, serialize = true) {
+ let xmlDoc = null;
+
+ if (_.isPlainObject(value)) {
+ xmlDoc = new DOMParser().parseFromString('', 'text/xml');
+ for (const [subKey, subValue] of _.toPairs(value)) {
+ const keyEl = xmlDoc.createElement('key');
+ const keyTextEl = xmlDoc.createTextNode(subKey);
+ keyEl.appendChild(keyTextEl);
+ xmlDoc.documentElement.appendChild(keyEl);
+ const subValueEl = xmlDoc.importNode(toXmlArg(subValue, false), true);
+ xmlDoc.documentElement.appendChild(subValueEl);
+ }
+ } else if (_.isArray(value)) {
+ xmlDoc = new DOMParser().parseFromString('', 'text/xml');
+ for (const subValue of value) {
+ const subValueEl = xmlDoc.importNode(toXmlArg(subValue, false), true);
+ xmlDoc.documentElement.appendChild(subValueEl);
+ }
+ } else if (_.isBoolean(value)) {
+ xmlDoc = new DOMParser().parseFromString(value ? '' : '', 'text/xml');
+ } else if (_.isInteger(value)) {
+ xmlDoc = new DOMParser().parseFromString(`${value}`, 'text/xml');
+ } else if (_.isNumber(value)) {
+ xmlDoc = new DOMParser().parseFromString(`${value}`, 'text/xml');
+ } else if (_.isString(value)) {
+ xmlDoc = new DOMParser().parseFromString(``, 'text/xml');
+ const valueTextEl = xmlDoc.createTextNode(value);
+ xmlDoc.documentElement.appendChild(valueTextEl);
+ }
+
+ if (!xmlDoc) {
+ throw new TypeError(`The defaults value ${JSON.stringify(value)} cannot be written, ` +
+ `because it is not known how to handle its type`);
+ }
+
+ return serialize
+ ? new XMLSerializer().serializeToString(xmlDoc.documentElement)
+ : xmlDoc.documentElement;
+}
+
+/**
+ * Generates command line args for the `defaults`
+ * command line utility based on the given preference values mapping.
+ * See https://shadowfile.inode.link/blog/2018/06/advanced-defaults1-usage/
+ * for more details.
+ *
+ * @param {Object} valuesMap Preferences mapping
+ * @returns {Array>} Each item in the array
+ * is the `defaults write ` command suffix
+ */
+function generateUpdateCommandArgs (valuesMap) {
+ const resultArgs = [];
+ for (const [key, value] of _.toPairs(valuesMap)) {
+ try {
+ if (_.isPlainObject(value)) {
+ const dictArgs = [key, '-dict-add'];
+ for (const [subKey, subValue] of _.toPairs(value)) {
+ dictArgs.push(subKey, toXmlArg(subValue));
+ }
+ resultArgs.push(dictArgs);
+ } else if (_.isArray(value)) {
+ const arrayArgs = [key, '-array-add'];
+ for (const subValue of arrayArgs) {
+ arrayArgs.push(toXmlArg(subValue));
+ }
+ resultArgs.push(arrayArgs);
+ } else {
+ resultArgs.push([key, toXmlArg(value)]);
+ }
+ } catch (e) {
+ if (e instanceof TypeError) {
+ log.warn(e.message);
+ } else {
+ throw e;
+ }
+ }
+ }
+ return resultArgs;
+}
+
+
+class NSUserDefaults {
+ constructor (plist) {
+ this.plist = plist;
+ }
+
+ /**
+ * Reads the content of the given plist file using plutil command line tool
+ * and serializes it to a JSON representation
+ *
+ * @returns {Object} The serialized plist content
+ * @throws {Error} If there was an error during serialization
+ */
+ async asJson () {
+ try {
+ const {stdout} = await exec('plutil', ['-convert', 'json', '-o', '-', this.plist]);
+ return JSON.parse(stdout);
+ } catch (e) {
+ throw new Error(`'${this.plist}' cannot be converted to JSON. Original error: ${e.stderr || e.message}`);
+ }
+ }
+
+ /**
+ * Updates the content of the given plist file.
+ * If the plist does not exist yet then it is going to be created.
+ *
+ * @param {Object} valuesMap Mapping of preference values to update.
+ * If any of item values are of dictionary type then only the first level dictionary gets
+ * updated. Everything below this level will be replaced. This is the known limitation
+ * of the `defaults` command line tool. A workaround for it would be to read the current
+ * preferences mapping first and merge it with this value.
+ * @throws {Error} If there was an error while updating the plist
+ */
+ async update (valuesMap) {
+ if (!_.isPlainObject(valuesMap)) {
+ throw new TypeError(`plist values must be a map. '${valuesMap}' is given instead`);
+ }
+ if (_.isEmpty(valuesMap)) {
+ return;
+ }
+
+ const commandArgs = generateUpdateCommandArgs(valuesMap);
+ try {
+ await B.all(commandArgs.map((args) => exec('defaults', ['write', this.plist, ...args])));
+ } catch (e) {
+ throw new Error(`Could not write defaults into '${this.plist}'. Original error: ${e.stderr || e.message}`);
+ }
+ }
+}
+
+
+export {
+ NSUserDefaults,
+ toXmlArg, generateUpdateCommandArgs,
+};
diff --git a/lib/simulator-xcode-6.js b/lib/simulator-xcode-6.js
index 2911b96f..9822987e 100644
--- a/lib/simulator-xcode-6.js
+++ b/lib/simulator-xcode-6.js
@@ -470,18 +470,8 @@ class SimulatorXcode6 extends EventEmitter {
* @property {?string} scaleFactor [null] - Defines the window scale value for the UI client window for the current Simulator.
* Equals to null by default, which keeps the current scale unchanged.
* It should be one of ['1.0', '0.75', '0.5', '0.33', '0.25'].
- * @property {boolean} connectHardwareKeyboard [false] - Whether to connect the hardware keyboard to the
- * Simulator UI client. Defaults to false.
- * @property {string} pasteboardAutomaticSync ['off'] - Whether to disable pasteboard sync with the
- * Simulator UI client or respect the system wide preference. 'on', 'off', or 'system' is available.
- * The sync increases launching simulator process time, but it allows system to sync pasteboard
- * with simulators. Follows system-wide preference if the value is 'system'.
- * Defaults to 'off'.
* @property {number} startupTimeout [60000] - Number of milliseconds to wait until Simulator booting
* process is completed. The default timeout will be used if not set explicitly.
- * @property {?boolean} tracePointer [false] - Whether to highlight touches on Simulator
- * screen. This is helpful while debugging automated tests or while observing the automation
- * recordings.
*/
/**
@@ -492,9 +482,6 @@ class SimulatorXcode6 extends EventEmitter {
opts = _.cloneDeep(opts);
_.defaultsDeep(opts, {
scaleFactor: null,
- connectHardwareKeyboard: false,
- pasteboardAutomaticSync: 'off',
- tracePointer: false,
startupTimeout: this.startupTimeout,
});
@@ -502,7 +489,6 @@ class SimulatorXcode6 extends EventEmitter {
const args = [
'-Fn', simulatorApp,
'--args', '-CurrentDeviceUDID', this.udid,
- '-RotateWindowWhenSignaledByGuest', '1',
];
if (opts.scaleFactor) {
@@ -512,36 +498,6 @@ class SimulatorXcode6 extends EventEmitter {
args.push(argumentName, opts.scaleFactor);
}
- if (_.isBoolean(opts.connectHardwareKeyboard)) {
- args.push('-ConnectHardwareKeyboard', `${+opts.connectHardwareKeyboard}`);
- }
-
- if (opts.tracePointer === true) {
- args.push(
- '-ShowSingleTouches', '1',
- '-ShowPinches', '1',
- '-ShowPinchPivotPoint', '1',
- '-HighlightEdgeGestures', '1'
- );
- }
-
- switch (_.lowerCase(opts.pasteboardAutomaticSync)) {
- case 'on':
- args.push('-PasteboardAutomaticSync', '1');
- break;
- case 'off':
- // Improve launching simulator performance
- // https://github.com/WebKit/webkit/blob/master/Tools/Scripts/webkitpy/xcode/simulated_device.py#L413
- args.push('-PasteboardAutomaticSync', '0');
- break;
- case 'system':
- // Do not add -PasteboardAutomaticSync
- break;
- default:
- log.warn(`['on', 'off' or 'system'] are available as the pasteboard automatic sync option. Defaulting to 'off'.`);
- args.push('-PasteboardAutomaticSync', '0');
- }
-
log.info(`Starting Simulator UI with command: open ${args.join(' ')}`);
try {
await exec('open', args, {timeout: opts.startupTimeout});
@@ -684,7 +640,7 @@ class SimulatorXcode6 extends EventEmitter {
log.debug('Attempting to launch and quit the simulator, to create directory structure');
log.debug(`Will launch with Safari? ${safari}`);
- await this.run(startupTimeout);
+ await this.run({startupTimeout});
if (safari) {
await this.openUrl('http://www.appium.io');
diff --git a/lib/simulator-xcode-9.js b/lib/simulator-xcode-9.js
index d7bcb012..19a5f7f0 100644
--- a/lib/simulator-xcode-9.js
+++ b/lib/simulator-xcode-9.js
@@ -1,11 +1,12 @@
import SimulatorXcode8 from './simulator-xcode-8';
import _ from 'lodash';
import path from 'path';
-import { fs, plist, timing } from 'appium-support';
+import { fs, timing } from 'appium-support';
import AsyncLock from 'async-lock';
import log from './logger';
import { waitForCondition, retryInterval } from 'asyncbox';
import { toBiometricDomainComponent, getDeveloperRoot } from './utils.js';
+import { NSUserDefaults } from './defaults-utils';
const SIMULATOR_SHUTDOWN_TIMEOUT = 15 * 1000;
const startupLock = new AsyncLock();
@@ -53,6 +54,11 @@ class SimulatorXcode9 extends SimulatorXcode8 {
* @property {?boolean} tracePointer [false] - Whether to highlight touches on Simulator
* screen. This is helpful while debugging automated tests or while observing the automation
* recordings.
+ * @property {string} pasteboardAutomaticSync ['off'] - Whether to disable pasteboard sync with the
+ * Simulator UI client or respect the system wide preference. 'on', 'off', or 'system' is available.
+ * The sync increases launching simulator process time, but it allows system to sync pasteboard
+ * with simulators. Follows system-wide preference if the value is 'system'.
+ * Defaults to 'off'.
* @property {DevicePreferences} devicePreferences: preferences of the newly created Simulator
* device
*/
@@ -71,6 +77,7 @@ class SimulatorXcode9 extends SimulatorXcode8 {
isHeadless: false,
startupTimeout: this.startupTimeout,
});
+
if (opts.scaleFactor) {
opts.devicePreferences.SimulatorWindowLastScale = parseFloat(opts.scaleFactor);
}
@@ -79,13 +86,34 @@ class SimulatorXcode9 extends SimulatorXcode8 {
const commonPreferences = {
RotateWindowWhenSignaledByGuest: true
};
- if (_.isBoolean(opts.connectHardwareKeyboard)) {
- opts.devicePreferences.ConnectHardwareKeyboard = opts.connectHardwareKeyboard;
- commonPreferences.ConnectHardwareKeyboard = opts.connectHardwareKeyboard;
+ if (_.isBoolean(opts.connectHardwareKeyboard) || _.isNil(opts.connectHardwareKeyboard)) {
+ opts.devicePreferences.ConnectHardwareKeyboard = opts.connectHardwareKeyboard ?? false;
+ commonPreferences.ConnectHardwareKeyboard = opts.connectHardwareKeyboard ?? false;
+ }
+ if (_.isBoolean(opts.tracePointer)) {
+ commonPreferences.ShowSingleTouches = opts.tracePointer;
+ commonPreferences.ShowPinches = opts.tracePointer;
+ commonPreferences.ShowPinchPivotPoint = opts.tracePointer;
+ commonPreferences.HighlightEdgeGestures = opts.tracePointer;
}
- if (!_.isEmpty(opts.devicePreferences) || !_.isEmpty(commonPreferences)) {
- await this.updatePreferences(opts.devicePreferences, commonPreferences);
+ switch (_.lowerCase(opts.pasteboardAutomaticSync)) {
+ case 'on':
+ commonPreferences.PasteboardAutomaticSync = true;
+ break;
+ case 'off':
+ // Improve launching simulator performance
+ // https://github.com/WebKit/webkit/blob/master/Tools/Scripts/webkitpy/xcode/simulated_device.py#L413
+ commonPreferences.PasteboardAutomaticSync = false;
+ break;
+ case 'system':
+ // Do not add -PasteboardAutomaticSync
+ break;
+ default:
+ log.info(`['on', 'off' or 'system'] are available as the pasteboard automatic sync option. Defaulting to 'off'`);
+ commonPreferences.PasteboardAutomaticSync = false;
}
+ await this.updatePreferences(opts.devicePreferences, commonPreferences);
+
const timer = new timing.Timer().start();
const shouldWaitForBoot = await startupLock.acquire(this.uiClientBundleId, async () => {
const isServerRunning = await this.isRunning();
@@ -96,7 +124,7 @@ class SimulatorXcode9 extends SimulatorXcode8 {
return false;
}
if (await this.killUIClient({pid: uiClientPid})) {
- log.info(`Detected the Simulator UI client was running and killed it. Verifying the current Simulator state...`);
+ log.info(`Detected the Simulator UI client was running and killed it. Verifying the current Simulator state`);
}
try {
// Stopping the UI client kills all running servers for some early XCode versions. This is a known bug
@@ -110,7 +138,8 @@ class SimulatorXcode9 extends SimulatorXcode8 {
}
return false;
}
- log.info(`Booting Simulator with UDID '${this.udid}' in headless mode. All UI-related capabilities are going to be ignored`);
+ log.info(`Booting Simulator with UDID '${this.udid}' in headless mode. ` +
+ `All UI-related capabilities are going to be ignored`);
await this.boot();
} else {
if (isServerRunning && uiClientPid) {
@@ -242,25 +271,33 @@ class SimulatorXcode9 extends SimulatorXcode8 {
}
this.verifyDevicePreferences(devicePrefs);
const plistPath = path.resolve(homeFolderPath, 'Library', 'Preferences', 'com.apple.iphonesimulator.plist');
- if (!await fs.hasAccess(plistPath)) {
- log.warn(`Simulator preferences file '${plistPath}' is not accessible. ` +
- `Ignoring Simulator preferences update.`);
- return false;
- }
- let newPrefs = {};
- if (!_.isEmpty(devicePrefs)) {
- newPrefs.DevicePreferences = {[this.udid.toUpperCase()]: devicePrefs};
- }
- newPrefs = _.merge(newPrefs, commonPrefs);
return await preferencesPlistGuard.acquire(SimulatorXcode9.name, async () => {
+ const defaults = new NSUserDefaults(plistPath);
+ const prefsToUpdate = _.clone(commonPrefs);
try {
- const currentPlistContent = await plist.parsePlistFile(plistPath);
- await plist.updatePlistFile(plistPath, _.merge(currentPlistContent, newPrefs), true);
- log.debug(`Updated ${this.udid} Simulator preferences at '${plistPath}' with ${JSON.stringify(newPrefs)}`);
+ if (!_.isEmpty(devicePrefs)) {
+ let existingDevicePrefs;
+ const udidKey = this.udid.toUpperCase();
+ if (await fs.exists(plistPath)) {
+ const currentPlistContent = await defaults.asJson();
+ if (_.isPlainObject(currentPlistContent.DevicePreferences)
+ && _.isPlainObject(currentPlistContent.DevicePreferences[udidKey])) {
+ existingDevicePrefs = currentPlistContent.DevicePreferences[udidKey];
+ }
+ }
+ Object.assign(prefsToUpdate, {
+ DevicePreferences: {
+ [udidKey]: Object.assign({}, existingDevicePrefs || {}, devicePrefs)
+ }
+ });
+ }
+ await defaults.update(prefsToUpdate);
+ log.debug(`Updated ${this.udid} Simulator preferences at '${plistPath}' with ` +
+ JSON.stringify(prefsToUpdate));
return true;
} catch (e) {
log.warn(`Cannot update ${this.udid} Simulator preferences at '${plistPath}'. ` +
- `Try to delete the file manually in order to reset it. Original error: ${e.message}`);
+ `Try to delete the file manually in order to reset it. Original error: ${e.message}`);
return false;
}
});
diff --git a/package.json b/package.json
index 13037bb0..3348d59e 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,8 @@
"node-simctl": "^6.4.0",
"semver": "^7.0.0",
"source-map-support": "^0.5.3",
- "teen_process": "^1.3.0"
+ "teen_process": "^1.3.0",
+ "xmldom": "^0.4.0"
},
"scripts": {
"clean": "rm -rf node_modules && rm -f package-lock.json && npm install",
diff --git a/test/unit/defaults-utils-specs.js b/test/unit/defaults-utils-specs.js
new file mode 100644
index 00000000..177ea590
--- /dev/null
+++ b/test/unit/defaults-utils-specs.js
@@ -0,0 +1,69 @@
+import { toXmlArg, generateUpdateCommandArgs } from '../../lib/defaults-utils';
+import chai, { expect } from 'chai';
+
+chai.should();
+
+describe('defaults-utils', function () {
+
+ describe('toXmlArg', function () {
+
+ it('could properly convert simple value types to a XML representation', function () {
+ for (const [actual, expected] of [
+ [1, '1'],
+ [1.1, '1.1'],
+ ['1', '1'],
+ [true, ''],
+ [false, ''],
+ ]) {
+ toXmlArg(actual).should.eql(expected);
+ }
+ });
+
+ it('could properly convert array value types to a XML representation', function () {
+ toXmlArg([1.1, false]).should.eql('1.1');
+ });
+
+ it('could properly convert dict value types to a XML representation', function () {
+ toXmlArg({k1: true, k2: {k3: 1.1, k4: []}}).should.eql(
+ 'k1k2k31.1k4');
+ });
+
+ it('fails to convert an unknown value type', function () {
+ expect(() => toXmlArg(null)).to.throw;
+ });
+
+ });
+
+ describe('generateUpdateCommandArgs', function () {
+
+ it('could properly generate command args for simple value types', function () {
+ generateUpdateCommandArgs({
+ k1: 1,
+ k2: 1.1,
+ k3: '1',
+ k4: true,
+ k5: false,
+ }).should.eql([
+ ['k1', '1'],
+ ['k2', '1.1'],
+ ['k3', '1'],
+ ['k4', ''],
+ ['k5', ''],
+ ]);
+ });
+
+ it('could properly generate command args for dict value types', function () {
+ generateUpdateCommandArgs({
+ k1: {
+ k2: {
+ k3: 1,
+ },
+ }
+ }).should.eql([
+ ['k1', '-dict-add', 'k2', 'k31'],
+ ]);
+ });
+
+ });
+
+});