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]

Reply via email to