mobile-debug-mcp 0.10.0 → 0.12.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 (67) hide show
  1. package/README.md +20 -5
  2. package/dist/android/diagnostics.js +24 -0
  3. package/dist/android/interact.js +1 -145
  4. package/dist/android/manage.js +162 -0
  5. package/dist/android/observe.js +133 -88
  6. package/dist/android/run.js +187 -0
  7. package/dist/android/utils.js +137 -147
  8. package/dist/ios/interact.js +4 -175
  9. package/dist/ios/manage.js +169 -0
  10. package/dist/ios/observe.js +129 -13
  11. package/dist/ios/run.js +200 -0
  12. package/dist/ios/utils.js +138 -124
  13. package/dist/server.js +45 -17
  14. package/dist/tools/interact.js +21 -71
  15. package/dist/tools/manage.js +180 -0
  16. package/dist/tools/observe.js +23 -69
  17. package/dist/tools/run.js +180 -0
  18. package/dist/utils/diagnostics.js +25 -0
  19. package/docs/CHANGELOG.md +14 -0
  20. package/eslint.config.js +22 -1
  21. package/package.json +8 -5
  22. package/scripts/check-idb.js +83 -0
  23. package/scripts/check-idb.ts +73 -0
  24. package/scripts/idb-helper.ts +76 -0
  25. package/scripts/install-idb.js +88 -0
  26. package/scripts/install-idb.ts +90 -0
  27. package/scripts/run-ios-smoke.ts +34 -0
  28. package/scripts/run-ios-ui-tree-tap.ts +33 -0
  29. package/src/android/diagnostics.ts +23 -0
  30. package/src/android/interact.ts +2 -155
  31. package/src/android/manage.ts +157 -0
  32. package/src/android/observe.ts +129 -97
  33. package/src/android/utils.ts +147 -149
  34. package/src/ios/interact.ts +5 -181
  35. package/src/ios/manage.ts +164 -0
  36. package/src/ios/observe.ts +130 -14
  37. package/src/ios/utils.ts +127 -128
  38. package/src/server.ts +47 -17
  39. package/src/tools/interact.ts +23 -62
  40. package/src/tools/manage.ts +171 -0
  41. package/src/tools/observe.ts +24 -74
  42. package/src/types.ts +9 -0
  43. package/src/utils/diagnostics.ts +36 -0
  44. package/test/device/README.md +49 -0
  45. package/test/device/index.ts +27 -0
  46. package/test/device/manage/run-build-install-ios.ts +82 -0
  47. package/test/{integration → device/manage}/run-install-android.ts +4 -4
  48. package/test/{integration → device/manage}/run-install-ios.ts +4 -4
  49. package/test/{integration → device/observe}/logstream-real.ts +5 -4
  50. package/test/{integration → device/utils}/test-dist.ts +2 -2
  51. package/test/unit/index.ts +10 -6
  52. package/test/unit/manage/build.test.ts +83 -0
  53. package/test/unit/manage/build_and_install.test.ts +134 -0
  54. package/test/unit/manage/diagnostics.test.ts +85 -0
  55. package/test/unit/{install.test.ts → manage/install.test.ts} +27 -18
  56. package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
  57. package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +9 -10
  58. package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
  59. package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
  60. package/tsconfig.json +2 -1
  61. package/test/integration/index.ts +0 -8
  62. package/test/integration/test-dist.mjs +0 -41
  63. /package/test/{integration → device/interact}/run-real-test.ts +0 -0
  64. /package/test/{integration → device/interact}/smoke-test.ts +0 -0
  65. /package/test/{integration → device/manage}/install.integration.ts +0 -0
  66. /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
  67. /package/test/{integration → device/observe}/wait_for_element_real.ts +0 -0
@@ -1,89 +1,39 @@
1
- import { promises as fs } from 'fs';
2
- import path from 'path';
3
1
  import { resolveTargetDevice } from '../resolve-device.js';
