mobile-debug-mcp 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/android/interact.js +2 -2
  2. package/dist/android/observe.js +13 -0
  3. package/dist/cli/ios/run-ios-smoke.js +2 -2
  4. package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
  5. package/dist/interact/android.js +91 -0
  6. package/dist/interact/index.js +76 -0
  7. package/dist/interact/ios.js +120 -0
  8. package/dist/interact/shared/fingerprint.js +1 -0
  9. package/dist/interact/shared/scroll_to_element.js +1 -0
  10. package/dist/ios/interact.js +2 -2
  11. package/dist/ios/observe.js +12 -0
  12. package/dist/manage/android.js +162 -0
  13. package/dist/manage/index.js +364 -0
  14. package/dist/manage/ios.js +353 -0
  15. package/dist/observe/android.js +351 -0
  16. package/dist/observe/fingerprint.js +1 -0
  17. package/dist/observe/index.js +85 -0
  18. package/dist/observe/ios.js +320 -0
  19. package/dist/observe/test/device/logstream-real.js +34 -0
  20. package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
  21. package/dist/observe/test/device/run-scroll-test-android.js +22 -0
  22. package/dist/observe/test/device/test-ui-tree.js +67 -0
  23. package/dist/observe/test/device/wait_for_element_real.js +69 -0
  24. package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
  25. package/dist/observe/test/unit/logparse.test.js +39 -0
  26. package/dist/observe/test/unit/logstream.test.js +41 -0
  27. package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
  28. package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
  29. package/dist/server.js +41 -5
  30. package/dist/shared/fingerprint.js +72 -0
  31. package/dist/shared/scroll_to_element.js +98 -0
  32. package/dist/tools/interact.js +2 -2
  33. package/dist/tools/manage.js +2 -2
  34. package/dist/tools/observe.js +45 -43
  35. package/dist/utils/android/utils.js +373 -0
  36. package/dist/utils/cli/idb/check-idb.js +84 -0
  37. package/dist/utils/cli/idb/idb-helper.js +91 -0
  38. package/dist/utils/cli/idb/install-idb.js +82 -0
  39. package/dist/utils/cli/ios/preflight-ios.js +155 -0
  40. package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
  41. package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
  42. package/dist/utils/diagnostics.js +1 -1
  43. package/dist/utils/exec.js +34 -0
  44. package/dist/utils/ios/utils.js +301 -0
  45. package/dist/utils/resolve-device.js +2 -2
  46. package/dist/utils/ui/index.js +169 -0
  47. package/docs/CHANGELOG.md +8 -0
  48. package/docs/tools/interact.md +29 -0
  49. package/docs/tools/observe.md +24 -0
  50. package/package.json +1 -1
  51. package/src/{android/interact.ts → interact/android.ts} +3 -3
  52. package/src/{tools/interact.ts → interact/index.ts} +47 -3
  53. package/src/{ios/interact.ts → interact/ios.ts} +3 -3
  54. package/src/{android/manage.ts → manage/android.ts} +2 -2
  55. package/src/{tools/manage.ts → manage/index.ts} +7 -4
  56. package/src/{ios/manage.ts → manage/ios.ts} +1 -1
  57. package/src/{android/observe.ts → observe/android.ts} +14 -26
  58. package/src/observe/index.ts +92 -0
  59. package/src/{ios/observe.ts → observe/ios.ts} +17 -35
  60. package/src/server.ts +45 -6
  61. package/src/types.ts +1 -0
  62. package/src/{android → utils/android}/utils.ts +12 -79
  63. package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
  64. package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
  65. package/src/utils/diagnostics.ts +1 -1
  66. package/src/utils/exec.ts +33 -0
  67. package/src/{ios → utils/ios}/utils.ts +2 -2
  68. package/src/utils/resolve-device.ts +2 -2
  69. package/src/{tools/scroll_to_element.ts → utils/ui/index.ts} +73 -2
  70. package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
  71. package/test/interact/unit/wait_for_screen_change.test.ts +32 -0
  72. package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
  73. package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
  74. package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
  75. package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
  76. package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
  77. package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
  78. package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
  79. package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
  80. package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
  81. package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
  82. package/test/observe/device/run-screen-fingerprint.ts +36 -0
  83. package/test/{device/observe → observe/device}/run-scroll-test-android.ts +2 -2
  84. package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
  85. package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
  86. package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
  87. package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
  88. package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
  89. package/test/{unit/observe → observe/unit}/scroll_to_element.test.ts +3 -3
  90. package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +2 -2
  91. package/test/unit/index.ts +13 -11
  92. package/src/tools/observe.ts +0 -82
  93. package/test/device/README.md +0 -49
  94. package/test/device/index.ts +0 -27
  95. package/test/device/utils/test-dist.ts +0 -41
  96. package/test/unit/utils/detect-java.test.ts +0 -22
  97. /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
  98. /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
  99. /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
  100. /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
  101. /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
  102. /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
  103. /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { spawn } from 'child_process';
