This is an automated email from the ASF dual-hosted git repository.
erisu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cordova-ios.git
The following commit(s) were added to refs/heads/master by this push:
new 8c2f680b feat(devicectl): Use devicectl for deploying to devices
(#1636)
8c2f680b is described below
commit 8c2f680bd148389eff925481ec4aef9ea168d6ce
Author: Darryl Pogue <[email protected]>
AuthorDate: Mon Oct 12 22:33:19 2026 -0700
feat(devicectl): Use devicectl for deploying to devices (#1636)
* feat(devicectl): Use devicectl for deploying to devices
* test(devicectl): cover run.js deployment and fix regressions (#6)
* test(devicectl): support read-only bplist-parser exports
---------
Co-authored-by: Manuel Beck <[email protected]>
---
lib/listDevices.js | 47 ++--
lib/run.js | 129 ++++++----
package-lock.json | 22 +-
package.json | 11 +
tests/spec/fixtures/sample-ioreg-output.txt | 66 ++++++
tests/spec/list-devices.spec.js | 48 ----
tests/spec/listDevices.spec.js | 97 ++++++++
tests/spec/run-devicectl.spec.js | 352 ++++++++++++++++++++++++++++
8 files changed, 662 insertions(+), 110 deletions(-)
diff --git a/lib/listDevices.js b/lib/listDevices.js
index a4f978d1..dab4d81e 100644
--- a/lib/listDevices.js
+++ b/lib/listDevices.js
@@ -17,26 +17,45 @@
under the License.
*/
-const execa = require('execa');
-
-const DEVICE_REGEX = /-o (iPhone|iPad|iPod)@.*?"USB Serial Number" =
"([^"]*)"/gs;
+const childProcess = require('node:child_process');
+const devicectl = require('devicectl');
/**
- * Gets list of connected iOS devices
- * @return {Promise} Promise fulfilled with list of available iOS devices
+ * Gets list of available iOS devices for deployment.
+ * @return {Promise} Promise fulfilled with list of available iOS devices.
*/
function listDevices () {
- return execa('ioreg', ['-p', 'IOUSB', '-l'])
- .then(({ stdout }) => {
- return [...matchAll(stdout, DEVICE_REGEX)]
- .map(m => m.slice(1).reverse().join(' '));
- });
+ const availableDevices = listFromDeviceCtl();
+ const connectedDevices = listFromUSB();
+
+ // We prefer devicectl for newer devices, so filter out any duplicate
+ // devices from the ioreg list.
+ // Sadly the UDID format is very slightly different between the two...
+ const targets = availableDevices.map(d => d.target.replace('-', ''));
+ const devices = [].concat(availableDevices, connectedDevices.filter(d =>
!targets.includes(d.target)));
+
+ return Promise.resolve(devices.map(d => `${d.target} ${d.name}`));
}
-// TODO: Should be replaced with String#matchAll once available
-function * matchAll (s, r) {
- let match;
- while ((match = r.exec(s))) yield match;
+function listFromDeviceCtl () {
+ const result = devicectl.list().json.result.devices;
+
+ return result
+ .filter((d) => d.connectionProperties.transportType === 'wired')
+ .map((d) => ({
+ target: d.hardwareProperties.udid,
+ name: `${d.deviceProperties.name}
(${d.hardwareProperties.marketingName}, ${d.deviceProperties.osVersionNumber})`
+ }));
+}
+
+const DEVICE_REGEX = /-o (iPhone|iPad|iPod)@.*?"USB Serial Number" =
"([^"]*)"/gs;
+function listFromUSB () {
+ const result = childProcess.spawnSync('ioreg', ['-p', 'IOUSB', '-l'], {
encoding: 'utf8' });
+
+ return [...result.stdout.matchAll(DEVICE_REGEX)].map((m) => ({
+ target: m.pop(),
+ name: m.slice(1).reverse().join(' ')
+ }));
}
exports.run = listDevices;
diff --git a/lib/run.js b/lib/run.js
index 9df8231f..fccaa552 100644
--- a/lib/run.js
+++ b/lib/run.js
@@ -22,6 +22,7 @@ const path = require('node:path');
const bplist = require('bplist-parser');
const plist = require('plist');
const execa = require('execa');
+const devicectl = require('devicectl');
const { CordovaError, events } = require('cordova-common');
const build = require('./build');
const check_reqs = require('./check_reqs');
@@ -64,7 +65,11 @@ module.exports.run = function (runOptions) {
// we also explicitly set device flag in options as we
pass
// those parameters to other api (build as an example)
runOptions.device = true;
- return check_reqs.check_ios_deploy();
+ runOptions.target = runOptions.target ||
devices[0].split(' ')[0];
+
+ if (!runOptions.target.includes('-')) {
+ return check_reqs.check_ios_deploy();
+ }
}
});
}
@@ -90,7 +95,7 @@ module.exports.run = function (runOptions) {
const buildOutputDir = path.join(projectPath, 'build',
`${configuration}-iphoneos`);
const appPath = path.join(buildOutputDir,
`${productName}.app`);
- return module.exports.checkDeviceConnected()
+ return module.exports.checkDeviceConnected(runOptions.target)
.then(() => {
// Unpack IPA
const ipafile = path.join(buildOutputDir,
`${productName}.ipa`);
@@ -172,8 +177,20 @@ function filterSupportedArgs (args) {
* Checks if any iOS device is connected
* @return {Promise} Fullfilled when any device is connected, rejected
otherwise
*/
-function checkDeviceConnected () {
- return execa('ios-deploy', ['-c', '-t', '1'], { stdio: 'inherit' });
+function checkDeviceConnected (target = '') {
+ if (target.includes('-')) {
+ const details = devicectl.info(devicectl.InfoTypes.Details,
target).json?.result;
+
+ return new Promise((resolve, reject) => {
+ if (details?.connectionProperties?.transportType === 'wired') {
+ resolve();
+ } else {
+ reject(new CordovaError(`Run destination device '${target}' is
not connected.`));
+ }
+ });
+ } else {
+ return execa('ios-deploy', ['-c', '-t', '1'], { stdio: 'inherit' });
+ }
}
/**
@@ -182,15 +199,35 @@ function checkDeviceConnected () {
* @param {String} appPath Path to application package
* @return {Promise} Resolves when deploy succeeds otherwise rejects
*/
-function deployToDevice (appPath, target, extraArgs) {
+async function deployToDevice (appPath, target = '', extraArgs = []) {
events.emit('log', 'Deploying to device');
- const args = ['--justlaunch', '-d', '-b', appPath];
- if (target) {
- args.push('-i', target);
+ if (target.includes('-')) {
+ const installation = devicectl.install(target, appPath, { stdio:
'inherit' });
+ if (installation.error) {
+ throw new CordovaError(`Failed to install app on device
'${target}'.`, installation.error);
+ }
+ if (installation.status !== 0) {
+ throw new CordovaError(`Failed to install app on device
'${target}' (exit code ${installation.status}).`);
+ }
+
+ const appIdentifier = await getBundleIdentifierFromApp(appPath);
+ const launch = devicectl.launch(target, appIdentifier, extraArgs, {
stdio: 'inherit', console: true });
+ if (launch.error) {
+ throw new CordovaError(`Failed to launch app on device
'${target}'.`, launch.error);
+ }
+ if (launch.status !== 0) {
+ throw new CordovaError(`Failed to launch app on device '${target}'
(exit code ${launch.status}).`);
+ }
+ return launch;
} else {
- args.push('--no-wifi');
+ const args = ['--justlaunch', '-d', '-b', appPath];
+ if (target) {
+ args.push('-i', target);
+ } else {
+ args.push('--no-wifi');
+ }
+ return execa('ios-deploy', args.concat(extraArgs), { stdio: 'inherit'
});
}
- return execa('ios-deploy', args.concat(extraArgs), { stdio: 'inherit' });
}
/**
@@ -223,48 +260,56 @@ async function deployToSim (appPath, target) {
return module.exports.startSim(appPath, target);
}
+function getBundleIdentifierFromApp (appPath) {
+ const infoPlistPath = path.join(appPath, 'Info.plist');
+ if (!fs.existsSync(infoPlistPath)) {
+ return Promise.reject(new Error(`${infoPlistPath} file not found.`));
+ }
+
+ return new Promise((resolve, reject) => {
+ bplist.parseFile(infoPlistPath, (err, obj) => {
+ try {
+ if (err) {
+ obj = plist.parse(fs.readFileSync(infoPlistPath, 'utf8'));
+ if (obj) {
+ return resolve(obj.CFBundleIdentifier);
+ } else {
+ return reject(err);
+ }
+ } else {
+ return resolve(obj[0].CFBundleIdentifier);
+ }
+ } catch (parseError) {
+ reject(parseError);
+ }
+ });
+ });
+}
+
async function startSim (appPath, target) {
const projectPath = path.join(path.dirname(appPath), '../..');
const logPath = path.join(projectPath, 'cordova/console.log');
const deviceTypeId = `com.apple.CoreSimulator.SimDeviceType.${target}`;
try {
- const infoPlistPath = path.join(appPath, 'Info.plist');
- if (!fs.existsSync(infoPlistPath)) {
- throw new Error(`${infoPlistPath} file not found.`);
- }
-
- bplist.parseFile(infoPlistPath, function (err, obj) {
- let appIdentifier;
-
- if (err) {
- obj = plist.parse(fs.readFileSync(infoPlistPath, 'utf8'));
- if (obj) {
- appIdentifier = obj.CFBundleIdentifier;
- } else {
- throw err;
- }
- } else {
- appIdentifier = obj[0].CFBundleIdentifier;
- }
+ const appIdentifier = await getBundleIdentifierFromApp(appPath);
- // get the deviceid from --devicetypeid
- // --devicetypeid is a string in the form "devicetype,
runtime_version" (optional: runtime_version)
- const device = getDeviceFromDeviceTypeId(deviceTypeId);
+ // get the deviceid from --devicetypeid
+ // --devicetypeid is a string in the form "devicetype,
runtime_version" (optional: runtime_version)
+ const device = getDeviceFromDeviceTypeId(deviceTypeId);
- // so now we have the deviceid, we can proceed
- try {
- startSimulator(device, { appPath, appIdentifier, logPath,
waitForDebugger: false });
- } catch (e) {
- events.emit('warn', `Failed to start simulator with error:
"${e.message}"`);
- }
+ // so now we have the deviceid, we can proceed
+ try {
+ startSimulator(device, { appPath, appIdentifier, logPath,
waitForDebugger: false });
+ } catch (e) {
+ events.emit('warn', `Failed to start simulator with error:
"${e.message}"`);
+ }
- if (logPath) {
- events.emit('log', `Log Path: ${path.resolve(logPath)}`);
- }
+ if (logPath) {
+ events.emit('log', `Log Path: ${path.resolve(logPath)}`);
+ }
- process.exit(0);
- });
+ process.exit(0);
} catch (e) {
events.emit('warn', `Failed to launch simulator with error:
${e.message}`);
process.exit(1);
diff --git a/package-lock.json b/package-lock.json
index 6a364a41..9dc46e70 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"dependencies": {
"bplist-parser": "^0.5.0",
"cordova-common": "^6.0.0",
+ "devicectl": "^0.0.2",
"elementtree": "^0.1.7",
"execa": "^5.1.1",
"nopt": "^9.0.0",
@@ -310,9 +311,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved":
"https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity":
"sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "version": "1.6.0",
+ "resolved":
"https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity":
"sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
@@ -1504,6 +1505,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/devicectl": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/devicectl/-/devicectl-0.0.2.tgz",
+ "integrity":
"sha512-DbGQ7LokAC7fDki1owNXyt5XuyDiwGyR2ki9hMlnJtYe2gPFD3rK0oM9JjJl9OJ/33/9+nnjHbUyucH40xtbqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.17.0"
+ }
+ },
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -2227,9 +2237,9 @@
"license": "MIT"
},
"node_modules/fastq": {
- "version": "1.20.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
- "integrity":
"sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "version": "1.20.2",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.2.tgz",
+ "integrity":
"sha512-UpGiiODyCGprM8EPP6JodP6jC9Rws6TCuiDOD+nn0CJhR8guI3g/ozo4ugL0vJ+Yz1UtJuuRPqvQuybVOF1VQA==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
diff --git a/package.json b/package.json
index b1eede82..99daf830 100644
--- a/package.json
+++ b/package.json
@@ -36,9 +36,15 @@
"engines": {
"node": "^20.17.0 || >=22.9.0"
},
+ "exports": {
+ ".": "./lib/Api.js",
+ "./package.json": "./package.json",
+ "./lib/*": "./lib/*.js"
+ },
"dependencies": {
"bplist-parser": "^0.5.0",
"cordova-common": "^6.0.0",
+ "devicectl": "^0.0.2",
"elementtree": "^0.1.7",
"execa": "^5.1.1",
"nopt": "^9.0.0",
@@ -56,5 +62,10 @@
"lcov",
"text"
]
+ },
+ "overrides": {
+ "c8": {
+ "yargs": "^18.0.0"
+ }
}
}
diff --git a/tests/spec/fixtures/sample-ioreg-output.txt
b/tests/spec/fixtures/sample-ioreg-output.txt
index e997c2a3..4eae6ef1 100644
--- a/tests/spec/fixtures/sample-ioreg-output.txt
+++ b/tests/spec/fixtures/sample-ioreg-output.txt
@@ -24,6 +24,72 @@
| "IOKitDiagnostics" = {"Foobar"="Foobar"}
| }
|
+ +-o AppleT6000USBXHCI@02000000 <class AppleT6000USBXHCI, id 0x100000473,
registered, matched, active, busy 0 (598 ms), retain 98>
+ | | {
+ | | "IOClass" = "AppleT6000USBXHCI"
+ | | "kUSBSleepPortCurrentLimit" = 3000
+ | | "UsbHostControllerSoftRetryPolicy" = 0
+ | | "IOPersonalityPublisher" =
"com.apple.driver.usb.AppleSynopsysUSB40XHCI"
+ | | "IOMatchedAtBoot" = Yes
+ | | "IOPowerManagement" =
{"ChildrenPowerState"=3,"DevicePowerState"=0,"CurrentPowerState"=3,"CapabilityFlags"=32768,"MaxPowerState"=3,"DriverPowerState"=0}
+ | | "IOProviderClass" = "AppleARMIODevice"
+ | | "IOProbeScore" = 10002
+ | | "locationID" = 33554432
+ | | "kUSBWakePortCurrentLimit" = 3000
+ | | "IONameMatch" = "usb-drd,t6000"
+ | | "CFBundleIdentifierKernel" =
"com.apple.driver.usb.AppleSynopsysUSB40XHCI"
+ | | "UsbHostControllerDeferRegisterService" = Yes
+ | | "IOMatchCategory" = "usb-host"
+ | | "CFBundleIdentifier" = "com.apple.driver.usb.AppleSynopsysUSB40XHCI"
+ | | "Revision" = <0103>
+ | | "IONameMatched" = "usb-drd,t6000"
+ | | "UsbHostControllerUSB4LPMPolicy" = 1
+ | | "UsbHostControllerTierLimit" = 6
+ | | "controller-statistics" =
{"kControllerStatIOCount"=46575,"kControllerStatPowerStateTime"={"kPowerStateInitialize"="0ms
(0%)","kPowerStateOff"="327655037ms (97%)","kPowerStateSle$
+ | | "kUSBSleepSupported" = Yes
+ | | }
+ | |
+ | +-o iPad@02100000 <class IOUSBHostDevice, id 0x1003271c8, registered,
matched, active, busy 0 (276 ms), retain 62>
+ | {
+ | "kUSBSerialNumberString" = "IPADMINI_UDID"
+ | "bDeviceClass" = 0
+ | "UsbAppleDeviceECID" = 6259374711472158
+ | "bDeviceSubClass" = 0
+ | "iSerialNumber" = 3
+ | "UsbPowerSinkCapability" = 2400
+ | "IOServiceDEXTEntitlements" =
(("com.apple.developer.driverkit.transport.usb"))
+ | "iProduct" = 2
+ | "USB Serial Number" = "IPADMINI_UDID"
+ | "USB Vendor Name" = "Apple Inc."
+ | "USBSpeed" = 3
+ | "IOPowerManagement" =
{"PowerOverrideOn"=Yes,"DevicePowerState"=2,"CurrentPowerState"=2,"CapabilityFlags"=32768,"MaxPowerState"=2,"DriverPowerState"=0}
+ | "bNumConfigurations" = 5
+ | "kUSBProductString" = "iPad"
+ | "kCDCDoNotMatchThisDevice" = Yes
+ | "kCallInterfaceOpenWithGate" = Yes
+ | "kUSBVendorString" = "Apple Inc."
+ | "USB Product Name" = "iPad"
+ | "iManufacturer" = 1
+ | "idVendor" = 1452
+ | "Device Speed" = 2
+ | "kUSBCurrentConfiguration" = 5
+ | "idProduct" = 4779
+ | "bcdDevice" = 5121
+ | "UsbDeviceSignature" =
<ac05ab120114303030303831313030303136334344453345333138303145000000060101010100010200030000fffe02fffd01020d000a0001>
+ | "sessionID" = 7864622857660
+ | "USB Address" = 1
+ | "kUSBPreferredConfiguration" = 3
+ | "IOCFPlugInTypes" =
{"9dc7b780-9ec0-11d4-a54f-000a27052861"="IOUSBHostFamily.kext/Contents/PlugIns/IOUSBLib.bundle"}
+ | "SupportsIPhoneOS" = Yes
+ | "USBPortType" = 0
+ | "bDeviceProtocol" = 0
+ | "locationID" = 34603008
+ | "kUSBAddress" = 1
+ | "bcdUSB" = 528
+ | "IOGeneralInterest" = "IOCommand is not serializable"
+ | "bMaxPacketSize0" = 64
+ | }
+ |
+-o AppleUSBXHCI Root Hub Simulation@14000000 <class AppleUSBRootHubDevice,
id 0x100020cab, registered, matched, active, busy 0 (2 ms), retain 9>
| {
| "iManufacturer" = 0
diff --git a/tests/spec/list-devices.spec.js b/tests/spec/list-devices.spec.js
deleted file mode 100644
index 9bcaacae..00000000
--- a/tests/spec/list-devices.spec.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- Licensed to the Apache Software Foundation (ASF) under one
- or more contributor license agreements. See the NOTICE file
- distributed with this work for additional information
- regarding copyright ownership. The ASF licenses this file
- to you under the Apache License, Version 2.0 (the
- "License"); you may not use this file except in compliance
- with the License. You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing,
- software distributed under the License is distributed on an
- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- KIND, either express or implied. See the License for the
- specific language governing permissions and limitations
- under the License.
-*/
-
-const fs = require('node:fs');
-const path = require('node:path');
-const rewire = require('rewire');
-
-const list_devices = rewire('../../lib/listDevices');
-
-const sampleData = fs.readFileSync(path.resolve(__dirname,
'fixtures/sample-ioreg-output.txt'), 'utf-8');
-
-describe('cordova/lib/listDevices', () => {
- describe('run method', () => {
- let execaSpy;
-
- beforeEach(() => {
- execaSpy = jasmine.createSpy('execa').and.resolveTo({ stdout:
sampleData });
- list_devices.__set__('execa', execaSpy);
- });
-
- it('should trim and split standard output and return as array', () => {
- return list_devices.run()
- .then(results => {
- expect(execaSpy).toHaveBeenCalledWith('ioreg', ['-p',
'IOUSB', '-l']);
- expect(results).toEqual([
- 'THE_IPHONE_SERIAL iPhone',
- 'THE_IPAD_SERIAL iPad'
- ]);
- });
- });
- });
-});
diff --git a/tests/spec/listDevices.spec.js b/tests/spec/listDevices.spec.js
new file mode 100644
index 00000000..17faf32a
--- /dev/null
+++ b/tests/spec/listDevices.spec.js
@@ -0,0 +1,97 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+*/
+
+const fs = require('node:fs');
+const path = require('node:path');
+const childProcess = require('node:child_process');
+const devicectl = require('devicectl');
+
+const list_devices = require('../../lib/listDevices');
+
+const sampleData = fs.readFileSync(path.resolve(__dirname,
'fixtures/sample-ioreg-output.txt'), 'utf-8');
+
+const sampleDevicectl = {
+ result: {
+ devices: [
+ {
+ deviceProperties: {
+ name: 'test_iPad',
+ osBuildUpdate: '23D127',
+ osVersionNumber: '26.3'
+ },
+ connectionProperties: {
+ transportType: 'wired'
+ },
+ hardwareProperties: {
+ deviceType: 'iPad',
+ marketingName: 'iPad mini (6th generation)',
+ platform: 'iOS',
+ productType: 'iPad14,1',
+ thinningProductType: 'iPad14,1',
+ udid: 'IPAD-MINI_UDID'
+ },
+ identifier: '8F721AAF-DEDC-4D98-9098-AEF2E78DAA36'
+ },
+ {
+ deviceProperties: {
+ name: 'test_iPad2',
+ osBuildUpdate: '23D127',
+ osVersionNumber: '26.3'
+ },
+ connectionProperties: {
+ transportType: 'localNetwork'
+ },
+ hardwareProperties: {
+ deviceType: 'iPad',
+ marketingName: 'iPad mini (6th generation)',
+ platform: 'iOS',
+ productType: 'iPad14,1',
+ thinningProductType: 'iPad14,1',
+ udid: 'IPAD_MINI_2_UDID'
+ },
+ identifier: '4F3355A3-505E-4533-B36A-3DB1C902D1C4'
+ }
+ ]
+ }
+};
+
+describe('listDevices', () => {
+ describe('run method', () => {
+ let spawnMock;
+ beforeEach(function () {
+ spawnMock = spyOn(childProcess, 'spawnSync');
+ });
+
+ it('should trim and split standard output and return as array', () => {
+ spawnMock.and.returnValue({ stdout: sampleData });
+ spyOn(devicectl, 'list').and.returnValue({ json: sampleDevicectl
});
+
+ return list_devices.run()
+ .then(results => {
+ expect(spawnMock).toHaveBeenCalledWith('ioreg', ['-p',
'IOUSB', '-l'], { encoding: 'utf8' });
+
+ expect(results).toEqual([
+ 'IPAD-MINI_UDID test_iPad (iPad mini (6th generation),
26.3)',
+ 'THE_IPHONE_SERIAL iPhone',
+ 'THE_IPAD_SERIAL iPad'
+ ]);
+ });
+ });
+ });
+});
diff --git a/tests/spec/run-devicectl.spec.js b/tests/spec/run-devicectl.spec.js
new file mode 100644
index 00000000..f5303af3
--- /dev/null
+++ b/tests/spec/run-devicectl.spec.js
@@ -0,0 +1,352 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+*/
+
+const fs = require('node:fs');
+const path = require('node:path');
+// Since bplist-parser 0.5.0, exports are read-only; spy on an isolated copy.
+const bplist = { ...require('bplist-parser') };
+const plist = require('plist');
+const devicectl = require('devicectl');
+const { CordovaError, events } = require('cordova-common');
+const build = require('../../lib/build');
+const checkReqs = require('../../lib/check_reqs');
+const projectFile = require('../../lib/projectFile');
+
+const simctlHelper = require('../../lib/simctlHelper');
+const target = '00008110-0000000000000001';
+const legacyTarget = '000081100000000000000001';
+const appPath = path.join('/test', 'project', 'build', 'Debug-iphoneos',
'Test.app');
+const infoPath = path.join(appPath, 'Info.plist');
+const bundleId = 'org.apache.cordova.test';
+
+describe('cordova/lib/run devicectl deployment', () => {
+ let run;
+ let exec;
+ let getDevice;
+ let start;
+
+ beforeAll(() => {
+ // Load a separate instance with mocked subprocesses, then restore the
cache.
+ // Unlike rewire, this preserves source locations in V8 coverage
reports.
+ const runPath = require.resolve('../../lib/run');
+ const execaModule = require.cache[require.resolve('execa')];
+ const bplistModule = require.cache[require.resolve('bplist-parser')];
+ const originalRun = require.cache[runPath];
+ const originalExeca = execaModule.exports;
+ const originalBplist = bplistModule.exports;
+ const originalGetDevice = simctlHelper.getDeviceFromDeviceTypeId;
+ const originalStart = simctlHelper.startSimulator;
+ try {
+ execaModule.exports = (...args) => exec(...args);
+ bplistModule.exports = bplist;
+ simctlHelper.getDeviceFromDeviceTypeId = (...args) =>
getDevice(...args);
+ simctlHelper.startSimulator = (...args) => start(...args);
+ delete require.cache[runPath];
+ run = require('../../lib/run');
+ } finally {
+ execaModule.exports = originalExeca;
+ bplistModule.exports = originalBplist;
+ simctlHelper.getDeviceFromDeviceTypeId = originalGetDevice;
+ simctlHelper.startSimulator = originalStart;
+ if (originalRun) require.cache[runPath] = originalRun;
+ else delete require.cache[runPath];
+ }
+ });
+
+ beforeEach(() => {
+ exec = jasmine.createSpy('execa').and.resolveTo({});
+ spyOn(events, 'emit');
+ });
+
+ describe('target selection', () => {
+ beforeEach(() => {
+ run.root = path.join('/test', 'project');
+ run.locations = { pbxproj:
'/test/project/Test.xcodeproj/project.pbxproj' };
+ spyOn(run, 'execListDevices').and.resolveTo([`${target} Test
iPhone`]);
+ spyOn(checkReqs, 'check_ios_deploy').and.resolveTo();
+ spyOn(build, 'run').and.resolveTo();
+ spyOn(projectFile, 'parse').and.returnValue({
+ xcode: { getBuildProperty: () => 'Test' }
+ });
+ spyOn(run, 'checkDeviceConnected').and.resolveTo();
+ spyOn(run, 'deployToDevice').and.resolveTo();
+ spyOn(run, 'deployToSim').and.resolveTo();
+ spyOn(fs, 'rmSync');
+ spyOn(fs, 'renameSync');
+ });
+
+ it('selects the first device and deploys using its devicectl UDID',
async () => {
+ await run.run({ argv: ['node', 'run', '--device', '--nobuild',
'--verbose'] });
+ expect(checkReqs.check_ios_deploy).not.toHaveBeenCalled();
+ expect(build.run).toHaveBeenCalledWith(jasmine.objectContaining({
device: true, target }));
+ expect(run.checkDeviceConnected).toHaveBeenCalledWith(target);
+ expect(exec).toHaveBeenCalledWith('unzip', ['-o', '-qq',
path.join(path.dirname(appPath), 'Test.ipa')],
+ { cwd: path.dirname(appPath), stdio: 'inherit' });
+
expect(fs.renameSync).toHaveBeenCalledWith(path.join(path.dirname(appPath),
'Payload', 'Test.app'), appPath);
+ expect(run.deployToDevice).toHaveBeenCalledWith(appPath, target,
['--verbose']);
+ });
+
+ it('checks ios-deploy for an automatically selected legacy target',
async () => {
+ run.execListDevices.and.resolveTo([`${legacyTarget} Old iPhone`]);
+ await run.run({});
+ expect(checkReqs.check_ios_deploy).toHaveBeenCalled();
+ expect(run.deployToDevice).toHaveBeenCalledWith(appPath,
legacyTarget, []);
+ });
+
+ it('preserves an explicit target instead of overwriting it with the
first device', async () => {
+ await run.run({ device: true, target: legacyTarget });
+ expect(checkReqs.check_ios_deploy).toHaveBeenCalled();
+
expect(run.checkDeviceConnected).toHaveBeenCalledWith(legacyTarget);
+ expect(run.deployToDevice).toHaveBeenCalledWith(appPath,
legacyTarget, []);
+ });
+
+ it('does not require ios-deploy for an explicit devicectl target',
async () => {
+ run.execListDevices.and.resolveTo([`${legacyTarget} Old iPhone`]);
+ await run.run({ device: true, target });
+ expect(checkReqs.check_ios_deploy).not.toHaveBeenCalled();
+ expect(run.checkDeviceConnected).toHaveBeenCalledWith(target);
+ });
+
+ it('does not attempt deployment when the legacy tool requirement
fails', async () => {
+ run.execListDevices.and.resolveTo([`${legacyTarget} Old iPhone`]);
+ checkReqs.check_ios_deploy.and.rejectWith(new Error('ios-deploy
missing'));
+ await expectAsync(run.run({})).toBeRejectedWithError('ios-deploy
missing');
+ expect(build.run).not.toHaveBeenCalled();
+ expect(run.deployToDevice).not.toHaveBeenCalled();
+ });
+
+ it('retains simulator selection when no devices are found', async ()
=> {
+ run.execListDevices.and.resolveTo([]);
+ await run.run({});
+ expect(checkReqs.check_ios_deploy).not.toHaveBeenCalled();
+ expect(run.deployToSim).toHaveBeenCalledWith(
+ path.join(run.root, 'build', 'Debug-iphonesimulator',
'Test.app'), undefined);
+ });
+
+ it('retains the connection failure fallback without unpacking or
installing', async () => {
+ run.checkDeviceConnected.and.rejectWith(new Error('Disconnected'));
+ await run.run({ device: true, target });
+ expect(exec).not.toHaveBeenCalled();
+ expect(run.deployToDevice).not.toHaveBeenCalled();
+ expect(run.deployToSim).toHaveBeenCalledWith(appPath, target);
+ });
+ });
+
+ describe('connection checks', () => {
+ let info;
+ beforeEach(() => {
+ info = spyOn(devicectl, 'info');
+ });
+
+ it('accepts a wired devicectl device', async () => {
+ info.and.returnValue({ json: { result: { connectionProperties: {
transportType: 'wired' } } } });
+ await expectAsync(run.checkDeviceConnected(target)).toBeResolved();
+ expect(info).toHaveBeenCalledWith(devicectl.InfoTypes.Details,
target);
+ expect(exec).not.toHaveBeenCalled();
+ });
+
+ for (const result of [undefined, {}, { connectionProperties: {} }, {
connectionProperties: { transportType: 'localNetwork' } }]) {
+ it(`rejects a non-wired or unavailable device:
${JSON.stringify(result)}`, async () => {
+ info.and.returnValue({ json: { result } });
+ await
expectAsync(run.checkDeviceConnected(target)).toBeRejectedWithError(
+ CordovaError, `Run destination device '${target}' is not
connected.`);
+ expect(exec).not.toHaveBeenCalled();
+ });
+ }
+
+ it('rejects when devicectl returns no JSON', async () => {
+ info.and.returnValue({});
+ await
expectAsync(run.checkDeviceConnected(target)).toBeRejectedWithError(CordovaError);
+ });
+
+ for (const device of [legacyTarget, undefined]) {
+ it(`uses ios-deploy for ${device || 'an unspecified target'}`,
async () => {
+ await run.checkDeviceConnected(device);
+ expect(exec).toHaveBeenCalledWith('ios-deploy', ['-c', '-t',
'1'], { stdio: 'inherit' });
+ expect(info).not.toHaveBeenCalled();
+ });
+ }
+ });
+
+ describe('install and launch', () => {
+ let install;
+ let launch;
+ beforeEach(() => {
+ install = spyOn(devicectl, 'install').and.returnValue({ status: 0
});
+ launch = spyOn(devicectl, 'launch').and.returnValue({ status: 0 });
+ spyOn(fs, 'existsSync').and.returnValue(true);
+ spyOn(bplist, 'parseFile').and.callFake((file, callback) =>
callback(null, [{ CFBundleIdentifier: bundleId }]));
+ });
+
+ it('installs before launching with the bundle identifier and supplied
arguments', async () => {
+ const args = ['--verbose'];
+ await run.deployToDevice(appPath, target, args);
+ expect(install).toHaveBeenCalledWith(target, appPath, { stdio:
'inherit' });
+ expect(bplist.parseFile).toHaveBeenCalledWith(infoPath,
jasmine.any(Function));
+ expect(launch).toHaveBeenCalledWith(target, bundleId, args, {
stdio: 'inherit', console: true });
+ expect(install).toHaveBeenCalledBefore(launch);
+ expect(exec).not.toHaveBeenCalled();
+ });
+
+ it('defaults launch arguments to an empty array', async () => {
+ await run.deployToDevice(appPath, target);
+ expect(launch).toHaveBeenCalledWith(target, bundleId, [], { stdio:
'inherit', console: true });
+ });
+
+ it('reads an XML plist when binary parsing fails', async () => {
+ bplist.parseFile.and.callFake((file, callback) => callback(new
Error('Not binary')));
+ spyOn(fs, 'readFileSync').and.returnValue(plist.build({
CFBundleIdentifier: bundleId }));
+ await run.deployToDevice(appPath, target, []);
+ expect(fs.readFileSync).toHaveBeenCalledWith(infoPath, 'utf8');
+ expect(launch).toHaveBeenCalledWith(target, bundleId, [],
jasmine.any(Object));
+ });
+
+ it('rejects a missing plist without launching', async () => {
+ fs.existsSync.and.returnValue(false);
+ await expectAsync(run.deployToDevice(appPath, target,
[])).toBeRejectedWithError(`${infoPath} file not found.`);
+ expect(launch).not.toHaveBeenCalled();
+ });
+
+ it('rejects XML parsing errors delivered by an asynchronous binary
parser', async () => {
+ const error = new Error('Invalid XML');
+ bplist.parseFile.and.callFake((file, callback) => setImmediate(()
=> callback(new Error('Not binary'))));
+ spyOn(fs, 'readFileSync').and.returnValue('invalid');
+ spyOn(plist, 'parse').and.throwError(error);
+ await expectAsync(run.deployToDevice(appPath, target,
[])).toBeRejectedWith(error);
+ expect(launch).not.toHaveBeenCalled();
+ });
+
+ it('rejects XML read errors without launching', async () => {
+ const error = new Error('Cannot read plist');
+ bplist.parseFile.and.callFake((file, callback) => setImmediate(()
=> callback(new Error('Not binary'))));
+ spyOn(fs, 'readFileSync').and.throwError(error);
+ await expectAsync(run.deployToDevice(appPath, target,
[])).toBeRejectedWith(error);
+ expect(launch).not.toHaveBeenCalled();
+ });
+
+ it('rejects the binary error when XML parsing yields no plist', async
() => {
+ const error = new Error('Not binary');
+ bplist.parseFile.and.callFake((file, callback) => callback(error));
+ spyOn(fs, 'readFileSync').and.returnValue('');
+ spyOn(plist, 'parse').and.returnValue(null);
+ await expectAsync(run.deployToDevice(appPath, target,
[])).toBeRejectedWith(error);
+ expect(launch).not.toHaveBeenCalled();
+ });
+
+ it('propagates a launch failure', async () => {
+ launch.and.throwError('Launch failed');
+ await expectAsync(run.deployToDevice(appPath, target,
[])).toBeRejectedWithError('Launch failed');
+ });
+
+ it('propagates an install failure without launching', async () => {
+ install.and.throwError('Install failed');
+ await expectAsync(run.deployToDevice(appPath, target,
[])).toBeRejectedWithError('Install failed');
+ expect(launch).not.toHaveBeenCalled();
+ });
+
+ for (const status of [1, null]) {
+ it(`rejects unsuccessful installation status ${status} without
launching`, async () => {
+ install.and.returnValue({ status });
+ await expectAsync(run.deployToDevice(appPath, target,
[])).toBeRejectedWithError(
+ CordovaError, `Failed to install app on device '${target}'
(exit code ${status}).`);
+ expect(launch).not.toHaveBeenCalled();
+ });
+
+ it(`rejects unsuccessful launch status ${status}`, async () => {
+ launch.and.returnValue({ status });
+ await expectAsync(run.deployToDevice(appPath, target,
[])).toBeRejectedWithError(
+ CordovaError, `Failed to launch app on device '${target}'
(exit code ${status}).`);
+ });
+ }
+
+ for (const operation of ['install', 'launch']) {
+ it(`wraps a ${operation} subprocess error in CordovaError with its
cause`, async () => {
+ const error = new Error('Subprocess failed');
+ (operation === 'install' ? install : launch).and.returnValue({
error, status: null });
+ const deployment = run.deployToDevice(appPath, target, []);
+ await
expectAsync(deployment).toBeRejectedWithError(CordovaError);
+ await deployment.catch(wrappedError => {
+ expect(wrappedError.cause()).toBe(error);
+ expect(wrappedError.message).toContain(`Failed to
${operation} app on device '${target}'.`);
+ });
+ if (operation === 'install')
expect(launch).not.toHaveBeenCalled();
+ });
+ }
+
+ for (const device of [legacyTarget, '', undefined]) {
+ it(`preserves ios-deploy arguments for ${JSON.stringify(device)}`,
async () => {
+ await run.deployToDevice(appPath, device, ['--verbose']);
+ expect(exec).toHaveBeenCalledWith('ios-deploy',
+ ['--justlaunch', '-d', '-b', appPath].concat(device ?
['-i', device] : ['--no-wifi'], ['--verbose']),
+ { stdio: 'inherit' });
+ expect(install).not.toHaveBeenCalled();
+ expect(launch).not.toHaveBeenCalled();
+ });
+ }
+
+ it('propagates ios-deploy failures', async () => {
+ exec.and.rejectWith(new Error('Legacy deployment failed'));
+ await expectAsync(run.deployToDevice(appPath, legacyTarget,
[])).toBeRejectedWithError('Legacy deployment failed');
+ });
+ });
+
+ describe('simulator launch using the shared plist reader', () => {
+ beforeEach(() => {
+ getDevice =
jasmine.createSpy('getDeviceFromDeviceTypeId').and.returnValue({ udid:
'simulator' });
+ start = jasmine.createSpy('startSimulator');
+ spyOn(process, 'exit');
+ spyOn(fs, 'existsSync').and.returnValue(true);
+ spyOn(bplist, 'parseFile').and.callFake((file, callback) =>
callback(null, [{ CFBundleIdentifier: bundleId }]));
+ });
+
+ it('starts the selected simulator with the extracted bundle
identifier', async () => {
+ await run.startSim(appPath, 'iPhone 17');
+
expect(getDevice).toHaveBeenCalledWith('com.apple.CoreSimulator.SimDeviceType.iPhone
17');
+ expect(start).toHaveBeenCalledWith({ udid: 'simulator' }, {
+ appPath,
+ appIdentifier: bundleId,
+ logPath: path.join('/test', 'project', 'cordova',
'console.log'),
+ waitForDebugger: false
+ });
+ expect(process.exit).toHaveBeenCalledWith(0);
+ });
+
+ it('reports plist failures and exits with failure', async () => {
+ fs.existsSync.and.returnValue(false);
+ await run.startSim(appPath, 'iPhone 17');
+ expect(events.emit).toHaveBeenCalledWith('warn', `Failed to launch
simulator with error: ${infoPath} file not found.`);
+ expect(start).not.toHaveBeenCalled();
+ expect(process.exit).toHaveBeenCalledWith(1);
+ });
+
+ it('reports device lookup errors and exits with failure', async () => {
+ getDevice.and.throwError('Unknown simulator');
+ await run.startSim(appPath, 'iPhone 17');
+ expect(events.emit).toHaveBeenCalledWith('warn', 'Failed to launch
simulator with error: Unknown simulator');
+ expect(process.exit).toHaveBeenCalledWith(1);
+ });
+
+ it('preserves the warning for simulator startup errors', async () => {
+ start.and.throwError('Startup failed');
+ await run.startSim(appPath, 'iPhone 17');
+ expect(events.emit).toHaveBeenCalledWith('warn', 'Failed to start
simulator with error: "Startup failed"');
+ expect(process.exit).toHaveBeenCalledWith(0);
+ });
+ });
+});
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]