mobile-debug-mcp 0.10.0 → 0.11.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.
- package/README.md +3 -1
- package/dist/android/interact.js +1 -145
- package/dist/android/manage.js +137 -0
- package/dist/android/observe.js +131 -86
- package/dist/android/run.js +187 -0
- package/dist/android/utils.js +134 -144
- package/dist/ios/interact.js +1 -168
- package/dist/ios/manage.js +145 -0
- package/dist/ios/observe.js +108 -1
- package/dist/ios/run.js +200 -0
- package/dist/ios/utils.js +17 -116
- package/dist/server.js +27 -17
- package/dist/tools/interact.js +21 -71
- package/dist/tools/manage.js +180 -0
- package/dist/tools/observe.js +23 -69
- package/dist/tools/run.js +180 -0
- package/docs/CHANGELOG.md +4 -0
- package/package.json +1 -1
- package/src/android/interact.ts +2 -155
- package/src/android/manage.ts +135 -0
- package/src/android/observe.ts +127 -95
- package/src/android/utils.ts +144 -146
- package/src/ios/interact.ts +2 -174
- package/src/ios/manage.ts +143 -0
- package/src/ios/observe.ts +109 -1
- package/src/ios/utils.ts +18 -120
- package/src/server.ts +28 -17
- package/src/tools/interact.ts +23 -62
- package/src/tools/manage.ts +171 -0
- package/src/tools/observe.ts +24 -74
- package/test/integration/logstream-real.ts +5 -4
- package/test/unit/build.test.ts +84 -0
- package/test/unit/build_and_install.test.ts +132 -0
- package/test/unit/install.test.ts +2 -2
- package/test/unit/logstream.test.ts +8 -9
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo } from './utils.js';
|
|
6
|
+
import { detectJavaHome } from '../utils/java.js';
|
|
7
|
+
export class AndroidManage {
|
|
8
|
+
async build(projectPath, _variant) {
|
|
9
|
+
void _variant;
|
|
10
|
+
try {
|
|
11
|
+
const { prepareGradle } = await import('./utils.js').catch(() => ({ prepareGradle: undefined }));
|
|
12
|
+
if (prepareGradle && typeof prepareGradle === 'function') {
|
|
13
|
+
const { execCmd, gradleArgs, spawnOpts } = await prepareGradle(projectPath);
|
|
14
|
+
await new Promise((resolve, reject) => {
|
|
15
|
+
const proc = spawn(execCmd, gradleArgs, spawnOpts);
|
|
16
|
+
let stderr = '';
|
|
17
|
+
proc.stderr?.on('data', d => stderr += d.toString());
|
|
18
|
+
proc.on('close', code => {
|
|
19
|
+
if (code === 0)
|
|
20
|
+
resolve();
|
|
21
|
+
else
|
|
22
|
+
reject(new Error(stderr || `Gradle failed with code ${code}`));
|
|
23
|
+
});
|
|
24
|
+
proc.on('error', err => reject(err));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const gradlewPath = path.join(projectPath, 'gradlew');
|
|
29
|
+
const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle';
|
|
30
|
+
const execCmd = existsSync(gradlewPath) ? gradlewPath : gradleCmd;
|
|
31
|
+
const gradleArgs = ['assembleDebug'];
|
|
32
|
+
await new Promise((resolve, reject) => {
|
|
33
|
+
const proc = spawn(execCmd, gradleArgs, { cwd: projectPath, shell: existsSync(gradlewPath) ? false : true });
|
|
34
|
+
let stderr = '';
|
|
35
|
+
proc.stderr?.on('data', d => stderr += d.toString());
|
|
36
|
+
proc.on('close', code => {
|
|
37
|
+
if (code === 0)
|
|
38
|
+
resolve();
|
|
39
|
+
else
|
|
40
|
+
reject(new Error(stderr || `Gradle failed with code ${code}`));
|
|
41
|
+
});
|
|
42
|
+
proc.on('error', err => reject(err));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async function findApk(dir) {
|
|
46
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
const full = path.join(dir, e.name);
|
|
49
|
+
if (e.isDirectory()) {
|
|
50
|
+
const found = await findApk(full);
|
|
51
|
+
if (found)
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
else if (e.isFile() && full.endsWith('.apk')) {
|
|
55
|
+
return full;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const apk = await findApk(projectPath);
|
|
61
|
+
if (!apk)
|
|
62
|
+
return { error: 'Could not find APK after build' };
|
|
63
|
+
return { artifactPath: apk };
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async installApp(apkPath, deviceId) {
|
|
70
|
+
const metadata = await getAndroidDeviceMetadata('', deviceId);
|
|
71
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
72
|
+
async function findApk(dir) {
|
|
73
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
const full = path.join(dir, e.name);
|
|
76
|
+
if (e.isDirectory()) {
|
|
77
|
+
const found = await findApk(full);
|
|
78
|
+
if (found)
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
else if (e.isFile() && full.endsWith('.apk')) {
|
|
82
|
+
return full;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
let apkToInstall = apkPath;
|
|
89
|
+
const stat = await fs.stat(apkPath).catch(() => null);
|
|
90
|
+
if (stat && stat.isDirectory()) {
|
|
91
|
+
const detectedJavaHome = await detectJavaHome().catch(() => undefined);
|
|
92
|
+
const env = Object.assign({}, process.env);
|
|
93
|
+
if (detectedJavaHome) {
|
|
94
|
+
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
95
|
+
env.JAVA_HOME = detectedJavaHome;
|
|
96
|
+
env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`;
|
|
97
|
+
console.debug('[android-run] Overriding JAVA_HOME with detected path:', detectedJavaHome);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
delete env.SHELL;
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
const gradleArgs = ['assembleDebug'];
|
|
105
|
+
if (detectedJavaHome) {
|
|
106
|
+
gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`);
|
|
107
|
+
gradleArgs.push('--no-daemon');
|
|
108
|
+
env.GRADLE_JAVA_HOME = detectedJavaHome;
|
|
109
|
+
}
|
|
110
|
+
const wrapperPath = path.join(apkPath, 'gradlew');
|
|
111
|
+
const useWrapper = existsSync(wrapperPath);
|
|
112
|
+
const execCmd = useWrapper ? wrapperPath : 'gradle';
|
|
113
|
+
const spawnOpts = { cwd: apkPath, env };
|
|
114
|
+
if (useWrapper) {
|
|
115
|
+
await fs.chmod(wrapperPath, 0o755).catch(() => { });
|
|
116
|
+
spawnOpts.shell = false;
|
|
117
|
+
}
|
|
118
|
+
else
|
|
119
|
+
spawnOpts.shell = true;
|
|
120
|
+
const proc = spawn(execCmd, gradleArgs, spawnOpts);
|
|
121
|
+
let stderr = '';
|
|
122
|
+
await new Promise((resolve, reject) => {
|
|
123
|
+
proc.stderr?.on('data', d => stderr += d.toString());
|
|
124
|
+
proc.on('close', code => {
|
|
125
|
+
if (code === 0)
|
|
126
|
+
resolve();
|
|
127
|
+
else
|
|
128
|
+
reject(new Error(stderr || `Gradle build failed with code ${code}`));
|
|
129
|
+
});
|
|
130
|
+
proc.on('error', err => reject(err));
|
|
131
|
+
});
|
|
132
|
+
const built = await findApk(apkPath);
|
|
133
|
+
if (!built)
|
|
134
|
+
throw new Error('Could not locate built APK after running Gradle');
|
|
135
|
+
apkToInstall = built;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const res = await spawnAdb(['install', '-r', apkToInstall], deviceId);
|
|
139
|
+
if (res.code === 0) {
|
|
140
|
+
return { device: deviceInfo, installed: true, output: res.stdout };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
console.debug('[android-run] adb install failed, attempting push+pm fallback:', e instanceof Error ? e.message : String(e));
|
|
145
|
+
}
|
|
146
|
+
const basename = path.basename(apkToInstall);
|
|
147
|
+
const remotePath = `/data/local/tmp/${basename}`;
|
|
148
|
+
await execAdb(['push', apkToInstall, remotePath], deviceId);
|
|
149
|
+
const pmOut = await execAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId);
|
|
150
|
+
try {
|
|
151
|
+
await execAdb(['shell', 'rm', remotePath], deviceId);
|
|
152
|
+
}
|
|
153
|
+
catch { }
|
|
154
|
+
return { device: deviceInfo, installed: true, output: pmOut };
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async startApp(appId, deviceId) {
|
|
161
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
162
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
163
|
+
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
|
|
164
|
+
return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
|
|
165
|
+
}
|
|
166
|
+
async terminateApp(appId, deviceId) {
|
|
167
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
168
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
169
|
+
await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
|
|
170
|
+
return { device: deviceInfo, appTerminated: true };
|
|
171
|
+
}
|
|
172
|
+
async restartApp(appId, deviceId) {
|
|
173
|
+
await this.terminateApp(appId, deviceId);
|
|
174
|
+
const startResult = await this.startApp(appId, deviceId);
|
|
175
|
+
return {
|
|
176
|
+
device: startResult.device,
|
|
177
|
+
appRestarted: startResult.appStarted,
|
|
178
|
+
launchTimeMs: startResult.launchTimeMs
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
async resetAppData(appId, deviceId) {
|
|
182
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
183
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
184
|
+
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
|
|
185
|
+
return { device: deviceInfo, dataCleared: output === 'Success' };
|
|
186
|
+
}
|
|
187
|
+
}
|
package/dist/android/utils.js
CHANGED
|
@@ -1,7 +1,46 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { promises as fsPromises, existsSync } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { detectJavaHome } from '../utils/java.js';
|
|
4
5
|
export const ADB = process.env.ADB_PATH || 'adb';
|
|
6
|
+
/**
|
|
7
|
+
* Prepare Gradle execution options for building an Android project.
|
|
8
|
+
* Returns execCmd (wrapper or gradle), base gradleArgs array, and spawn options including env.
|
|
9
|
+
*/
|
|
10
|
+
export async function prepareGradle(projectPath) {
|
|
11
|
+
const gradlewPath = path.join(projectPath, 'gradlew');
|
|
12
|
+
const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle';
|
|
13
|
+
const execCmd = existsSync(gradlewPath) ? gradlewPath : gradleCmd;
|
|
14
|
+
const gradleArgs = ['assembleDebug'];
|
|
15
|
+
const detectedJavaHome = await detectJavaHome().catch(() => undefined);
|
|
16
|
+
const env = Object.assign({}, process.env);
|
|
17
|
+
if (detectedJavaHome) {
|
|
18
|
+
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
19
|
+
env.JAVA_HOME = detectedJavaHome;
|
|
20
|
+
env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`;
|
|
21
|
+
}
|
|
22
|
+
gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`);
|
|
23
|
+
gradleArgs.push('--no-daemon');
|
|
24
|
+
env.GRADLE_JAVA_HOME = detectedJavaHome;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
delete env.SHELL;
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
30
|
+
const useWrapper = existsSync(gradlewPath);
|
|
31
|
+
const spawnOpts = { cwd: projectPath, env };
|
|
32
|
+
if (useWrapper) {
|
|
33
|
+
try {
|
|
34
|
+
await fsPromises.chmod(gradlewPath, 0o755);
|
|
35
|
+
}
|
|
36
|
+
catch { }
|
|
37
|
+
spawnOpts.shell = false;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
spawnOpts.shell = true;
|
|
41
|
+
}
|
|
42
|
+
return { execCmd, gradleArgs, spawnOpts };
|
|
43
|
+
}
|
|
5
44
|
// Helper to construct ADB args with optional device ID
|
|
6
45
|
function getAdbArgs(args, deviceId) {
|
|
7
46
|
if (deviceId) {
|
|
@@ -141,6 +180,21 @@ export async function getAndroidDeviceMetadata(appId, deviceId) {
|
|
|
141
180
|
return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false };
|
|
142
181
|
}
|
|
143
182
|
}
|
|
183
|
+
export async function findApk(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
|
+
const found = await findApk(full);
|
|
189
|
+
if (found)
|
|
190
|
+
return found;
|
|
191
|
+
}
|
|
192
|
+
else if (e.isFile() && full.endsWith('.apk')) {
|
|
193
|
+
return full;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
144
198
|
export async function listAndroidDevices(appId) {
|
|
145
199
|
try {
|
|
146
200
|
const devicesOutput = await execAdb(['devices', '-l']);
|
|
@@ -178,18 +232,88 @@ export async function listAndroidDevices(appId) {
|
|
|
178
232
|
return [];
|
|
179
233
|
}
|
|
180
234
|
}
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
235
|
+
// UI helper utilities shared by observe/interact
|
|
236
|
+
export const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
237
|
+
export function parseBounds(bounds) {
|
|
238
|
+
const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
|
239
|
+
if (match) {
|
|
240
|
+
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4])];
|
|
241
|
+
}
|
|
242
|
+
return [0, 0, 0, 0];
|
|
243
|
+
}
|
|
244
|
+
export function getCenter(bounds) {
|
|
245
|
+
const [x1, y1, x2, y2] = bounds;
|
|
246
|
+
return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
|
|
186
247
|
}
|
|
187
|
-
export function
|
|
188
|
-
|
|
248
|
+
export async function getScreenResolution(deviceId) {
|
|
249
|
+
try {
|
|
250
|
+
const output = await execAdb(['shell', 'wm', 'size'], deviceId);
|
|
251
|
+
const match = output.match(/Physical size: (\d+)x(\d+)/);
|
|
252
|
+
if (match) {
|
|
253
|
+
return { width: parseInt(match[1]), height: parseInt(match[2]) };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// ignore
|
|
258
|
+
}
|
|
259
|
+
return { width: 0, height: 0 };
|
|
260
|
+
}
|
|
261
|
+
export function traverseNode(node, elements, parentIndex = -1, depth = 0) {
|
|
262
|
+
if (!node)
|
|
263
|
+
return -1;
|
|
264
|
+
let currentIndex = -1;
|
|
265
|
+
if (node['@_class']) {
|
|
266
|
+
const text = node['@_text'] || null;
|
|
267
|
+
const contentDescription = node['@_content-desc'] || null;
|
|
268
|
+
const clickable = node['@_clickable'] === 'true';
|
|
269
|
+
const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
|
|
270
|
+
const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
|
|
271
|
+
if (isUseful) {
|
|
272
|
+
const element = {
|
|
273
|
+
text,
|
|
274
|
+
contentDescription,
|
|
275
|
+
type: node['@_class'] || 'unknown',
|
|
276
|
+
resourceId: node['@_resource-id'] || null,
|
|
277
|
+
clickable,
|
|
278
|
+
enabled: node['@_enabled'] === 'true',
|
|
279
|
+
visible: true,
|
|
280
|
+
bounds,
|
|
281
|
+
center: getCenter(bounds),
|
|
282
|
+
depth
|
|
283
|
+
};
|
|
284
|
+
if (parentIndex !== -1) {
|
|
285
|
+
element.parentId = parentIndex;
|
|
286
|
+
}
|
|
287
|
+
elements.push(element);
|
|
288
|
+
currentIndex = elements.length - 1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
|
|
292
|
+
const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
|
|
293
|
+
const childrenIndices = [];
|
|
294
|
+
if (node.node) {
|
|
295
|
+
if (Array.isArray(node.node)) {
|
|
296
|
+
node.node.forEach((child) => {
|
|
297
|
+
const childIndex = traverseNode(child, elements, nextParentIndex, nextDepth);
|
|
298
|
+
if (childIndex !== -1)
|
|
299
|
+
childrenIndices.push(childIndex);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
const childIndex = traverseNode(node.node, elements, nextParentIndex, nextDepth);
|
|
304
|
+
if (childIndex !== -1)
|
|
305
|
+
childrenIndices.push(childIndex);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (currentIndex !== -1 && childrenIndices.length > 0) {
|
|
309
|
+
elements[currentIndex].children = childrenIndices;
|
|
310
|
+
}
|
|
311
|
+
return currentIndex;
|
|
189
312
|
}
|
|
313
|
+
// Log stream management (one stream per session)
|
|
314
|
+
// (Legacy active stream map removed from utils during refactor; Observe modules manage their own active streams.)
|
|
190
315
|
// Robust log line parser supporting multiple logcat formats
|
|
191
316
|
export function parseLogLine(line) {
|
|
192
|
-
// Collapse internal newlines so multiline stack traces are parseable as a single entry
|
|
193
317
|
const rawLine = line;
|
|
194
318
|
const normalizedLine = rawLine.replace(/\r?\n/g, ' ');
|
|
195
319
|
const entry = { timestamp: '', level: '', tag: '', message: rawLine, _iso: null, crash: false };
|
|
@@ -292,138 +416,4 @@ export function parseLogLine(line) {
|
|
|
292
416
|
}
|
|
293
417
|
return entry;
|
|
294
418
|
}
|
|
295
|
-
|
|
296
|
-
try {
|
|
297
|
-
// Determine PID
|
|
298
|
-
const pidOutput = await execAdb(['shell', 'pidof', packageName], deviceId).catch(() => '');
|
|
299
|
-
const pid = (pidOutput || '').trim();
|
|
300
|
-
if (!pid) {
|
|
301
|
-
return { success: false, error: 'app_not_running' };
|
|
302
|
-
}
|
|
303
|
-
// Map level to logcat filter
|
|
304
|
-
const levelMap = { error: '*:E', warn: '*:W', info: '*:I', debug: '*:D' };
|
|
305
|
-
const filter = levelMap[level] || levelMap['error'];
|
|
306
|
-
// Prevent multiple streams per session
|
|
307
|
-
if (activeLogStreams.has(sessionId)) {
|
|
308
|
-
// stop existing
|
|
309
|
-
try {
|
|
310
|
-
activeLogStreams.get(sessionId).proc.kill();
|
|
311
|
-
}
|
|
312
|
-
catch { }
|
|
313
|
-
activeLogStreams.delete(sessionId);
|
|
314
|
-
}
|
|
315
|
-
// Start logcat process
|
|
316
|
-
const args = ['logcat', `--pid=${pid}`, filter];
|
|
317
|
-
const proc = spawn(ADB, args);
|
|
318
|
-
// Prepare output file
|
|
319
|
-
const tmpDir = process.env.TMPDIR || '/tmp';
|
|
320
|
-
const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`);
|
|
321
|
-
const stream = createWriteStream(file, { flags: 'a' });
|
|
322
|
-
proc.stdout.on('data', (chunk) => {
|
|
323
|
-
const text = chunk.toString();
|
|
324
|
-
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
325
|
-
for (const l of lines) {
|
|
326
|
-
const entry = parseLogLine(l);
|
|
327
|
-
stream.write(JSON.stringify(entry) + '\n');
|
|
328
|
-
}
|
|
329
|
-
});
|
|
330
|
-
proc.stderr.on('data', (chunk) => {
|
|
331
|
-
// write stderr lines as message with level 'E'
|
|
332
|
-
const text = chunk.toString();
|
|
333
|
-
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
334
|
-
for (const l of lines) {
|
|
335
|
-
const entry = { timestamp: '', level: 'E', tag: 'adb', message: l };
|
|
336
|
-
stream.write(JSON.stringify(entry) + '\n');
|
|
337
|
-
}
|
|
338
|
-
});
|
|
339
|
-
proc.on('close', () => {
|
|
340
|
-
stream.end();
|
|
341
|
-
activeLogStreams.delete(sessionId);
|
|
342
|
-
});
|
|
343
|
-
activeLogStreams.set(sessionId, { proc, file });
|
|
344
|
-
return { success: true, stream_started: true };
|
|
345
|
-
}
|
|
346
|
-
catch {
|
|
347
|
-
return { success: false, error: 'log_stream_start_failed' };
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
export async function stopAndroidLogStream(sessionId = 'default') {
|
|
351
|
-
const entry = activeLogStreams.get(sessionId);
|
|
352
|
-
if (!entry)
|
|
353
|
-
return { success: true };
|
|
354
|
-
try {
|
|
355
|
-
entry.proc.kill();
|
|
356
|
-
}
|
|
357
|
-
catch { }
|
|
358
|
-
activeLogStreams.delete(sessionId);
|
|
359
|
-
return { success: true };
|
|
360
|
-
}
|
|
361
|
-
export async function readLogStreamLines(sessionId = 'default', limit = 100, since) {
|
|
362
|
-
const entry = activeLogStreams.get(sessionId);
|
|
363
|
-
if (!entry)
|
|
364
|
-
return { entries: [] };
|
|
365
|
-
try {
|
|
366
|
-
const data = await fsPromises.readFile(entry.file, 'utf8').catch(() => '');
|
|
367
|
-
if (!data)
|
|
368
|
-
return { entries: [], crash_summary: { crash_detected: false } };
|
|
369
|
-
const lines = data.split(/\r?\n/).filter(Boolean);
|
|
370
|
-
// Parse NDJSON lines into objects. Prefer fields written by parseLogLine. For backward compatibility, if _iso or crash are missing, enrich minimally here (avoid duplicating full parse logic).
|
|
371
|
-
const parsed = lines.map(l => {
|
|
372
|
-
try {
|
|
373
|
-
const obj = JSON.parse(l);
|
|
374
|
-
// Ensure _iso: if missing, try to derive using Date()
|
|
375
|
-
if (typeof obj._iso === 'undefined') {
|
|
376
|
-
let iso = null;
|
|
377
|
-
if (obj.timestamp) {
|
|
378
|
-
const d = new Date(obj.timestamp);
|
|
379
|
-
if (!isNaN(d.getTime()))
|
|
380
|
-
iso = d.toISOString();
|
|
381
|
-
}
|
|
382
|
-
obj._iso = iso;
|
|
383
|
-
}
|
|
384
|
-
// Ensure crash flag: if missing, run minimal heuristic
|
|
385
|
-
if (typeof obj.crash === 'undefined') {
|
|
386
|
-
const msg = (obj.message || '').toString();
|
|
387
|
-
const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/);
|
|
388
|
-
if (/FATAL EXCEPTION/i.test(msg) || exMatch) {
|
|
389
|
-
obj.crash = true;
|
|
390
|
-
if (exMatch)
|
|
391
|
-
obj.exception = exMatch[1];
|
|
392
|
-
}
|
|
393
|
-
else {
|
|
394
|
-
obj.crash = false;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
return obj;
|
|
398
|
-
}
|
|
399
|
-
catch {
|
|
400
|
-
return { message: l, _iso: null, crash: false };
|
|
401
|
-
}
|
|
402
|
-
});
|
|
403
|
-
// Filter by since if provided (accept ISO or epoch ms)
|
|
404
|
-
let filtered = parsed;
|
|
405
|
-
if (since) {
|
|
406
|
-
let sinceMs = null;
|
|
407
|
-
// If numeric string
|
|
408
|
-
if (/^\d+$/.test(since))
|
|
409
|
-
sinceMs = Number(since);
|
|
410
|
-
else {
|
|
411
|
-
const sDate = new Date(since);
|
|
412
|
-
if (!isNaN(sDate.getTime()))
|
|
413
|
-
sinceMs = sDate.getTime();
|
|
414
|
-
}
|
|
415
|
-
if (sinceMs !== null) {
|
|
416
|
-
filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs));
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
// Return the last `limit` entries (most recent)
|
|
420
|
-
const entries = filtered.slice(-Math.max(0, limit));
|
|
421
|
-
// Crash summary
|
|
422
|
-
const crashEntry = entries.find(e => e.crash);
|
|
423
|
-
const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false };
|
|
424
|
-
return { entries, crash_summary };
|
|
425
|
-
}
|
|
426
|
-
catch {
|
|
427
|
-
return { entries: [], crash_summary: { crash_detected: false } };
|
|
428
|
-
}
|
|
429
|
-
}
|
|
419
|
+
// Legacy readLogStreamLines shim removed. Use AndroidObserve.readLogStream(sessionId, limit, since) instead.
|
package/dist/ios/interact.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { promises as fs } from "fs";
|
|
2
1
|
import { spawn } from "child_process";
|
|
3
|
-
import {
|
|
2
|
+
import { getIOSDeviceMetadata, IDB } from "./utils.js";
|
|
4
3
|
import { iOSObserve } from "./observe.js";
|
|
5
|
-
import path from "path";
|
|
6
4
|
export class iOSInteract {
|
|
7
5
|
observe = new iOSObserve();
|
|
8
6
|
async waitForElement(text, timeout, deviceId = "booted") {
|
|
@@ -72,169 +70,4 @@ export class iOSInteract {
|
|
|
72
70
|
return { device, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
|
|
73
71
|
}
|
|
74
72
|
}
|
|
75
|
-
async installApp(appPath, deviceId = "booted") {
|
|
76
|
-
const device = await getIOSDeviceMetadata(deviceId);
|
|
77
|
-
// Helper to find .app bundles under a directory
|
|
78
|
-
async function findAppBundle(dir) {
|
|
79
|
-
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
80
|
-
for (const e of entries) {
|
|
81
|
-
const full = path.join(dir, e.name);
|
|
82
|
-
if (e.isDirectory()) {
|
|
83
|
-
if (full.endsWith('.app'))
|
|
84
|
-
return full;
|
|
85
|
-
const found = await findAppBundle(full);
|
|
86
|
-
if (found)
|
|
87
|
-
return found;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return undefined;
|
|
91
|
-
}
|
|
92
|
-
try {
|
|
93
|
-
let toInstall = appPath;
|
|
94
|
-
const stat = await fs.stat(appPath).catch(() => null);
|
|
95
|
-
if (stat && stat.isDirectory()) {
|
|
96
|
-
// If directory already contains a .app, use it
|
|
97
|
-
const found = await findAppBundle(appPath);
|
|
98
|
-
if (found) {
|
|
99
|
-
toInstall = found;
|
|
100
|
-
}
|
|
101
|
-
else {
|
|
102
|
-
// Attempt to locate an Xcode project and build for simulator
|
|
103
|
-
const files = await fs.readdir(appPath).catch(() => []);
|
|
104
|
-
// Prefer workspace when present (CocoaPods / multi-project setups)
|
|
105
|
-
const workspace = files.find(f => f.endsWith('.xcworkspace'));
|
|
106
|
-
const proj = files.find(f => f.endsWith('.xcodeproj'));
|
|
107
|
-
if (!workspace && !proj)
|
|
108
|
-
throw new Error('No .app bundle, .xcworkspace or .xcodeproj found in directory');
|
|
109
|
-
let buildArgs;
|
|
110
|
-
let scheme;
|
|
111
|
-
if (workspace) {
|
|
112
|
-
const workspacePath = path.join(appPath, workspace);
|
|
113
|
-
scheme = workspace.replace(/\.xcworkspace$/, '');
|
|
114
|
-
buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build', '-quiet'];
|
|
115
|
-
}
|
|
116
|
-
else {
|
|
117
|
-
const projectPath = path.join(appPath, proj);
|
|
118
|
-
scheme = proj.replace(/\.xcodeproj$/, '');
|
|
119
|
-
buildArgs = ['-project', projectPath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build', '-quiet'];
|
|
120
|
-
}
|
|
121
|
-
await new Promise((resolve, reject) => {
|
|
122
|
-
const proc = spawn('xcodebuild', buildArgs, { cwd: appPath });
|
|
123
|
-
let stderr = '';
|
|
124
|
-
proc.stderr?.on('data', d => stderr += d.toString());
|
|
125
|
-
proc.on('close', code => {
|
|
126
|
-
if (code === 0)
|
|
127
|
-
resolve();
|
|
128
|
-
else
|
|
129
|
-
reject(new Error(stderr || `xcodebuild failed with code ${code}`));
|
|
130
|
-
});
|
|
131
|
-
proc.on('error', err => reject(err));
|
|
132
|
-
});
|
|
133
|
-
const built = await findAppBundle(appPath);
|
|
134
|
-
if (!built)
|
|
135
|
-
throw new Error('Could not locate built .app after xcodebuild');
|
|
136
|
-
toInstall = built;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
// Try simulator install first
|
|
140
|
-
try {
|
|
141
|
-
const res = await execCommand(['simctl', 'install', deviceId, toInstall], deviceId);
|
|
142
|
-
return { device, installed: true, output: res.output };
|
|
143
|
-
}
|
|
144
|
-
catch (e) {
|
|
145
|
-
// If simctl fails and idb is available, try idb install for physical devices
|
|
146
|
-
try {
|
|
147
|
-
const child = spawn(IDB, ['--version']);
|
|
148
|
-
const idbExists = await new Promise((resolve) => {
|
|
149
|
-
child.on('error', () => resolve(false));
|
|
150
|
-
child.on('close', (code) => resolve(code === 0));
|
|
151
|
-
});
|
|
152
|
-
if (idbExists) {
|
|
153
|
-
// Use idb to install (works for physical devices and simulators)
|
|
154
|
-
await new Promise((resolve, reject) => {
|
|
155
|
-
const proc = spawn(IDB, ['install', toInstall, '--udid', device.id]);
|
|
156
|
-
let stderr = '';
|
|
157
|
-
proc.stderr.on('data', d => stderr += d.toString());
|
|
158
|
-
proc.on('close', code => {
|
|
159
|
-
if (code === 0)
|
|
160
|
-
resolve();
|
|
161
|
-
else
|
|
162
|
-
reject(new Error(stderr || `idb install failed with code ${code}`));
|
|
163
|
-
});
|
|
164
|
-
proc.on('error', err => reject(err));
|
|
165
|
-
});
|
|
166
|
-
return { device, installed: true };
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
catch {
|
|
170
|
-
// fallthrough
|
|
171
|
-
}
|
|
172
|
-
return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
catch (e) {
|
|
176
|
-
return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
async startApp(bundleId, deviceId = "booted") {
|
|
180
|
-
validateBundleId(bundleId);
|
|
181
|
-
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
182
|
-
const device = await getIOSDeviceMetadata(deviceId);
|
|
183
|
-
// Simulate launch time and appStarted for demonstration
|
|
184
|
-
return {
|
|
185
|
-
device,
|
|
186
|
-
appStarted: !!result.output,
|
|
187
|
-
launchTimeMs: 1000,
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
async terminateApp(bundleId, deviceId = "booted") {
|
|
191
|
-
validateBundleId(bundleId);
|
|
192
|
-
await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
193
|
-
const device = await getIOSDeviceMetadata(deviceId);
|
|
194
|
-
return {
|
|
195
|
-
device,
|
|
196
|
-
appTerminated: true
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
async restartApp(bundleId, deviceId = "booted") {
|
|
200
|
-
// terminateApp already validates bundleId
|
|
201
|
-
await this.terminateApp(bundleId, deviceId);
|
|
202
|
-
const startResult = await this.startApp(bundleId, deviceId);
|
|
203
|
-
return {
|
|
204
|
-
device: startResult.device,
|
|
205
|
-
appRestarted: startResult.appStarted,
|
|
206
|
-
launchTimeMs: startResult.launchTimeMs
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
async resetAppData(bundleId, deviceId = "booted") {
|
|
210
|
-
validateBundleId(bundleId);
|
|
211
|
-
await this.terminateApp(bundleId, deviceId);
|
|
212
|
-
const device = await getIOSDeviceMetadata(deviceId);
|
|
213
|
-
// Get data container path
|
|
214
|
-
const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
|
|
215
|
-
const dataPath = containerResult.output.trim();
|
|
216
|
-
if (!dataPath) {
|
|
217
|
-
throw new Error(`Could not find data container for ${bundleId}`);
|
|
218
|
-
}
|
|
219
|
-
// Clear contents of Library and Documents
|
|
220
|
-
try {
|
|
221
|
-
const libraryPath = `${dataPath}/Library`;
|
|
222
|
-
const documentsPath = `${dataPath}/Documents`;
|
|
223
|
-
const tmpPath = `${dataPath}/tmp`;
|
|
224
|
-
await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => { });
|
|
225
|
-
await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => { });
|
|
226
|
-
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => { });
|
|
227
|
-
// Re-create empty directories as they are expected by apps
|
|
228
|
-
await fs.mkdir(libraryPath, { recursive: true }).catch(() => { });
|
|
229
|
-
await fs.mkdir(documentsPath, { recursive: true }).catch(() => { });
|
|
230
|
-
await fs.mkdir(tmpPath, { recursive: true }).catch(() => { });
|
|
231
|
-
return {
|
|
232
|
-
device,
|
|
233
|
-
dataCleared: true
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
catch (e) {
|
|
237
|
-
throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
73
|
}
|