4
2
  import { AndroidInteract } from '../android/interact.js';
5
3
  import { iOSInteract } from '../ios/interact.js';
6
4
  export class ToolsInteract {
7
- static async installAppHandler({ platform, appPath, deviceId }) {
8
- let chosenPlatform = platform;
9
- try {
10
- const stat = await fs.stat(appPath).catch(() => null);
11
- if (stat && stat.isDirectory()) {
12
- const files = (await fs.readdir(appPath).catch(() => []));
13
- if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) {
14
- chosenPlatform = 'ios';
15
- }
16
- else if (files.includes('gradlew') || files.includes('build.gradle') || files.includes('settings.gradle') || (files.includes('app') && (await fs.stat(path.join(appPath, 'app')).catch(() => null)))) {
17
- chosenPlatform = 'android';
18
- }
19
- else {
20
- chosenPlatform = 'android';
21
- }
22
- }
23
- else if (typeof appPath === 'string') {
24
- const ext = path.extname(appPath).toLowerCase();
25
- if (ext === '.apk')
26
- chosenPlatform = 'android';
27
- else if (ext === '.ipa' || ext === '.app')
28
- chosenPlatform = 'ios';
29
- else
30
- chosenPlatform = 'android';
31
- }
32
- }
33
- catch {
34
- chosenPlatform = 'android';
35
- }
36
- if (chosenPlatform === 'android') {
5
+ static async waitForElementHandler({ platform, text, timeout, deviceId }) {
6
+ const effectiveTimeout = timeout ?? 10000;
7
+ if (platform === 'android') {
37
8
  const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
38
- const androidInteract = new AndroidInteract();
39
- const result = await androidInteract.installApp(appPath, resolved.id);
40
- return result;
9
+ return await new AndroidInteract().waitForElement(text, effectiveTimeout, resolved.id);
41
10
  }
42
11
  else {
43
12
  const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
44
- const iosInteract = new iOSInteract();
45
- const result = await iosInteract.installApp(appPath, resolved.id);
46
- return result;
13
+ return await new iOSInteract().waitForElement(text, effectiveTimeout, resolved.id);
47
14
  }
48
15
  }
49
- static async startAppHandler({ platform, appId, deviceId }) {
50
- if (platform === 'android') {
51
- const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
52
- return await new AndroidInteract().startApp(appId, resolved.id);
16
+ static async tapHandler({ platform, x, y, deviceId }) {
17
+ const effectivePlatform = platform || 'android';
18
+ if (effectivePlatform === 'android') {
19
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
20
+ return await new AndroidInteract().tap(x, y, resolved.id);
53
21
  }
54
22
  else {
55
- const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
56
- return await new iOSInteract().startApp(appId, resolved.id);
23
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
24
+ return await new iOSInteract().tap(x, y, resolved.id);
57
25
  }
58
26
  }
59
- static async terminateAppHandler({ platform, appId, deviceId }) {
60
- if (platform === 'android') {
61
- const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
62
- return await new AndroidInteract().terminateApp(appId, resolved.id);
63
- }
64
- else {
65
- const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
66
- return await new iOSInteract().terminateApp(appId, resolved.id);
67
- }
27
+ static async swipeHandler({ x1, y1, x2, y2, duration, deviceId }) {
28
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
29
+ return await new AndroidInteract().swipe(x1, y1, x2, y2, duration, resolved.id);
68
30
  }
69
- static async restartAppHandler({ platform, appId, deviceId }) {
70
- if (platform === 'android') {
71
- const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
72
- return await new AndroidInteract().restartApp(appId, resolved.id);
73
- }
74
- else {
75
- const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
76
- return await new iOSInteract().restartApp(appId, resolved.id);
77
- }
31
+ static async typeTextHandler({ text, deviceId }) {
32
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
33
+ return await new AndroidInteract().typeText(text, resolved.id);
78
34
  }
79
- static async resetAppDataHandler({ platform, appId, deviceId }) {
80
- if (platform === 'android') {
81
- const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
82
- return await new AndroidInteract().resetAppData(appId, resolved.id);
83
- }
84
- else {
85
- const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
86
- return await new iOSInteract().resetAppData(appId, resolved.id);
87
- }
35
+ static async pressBackHandler({ deviceId }) {
36
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
37
+ return await new AndroidInteract().pressBack(resolved.id);
88
38
  }
89
39
  }
@@ -0,0 +1,180 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { resolveTargetDevice, listDevices } from '../resolve-device.js';
4
+ import { AndroidManage } from '../android/manage.js';
5
+ import { iOSManage } from '../ios/manage.js';
6
+ export class ToolsManage {
7
+ static async buildAppHandler({ platform, projectPath, variant }) {
8
+ // delegate to platform-specific build implementations
9
+ const chosen = platform || 'android';
10
+ if (chosen === 'android') {
11
+ const android = new AndroidManage();
12
+ const artifact = await android.build(projectPath, variant);
13
+ return artifact;
14
+ }
15
+ else {
16
+ const ios = new iOSManage();
17
+ const artifact = await ios.build(projectPath, variant);
18
+ return artifact;
19
+ }
20
+ }
21
+ static async installAppHandler({ platform, appPath, deviceId }) {
22
+ let chosenPlatform = platform;
23
+ try {
24
+ const stat = await fs.stat(appPath).catch(() => null);
25
+ if (stat && stat.isDirectory()) {
26
+ // If the directory itself looks like an .app bundle, treat as iOS
27
+ if (appPath.endsWith('.app')) {
28
+ chosenPlatform = 'ios';
29
+ }
30
+ else {
31
+ const files = (await fs.readdir(appPath).catch(() => []));
32
+ if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) {
33
+ chosenPlatform = 'ios';
34
+ }
35
+ else if (files.includes('gradlew') || files.includes('build.gradle') || files.includes('settings.gradle') || (files.includes('app') && (await fs.stat(path.join(appPath, 'app')).catch(() => null)))) {
36
+ chosenPlatform = 'android';
37
+ }
38
+ else {
39
+ chosenPlatform = 'android';
40
+ }
41
+ }
42
+ }
43
+ else if (typeof appPath === 'string') {
44
+ const ext = path.extname(appPath).toLowerCase();
45
+ if (ext === '.apk')
46
+ chosenPlatform = 'android';
47
+ else if (ext === '.ipa' || ext === '.app')
48
+ chosenPlatform = 'ios';
49
+ else
50
+ chosenPlatform = 'android';
51
+ }
52
+ }
53
+ catch {
54
+ chosenPlatform = 'android';
55
+ }
56
+ if (chosenPlatform === 'android') {
57
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
58
+ const androidRun = new AndroidManage();
59
+ const result = await androidRun.installApp(appPath, resolved.id);
60
+ return result;
61
+ }
62
+ else {
63
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
64
+ const iosRun = new iOSManage();
65
+ const result = await iosRun.installApp(appPath, resolved.id);
66
+ return result;
67
+ }
68
+ }
69
+ static async startAppHandler({ platform, appId, deviceId }) {
70
+ if (platform === 'android') {
71
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
72
+ return await new AndroidManage().startApp(appId, resolved.id);
73
+ }
74
+ else {
75
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
76
+ return await new iOSManage().startApp(appId, resolved.id);
77
+ }
78
+ }
79
+ static async terminateAppHandler({ platform, appId, deviceId }) {
80
+ if (platform === 'android') {
81
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
82
+ return await new AndroidManage().terminateApp(appId, resolved.id);
83
+ }
84
+ else {
85
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
86
+ return await new iOSManage().terminateApp(appId, resolved.id);
87
+ }
88
+ }
89
+ static async restartAppHandler({ platform, appId, deviceId }) {
90
+ if (platform === 'android') {
91
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
92
+ return await new AndroidManage().restartApp(appId, resolved.id);
93
+ }
94
+ else {
95
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
96
+ return await new iOSManage().restartApp(appId, resolved.id);
97
+ }
98
+ }
99
+ static async resetAppDataHandler({ platform, appId, deviceId }) {
100
+ if (platform === 'android') {
101
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
102
+ return await new AndroidManage().resetAppData(appId, resolved.id);
103
+ }
104
+ else {
105
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
106
+ return await new iOSManage().resetAppData(appId, resolved.id);
107
+ }
108
+ }
109
+ static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout }) {
110
+ const events = [];
111
+ const pushEvent = (obj) => events.push(JSON.stringify(obj));
112
+ const effectiveTimeout = timeout ?? 180000; // reserved for future streaming/timeouts
113
+ void effectiveTimeout;
114
+ // determine platform if not provided by inspecting path
115
+ let chosenPlatform = platform;
116
+ try {
117
+ const stat = await fs.stat(projectPath).catch(() => null);
118
+ if (!chosenPlatform) {
119
+ if (stat && stat.isDirectory()) {
120
+ const files = (await fs.readdir(projectPath).catch(() => []));
121
+ if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace')))
122
+ chosenPlatform = 'ios';
123
+ else
124
+ chosenPlatform = 'android';
125
+ }
126
+ else {
127
+ const ext = path.extname(projectPath).toLowerCase();
128
+ if (ext === '.apk')
129
+ chosenPlatform = 'android';
130
+ else if (ext === '.ipa' || ext === '.app')
131
+ chosenPlatform = 'ios';
132
+ else
133
+ chosenPlatform = 'android';
134
+ }
135
+ }
136
+ }
137
+ catch {
138
+ chosenPlatform = chosenPlatform || 'android';
139
+ }
140
+ pushEvent({ type: 'build', status: 'started', platform: chosenPlatform });
141
+ let buildRes;
142
+ try {
143
+ buildRes = await ToolsManage.buildAppHandler({ platform: chosenPlatform, projectPath });
144
+ if (buildRes && buildRes.error) {
145
+ pushEvent({ type: 'build', status: 'failed', error: buildRes.error });
146
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: buildRes.error } };
147
+ }
148
+ pushEvent({ type: 'build', status: 'finished', artifactPath: buildRes.artifactPath });
149
+ }
150
+ catch (e) {
151
+ const msg = e instanceof Error ? e.message : String(e);
152
+ pushEvent({ type: 'build', status: 'failed', error: msg });
153
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: msg } };
154
+ }
155
+ // Install phase
156
+ const artifact = buildRes.artifactPath || projectPath;
157
+ pushEvent({ type: 'install', status: 'started', artifactPath: artifact, deviceId });
158
+ let installRes;
159
+ try {
160
+ installRes = await ToolsManage.installAppHandler({ platform: chosenPlatform, appPath: artifact, deviceId });
161
+ if (installRes && installRes.installed === true) {
162
+ pushEvent({ type: 'install', status: 'finished', artifactPath: artifact, device: installRes.device });
163
+ return { ndjson: events.join('\n') + '\n', result: { success: true, artifactPath: artifact, device: installRes.device, output: installRes.output } };
164
+ }
165
+ else {
166
+ pushEvent({ type: 'install', status: 'failed', error: installRes.error || 'unknown' });
167
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: installRes.error || 'install failed' } };
168
+ }
169
+ }
170
+ catch (e) {
171
+ const msg = e instanceof Error ? e.message : String(e);
172
+ pushEvent({ type: 'install', status: 'failed', error: msg });
173
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: msg } };
174
+ }
175
+ }
176
+ static async listDevicesHandler({ platform, appId }) {
177
+ const devices = await listDevices(platform, appId);
178
+ return { devices };
179
+ }
180
+ }
@@ -1,9 +1,21 @@
1
- import { resolveTargetDevice, listDevices } from '../resolve-device.js';
1
+ import { resolveTargetDevice } from '../resolve-device.js';
2
2
  import { AndroidObserve } from '../android/observe.js';
