mobile-debug-mcp 0.21.2 → 0.21.4
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/dist/interact/index.js +202 -7
- package/dist/observe/android.js +88 -11
- package/dist/observe/index.js +32 -10
- package/dist/observe/ios.js +149 -12
- package/dist/server.js +30 -16
- package/dist/utils/cli/ios/run-ios-smoke.js +1 -1
- package/docs/CHANGELOG.md +7 -0
- package/docs/tools/observe.md +25 -10
- package/package.json +1 -1
- package/src/interact/index.ts +195 -7
- package/src/observe/android.ts +88 -15
- package/src/observe/index.ts +33 -12
- package/src/observe/ios.ts +151 -12
- package/src/server.ts +31 -16
- package/src/types.ts +13 -1
- package/src/utils/cli/ios/run-ios-smoke.ts +1 -1
- package/test/helpers/run-get-logs.ts +20 -0
- package/test/interact/unit/wait_for_ui_contract.test.ts +33 -0
- package/test/interact/unit/wait_for_ui_new.test.ts +57 -0
- package/test/observe/device/get_logs.android.smoke.ts +35 -0
- package/test/observe/device/get_logs.ios.smoke.ts +33 -0
- package/test/observe/unit/get_logs.test.ts +58 -0
- package/test/observe/unit/ios-getlogs.test.ts +67 -0
- package/test/manage/device/run-install-kmp.ts +0 -18
package/src/observe/ios.ts
CHANGED
|
@@ -121,20 +121,158 @@ export class iOSObserve {
|
|
|
121
121
|
return getIOSDeviceMetadata(deviceId);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
async getLogs(appId?: string, deviceId
|
|
125
|
-
const
|
|
124
|
+
async getLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
|
|
125
|
+
const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters
|
|
126
|
+
const args: string[] = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog']
|
|
127
|
+
|
|
128
|
+
// Default to last N seconds if no since_seconds provided; limit lines handled after parsing
|
|
129
|
+
const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50
|
|
130
|
+
|
|
131
|
+
if (since_seconds) {
|
|
132
|
+
// log show accepts --last <time>
|
|
133
|
+
args.push('--last', `${since_seconds}s`)
|
|
134
|
+
} else {
|
|
135
|
+
// default to last 60s to keep quick
|
|
136
|
+
args.push('--last', '60s')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let processNameUsed: string | undefined = undefined
|
|
126
140
|
if (appId) {
|
|
127
141
|
validateBundleId(appId)
|
|
128
|
-
|
|
142
|
+
// prefer matching the simple process name (last segment of bundle id), but also match full bundle id in subsystem
|
|
143
|
+
const parts = appId.split('.')
|
|
144
|
+
const simpleName = parts[parts.length - 1]
|
|
145
|
+
processNameUsed = simpleName
|
|
146
|
+
// predicate: match process by simple name or full bundle id, or subsystem contains bundle id
|
|
147
|
+
args.push('--predicate', `process == "${simpleName}" or process == "${appId}" or subsystem contains "${appId}"`)
|
|
148
|
+
} else if (tag) {
|
|
149
|
+
// predicate by subsystem/category
|
|
150
|
+
args.push('--predicate', `subsystem contains "${tag}"`)
|
|
129
151
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
// Attempt pid detection if appId provided and no explicit pid supplied — prefer process name derived from bundle id
|
|
155
|
+
let detectedPid: number | null = null
|
|
156
|
+
if (appId && !pid) {
|
|
157
|
+
const parts = appId.split('.')
|
|
158
|
+
const simpleName = parts[parts.length - 1]
|
|
159
|
+
try {
|
|
160
|
+
const pgrepRes = await execCommand(['simctl','spawn', deviceId, 'pgrep', '-f', simpleName], deviceId)
|
|
161
|
+
const out = pgrepRes && pgrepRes.output ? pgrepRes.output.trim() : ''
|
|
162
|
+
const firstLine = out.split(/\r?\n/).find(Boolean)
|
|
163
|
+
if (firstLine) {
|
|
164
|
+
const n = Number(firstLine.trim())
|
|
165
|
+
if (!isNaN(n) && n > 0) detectedPid = n
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// ignore pgrep failures — we'll fall back to process/bundle matching
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const effectivePid = pid || detectedPid || null
|
|
172
|
+
const result = await execCommand(args, deviceId)
|
|
173
|
+
const device = await getIOSDeviceMetadata(deviceId)
|
|
174
|
+
const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : []
|
|
175
|
+
|
|
176
|
+
// Parse lines into structured entries. iOS log format: timestamp [PID:tid] <level> subsystem:category: message
|
|
177
|
+
const parsed = rawLines.map(line => {
|
|
178
|
+
// Example: 2023-08-12 12:34:56.789012+0000 pid <Debug> MyApp[123:456] <info> MySubsystem: MyCategory: Message here
|
|
179
|
+
// Simpler approach: try to extract ISO timestamp at start
|
|
180
|
+
let ts: string | null = null
|
|
181
|
+
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)
|
|
182
|
+
if (tsMatch) {
|
|
183
|
+
const d = new Date(tsMatch[1])
|
|
184
|
+
if (!isNaN(d.getTime())) ts = d.toISOString()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// level mapping
|
|
188
|
+
let lvl = 'INFO'
|
|
189
|
+
const lvlMatch = line.match(/\b(Debug|Info|Default|Error|Fault|Warning)\b/i)
|
|
190
|
+
if (lvlMatch) {
|
|
191
|
+
const map: Record<string, string> = { 'debug': 'DEBUG', 'info': 'INFO', 'default': 'DEBUG', 'error': 'ERROR', 'fault': 'ERROR', 'warning': 'WARN' }
|
|
192
|
+
lvl = map[(lvlMatch[1] || '').toLowerCase()] || 'INFO'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// subsystem/category -> tag
|
|
196
|
+
let tagVal = ''
|
|
197
|
+
const tagMatch = line.match(/\s([A-Za-z0-9_./-]+):\s/)
|
|
198
|
+
if (tagMatch) tagVal = tagMatch[1]
|
|
199
|
+
|
|
200
|
+
// pid extraction
|
|
201
|
+
let pidNum: number | null = null
|
|
202
|
+
const pidMatch = line.match(/\[(\d+):\d+\]/)
|
|
203
|
+
if (pidMatch) pidNum = Number(pidMatch[1])
|
|
204
|
+
|
|
205
|
+
// message: extract robustly after the subsystem/category token when available
|
|
206
|
+
let message = line
|
|
207
|
+
if (tagMatch) {
|
|
208
|
+
// tagMatch[0] includes the delimiter (e.g. " MySubsystem: ") — use it to find the message start
|
|
209
|
+
const marker = tagMatch[0]
|
|
210
|
+
const idx = line.indexOf(marker)
|
|
211
|
+
if (idx !== -1) {
|
|
212
|
+
message = line.slice(idx + marker.length).trim()
|
|
213
|
+
} else {
|
|
214
|
+
// fallback: try to trim off common prefixes (timestamp, pid, level) and keep the rest
|
|
215
|
+
const afterPidMatch = line.match(/\]\s+/)
|
|
216
|
+
if (afterPidMatch) {
|
|
217
|
+
const afterPidIdx = line.indexOf(afterPidMatch[0]) + afterPidMatch[0].length
|
|
218
|
+
message = line.slice(afterPidIdx).trim()
|
|
219
|
+
} else {
|
|
220
|
+
// remove leading level tokens like <Debug> and keep remainder
|
|
221
|
+
message = line.replace(/^.*?<.*?>\s*/,'').trim()
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
// No tag found — strip obvious prefixes and keep remainder (preserve colons in message)
|
|
226
|
+
message = line.replace(/^.*?<.*?>\s*/,'').trim()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { timestamp: ts, level: lvl, tag: tagVal, pid: pidNum, message }
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// Apply contains filter
|
|
233
|
+
let filtered = parsed
|
|
234
|
+
if (contains) filtered = filtered.filter(e => e.message && e.message.includes(contains))
|
|
235
|
+
|
|
236
|
+
// Apply since_seconds already applied by log show, but double-check timestamps
|
|
237
|
+
if (since_seconds) {
|
|
238
|
+
const sinceMs = Date.now() - (since_seconds * 1000)
|
|
239
|
+
filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// level filter
|
|
243
|
+
if (level) {
|
|
244
|
+
const L = level.toUpperCase()
|
|
245
|
+
filtered = filtered.filter(e => e.level && e.level.toUpperCase() === L)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// tag filter
|
|
249
|
+
if (tag) filtered = filtered.filter(e => e.tag && e.tag.includes(tag))
|
|
250
|
+
|
|
251
|
+
// pid filter (use detected/effective pid if available)
|
|
252
|
+
const pidToFilter = effectivePid
|
|
253
|
+
if (pidToFilter) filtered = filtered.filter(e => e.pid === pidToFilter)
|
|
254
|
+
|
|
255
|
+
// If appId present but no predicate returned lines, try substring match
|
|
256
|
+
if (appId && filtered.length === 0) {
|
|
257
|
+
const matched = parsed.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)) || (e.message && processNameUsed && e.message.includes(processNameUsed)))
|
|
258
|
+
if (matched.length > 0) filtered = matched
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Order oldest -> newest
|
|
262
|
+
filtered.sort((a,b) => {
|
|
263
|
+
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0
|
|
264
|
+
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0
|
|
265
|
+
return ta - tb
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const source = pidToFilter ? 'pid' : (appId ? 'process' : 'broad')
|
|
269
|
+
const meta = { appIdProvided: !!appId, processNameUsed: processNameUsed || null, detectedPid: detectedPid || null, filters: { tag, level, contains, since_seconds, limit: effectiveLimit }, pidExplicit: !!pid }
|
|
270
|
+
const limited = filtered.slice(-Math.max(0, effectiveLimit))
|
|
271
|
+
return { device, logs: limited, logCount: limited.length, source, meta }
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error('iOS getLogs failed:', err)
|
|
274
|
+
const device = await getIOSDeviceMetadata(deviceId)
|
|
275
|
+
return { device, logs: [], logCount: 0, source: 'broad', meta: { error: err instanceof Error ? err.message : String(err) } }
|
|
138
276
|
}
|
|
139
277
|
}
|
|
140
278
|
|
|
@@ -327,7 +465,8 @@ export class iOSObserve {
|
|
|
327
465
|
|
|
328
466
|
async startLogStream(bundleId: string, deviceId: string = 'booted', sessionId: string = 'default') : Promise<{ success: boolean; stream_started?: boolean; error?: string }> {
|
|
329
467
|
try {
|
|
330
|
-
const
|
|
468
|
+
const simple = bundleId.split('.').pop() || bundleId
|
|
469
|
+
const predicate = `process == "${simple}" or process == "${bundleId}" or subsystem contains "${bundleId}"`
|
|
331
470
|
|
|
332
471
|
if (iosActiveLogStreams.has(sessionId)) {
|
|
333
472
|
try { iosActiveLogStreams.get(sessionId)!.proc.kill() } catch {}
|
package/src/server.ts
CHANGED
|
@@ -171,7 +171,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
171
171
|
|
|
172
172
|
{
|
|
173
173
|
name: "get_logs",
|
|
174
|
-
description: "Get recent logs from Android or iOS simulator. Returns device metadata and
|
|
174
|
+
description: "Get recent logs from Android or iOS simulator. Returns device metadata and structured logs suitable for AI consumption.",
|
|
175
175
|
inputSchema: {
|
|
176
176
|
type: "object",
|
|
177
177
|
properties: {
|
|
@@ -187,9 +187,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
187
187
|
type: "string",
|
|
188
188
|
description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
|
|
189
189
|
},
|
|
190
|
+
pid: { type: "number", description: "Filter by process id" },
|
|
191
|
+
tag: { type: "string", description: "Filter by tag (Android) or subsystem/category (iOS)" },
|
|
192
|
+
level: { type: "string", description: "Log level filter (VERBOSE, DEBUG, INFO, WARN, ERROR)" },
|
|
193
|
+
contains: { type: "string", description: "Substring to match in log message" },
|
|
194
|
+
since_seconds: { type: "number", description: "Only return logs from the last N seconds" },
|
|
195
|
+
limit: { type: "number", description: "Override default number of returned lines" },
|
|
190
196
|
lines: {
|
|
191
197
|
type: "number",
|
|
192
|
-
description: "
|
|
198
|
+
description: "Legacy - number of log lines (android only)"
|
|
193
199
|
}
|
|
194
200
|
},
|
|
195
201
|
required: ["platform"]
|
|
@@ -341,17 +347,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
341
347
|
},
|
|
342
348
|
{
|
|
343
349
|
name: "wait_for_ui",
|
|
344
|
-
description: "
|
|
350
|
+
description: "Deterministic UI wait primitive. Waits for selector condition with retries and backoff.",
|
|
345
351
|
inputSchema: {
|
|
346
352
|
type: "object",
|
|
347
353
|
properties: {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
354
|
+
selector: {
|
|
355
|
+
type: "object",
|
|
356
|
+
properties: {
|
|
357
|
+
text: { type: "string" },
|
|
358
|
+
resource_id: { type: "string" },
|
|
359
|
+
accessibility_id: { type: "string" },
|
|
360
|
+
contains: { type: "boolean", description: "When true, perform substring matching", default: false }
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
condition: { type: "string", enum: ["exists","not_exists","visible","clickable"], default: "exists" },
|
|
364
|
+
timeout_ms: { type: "number", default: 60000 },
|
|
365
|
+
poll_interval_ms: { type: "number", default: 300 },
|
|
366
|
+
match: { type: "object", properties: { index: { type: "number" } } },
|
|
367
|
+
retry: { type: "object", properties: { max_attempts: { type: "number", default: 1 }, backoff_ms: { type: "number", default: 0 } } },
|
|
355
368
|
platform: { type: "string", enum: ["android","ios"], description: "Optional platform override" },
|
|
356
369
|
deviceId: { type: "string", description: "Optional device serial/udid" }
|
|
357
370
|
}
|
|
@@ -359,6 +372,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
359
372
|
},
|
|
360
373
|
|
|
361
374
|
|
|
375
|
+
|
|
362
376
|
{
|
|
363
377
|
name: "find_element",
|
|
364
378
|
description: "Find a UI element by semantic query (text, content-desc, resource-id, class). Returns best match.",
|
|
@@ -603,12 +617,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
|
|
|
603
617
|
|
|
604
618
|
|
|
605
619
|
if (name === "get_logs") {
|
|
606
|
-
const { platform, appId, deviceId, lines } = args as any
|
|
607
|
-
const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines })
|
|
620
|
+
const { platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines } = args as any
|
|
621
|
+
const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines })
|
|
622
|
+
const filtered = !!(pid || tag || level || contains || since_seconds || appId)
|
|
608
623
|
return {
|
|
609
624
|
content: [
|
|
610
|
-
{ type: 'text', text: JSON.stringify({ device: res.device, result: {
|
|
611
|
-
{ type: 'text', text: (res.logs
|
|
625
|
+
{ type: 'text', text: JSON.stringify({ device: res.device, result: { count: res.logCount, filtered, crashLines: (res.crashLines || []), source: res.source, meta: res.meta || {} } }, null, 2) },
|
|
626
|
+
{ type: 'text', text: JSON.stringify({ logs: res.logs }, null, 2) }
|
|
612
627
|
]
|
|
613
628
|
}
|
|
614
629
|
}
|
|
@@ -673,8 +688,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
|
|
|
673
688
|
|
|
674
689
|
|
|
675
690
|
if (name === "wait_for_ui") {
|
|
676
|
-
const {
|
|
677
|
-
const res = await ToolsInteract.waitForUIHandler({
|
|
691
|
+
const { selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry, platform, deviceId } = (args || {}) as any
|
|
692
|
+
const res = await ToolsInteract.waitForUIHandler({ selector, condition, timeout_ms, poll_interval_ms, match, retry, platform, deviceId })
|
|
678
693
|
return wrapResponse(res)
|
|
679
694
|
}
|
|
680
695
|
|
package/src/types.ts
CHANGED
|
@@ -36,10 +36,22 @@ export interface ResetAppDataResponse {
|
|
|
36
36
|
diagnostics?: any;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export interface StructuredLogEntry {
|
|
40
|
+
timestamp: string | null; // ISO string
|
|
41
|
+
level: string; // VERBOSE, DEBUG, INFO, WARN, ERROR
|
|
42
|
+
tag: string;
|
|
43
|
+
pid: number | null;
|
|
44
|
+
message: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
export interface GetLogsResponse {
|
|
40
48
|
device: DeviceInfo;
|
|
41
|
-
logs:
|
|
49
|
+
logs: StructuredLogEntry[];
|
|
42
50
|
logCount: number;
|
|
51
|
+
// Source indicates the filtering method used: 'pid', 'package'/'process', or 'broad'
|
|
52
|
+
source?: string;
|
|
53
|
+
// Meta contains debugging information about how logs were collected and filters applied
|
|
54
|
+
meta?: Record<string, any>;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
export interface GetCrashResponse {
|
|
@@ -17,7 +17,7 @@ async function main() {
|
|
|
17
17
|
console.log('screenshot OK? size:', shot && shot.screenshot ? shot.screenshot.length : 0)
|
|
18
18
|
|
|
19
19
|
console.log('[3] getLogs')
|
|
20
|
-
const logs = await obs.getLogs(appId,
|
|
20
|
+
const logs = await obs.getLogs({ appId, deviceId });
|
|
21
21
|
console.log('logs count:', logs.logCount)
|
|
22
22
|
|
|
23
23
|
console.log('[4] terminateApp')
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ToolsObserve } from '../../src/observe/index.js'
|
|
2
|
+
import minimist from 'minimist'
|
|
3
|
+
|
|
4
|
+
async function main() {
|
|
5
|
+
const args = minimist(process.argv.slice(2))
|
|
6
|
+
const platform = args.platform || args.p || 'android'
|
|
7
|
+
const id = args.id || args.device || args.deviceId || 'default'
|
|
8
|
+
const limit = typeof args.limit === 'number' ? args.limit : (typeof args.lines === 'number' ? args.lines : 50)
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const res = await ToolsObserve.getLogsHandler({ platform, id, limit })
|
|
12
|
+
console.log(JSON.stringify(res))
|
|
13
|
+
process.exit(0)
|
|
14
|
+
} catch (err: any) {
|
|
15
|
+
console.error(JSON.stringify({ error: { message: err.message || String(err) } }))
|
|
16
|
+
process.exit(2)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
main()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
2
|
+
import * as Observe from '../../../src/observe/index.js'
|
|
3
|
+
import assert from 'assert'
|
|
4
|
+
|
|
5
|
+
async function run() {
|
|
6
|
+
console.log('Starting wait_for_ui contract tests...')
|
|
7
|
+
const orig = (Observe as any).ToolsObserve.getUITreeHandler
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
// success shape
|
|
11
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [ { text: 'OK', resourceId: 'rid', contentDescription: 'acc', type: 'TextView', bounds: [0,0,10,10], visible: true, clickable: false, enabled: true } ] })
|
|
12
|
+
const s = await ToolsInteract.waitForUIHandler({ selector: { text: 'OK' }, condition: 'exists', timeout_ms: 500, poll_interval_ms: 50, platform: 'android' })
|
|
13
|
+
// Assert contract fields for success
|
|
14
|
+
assert.strictEqual(s.status, 'success', 'status must be success')
|
|
15
|
+
assert.strictEqual(typeof s.matched, 'number', 'matched must be number')
|
|
16
|
+
assert.ok(s.element, 'element must be present')
|
|
17
|
+
assert.ok(s.metrics && typeof s.metrics.latency_ms === 'number' && typeof s.metrics.poll_count === 'number' && typeof s.metrics.attempts === 'number', 'metrics must include latency_ms, poll_count, attempts')
|
|
18
|
+
assert.ok(['string','object'].includes(typeof s.element.bounds) || Array.isArray(s.element.bounds), 'element.bounds must be present')
|
|
19
|
+
|
|
20
|
+
// timeout shape
|
|
21
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [] })
|
|
22
|
+
const t = await ToolsInteract.waitForUIHandler({ selector: { text: 'Nope' }, condition: 'exists', timeout_ms: 200, poll_interval_ms: 50, platform: 'android' })
|
|
23
|
+
assert.strictEqual(t.status, 'timeout', 'status must be timeout on no match')
|
|
24
|
+
assert.ok(t.error && t.error.code && t.error.message, 'timeout must include error with code and message')
|
|
25
|
+
assert.ok(t.metrics && typeof t.metrics.latency_ms === 'number', 'timeout metrics must include latency_ms')
|
|
26
|
+
|
|
27
|
+
console.log('wait_for_ui contract tests: PASS')
|
|
28
|
+
} finally {
|
|
29
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = orig
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
run().catch(err => { console.error('wait_for_ui_contract tests failed:', err); process.exit(1) })
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
2
|
+
import * as Observe from '../../../src/observe/index.js'
|
|
3
|
+
|
|
4
|
+
async function run() {
|
|
5
|
+
console.log('Starting new wait_for_ui unit tests...')
|
|
6
|
+
const origGetUITree = (Observe as any).ToolsObserve.getUITreeHandler
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
// Test 1: exact text match -> exists
|
|
10
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [ { text: 'Hello', resourceId: 'rid1', contentDescription: 'acc1', type: 'Button', bounds: [0,0,10,10], visible: true, clickable: false, enabled: true } ] })
|
|
11
|
+
const r1 = await ToolsInteract.waitForUIHandler({ selector: { text: 'Hello' }, condition: 'exists', timeout_ms: 1000, poll_interval_ms: 50, platform: 'android' })
|
|
12
|
+
const ok1 = r1 && r1.status === 'success' && r1.matched === 1 && r1.element && r1.element.text === 'Hello'
|
|
13
|
+
console.log('Exact match exists:', ok1 ? 'PASS' : 'FAIL', JSON.stringify(r1, null, 2))
|
|
14
|
+
|
|
15
|
+
// Test 2: contains matching
|
|
16
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [ { text: 'Welcome User', resourceId: 'rid2', contentDescription: 'acc2', type: 'TextView', bounds: [0,0,50,10], visible: true } ] })
|
|
17
|
+
const r2 = await ToolsInteract.waitForUIHandler({ selector: { text: 'User', contains: true }, condition: 'exists', timeout_ms: 1000, poll_interval_ms: 50, platform: 'android' })
|
|
18
|
+
const ok2 = r2 && r2.status === 'success' && r2.matched === 1 && r2.element && r2.element.text && r2.element.text.includes('Welcome')
|
|
19
|
+
console.log('Contains match:', ok2 ? 'PASS' : 'FAIL', JSON.stringify(r2, null, 2))
|
|
20
|
+
|
|
21
|
+
// Test 3: visible condition
|
|
22
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [ { text: 'Hidden', resourceId: 'rid3', bounds: [0,0,0,0], visible: false } ] })
|
|
23
|
+
const r3 = await ToolsInteract.waitForUIHandler({ selector: { text: 'Hidden' }, condition: 'visible', timeout_ms: 300, poll_interval_ms: 50, platform: 'android' })
|
|
24
|
+
const ok3 = r3 && r3.status === 'timeout' && r3.error && r3.error.code === 'ELEMENT_NOT_FOUND'
|
|
25
|
+
console.log('Visible negative (hidden element):', ok3 ? 'PASS' : 'FAIL', JSON.stringify(r3, null, 2))
|
|
26
|
+
|
|
27
|
+
// Test 4: clickable condition
|
|
28
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [ { text: 'TapMe', resourceId: 'rid4', bounds: [0,0,20,20], visible: true, clickable: true, enabled: true } ] })
|
|
29
|
+
const r4 = await ToolsInteract.waitForUIHandler({ selector: { text: 'TapMe' }, condition: 'clickable', timeout_ms: 1000, poll_interval_ms: 50, platform: 'android' })
|
|
30
|
+
const ok4 = r4 && r4.status === 'success' && r4.matched === 1 && r4.element && r4.element.index === 0
|
|
31
|
+
console.log('Clickable match:', ok4 ? 'PASS' : 'FAIL', JSON.stringify(r4, null, 2))
|
|
32
|
+
|
|
33
|
+
// Test 5: retry behavior - first attempt times out, second attempt succeeds
|
|
34
|
+
const start = Date.now()
|
|
35
|
+
let seqTree = async () => {
|
|
36
|
+
const now = Date.now()
|
|
37
|
+
// for first ~400ms return no elements, afterwards return match
|
|
38
|
+
if (now - start < 400) return { elements: [] }
|
|
39
|
+
return { elements: [ { text: 'Retried', resourceId: 'rid5', bounds: [0,0,10,10], visible: true } ] }
|
|
40
|
+
}
|
|
41
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = seqTree
|
|
42
|
+
const r5 = await ToolsInteract.waitForUIHandler({ selector: { text: 'Retried' }, condition: 'exists', timeout_ms: 200, poll_interval_ms: 50, match: undefined, retry: { max_attempts: 3, backoff_ms: 150 }, platform: 'android' })
|
|
43
|
+
const ok5 = r5 && r5.status === 'success' && r5.metrics && r5.metrics.attempts >= 2
|
|
44
|
+
console.log('Retry behavior:', ok5 ? 'PASS' : 'FAIL', JSON.stringify(r5, null, 2))
|
|
45
|
+
|
|
46
|
+
// Test 6: timeout with no selector match -> correct error code
|
|
47
|
+
(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [] })
|
|
48
|
+
const r6 = await ToolsInteract.waitForUIHandler({ selector: { text: 'Nope' }, condition: 'exists', timeout_ms: 300, poll_interval_ms: 50, retry: { max_attempts: 1 }, platform: 'android' })
|
|
49
|
+
const ok6 = r6 && r6.status === 'timeout' && r6.error && r6.error.code === 'ELEMENT_NOT_FOUND'
|
|
50
|
+
console.log('Timeout no match:', ok6 ? 'PASS' : 'FAIL', JSON.stringify(r6, null, 2))
|
|
51
|
+
|
|
52
|
+
} finally {
|
|
53
|
+
(Observe as any).ToolsObserve.getUITreeHandler = origGetUITree
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
run().catch(err => { console.error('wait_for_ui_new tests failed:', err); process.exit(1) })
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import { execSync } from 'child_process'
|
|
3
|
+
|
|
4
|
+
function log(msg: string) { console.log(msg) }
|
|
5
|
+
|
|
6
|
+
if (process.env.SKIP_DEVICE_TESTS === '1') {
|
|
7
|
+
log('SKIP_DEVICE_TESTS=1 detected - skipping android device smoke test')
|
|
8
|
+
process.exit(0)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Ensure helper script exists
|
|
12
|
+
const helperScript = 'test/helpers/run-get-logs.ts'
|
|
13
|
+
if (!fs.existsSync(helperScript)) {
|
|
14
|
+
console.error(`Missing ${helperScript}. Run 'npm run build' first or ensure the helper exists.`)
|
|
15
|
+
process.exit(1)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
// Run the helper smoke script for android
|
|
20
|
+
const cmd = `tsx ${helperScript} --platform android --id default --limit 20`
|
|
21
|
+
const out = execSync(cmd, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 30000 })
|
|
22
|
+
const parsed = JSON.parse(out)
|
|
23
|
+
|
|
24
|
+
if (!parsed || !Array.isArray(parsed.logs)) throw new Error('Output missing logs array')
|
|
25
|
+
const count = parsed.count ?? parsed.logs.length
|
|
26
|
+
if (count !== parsed.logs.length) throw new Error('count mismatch')
|
|
27
|
+
if (parsed.logs.some((e: any) => !e.timestamp || !e.level || typeof e.message !== 'string')) throw new Error('log entry missing fields')
|
|
28
|
+
|
|
29
|
+
log('Android device smoke test: PASS')
|
|
30
|
+
process.exit(0)
|
|
31
|
+
} catch (err: any) {
|
|
32
|
+
console.error('Android device smoke test: FAIL')
|
|
33
|
+
console.error(err && err.message ? err.message : err)
|
|
34
|
+
process.exit(2)
|
|
35
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import { execSync } from 'child_process'
|
|
3
|
+
|
|
4
|
+
function log(msg: string) { console.log(msg) }
|
|
5
|
+
|
|
6
|
+
if (process.env.SKIP_DEVICE_TESTS === '1') {
|
|
7
|
+
log('SKIP_DEVICE_TESTS=1 detected - skipping ios device smoke test')
|
|
8
|
+
process.exit(0)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const helperScript = 'test/helpers/run-get-logs.ts'
|
|
12
|
+
if (!fs.existsSync(helperScript)) {
|
|
13
|
+
console.error(`Missing ${helperScript}. Run 'npm run build' first or ensure the helper exists.`)
|
|
14
|
+
process.exit(1)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const cmd = `tsx ${helperScript} --platform ios --id booted --limit 20`
|
|
19
|
+
const out = execSync(cmd, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 30000 })
|
|
20
|
+
const parsed = JSON.parse(out)
|
|
21
|
+
|
|
22
|
+
if (!parsed || !Array.isArray(parsed.logs)) throw new Error('Output missing logs array')
|
|
23
|
+
const count = parsed.count ?? parsed.logs.length
|
|
24
|
+
if (count !== parsed.logs.length) throw new Error('count mismatch')
|
|
25
|
+
if (parsed.logs.some((e: any) => !e.timestamp || !e.level || typeof e.message !== 'string')) throw new Error('log entry missing fields')
|
|
26
|
+
|
|
27
|
+
log('iOS device smoke test: PASS')
|
|
28
|
+
process.exit(0)
|
|
29
|
+
} catch (err: any) {
|
|
30
|
+
console.error('iOS device smoke test: FAIL')
|
|
31
|
+
console.error(err && err.message ? err.message : err)
|
|
32
|
+
process.exit(2)
|
|
33
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { StructuredLogEntry } from '../../../src/types.js'
|
|
2
|
+
|
|
3
|
+
function assert(cond: boolean, msg?: string) { if (!cond) throw new Error(msg || 'Assertion failed') }
|
|
4
|
+
|
|
5
|
+
function applyFilters(entries: StructuredLogEntry[], opts: { contains?: string, level?: string, tag?: string, pid?: number, since_seconds?: number, limit?: number }) {
|
|
6
|
+
let filtered = entries.slice()
|
|
7
|
+
if (opts.contains) filtered = filtered.filter(e => e.message && e.message.includes(opts.contains!))
|
|
8
|
+
if (opts.since_seconds) {
|
|
9
|
+
const sinceMs = Date.now() - (opts.since_seconds * 1000)
|
|
10
|
+
filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs))
|
|
11
|
+
}
|
|
12
|
+
if (opts.level) filtered = filtered.filter(e => e.level && e.level.toUpperCase() === opts.level!.toUpperCase())
|
|
13
|
+
if (opts.tag) filtered = filtered.filter(e => e.tag && e.tag.includes(opts.tag!))
|
|
14
|
+
if (typeof opts.pid === 'number') filtered = filtered.filter(e => e.pid === opts.pid)
|
|
15
|
+
// oldest -> newest
|
|
16
|
+
filtered.sort((a,b) => (a.timestamp? new Date(a.timestamp).getTime():0) - (b.timestamp? new Date(b.timestamp).getTime():0))
|
|
17
|
+
const lim = typeof opts.limit === 'number' && opts.limit > 0 ? opts.limit : 50
|
|
18
|
+
return filtered.slice(-Math.max(0, lim))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function run() {
|
|
22
|
+
const now = Date.now()
|
|
23
|
+
const entries: StructuredLogEntry[] = [
|
|
24
|
+
{ timestamp: new Date(now - 60000).toISOString(), level: 'INFO', tag: 'A', pid: 123, message: 'startup complete' },
|
|
25
|
+
{ timestamp: new Date(now - 45000).toISOString(), level: 'WARN', tag: 'B', pid: 124, message: 'slow response' },
|
|
26
|
+
{ timestamp: new Date(now - 30000).toISOString(), level: 'ERROR', tag: 'A', pid: 123, message: 'Unhandled exception' },
|
|
27
|
+
{ timestamp: new Date(now - 15000).toISOString(), level: 'DEBUG', tag: 'C', pid: 125, message: 'debug info' },
|
|
28
|
+
{ timestamp: new Date(now - 5000).toISOString(), level: 'INFO', tag: 'A', pid: 123, message: 'user action happened' }
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
// contains filter
|
|
32
|
+
const c1 = applyFilters(entries, { contains: 'user' })
|
|
33
|
+
assert(c1.length === 1 && c1[0].message.includes('user'), 'contains filter failed')
|
|
34
|
+
|
|
35
|
+
// level filter
|
|
36
|
+
const e1 = applyFilters(entries, { level: 'ERROR' })
|
|
37
|
+
assert(e1.length === 1 && e1[0].level === 'ERROR', 'level filter failed')
|
|
38
|
+
|
|
39
|
+
// tag filter
|
|
40
|
+
const t1 = applyFilters(entries, { tag: 'A' })
|
|
41
|
+
assert(t1.length === 3, 'tag filter failed')
|
|
42
|
+
|
|
43
|
+
// pid filter
|
|
44
|
+
const p1 = applyFilters(entries, { pid: 123 })
|
|
45
|
+
assert(p1.length === 3, 'pid filter failed')
|
|
46
|
+
|
|
47
|
+
// since_seconds filter (last 20s) should include last two entries
|
|
48
|
+
const s1 = applyFilters(entries, { since_seconds: 20 })
|
|
49
|
+
if (s1.length !== 2) throw new Error('since_seconds filter expected 2 entries, got ' + s1.length)
|
|
50
|
+
|
|
51
|
+
// limit
|
|
52
|
+
const l1 = applyFilters(entries, { limit: 2 })
|
|
53
|
+
assert(l1.length === 2 && l1[0].timestamp <= l1[1].timestamp, 'limit or ordering failed')
|
|
54
|
+
|
|
55
|
+
console.log('get_logs unit tests passed')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
run()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { iOSObserve } from '../../../src/observe/ios'
|
|
2
|
+
import assert from 'assert'
|
|
3
|
+
|
|
4
|
+
// Lightweight unit tests: verify predicate construction and meta extraction logic using internal functions
|
|
5
|
+
// Since getLogs executes xcrun, run tests in SKIP_DEVICE_TESTS=1 environment by stubbing execCommand where possible.
|
|
6
|
+
|
|
7
|
+
import * as iosUtils from '../../../src/utils/ios/utils'
|
|
8
|
+
|
|
9
|
+
function stubExecCommand(original: any, expectedArgsChecker: (args: string[]) => boolean, output: string) {
|
|
10
|
+
return async function (args: string[], deviceId?: string) {
|
|
11
|
+
if (!expectedArgsChecker(args)) throw new Error('Unexpected args: ' + JSON.stringify(args))
|
|
12
|
+
return { output, device: { platform: 'ios', id: deviceId || 'booted' } }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('iOS getLogs predicate and meta', () => {
|
|
17
|
+
let obs: iOSObserve
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
obs = new iOSObserve()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('uses simple process name predicate when appId provided', async () => {
|
|
23
|
+
const bundle = 'com.ideamechanics.modul8'
|
|
24
|
+
// stub execCommand twice: first for pgrep, second for log show
|
|
25
|
+
const pgrepOutput = '12345\n'
|
|
26
|
+
const logOutput = '2026-03-31 09:21:20.085 Module[12345:678] <Info> Modul8: Test message'
|
|
27
|
+
|
|
28
|
+
const orig = (iosUtils as any).execCommand
|
|
29
|
+
try {
|
|
30
|
+
(iosUtils as any).execCommand = stubExecCommand(orig, (args) => args.includes('pgrep'), pgrepOutput)
|
|
31
|
+
// second replacement for the log show call
|
|
32
|
+
let called = false
|
|
33
|
+
(iosUtils as any).execCommand = async function (args: string[]) {
|
|
34
|
+
if (args.includes('pgrep')) return { output: pgrepOutput, device: { platform: 'ios', id: 'booted' } }
|
|
35
|
+
if (args.includes('log') && args.includes('show')) { called = true; return { output: logOutput, device: { platform: 'ios', id: 'booted' } } }
|
|
36
|
+
throw new Error('Unexpected args: ' + JSON.stringify(args))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const res = await obs.getLogs({ appId: bundle, deviceId: 'booted' })
|
|
40
|
+
assert(res.meta.processNameUsed === 'modul8' || res.meta.processNameUsed === 'Modul8' || !!res.meta.processNameUsed)
|
|
41
|
+
assert(res.meta.detectedPid === 12345)
|
|
42
|
+
assert(res.source === 'pid')
|
|
43
|
+
assert(res.logCount === 1)
|
|
44
|
+
assert(res.logs[0].message.includes('Test message'))
|
|
45
|
+
assert(called, 'log show must have been called')
|
|
46
|
+
} finally {
|
|
47
|
+
(iosUtils as any).execCommand = orig
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('falls back to broad when no appId', async () => {
|
|
52
|
+
const logOutput = '2026-03-31 09:21:20.085 SomeOther[222:333] <Info> Other: Hello'
|
|
53
|
+
const orig = (iosUtils as any).execCommand
|
|
54
|
+
try {
|
|
55
|
+
(iosUtils as any).execCommand = async function (args: string[]) {
|
|
56
|
+
if (args.includes('log') && args.includes('show')) return { output: logOutput, device: { platform: 'ios', id: 'booted' } }
|
|
57
|
+
throw new Error('Unexpected args: ' + JSON.stringify(args))
|
|
58
|
+
}
|
|
59
|
+
const obs = new iOSObserve()
|
|
60
|
+
const res = await obs.getLogs({ deviceId: 'booted' })
|
|
61
|
+
assert(res.source === 'broad')
|
|
62
|
+
assert(res.logCount === 1)
|
|
63
|
+
} finally {
|
|
64
|
+
(iosUtils as any).execCommand = orig
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { ToolsManage } from '../../../dist/manage/index.js'
|
|
3
|
-
import path from 'path'
|
|
4
|
-
|
|
5
|
-
async function main() {
|
|
6
|
-
// Prefer a repo-local sample modul8 project if present, otherwise allow overriding via KMP_PROJECT env var
|
|
7
|
-
const defaultRelative = path.join(process.cwd(), '..', '..', '..', '..', 'test-fixtures', 'modul8')
|
|
8
|
-
const project = process.env.KMP_PROJECT || defaultRelative
|
|
9
|
-
console.log('Running KMP build+install for project', project)
|
|
10
|
-
// Use projectType=kmp and let handler pick android by default for KMP
|
|
11
|
-
// Request iOS explicitly for this run to test iOS build path
|
|
12
|
-
const res = await ToolsManage.buildAndInstallHandler({ platform: 'ios', projectPath: project, projectType: 'kmp', timeout: 600000, deviceId: undefined })
|
|
13
|
-
console.log(JSON.stringify(res, null, 2))
|
|
14
|
-
if (res.result && res.result.success) process.exit(0)
|
|
15
|
-
process.exit(1)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
main().catch(e => { console.error(e); process.exit(2) })
|