5
+ import { getIdbCmd, isIDBInstalled, commandWhich } from '../idb/idb-helper.js';
6
+ async function exists(p) {
7
+ try {
8
+ await fs.promises.access(p);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ async function findFirst(root, patterns, maxDepth = 4) {
16
+ const queue = [{ dir: root, depth: 0 }];
17
+ while (queue.length) {
18
+ const { dir, depth } = queue.shift();
19
+ try {
20
+ console.error('DEBUG findFirst: reading dir', dir, 'depth', depth);
21
+ const ents = await fs.promises.readdir(dir, { withFileTypes: true });
22
+ console.error('DEBUG findFirst: entries', ents.map(e => e.name));
23
+ for (const e of ents) {
24
+ const full = path.join(dir, e.name);
25
+ for (const p of patterns) {
26
+ if (e.name.endsWith(p)) {
27
+ console.error('DEBUG findFirst: matched', full);
28
+ return full;
29
+ }
30
+ }
31
+ if (e.isDirectory() && depth < maxDepth)
32
+ queue.push({ dir: full, depth: depth + 1 });
33
+ }
34
+ }
35
+ catch (err) {
36
+ console.error('DEBUG findFirst: read failed for', dir, err);
37
+ // ignore
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ function startCompanionIfNeeded(companionPath, udid) {
43
+ if (!companionPath || !udid)
44
+ return { started: false, error: 'missing companion or udid' };
45
+ try {
46
+ const child = spawn(companionPath, ['--udid', udid], { detached: true, stdio: 'ignore' });
47
+ child.unref();
48
+ return { started: true };
49
+ }
50
+ catch (e) {
51
+ return { started: false, error: e.message };
52
+ }
53
+ }
54
+ async function main() {
55
+ const args = process.argv.slice(2);
56
+ let projectArg;
57
+ let udid;
58
+ let startCompanion = false;
59
+ let kmpBuild = null;
60
+ for (let i = 0; i < args.length; i++) {
61
+ const a = args[i];
62
+ if (a === '--project' && args[i + 1]) {
63
+ projectArg = args[i + 1];
64
+ i++;
65
+ }
66
+ else if (a === '--udid' && args[i + 1]) {
67
+ udid = args[i + 1];
68
+ i++;
69
+ }
70
+ else if (a === '--start-companion')
71
+ startCompanion = true;
72
+ else if (!projectArg)
73
+ projectArg = a;
74
+ }
75
+ const cwd = process.cwd();
76
+ const projectRoot = projectArg ? path.resolve(projectArg) : cwd;
77
+ // If user passed a direct .xcodeproj or .xcworkspace path, accept it as the projectFile
78
+ let projectFile = null;
79
+ try {
80
+ const stat = await fs.promises.stat(projectRoot);
81
+ if (stat.isFile() && (projectRoot.endsWith('.xcodeproj') || projectRoot.endsWith('.xcworkspace'))) {
82
+ projectFile = projectRoot;
83
+ }
84
+ }
85
+ catch {
86
+ // ignore
87
+ }
88
+ // detect project if not a direct file
89
+ if (!projectFile) {
90
+ const projectFound = await exists(projectRoot);
91
+ if (projectFound) {
92
+ projectFile = await findFirst(projectRoot, ['.xcworkspace', '.xcodeproj'], 2);
93
+ }
94
+ else {
95
+ // attempt to find under cwd
96
+ projectFile = await findFirst(cwd, ['.xcworkspace', '.xcodeproj'], 3);
97
+ }
98
+ }
99
+ // 2) KMP Shared.framework detection (search for *.framework named Shared.framework)
100
+ let kmpFramework = null;
101
+ const projectSearchRoot = projectFile ? (fs.existsSync(projectFile) && fs.lstatSync(projectFile).isDirectory() ? projectFile : path.dirname(projectFile)) : cwd;
102
+ kmpFramework = await findFirst(projectSearchRoot, ['Shared.framework'], 5);
103
+ if (!kmpFramework)
104
+ kmpFramework = await findFirst(cwd, ['Shared.framework'], 6);
105
+ // 3) idb detection
106
+ const idbPath = getIdbCmd();
107
+ const idbAvailable = idbPath ? isIDBInstalled() : false;
108
+ // 4) idb_companion
109
+ const companionPath = commandWhich('idb_companion');
110
+ const companionAvailable = !!companionPath;
111
+ const suggestions = [];
112
+ if (!projectFile)
113
+ suggestions.push('Provide correct project path or ensure .xcodeproj/.xcworkspace exists in project dir');
114
+ if (!kmpFramework)
115
+ suggestions.push('Run KMP Gradle task to produce Shared.framework before xcodebuild (e.g., :shared:embedAndSignAppleFrameworkForXcode)');
116
+ if (!idbAvailable)
117
+ suggestions.push('Ensure idb is in PATH or set MCP_IDB_PATH / IDB_PATH');
118
+ if (!companionAvailable)
119
+ suggestions.push('Install idb_companion and ensure it is in PATH');
120
+ const result = {
121
+ ok: !!projectFile && !!idbAvailable,
122
+ project: {
123
+ root: projectRoot,
124
+ found: !!projectFile,
125
+ projectFile: projectFile
126
+ },
127
+ kmp: {
128
+ found: !!kmpFramework,
129
+ path: kmpFramework,
130
+ build: kmpBuild
131
+ },
132
+ idb: {
133
+ cmd: idbPath,
134
+ installed: idbAvailable
135
+ },
136
+ idb_companion: {
137
+ cmd: companionPath,
138
+ installed: companionAvailable
139
+ },
140
+ suggestions
141
+ };
142
+ if (startCompanion && udid) {
143
+ const started = startCompanionIfNeeded(companionPath, udid);
144
+ result.idb_companion.start = started;
145
+ if (started.started)
146
+ result.ok = result.ok && true;
147
+ }
148
+ console.log(JSON.stringify(result, null, 2));
149
+ process.exit(result.ok ? 0 : 2);
150
+ }
151
+ main().catch(e => {
152
+ // Report structured error on stdout (avoid noisy stderr in normal runs)
153
+ console.log(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }, null, 2));
154
+ process.exit(2);
155
+ });
@@ -0,0 +1,28 @@
1
+ import { iOSObserve } from '../../../observe/index.js';
2
+ import { iOSManage } from '../../../manage/index.js';
3
+ async function main() {
4
+ const appId = process.argv[2] || 'com.apple.springboard';
5
+ const deviceId = 'booted';
6
+ const obs = new iOSObserve();
7
+ const manage = new iOSManage();
8
+ try {
9
+ console.log('[1] startApp ->', appId);
10
+ const start = await manage.startApp(appId, deviceId);
11
+ console.log('start result:', start);
12
+ console.log('[2] captureScreenshot');
13
+ const shot = await obs.captureScreenshot(deviceId);
14
+ console.log('screenshot OK? size:', shot && shot.screenshot ? shot.screenshot.length : 0);
15
+ console.log('[3] getLogs');
16
+ const logs = await obs.getLogs(appId, undefined);
17
+ console.log('logs count:', logs.logCount);
18
+ console.log('[4] terminateApp');
19
+ const term = await manage.terminateApp(appId, deviceId);
20
+ console.log('terminate:', term);
21
+ console.log('SMOKE OK');
22
+ }
23
+ catch (err) {
24
+ console.error('SMOKE ERROR:', err instanceof Error ? err.message : String(err));
25
+ process.exit(1);
26
+ }
27
+ }
28
+ main();
@@ -0,0 +1,29 @@
1
+ import { iOSObserve } from '../../../observe/index.js';
2
+ import { iOSInteract } from '../../../interact/index.js';
3
+ async function main() {
4
+ const deviceId = 'booted';
5
+ const obs = new iOSObserve();
6
+ const interact = new iOSInteract();
7
+ console.log('Fetching UI tree...');
8
+ const tree = await obs.getUITree(deviceId);
9
+ if (tree.error) {
10
+ console.error('getUITree error:', tree.error);
11
+ process.exit(2);
12
+ }
13
+ console.log('Elements found:', tree.elements.length);
14
+ if (!tree.elements || tree.elements.length === 0) {
15
+ console.error('No elements found; aborting');
16
+ process.exit(3);
17
+ }
18
+ const clickable = tree.elements.find((e) => e.clickable) || tree.elements[0];
19
+ console.log('Using element:', clickable.text || '(no text)', 'clickable=', clickable.clickable, 'center=', clickable.center);
20
+ const [x, y] = clickable.center || [0, 0];
21
+ console.log(`Tapping at ${x},${y}...`);
22
+ const res = await interact.tap(x, y, deviceId);
23
+ console.log('Tap result:', res);
24
+ if (res.success)
25
+ process.exit(0);
26
+ else
27
+ process.exit(4);
28
+ }
29
+ main();
@@ -25,7 +25,7 @@ export class DiagnosticError extends Error {
25
25
  }
26
26
  // Exec ADB with diagnostics — moved from src/android/diagnostics.ts
27
27
  import { spawnSync } from 'child_process';
28
- import { getAdbCmd } from '../android/utils.js';
28
+ import { getAdbCmd } from './android/utils.js';
29
29
  export function execAdbWithDiagnostics(args, deviceId) {
30
30
  const adbArgs = deviceId ? ['-s', deviceId, ...args] : args;
31
31
  const timeout = 120000;
@@ -0,0 +1,34 @@
1
+ import { spawn } from 'child_process';
2
+ export async function execCmd(cmd, args, opts = {}) {
3
+ const { timeout = 0, env, cwd, shell } = opts;
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell });
6
+ let stdout = '';
7
+ let stderr = '';
8
+ if (child.stdout)
9
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
10
+ if (child.stderr)
11
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
12
+ let timedOut = false;
13
+ const timer = timeout && timeout > 0 ? setTimeout(() => {
14
+ timedOut = true;
15
+ try {
16
+ child.kill();
17
+ }
18
+ catch { }
19
+ resolve({ exitCode: null, stdout: stdout.trim(), stderr: stderr.trim() });
20
+ }, timeout) : null;
21
+ child.on('close', (code) => {
22
+ if (timer)
23
+ clearTimeout(timer);
24
+ if (timedOut)
25
+ return;
26
+ resolve({ exitCode: code, stdout: stdout.trim(), stderr: stderr.trim() });
27
+ });
28
+ child.on('error', (err) => {
29
+ if (timer)
30
+ clearTimeout(timer);
31
+ reject(err);
32
+ });
33
+ });
34
+ }
@@ -0,0 +1,301 @@
1
+ import { execFile, spawn, execSync, spawnSync } from "child_process";
2
+ import { promises as fsPromises } from 'fs';
3
+ import path from 'path';
4
+ import { makeEnvSnapshot } from '../diagnostics.js';
5
+ export function getXcrunCmd() { return process.env.XCRUN_PATH || 'xcrun'; }
6
+ export function getConfiguredIdbPath() {
7
+ if (process.env.MCP_IDB_PATH)
8
+ return process.env.MCP_IDB_PATH;
9
+ if (process.env.IDB_PATH)
10
+ return process.env.IDB_PATH;
11
+ const cfgPaths = [
12
+ process.env.MCP_CONFIG_PATH || (process.env.HOME ? `${process.env.HOME}/.mcp/config.json` : ''),
13
+ `${process.cwd()}/mcp.config.json`
14
+ ];
15
+ try {
16
+ const fs = require('fs');
17
+ for (const p of cfgPaths) {
18
+ if (!p)
19
+ continue;
20
+ try {
21
+ if (fs.existsSync(p)) {
22
+ const raw = fs.readFileSync(p, 'utf8');
23
+ const json = JSON.parse(raw);
24
+ if (json) {
25
+ if (json.idbPath)
26
+ return json.idbPath;
27
+ if (json.IDB_PATH)
28
+ return json.IDB_PATH;
29
+ }
30
+ }
31
+ }
32
+ catch { }
33
+ }
34
+ }
35
+ catch { }
36
+ return undefined;
37
+ }
38
+ export function getIdbCmd() {
39
+ const cfg = getConfiguredIdbPath();
40
+ if (cfg)
41
+ return cfg;
42
+ if (process.env.IDB_PATH)
43
+ return process.env.IDB_PATH;
44
+ try {
45
+ const p = execSync('which idb', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
46
+ if (p)
47
+ return p;
48
+ }
49
+ catch { }
50
+ try {
51
+ const p2 = execSync('command -v idb', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
52
+ if (p2)
53
+ return p2;
54
+ }
55
+ catch { }
56
+ // check common user locations
57
+ const common = [
58
+ `${process.env.HOME}/Library/Python/3.9/bin/idb`,
59
+ `${process.env.HOME}/Library/Python/3.10/bin/idb`,
60
+ '/opt/homebrew/bin/idb',
61
+ '/usr/local/bin/idb',
62
+ ];
63
+ for (const c of common) {
64
+ try {
65
+ execSync(`test -x ${c}`, { stdio: ['ignore', 'pipe', 'ignore'] });
66
+ return c;
67
+ }
68
+ catch { }
69
+ }
70
+ return 'idb';
71
+ }
72
+ export async function isIDBInstalled() {
73
+ const cmd = getIdbCmd();
74
+ try {
75
+ execSync(`command -v ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] });
76
+ return true;
77
+ }
78
+ catch {
79
+ try {
80
+ execSync(`${cmd} list-targets --json`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 });
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ }
88
+ // Validate bundle ID to prevent any potential injection or invalid characters
89
+ export function validateBundleId(bundleId) {
90
+ if (!bundleId)
91
+ return;
92
+ // Allow alphanumeric, dots, hyphens, and underscores.
93
+ if (!/^[a-zA-Z0-9.\-_]+$/.test(bundleId)) {
94
+ throw new Error(`Invalid Bundle ID: ${bundleId}. Must contain only alphanumeric characters, dots, hyphens, or underscores.`);
95
+ }
96
+ }
97
+ export function execCommand(args, deviceId = "booted") {
98
+ return new Promise((resolve, reject) => {
99
+ // Use spawn for better stream control and consistency with Android implementation
100
+ const child = spawn(getXcrunCmd(), args);
101
+ let stdout = '';
102
+ let stderr = '';
103
+ if (child.stdout) {
104
+ child.stdout.on('data', (data) => {
105
+ stdout += data.toString();
106
+ });
107
+ }
108
+ if (child.stderr) {
109
+ child.stderr.on('data', (data) => {
110
+ stderr += data.toString();
111
+ });
112
+ }
113
+ const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000; // env (ms) or default 30s
114
+ const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000; // env (ms) or default 60s
115
+ const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT; // choose appropriate timeout
116
+ const timeout = setTimeout(() => {
117
+ child.kill();
118
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`));
119
+ }, timeoutMs);
120
+ child.on('close', (code) => {
121
+ clearTimeout(timeout);
122
+ if (code !== 0) {
123
+ reject(new Error(stderr.trim() || `Command failed with code ${code}`));
124
+ }
125
+ else {
126
+ resolve({ output: stdout.trim(), device: { platform: "ios", id: deviceId } });
127
+ }
128
+ });
129
+ child.on('error', (err) => {
130
+ clearTimeout(timeout);
131
+ reject(err);
132
+ });
133
+ });
134
+ }
135
+ export function execCommandWithDiagnostics(args, deviceId = "booted") {
136
+ // Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
137
+ const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000;
138
+ const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000;
139
+ const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT;
140
+ const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs });
141
+ const runResult = {
142
+ exitCode: typeof res.status === 'number' ? res.status : null,
143
+ stdout: res.stdout || '',
144
+ stderr: res.stderr || '',
145
+ envSnapshot: makeEnvSnapshot(['PATH', 'IDB_PATH', 'JAVA_HOME', 'HOME']),
146
+ command: getXcrunCmd(),
147
+ args,
148
+ deviceId
149
+ };
150
+ if (res.status !== 0) {
151
+ // include suggested fixes for common errors
152
+ const suggested = [];
153
+ if ((runResult.stderr || '').includes('xcodebuild: error')) {
154
+ suggested.push('Ensure the project/workspace path is correct and xcodebuild is installed and accessible.');
155
+ }
156
+ if ((runResult.stderr || '').includes('No such file or directory') || (runResult.stderr || '').includes('not found')) {
157
+ suggested.push('Check that Xcode Command Line Tools are installed and XCRUN_PATH is set if using non-standard location.');
158
+ }
159
+ // Return diagnostics object
160
+ return { runResult: { ...runResult, suggestedFixes: suggested } };
161
+ }
162
+ return { runResult: { ...runResult, suggestedFixes: [] } };
163
+ }
164
+ function parseRuntimeName(runtime) {
165
+ // Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
166
+ try {
167
+ const parts = runtime.split('.');
168
+ const lastPart = parts[parts.length - 1]; // e.g. "iOS-17-0"
169
+ // Split by hyphen to separate OS from version numbers
170
+ // e.g. "iOS-17-0" -> ["iOS", "17", "0"]
171
+ const segments = lastPart.split('-');
172
+ if (segments.length > 1) {
173
+ const os = segments[0]; // "iOS"
174
+ const version = segments.slice(1).join('.'); // "17.0"
175
+ return `${os} ${version}`;
176
+ }
177
+ return lastPart;
178
+ }
179
+ catch {
180
+ return runtime;
181
+ }
182
+ }
183
+ export async function findAppBundle(dir) {
184
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true }).catch(() => []);
185
+ for (const e of entries) {
186
+ const full = path.join(dir, e.name);
187
+ if (e.isDirectory()) {
188
+ if (full.endsWith('.app'))
189
+ return full;
190
+ const found = await findAppBundle(full);
191
+ if (found)
192
+ return found;
193
+ }
194
+ }
195
+ return undefined;
196
+ }
197
+ export async function getIOSDeviceMetadata(deviceId = "booted") {
198
+ return new Promise((resolve) => {
199
+ // If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
200
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
201
+ const fallback = {
202
+ platform: "ios",
203
+ id: deviceId,
204
+ osVersion: "Unknown",
205
+ model: "Simulator",
206
+ simulator: true,
207
+ };
208
+ if (err || !stdout) {
209
+ resolve(fallback);
210
+ return;
211
+ }
212
+ try {
213
+ const data = JSON.parse(stdout);
214
+ const devicesMap = data.devices || {};
215
+ for (const runtime in devicesMap) {
216
+ const devices = devicesMap[runtime];
217
+ if (Array.isArray(devices)) {
218
+ for (const device of devices) {
219
+ if (deviceId === "booted" || device.udid === deviceId) {
220
+ resolve({
221
+ platform: "ios",
222
+ id: device.udid,
223
+ osVersion: parseRuntimeName(runtime),
224
+ model: device.name,
225
+ simulator: true,
226
+ });
227
+ return;
228
+ }
229
+ }
230
+ }
231
+ }
232
+ resolve(fallback);
233
+ }
234
+ catch {
235
+ resolve(fallback);
236
+ }
237
+ });
238
+ });
239
+ }
240
+ export async function listIOSDevices(appId) {
241
+ return new Promise((resolve) => {
242
+ // Query all devices and separately query booted devices to mark them
243
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
244
+ if (err || !stdout)
245
+ return resolve([]);
246
+ try {
247
+ const data = JSON.parse(stdout);
248
+ const devicesMap = data.devices || {};
249
+ const out = [];
250
+ const checks = [];
251
+ // Get booted devices set
252
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err2, stdout2) => {
253
+ const bootedSet = new Set();
254
+ if (!err2 && stdout2) {
255
+ try {
256
+ const bdata = JSON.parse(stdout2);
257
+ const bmap = bdata.devices || {};
258
+ for (const rt in bmap) {
259
+ const devs = bmap[rt];
260
+ if (Array.isArray(devs))
261
+ for (const d of devs)
262
+ bootedSet.add(d.udid);
263
+ }
264
+ }
265
+ catch { }
266
+ }
267
+ for (const runtime in devicesMap) {
268
+ const devices = devicesMap[runtime];
269
+ if (Array.isArray(devices)) {
270
+ for (const device of devices) {
271
+ const info = {
272
+ platform: 'ios',
273
+ id: device.udid,
274
+ osVersion: parseRuntimeName(runtime),
275
+ model: device.name,
276
+ simulator: true,
277
+ booted: bootedSet.has(device.udid)
278
+ };
279
+ if (appId) {
280
+ // check if installed
281
+ const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
282
+ .then(() => { info.appInstalled = true; })
283
+ .catch(() => { info.appInstalled = false; })
284
+ .then(() => { out.push(info); });
285
+ checks.push(p);
286
+ }
287
+ else {
288
+ out.push(info);
289
+ }
290
+ }
291
+ }
292
+ }
293
+ Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out));
294
+ });
295
+ }
296
+ catch {
297
+ resolve([]);
298
+ }
299
+ });
300
+ });
301
+ }
@@ -1,5 +1,5 @@
1
- import { listAndroidDevices } from "../android/utils.js";
2
- import { listIOSDevices } from "../ios/utils.js";
1
+ import { listAndroidDevices } from "./android/utils.js";
2
+ import { listIOSDevices } from "./ios/utils.js";
3
3
  function parseNumericVersion(v) {
4
4
  if (!v)
5
5
  return 0;