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.
- package/README.md +20 -5
- package/dist/android/diagnostics.js +24 -0
- package/dist/android/interact.js +1 -145
- package/dist/android/manage.js +162 -0
- package/dist/android/observe.js +133 -88
- package/dist/android/run.js +187 -0
- package/dist/android/utils.js +137 -147
- package/dist/ios/interact.js +4 -175
- package/dist/ios/manage.js +169 -0
- package/dist/ios/observe.js +129 -13
- package/dist/ios/run.js +200 -0
- package/dist/ios/utils.js +138 -124
- package/dist/server.js +45 -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/dist/utils/diagnostics.js +25 -0
- package/docs/CHANGELOG.md +14 -0
- package/eslint.config.js +22 -1
- package/package.json +8 -5
- package/scripts/check-idb.js +83 -0
- package/scripts/check-idb.ts +73 -0
- package/scripts/idb-helper.ts +76 -0
- package/scripts/install-idb.js +88 -0
- package/scripts/install-idb.ts +90 -0
- package/scripts/run-ios-smoke.ts +34 -0
- package/scripts/run-ios-ui-tree-tap.ts +33 -0
- package/src/android/diagnostics.ts +23 -0
- package/src/android/interact.ts +2 -155
- package/src/android/manage.ts +157 -0
- package/src/android/observe.ts +129 -97
- package/src/android/utils.ts +147 -149
- package/src/ios/interact.ts +5 -181
- package/src/ios/manage.ts +164 -0
- package/src/ios/observe.ts +130 -14
- package/src/ios/utils.ts +127 -128
- package/src/server.ts +47 -17
- package/src/tools/interact.ts +23 -62
- package/src/tools/manage.ts +171 -0
- package/src/tools/observe.ts +24 -74
- package/src/types.ts +9 -0
- package/src/utils/diagnostics.ts +36 -0
- package/test/device/README.md +49 -0
- package/test/device/index.ts +27 -0
- package/test/device/manage/run-build-install-ios.ts +82 -0
- package/test/{integration → device/manage}/run-install-android.ts +4 -4
- package/test/{integration → device/manage}/run-install-ios.ts +4 -4
- package/test/{integration → device/observe}/logstream-real.ts +5 -4
- package/test/{integration → device/utils}/test-dist.ts +2 -2
- package/test/unit/index.ts +10 -6
- package/test/unit/manage/build.test.ts +83 -0
- package/test/unit/manage/build_and_install.test.ts +134 -0
- package/test/unit/manage/diagnostics.test.ts +85 -0
- package/test/unit/{install.test.ts → manage/install.test.ts} +27 -18
- package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
- package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +9 -10
- package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
- package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
- package/tsconfig.json +2 -1
- package/test/integration/index.ts +0 -8
- package/test/integration/test-dist.mjs +0 -41
- /package/test/{integration → device/interact}/run-real-test.ts +0 -0
- /package/test/{integration → device/interact}/smoke-test.ts +0 -0
- /package/test/{integration → device/manage}/install.integration.ts +0 -0
- /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
- /package/test/{integration → device/observe}/wait_for_element_real.ts +0 -0
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, getIdbCmd, isIDBInstalled } 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") {
|
|
@@ -33,12 +31,8 @@ export class iOSInteract {
|
|
|
33
31
|
}
|
|
34
32
|
async tap(x, y, deviceId = "booted") {
|
|
35
33
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
36
|
-
//
|
|
37
|
-
const
|
|
38
|
-
const idbExists = await new Promise((resolve) => {
|
|
39
|
-
child.on('error', () => resolve(false));
|
|
40
|
-
child.on('close', (code) => resolve(code === 0));
|
|
41
|
-
});
|
|
34
|
+
// Use shared helper to detect idb
|
|
35
|
+
const idbExists = await isIDBInstalled();
|
|
42
36
|
if (!idbExists) {
|
|
43
37
|
return {
|
|
44
38
|
device,
|
|
@@ -55,7 +49,7 @@ export class iOSInteract {
|
|
|
55
49
|
args.push('--udid', targetUdid);
|
|
56
50
|
}
|
|
57
51
|
await new Promise((resolve, reject) => {
|
|
58
|
-
const proc = spawn(
|
|
52
|
+
const proc = spawn(getIdbCmd(), args);
|
|
59
53
|
let stderr = '';
|
|
60
54
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
61
55
|
proc.on('close', code => {
|
|
@@ -72,169 +66,4 @@ export class iOSInteract {
|
|
|
72
66
|
return { device, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
|
|
73
67
|
}
|
|
74
68
|
}
|
|
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
69
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import { spawn } from "child_process";
|
|
3
|
+
import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "./utils.js";
|
|
4
|
+
import path from "path";
|
|
5
|
+
export class iOSManage {
|
|
6
|
+
async build(projectPath, _variant) {
|
|
7
|
+
void _variant;
|
|
8
|
+
try {
|
|
9
|
+
const files = await fs.readdir(projectPath).catch(() => []);
|
|
10
|
+
const workspace = files.find(f => f.endsWith('.xcworkspace'));
|
|
11
|
+
const proj = files.find(f => f.endsWith('.xcodeproj'));
|
|
12
|
+
if (!workspace && !proj)
|
|
13
|
+
return { error: 'No Xcode project or workspace found' };
|
|
14
|
+
let buildArgs;
|
|
15
|
+
if (workspace) {
|
|
16
|
+
const workspacePath = path.join(projectPath, workspace);
|
|
17
|
+
const scheme = workspace.replace(/\.xcworkspace$/, '');
|
|
18
|
+
buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
const projectPathFull = path.join(projectPath, proj);
|
|
22
|
+
const scheme = proj.replace(/\.xcodeproj$/, '');
|
|
23
|
+
buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
|
|
24
|
+
}
|
|
25
|
+
await new Promise((resolve, reject) => {
|
|
26
|
+
const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
|
|
27
|
+
const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath });
|
|
28
|
+
let stderr = '';
|
|
29
|
+
proc.stderr?.on('data', d => stderr += d.toString());
|
|
30
|
+
proc.on('close', code => {
|
|
31
|
+
if (code === 0)
|
|
32
|
+
resolve();
|
|
33
|
+
else
|
|
34
|
+
reject(new Error(stderr || `xcodebuild failed with code ${code}`));
|
|
35
|
+
});
|
|
36
|
+
proc.on('error', err => reject(err));
|
|
37
|
+
});
|
|
38
|
+
const built = await findAppBundle(projectPath);
|
|
39
|
+
if (!built)
|
|
40
|
+
return { error: 'Could not find .app after build' };
|
|
41
|
+
return { artifactPath: built };
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async installApp(appPath, deviceId = "booted") {
|
|
48
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
49
|
+
try {
|
|
50
|
+
let toInstall = appPath;
|
|
51
|
+
const stat = await fs.stat(appPath).catch(() => null);
|
|
52
|
+
if (stat && stat.isDirectory()) {
|
|
53
|
+
if (appPath.endsWith('.app')) {
|
|
54
|
+
toInstall = appPath;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const found = await findAppBundle(appPath);
|
|
58
|
+
if (found) {
|
|
59
|
+
toInstall = found;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Reuse the existing build() implementation to avoid duplicating the xcodebuild logic
|
|
63
|
+
const buildRes = await this.build(appPath);
|
|
64
|
+
if (buildRes.error)
|
|
65
|
+
throw new Error(buildRes.error);
|
|
66
|
+
toInstall = buildRes.artifactPath;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const res = await execCommand(['simctl', 'install', deviceId, toInstall], deviceId);
|
|
72
|
+
return { device, installed: true, output: res.output };
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
// Gather diagnostics for simctl failure
|
|
76
|
+
const diag = execCommandWithDiagnostics(['simctl', 'install', deviceId, toInstall], deviceId);
|
|
77
|
+
try {
|
|
78
|
+
const child = spawn(getIdbCmd(), ['--version']);
|
|
79
|
+
const idbExists = await new Promise((resolve) => {
|
|
80
|
+
child.on('error', () => resolve(false));
|
|
81
|
+
child.on('close', (code) => resolve(code === 0));
|
|
82
|
+
});
|
|
83
|
+
if (idbExists) {
|
|
84
|
+
// attempt idb install via spawn but include diagnostics
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', device.id]);
|
|
87
|
+
let stderr = '';
|
|
88
|
+
proc.stderr.on('data', d => stderr += d.toString());
|
|
89
|
+
proc.on('close', code => {
|
|
90
|
+
if (code === 0)
|
|
91
|
+
resolve();
|
|
92
|
+
else
|
|
93
|
+
reject(new Error(stderr || `idb install failed with code ${code}`));
|
|
94
|
+
});
|
|
95
|
+
proc.on('error', err => reject(err));
|
|
96
|
+
});
|
|
97
|
+
return { device, installed: true };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch { }
|
|
101
|
+
return { device, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async startApp(bundleId, deviceId = "booted") {
|
|
109
|
+
validateBundleId(bundleId);
|
|
110
|
+
try {
|
|
111
|
+
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
112
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
113
|
+
return { device, appStarted: !!result.output, launchTimeMs: 1000 };
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
117
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
118
|
+
return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async terminateApp(bundleId, deviceId = "booted") {
|
|
122
|
+
validateBundleId(bundleId);
|
|
123
|
+
try {
|
|
124
|
+
await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
125
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
126
|
+
return { device, appTerminated: true };
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
130
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
131
|
+
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async restartApp(bundleId, deviceId = "booted") {
|
|
135
|
+
await this.terminateApp(bundleId, deviceId);
|
|
136
|
+
const startResult = await this.startApp(bundleId, deviceId);
|
|
137
|
+
return { device: startResult.device, appRestarted: startResult.appStarted, launchTimeMs: startResult.launchTimeMs };
|
|
138
|
+
}
|
|
139
|
+
async resetAppData(bundleId, deviceId = "booted") {
|
|
140
|
+
validateBundleId(bundleId);
|
|
141
|
+
await this.terminateApp(bundleId, deviceId);
|
|
142
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
143
|
+
try {
|
|
144
|
+
const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
|
|
145
|
+
const dataPath = containerResult.output.trim();
|
|
146
|
+
if (!dataPath)
|
|
147
|
+
throw new Error(`Could not find data container for ${bundleId}`);
|
|
148
|
+
try {
|
|
149
|
+
const libraryPath = `${dataPath}/Library`;
|
|
150
|
+
const documentsPath = `${dataPath}/Documents`;
|
|
151
|
+
const tmpPath = `${dataPath}/tmp`;
|
|
152
|
+
await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => { });
|
|
153
|
+
await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => { });
|
|
154
|
+
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => { });
|
|
155
|
+
await fs.mkdir(libraryPath, { recursive: true }).catch(() => { });
|
|
156
|
+
await fs.mkdir(documentsPath, { recursive: true }).catch(() => { });
|
|
157
|
+
await fs.mkdir(tmpPath, { recursive: true }).catch(() => { });
|
|
158
|
+
return { device, dataCleared: true };
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
const diag = execCommandWithDiagnostics(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
|
|
166
|
+
return { device, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
package/dist/ios/observe.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { promises as fs } from "fs";
|
|
3
|
-
import { execCommand, getIOSDeviceMetadata, validateBundleId,
|
|
3
|
+
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "./utils.js";
|
|
4
|
+
import { createWriteStream, promises as fsPromises } from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { parseLogLine } from '../android/utils.js';
|
|
4
7
|
// --- Helper Functions Specific to Observe ---
|
|
5
8
|
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
6
9
|
function parseIDBFrame(frame) {
|
|
7
10
|
if (!frame)
|
|
8
11
|
return [0, 0, 0, 0];
|
|
12
|
+
// Handle string frames like "{{0, 0}, {402, 874}}"
|
|
13
|
+
if (typeof frame === 'string') {
|
|
14
|
+
const nums = frame.match(/-?\d+(?:\.\d+)?/g);
|
|
15
|
+
if (!nums || nums.length < 4)
|
|
16
|
+
return [0, 0, 0, 0];
|
|
17
|
+
const x = Number(nums[0]);
|
|
18
|
+
const y = Number(nums[1]);
|
|
19
|
+
const w = Number(nums[2]);
|
|
20
|
+
const h = Number(nums[3]);
|
|
21
|
+
return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
|
|
22
|
+
}
|
|
9
23
|
const x = Number(frame.x || 0);
|
|
10
24
|
const y = Number(frame.y || 0);
|
|
11
25
|
const w = Number(frame.width || frame.w || 0);
|
|
@@ -69,14 +83,14 @@ function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
|
|
|
69
83
|
}
|
|
70
84
|
return currentIndex;
|
|
71
85
|
}
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
// iOS live log stream support (moved from ios/utils to observe)
|
|
87
|
+
const iosActiveLogStreams = new Map();
|
|
88
|
+
// Test helpers
|
|
89
|
+
export function _setIOSActiveLogStream(sessionId, file) {
|
|
90
|
+
iosActiveLogStreams.set(sessionId, { proc: {}, file });
|
|
91
|
+
}
|
|
92
|
+
export function _clearIOSActiveLogStream(sessionId) {
|
|
93
|
+
iosActiveLogStreams.delete(sessionId);
|
|
80
94
|
}
|
|
81
95
|
export class iOSObserve {
|
|
82
96
|
async getDeviceMetadata(deviceId = "booted") {
|
|
@@ -149,12 +163,12 @@ export class iOSObserve {
|
|
|
149
163
|
try {
|
|
150
164
|
// Stabilization delay
|
|
151
165
|
await delay(300 + (attempts * 100));
|
|
152
|
-
const args = ['ui', 'describe', '--json'];
|
|
166
|
+
const args = ['ui', 'describe-all', '--json'];
|
|
153
167
|
if (targetUdid) {
|
|
154
168
|
args.push('--udid', targetUdid);
|
|
155
169
|
}
|
|
156
170
|
const output = await new Promise((resolve, reject) => {
|
|
157
|
-
const child = spawn(
|
|
171
|
+
const child = spawn(getIdbCmd(), args);
|
|
158
172
|
let stdout = '';
|
|
159
173
|
let stderr = '';
|
|
160
174
|
child.stdout.on('data', (data) => stdout += data.toString());
|
|
@@ -189,8 +203,15 @@ export class iOSObserve {
|
|
|
189
203
|
}
|
|
190
204
|
try {
|
|
191
205
|
const elements = [];
|
|
192
|
-
|
|
193
|
-
|
|
206
|
+
// idb describe-all returns either a root object or an array of root nodes
|
|
207
|
+
if (Array.isArray(jsonContent)) {
|
|
208
|
+
for (const node of jsonContent) {
|
|
209
|
+
traverseIDBNode(node, elements);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
traverseIDBNode(jsonContent, elements);
|
|
214
|
+
}
|
|
194
215
|
// Infer resolution from root element if possible (usually the Window/Application frame)
|
|
195
216
|
let width = 0;
|
|
196
217
|
let height = 0;
|
|
@@ -216,4 +237,99 @@ export class iOSObserve {
|
|
|
216
237
|
};
|
|
217
238
|
}
|
|
218
239
|
}
|
|
240
|
+
// --- Log stream methods ---
|
|
241
|
+
async startLogStream(bundleId, deviceId = 'booted', sessionId = 'default') {
|
|
242
|
+
try {
|
|
243
|
+
const predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`;
|
|
244
|
+
if (iosActiveLogStreams.has(sessionId)) {
|
|
245
|
+
try {
|
|
246
|
+
iosActiveLogStreams.get(sessionId).proc.kill();
|
|
247
|
+
}
|
|
248
|
+
catch { }
|
|
249
|
+
iosActiveLogStreams.delete(sessionId);
|
|
250
|
+
}
|
|
251
|
+
const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate];
|
|
252
|
+
const proc = spawn(getXcrunCmd(), args);
|
|
253
|
+
// Prepare output file
|
|
254
|
+
const tmpDir = process.env.TMPDIR || '/tmp';
|
|
255
|
+
const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`);
|
|
256
|
+
const stream = createWriteStream(file, { flags: 'a' });
|
|
257
|
+
proc.stdout.on('data', (chunk) => {
|
|
258
|
+
const text = chunk.toString();
|
|
259
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
260
|
+
for (const l of lines) {
|
|
261
|
+
const entry = parseLogLine(l);
|
|
262
|
+
stream.write(JSON.stringify(entry) + '\n');
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
proc.stderr.on('data', (chunk) => {
|
|
266
|
+
const text = chunk.toString();
|
|
267
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
268
|
+
for (const l of lines) {
|
|
269
|
+
const entry = { timestamp: '', level: 'E', tag: 'xcrun', message: l };
|
|
270
|
+
stream.write(JSON.stringify(entry) + '\n');
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
proc.on('close', () => {
|
|
274
|
+
stream.end();
|
|
275
|
+
iosActiveLogStreams.delete(sessionId);
|
|
276
|
+
});
|
|
277
|
+
iosActiveLogStreams.set(sessionId, { proc, file });
|
|
278
|
+
return { success: true, stream_started: true };
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return { success: false, error: 'log_stream_start_failed' };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async stopLogStream(sessionId = 'default') {
|
|
285
|
+
const entry = iosActiveLogStreams.get(sessionId);
|
|
286
|
+
if (!entry)
|
|
287
|
+
return { success: true };
|
|
288
|
+
try {
|
|
289
|
+
entry.proc.kill();
|
|
290
|
+
}
|
|
291
|
+
catch { }
|
|
292
|
+
iosActiveLogStreams.delete(sessionId);
|
|
293
|
+
return { success: true };
|
|
294
|
+
}
|
|
295
|
+
async readLogStream(sessionId = 'default', limit = 100, since) {
|
|
296
|
+
const entry = iosActiveLogStreams.get(sessionId);
|
|
297
|
+
if (!entry)
|
|
298
|
+
return { entries: [] };
|
|
299
|
+
try {
|
|
300
|
+
const data = await fsPromises.readFile(entry.file, 'utf8').catch(() => '');
|
|
301
|
+
if (!data)
|
|
302
|
+
return { entries: [], crash_summary: { crash_detected: false } };
|
|
303
|
+
const lines = data.split(/\r?\n/).filter(Boolean);
|
|
304
|
+
const parsed = lines.map(l => {
|
|
305
|
+
try {
|
|
306
|
+
return JSON.parse(l);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return { message: l, _iso: null, crash: false };
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
let filtered = parsed;
|
|
313
|
+
if (since) {
|
|
314
|
+
let sinceMs = null;
|
|
315
|
+
if (/^\d+$/.test(since))
|
|
316
|
+
sinceMs = Number(since);
|
|
317
|
+
else {
|
|
318
|
+
const sDate = new Date(since);
|
|
319
|
+
if (!isNaN(sDate.getTime()))
|
|
320
|
+
sinceMs = sDate.getTime();
|
|
321
|
+
}
|
|
322
|
+
if (sinceMs !== null) {
|
|
323
|
+
filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const entries = filtered.slice(-Math.max(0, limit));
|
|
327
|
+
const crashEntry = entries.find(e => e.crash);
|
|
328
|
+
const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false };
|
|
329
|
+
return { entries, crash_summary };
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return { entries: [], crash_summary: { crash_detected: false } };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
219
335
|
}
|