3
3
  import { iOSObserve } from '../ios/observe.js';
4
- import { AndroidInteract } from '../android/interact.js';
5
- import { iOSInteract } from '../ios/interact.js';
6
4
  export class ToolsObserve {
5
+ static async getUITreeHandler({ platform, deviceId }) {
6
+ if (platform === 'android') {
7
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
8
+ return await new AndroidObserve().getUITree(resolved.id);
9
+ }
10
+ else {
11
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
12
+ return await new iOSObserve().getUITree(resolved.id);
13
+ }
14
+ }
15
+ static async getCurrentScreenHandler({ deviceId }) {
16
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
17
+ return await new AndroidObserve().getCurrentScreen(resolved.id);
18
+ }
7
19
  static async getLogsHandler({ platform, appId, deviceId, lines }) {
8
20
  if (platform === 'android') {
9
21
  const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
@@ -25,93 +37,35 @@ export class ToolsObserve {
25
37
  const sid = sessionId || 'default';
26
38
  if (effectivePlatform === 'android') {
27
39
  const resolved = await resolveTargetDevice({ platform: 'android', appId: packageName, deviceId });
28
- // AndroidObserve uses utils for log stream control; delegate to android/utils functions where appropriate
29
- const { startAndroidLogStream } = await import('../android/utils.js');
30
- return await startAndroidLogStream(packageName, level || 'error', resolved.id, sid);
40
+ // Delegate to AndroidObserve's log stream methods
41
+ return await new AndroidObserve().startLogStream(packageName, level || 'error', resolved.id, sid);
31
42
  }
32
43
  else {
33
44
  const resolved = await resolveTargetDevice({ platform: 'ios', appId: packageName, deviceId });
34
- // iOSObserve implements startIOSLogStream via ios/utils; use its helper
35
- const { startIOSLogStream } = await import('../ios/utils.js');
36
- return await startIOSLogStream(packageName, resolved.id, sid);
45
+ // Delegate to iOSObserve for starting log streams
46
+ return await new iOSObserve().startLogStream(packageName, resolved.id, sid);
37
47
  }
38
48
  }
39
49
  static async readLogStreamHandler({ platform, sessionId, limit, since }) {
40
50
  const effectivePlatform = platform || 'android';
41
51
  const sid = sessionId || 'default';
42
52
  if (effectivePlatform === 'android') {
43
- const { readLogStreamLines } = await import('../android/utils.js');
44
- return await readLogStreamLines(sid, limit ?? 100, since);
53
+ return await new AndroidObserve().readLogStream(sid, limit ?? 100, since);
45
54
  }
46
55
  else {
47
- const { readIOSLogStreamLines } = await import('../ios/utils.js');
48
- return await readIOSLogStreamLines(sid, limit ?? 100, since);
56
+ return await new iOSObserve().readLogStream(sid, limit ?? 100, since);
49
57
  }
50
58
  }
51
59
  static async stopLogStreamHandler({ platform, sessionId }) {
52
60
  const effectivePlatform = platform || 'android';
53
61
  const sid = sessionId || 'default';
54
62
  if (effectivePlatform === 'android') {
55
- const { stopAndroidLogStream } = await import('../android/utils.js');
56
- return await stopAndroidLogStream(sid);
57
- }
58
- else {
59
- const { stopIOSLogStream } = await import('../ios/utils.js');
60
- return await stopIOSLogStream(sid);
61
- }
62
- }
63
- static async getUITreeHandler({ platform, deviceId }) {
64
- if (platform === 'android') {
65
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
66
- return await new AndroidObserve().getUITree(resolved.id);
67
- }
68
- else {
69
- const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
70
- return await new iOSObserve().getUITree(resolved.id);
71
- }
72
- }
73
- static async getCurrentScreenHandler({ deviceId }) {
74
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
75
- return await new AndroidObserve().getCurrentScreen(resolved.id);
76
- }
77
- static async waitForElementHandler({ platform, text, timeout, deviceId }) {
78
- const effectiveTimeout = timeout ?? 10000;
79
- if (platform === 'android') {
80
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
81
- return await new AndroidInteract().waitForElement(text, effectiveTimeout, resolved.id);
82
- }
83
- else {
84
- const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
85
- return await new iOSInteract().waitForElement(text, effectiveTimeout, resolved.id);
86
- }
87
- }
88
- static async tapHandler({ platform, x, y, deviceId }) {
89
- const effectivePlatform = platform || 'android';
90
- if (effectivePlatform === 'android') {
91
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
92
- return await new AndroidInteract().tap(x, y, resolved.id);
63
+ return await new AndroidObserve().stopLogStream(sid);
93
64
  }
94
65
  else {
95
- const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
96
- return await new iOSInteract().tap(x, y, resolved.id);
66
+ return await new iOSObserve().stopLogStream(sid);
97
67
  }
98
68
  }
99
- static async swipeHandler({ x1, y1, x2, y2, duration, deviceId }) {
100
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
101
- return await new AndroidInteract().swipe(x1, y1, x2, y2, duration, resolved.id);
102
- }
103
- static async typeTextHandler({ text, deviceId }) {
104
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
105
- return await new AndroidInteract().typeText(text, resolved.id);
106
- }
107
- static async pressBackHandler({ deviceId }) {
108
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
109
- return await new AndroidInteract().pressBack(resolved.id);
110
- }
111
- static async listDevicesHandler({ platform, appId }) {
112
- const devices = await listDevices(platform, appId);
113
- return { devices };
114
- }
115
69
  static async captureScreenshotHandler({ platform, deviceId }) {
116
70
  const effectivePlatform = platform || 'android';
117
71
  if (effectivePlatform === 'android') {
@@ -0,0 +1,180 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { resolveTargetDevice, listDevices } from '../resolve-device.js';
4
+ import { AndroidManage } from '../android/manage.js';
5
+ import { iOSManage } from '../ios/manage.js';
6
+ export class ToolsRun {
7
+ static async buildAppHandler({ platform, projectPath, variant }) {
8
+ // delegate to platform-specific build implementations
9
+ const chosen = platform || 'android';
10
+ if (chosen === 'android') {
11
+ const android = new AndroidManage();
12
+ const artifact = await android.build(projectPath, variant);
13
+ return artifact;
14
+ }
15
+ else {
16
+ const ios = new iOSManage();
17
+ const artifact = await ios.build(projectPath, variant);
18
+ return artifact;
19
+ }
20
+ }
21
+ static async installAppHandler({ platform, appPath, deviceId }) {
22
+ let chosenPlatform = platform;
23
+ try {
24
+ const stat = await fs.stat(appPath).catch(() => null);
25
+ if (stat && stat.isDirectory()) {
26
+ // If the directory itself looks like an .app bundle, treat as iOS
27
+ if (appPath.endsWith('.app')) {
28
+ chosenPlatform = 'ios';
29
+ }
30
+ else {
31
+ const files = (await fs.readdir(appPath).catch(() => []));
32
+ if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) {
33
+ chosenPlatform = 'ios';
34
+ }
35
+ else if (files.includes('gradlew') || files.includes('build.gradle') || files.includes('settings.gradle') || (files.includes('app') && (await fs.stat(path.join(appPath, 'app')).catch(() => null)))) {
36
+ chosenPlatform = 'android';
37
+ }
38
+ else {
39
+ chosenPlatform = 'android';
40
+ }
41
+ }
42
+ }
43
+ else if (typeof appPath === 'string') {
44
+ const ext = path.extname(appPath).toLowerCase();
45
+ if (ext === '.apk')
46
+ chosenPlatform = 'android';
47
+ else if (ext === '.ipa' || ext === '.app')
48
+ chosenPlatform = 'ios';
49
+ else
50
+ chosenPlatform = 'android';
51
+ }
52
+ }
53
+ catch {
54
+ chosenPlatform = 'android';
55
+ }
56
+ if (chosenPlatform === 'android') {
57
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
58
+ const androidRun = new AndroidManage();
59
+ const result = await androidRun.installApp(appPath, resolved.id);
60
+ return result;
61
+ }
62
+ else {
63
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
64
+ const iosRun = new iOSManage();
65
+ const result = await iosRun.installApp(appPath, resolved.id);
66
+ return result;
67
+ }
68
+ }
69
+ static async startAppHandler({ platform, appId, deviceId }) {
70
+ if (platform === 'android') {
71
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
72
+ return await new AndroidManage().startApp(appId, resolved.id);
73
+ }
74
+ else {
75
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
76
+ return await new iOSManage().startApp(appId, resolved.id);
77
+ }
78
+ }
79
+ static async terminateAppHandler({ platform, appId, deviceId }) {
80
+ if (platform === 'android') {
81
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
82
+ return await new AndroidManage().terminateApp(appId, resolved.id);
83
+ }
84
+ else {
85
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
86
+ return await new iOSManage().terminateApp(appId, resolved.id);
87
+ }
88
+ }
89
+ static async restartAppHandler({ platform, appId, deviceId }) {
90
+ if (platform === 'android') {
91
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
92
+ return await new AndroidManage().restartApp(appId, resolved.id);
93
+ }
94
+ else {
95
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
96
+ return await new iOSManage().restartApp(appId, resolved.id);
97
+ }
98
+ }
99
+ static async resetAppDataHandler({ platform, appId, deviceId }) {
100
+ if (platform === 'android') {
101
+ const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
102
+ return await new AndroidManage().resetAppData(appId, resolved.id);
103
+ }
104
+ else {
105
+ const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
106
+ return await new iOSManage().resetAppData(appId, resolved.id);
107
+ }
108
+ }
109
+ static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout }) {
110
+ const events = [];
111
+ const pushEvent = (obj) => events.push(JSON.stringify(obj));
112
+ const effectiveTimeout = timeout ?? 180000; // reserved for future streaming/timeouts
113
+ void effectiveTimeout;
114
+ // determine platform if not provided by inspecting path
115
+ let chosenPlatform = platform;
116
+ try {
117
+ const stat = await fs.stat(projectPath).catch(() => null);
118
+ if (!chosenPlatform) {
119
+ if (stat && stat.isDirectory()) {
120
+ const files = (await fs.readdir(projectPath).catch(() => []));
121
+ if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace')))
122
+ chosenPlatform = 'ios';
123
+ else
124
+ chosenPlatform = 'android';
125
+ }
126
+ else {
127
+ const ext = path.extname(projectPath).toLowerCase();
128
+ if (ext === '.apk')
129
+ chosenPlatform = 'android';
130
+ else if (ext === '.ipa' || ext === '.app')
131
+ chosenPlatform = 'ios';
132
+ else
133
+ chosenPlatform = 'android';
134
+ }
135
+ }
136
+ }
137
+ catch {
138
+ chosenPlatform = chosenPlatform || 'android';
139
+ }
140
+ pushEvent({ type: 'build', status: 'started', platform: chosenPlatform });
141
+ let buildRes;
142
+ try {
143
+ buildRes = await ToolsRun.buildAppHandler({ platform: chosenPlatform, projectPath });
144
+ if (buildRes && buildRes.error) {
145
+ pushEvent({ type: 'build', status: 'failed', error: buildRes.error });
146
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: buildRes.error } };
147
+ }
148
+ pushEvent({ type: 'build', status: 'finished', artifactPath: buildRes.artifactPath });
149
+ }
150
+ catch (e) {
151
+ const msg = e instanceof Error ? e.message : String(e);
152
+ pushEvent({ type: 'build', status: 'failed', error: msg });
153
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: msg } };
154
+ }
155
+ // Install phase
156
+ const artifact = buildRes.artifactPath || projectPath;
157
+ pushEvent({ type: 'install', status: 'started', artifactPath: artifact, deviceId });
158
+ let installRes;
159
+ try {
160
+ installRes = await ToolsRun.installAppHandler({ platform: chosenPlatform, appPath: artifact, deviceId });
161
+ if (installRes && installRes.installed === true) {
162
+ pushEvent({ type: 'install', status: 'finished', artifactPath: artifact, device: installRes.device });
163
+ return { ndjson: events.join('\n') + '\n', result: { success: true, artifactPath: artifact, device: installRes.device, output: installRes.output } };
164
+ }
165
+ else {
166
+ pushEvent({ type: 'install', status: 'failed', error: installRes.error || 'unknown' });
167
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: installRes.error || 'install failed' } };
168
+ }
169
+ }
170
+ catch (e) {
171
+ const msg = e instanceof Error ? e.message : String(e);
172
+ pushEvent({ type: 'install', status: 'failed', error: msg });
173
+ return { ndjson: events.join('\n') + '\n', result: { success: false, error: msg } };
174
+ }
175
+ }
176
+ static async listDevicesHandler({ platform, appId }) {
177
+ const devices = await listDevices(platform, appId);
178
+ return { devices };
179
+ }
180
+ }
@@ -0,0 +1,25 @@
1
+ export function makeEnvSnapshot(keys) {
2
+ const snap = {};
3
+ for (const k of keys)
4
+ snap[k] = process.env[k];
5
+ return snap;
6
+ }
7
+ export function wrapExecResult(command, args, res) {
8
+ return {
9
+ exitCode: res.status,
10
+ stdout: res.stdout ? (typeof res.stdout === 'string' ? res.stdout : res.stdout.toString()) : '',
11
+ stderr: res.stderr ? (typeof res.stderr === 'string' ? res.stderr : res.stderr.toString()) : '',
12
+ envSnapshot: makeEnvSnapshot(['PATH', 'IDB_PATH', 'JAVA_HOME', 'HOME']),
13
+ command,
14
+ args,
15
+ suggestedFixes: []
16
+ };
17
+ }
18
+ export class DiagnosticError extends Error {
19
+ runResult;
20
+ constructor(message, runResult) {
21
+ super(message);
22
+ this.name = 'DiagnosticError';
23
+ this.runResult = runResult;
24
+ }
25
+ }
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.12.0]
6
+ - Add iOS idb integration: config-driven idb path resolution (MCP_IDB_PATH / MCP config / IDB_PATH), robust idb detection and parsing of `ui describe-all` output.
7
+ - Add reusable helpers: `isIDBInstalled()` and `getIdbCmd()` to centralise idb resolution and diagnostics.
8
+ - Ensure install_app can build binaries (xcodebuild / Gradle) before installing so agents can autonomously build, install and validate fixes.
9
+ - Add idb integration test (UI-tree + tap) and reorganise device-dependent tests under `test/device`.
10
+ - Gate device tests behind RUN_DEVICE_TESTS (device tests won't run in default CI); added `test:device` runner and updated npm scripts.
11
+ - Linting & tooling fixes: ESLint adjustments (ignore generated scripts), ESM entry fix for install-idb.ts, and various lint cleanups.
12
+ - Added `test/device/README.md` with device-test run instructions.
13
+
14
+
15
+ ## [0.11.0]
16
+ - Tools refactor - broke functions into 3 distinct class types; interact (for UI manipulation), manage (for build, installing etc) and observe (observing the app whilst running)
17
+ - Add convenience method to build and install
18
+
5
19
  ## [0.10.0]
6
20
 
7
21
  ### Added / Changed