mobile-debug-mcp 0.21.1 → 0.21.3
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 +25 -4
- package/dist/observe/android.js +170 -31
- package/dist/observe/index.js +32 -10
- package/dist/observe/ios.js +168 -11
- package/dist/server.js +24 -12
- package/dist/utils/cli/ios/run-ios-smoke.js +1 -1
- package/dist/utils/image.js +18 -0
- package/docs/CHANGELOG.md +6 -0
- package/docs/tools/observe.md +15 -10
- package/package.json +3 -2
- package/src/interact/index.ts +18 -4
- package/src/observe/android.ts +169 -34
- package/src/observe/index.ts +33 -12
- package/src/observe/ios.ts +176 -13
- package/src/server.ts +23 -11
- package/src/types.ts +15 -1
- package/src/utils/cli/ios/run-ios-smoke.ts +1 -1
- package/src/utils/image.ts +14 -0
- package/test/helpers/.gitkeep +0 -0
- package/test/helpers/run-get-logs.ts +20 -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/src/observe/ios.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { createWriteStream, promises as fsPromises } from 'fs'
|
|
|
6
6
|
import path from 'path'
|
|
7
7
|
import { parseLogLine } from '../utils/android/utils.js'
|
|
8
8
|
import { computeScreenFingerprint } from '../utils/ui/index.js'
|
|
9
|
+
import { parsePngSize } from '../utils/image.js'
|
|
9
10
|
|
|
10
11
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
11
12
|
|
|
@@ -120,20 +121,132 @@ export class iOSObserve {
|
|
|
120
121
|
return getIOSDeviceMetadata(deviceId);
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
async getLogs(appId?: string, deviceId
|
|
124
|
-
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
|
+
|
|
125
139
|
if (appId) {
|
|
126
140
|
validateBundleId(appId)
|
|
141
|
+
// constrain to subsystem or process matching appId
|
|
127
142
|
args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`)
|
|
143
|
+
} else if (tag) {
|
|
144
|
+
// predicate by subsystem/category
|
|
145
|
+
args.push('--predicate', `subsystem contains "${tag}"`)
|
|
128
146
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const result = await execCommand(args, deviceId)
|
|
150
|
+
const device = await getIOSDeviceMetadata(deviceId)
|
|
151
|
+
const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : []
|
|
152
|
+
|
|
153
|
+
// Parse lines into structured entries. iOS log format: timestamp [PID:tid] <level> subsystem:category: message
|
|
154
|
+
const parsed = rawLines.map(line => {
|
|
155
|
+
// Example: 2023-08-12 12:34:56.789012+0000 pid <Debug> MyApp[123:456] <info> MySubsystem: MyCategory: Message here
|
|
156
|
+
// Simpler approach: try to extract ISO timestamp at start
|
|
157
|
+
let ts: string | null = null
|
|
158
|
+
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)
|
|
159
|
+
if (tsMatch) {
|
|
160
|
+
const d = new Date(tsMatch[1])
|
|
161
|
+
if (!isNaN(d.getTime())) ts = d.toISOString()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// level mapping
|
|
165
|
+
let lvl = 'INFO'
|
|
166
|
+
const lvlMatch = line.match(/\b(Debug|Info|Default|Error|Fault|Warning)\b/i)
|
|
167
|
+
if (lvlMatch) {
|
|
168
|
+
const map: Record<string, string> = { 'debug': 'DEBUG', 'info': 'INFO', 'default': 'DEBUG', 'error': 'ERROR', 'fault': 'ERROR', 'warning': 'WARN' }
|
|
169
|
+
lvl = map[(lvlMatch[1] || '').toLowerCase()] || 'INFO'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// subsystem/category -> tag
|
|
173
|
+
let tagVal = ''
|
|
174
|
+
const tagMatch = line.match(/\s([A-Za-z0-9_./-]+):\s/)
|
|
175
|
+
if (tagMatch) tagVal = tagMatch[1]
|
|
176
|
+
|
|
177
|
+
// pid extraction
|
|
178
|
+
let pidNum: number | null = null
|
|
179
|
+
const pidMatch = line.match(/\[(\d+):\d+\]/)
|
|
180
|
+
if (pidMatch) pidNum = Number(pidMatch[1])
|
|
181
|
+
|
|
182
|
+
// message: extract robustly after the subsystem/category token when available
|
|
183
|
+
let message = line
|
|
184
|
+
if (tagMatch) {
|
|
185
|
+
// tagMatch[0] includes the delimiter (e.g. " MySubsystem: ") — use it to find the message start
|
|
186
|
+
const marker = tagMatch[0]
|
|
187
|
+
const idx = line.indexOf(marker)
|
|
188
|
+
if (idx !== -1) {
|
|
189
|
+
message = line.slice(idx + marker.length).trim()
|
|
190
|
+
} else {
|
|
191
|
+
// fallback: try to trim off common prefixes (timestamp, pid, level) and keep the rest
|
|
192
|
+
const afterPidMatch = line.match(/\]\s+/)
|
|
193
|
+
if (afterPidMatch) {
|
|
194
|
+
const afterPidIdx = line.indexOf(afterPidMatch[0]) + afterPidMatch[0].length
|
|
195
|
+
message = line.slice(afterPidIdx).trim()
|
|
196
|
+
} else {
|
|
197
|
+
// remove leading level tokens like <Debug> and keep remainder
|
|
198
|
+
message = line.replace(/^.*?<.*?>\s*/,'').trim()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
// No tag found — strip obvious prefixes and keep remainder (preserve colons in message)
|
|
203
|
+
message = line.replace(/^.*?<.*?>\s*/,'').trim()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { timestamp: ts, level: lvl, tag: tagVal, pid: pidNum, message }
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Apply contains filter
|
|
210
|
+
let filtered = parsed
|
|
211
|
+
if (contains) filtered = filtered.filter(e => e.message && e.message.includes(contains))
|
|
212
|
+
|
|
213
|
+
// Apply since_seconds already applied by log show, but double-check timestamps
|
|
214
|
+
if (since_seconds) {
|
|
215
|
+
const sinceMs = Date.now() - (since_seconds * 1000)
|
|
216
|
+
filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// level filter
|
|
220
|
+
if (level) {
|
|
221
|
+
const L = level.toUpperCase()
|
|
222
|
+
filtered = filtered.filter(e => e.level && e.level.toUpperCase() === L)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// tag filter
|
|
226
|
+
if (tag) filtered = filtered.filter(e => e.tag && e.tag.includes(tag))
|
|
227
|
+
|
|
228
|
+
// pid filter
|
|
229
|
+
if (pid) filtered = filtered.filter(e => e.pid === pid)
|
|
230
|
+
|
|
231
|
+
// If appId present but no predicate returned lines, try substring match
|
|
232
|
+
if (appId && filtered.length === 0) {
|
|
233
|
+
const matched = parsed.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)))
|
|
234
|
+
if (matched.length > 0) filtered = matched
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Order oldest -> newest
|
|
238
|
+
filtered.sort((a,b) => {
|
|
239
|
+
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0
|
|
240
|
+
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0
|
|
241
|
+
return ta - tb
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const limited = filtered.slice(-Math.max(0, effectiveLimit))
|
|
245
|
+
return { device, logs: limited, logCount: limited.length }
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error('iOS getLogs failed:', err)
|
|
248
|
+
const device = await getIOSDeviceMetadata(deviceId)
|
|
249
|
+
return { device, logs: [], logCount: 0 }
|
|
137
250
|
}
|
|
138
251
|
}
|
|
139
252
|
|
|
@@ -146,13 +259,63 @@ export class iOSObserve {
|
|
|
146
259
|
|
|
147
260
|
const buffer = await fs.readFile(tmpFile)
|
|
148
261
|
const base64 = buffer.toString('base64')
|
|
149
|
-
|
|
150
|
-
await fs.rm(tmpFile).catch(() => {})
|
|
151
262
|
|
|
263
|
+
const dims = parsePngSize(buffer)
|
|
264
|
+
|
|
265
|
+
// Try to generate WebP (preferred) and JPEG fallback using sharp (in-process, cross-platform)
|
|
266
|
+
try {
|
|
267
|
+
const sharpModule = await import('sharp'); const sharp = sharpModule && (sharpModule as any).default ? (sharpModule as any).default : sharpModule;
|
|
268
|
+
const img = sharp(buffer);
|
|
269
|
+
const meta = await img.metadata().catch((err: any) => { console.error('sharp.metadata failed:', err); return {} as any });
|
|
270
|
+
|
|
271
|
+
// If image has alpha channel, prefer lossless PNG to preserve transparency
|
|
272
|
+
const hasAlpha = !!meta.hasAlpha || (meta.channels && meta.channels > 3);
|
|
273
|
+
|
|
274
|
+
// Generate WebP and JPEG buffers; log failures
|
|
275
|
+
let webpBuf: Buffer | null = null;
|
|
276
|
+
let jpegBuf: Buffer | null = null;
|
|
277
|
+
try {
|
|
278
|
+
webpBuf = await img.webp({ quality: 80 }).toBuffer();
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error('WebP conversion failed (iOS):', err instanceof Error ? err.message : String(err));
|
|
281
|
+
webpBuf = null;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
jpegBuf = await img.jpeg({ quality: 80 }).toBuffer();
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.error('JPEG conversion failed (iOS):', err instanceof Error ? err.message : String(err));
|
|
287
|
+
jpegBuf = null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
await fs.rm(tmpFile).catch(() => {});
|
|
291
|
+
|
|
292
|
+
if (hasAlpha) {
|
|
293
|
+
// preserve alpha: return PNG if WebP not available
|
|
294
|
+
if (webpBuf) {
|
|
295
|
+
return { device, screenshot: webpBuf.toString('base64'), screenshot_mime: 'image/webp', screenshot_fallback: base64, screenshot_fallback_mime: 'image/png', resolution: { width: dims.width, height: dims.height } }
|
|
296
|
+
}
|
|
297
|
+
// if webp unavailable, return original PNG
|
|
298
|
+
return { device, screenshot: base64, screenshot_mime: 'image/png', resolution: { width: dims.width, height: dims.height } }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// No alpha: prefer webp, fall back to jpeg
|
|
302
|
+
if (webpBuf) {
|
|
303
|
+
return { device, screenshot: webpBuf.toString('base64'), screenshot_mime: 'image/webp', screenshot_fallback: jpegBuf ? jpegBuf.toString('base64') : undefined, screenshot_fallback_mime: jpegBuf ? 'image/jpeg' : undefined, resolution: { width: dims.width, height: dims.height } }
|
|
304
|
+
}
|
|
305
|
+
if (jpegBuf) {
|
|
306
|
+
return { device, screenshot: jpegBuf.toString('base64'), screenshot_mime: 'image/jpeg', resolution: { width: dims.width, height: dims.height } }
|
|
307
|
+
}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.error('Screenshot conversion pipeline failed (iOS):', err instanceof Error ? err.message : String(err));
|
|
310
|
+
// fall through to png fallback
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
await fs.rm(tmpFile).catch(() => {})
|
|
152
314
|
return {
|
|
153
315
|
device,
|
|
154
316
|
screenshot: base64,
|
|
155
|
-
|
|
317
|
+
screenshot_mime: 'image/png',
|
|
318
|
+
resolution: { width: dims.width, height: dims.height },
|
|
156
319
|
}
|
|
157
320
|
} catch (e) {
|
|
158
321
|
await fs.rm(tmpFile).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"]
|
|
@@ -603,12 +609,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
|
|
|
603
609
|
|
|
604
610
|
|
|
605
611
|
if (name === "get_logs") {
|
|
606
|
-
const { platform, appId, deviceId, lines } = args as any
|
|
607
|
-
const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines })
|
|
612
|
+
const { platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines } = args as any
|
|
613
|
+
const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines })
|
|
614
|
+
const filtered = !!(pid || tag || level || contains || since_seconds || appId)
|
|
608
615
|
return {
|
|
609
616
|
content: [
|
|
610
|
-
{ type: 'text', text: JSON.stringify({ device: res.device, result: {
|
|
611
|
-
{ type: 'text', text: (res.logs
|
|
617
|
+
{ type: 'text', text: JSON.stringify({ device: res.device, result: { count: res.logCount, filtered, crashLines: (res.crashLines || []) } }, null, 2) },
|
|
618
|
+
{ type: 'text', text: JSON.stringify({ logs: res.logs }, null, 2) }
|
|
612
619
|
]
|
|
613
620
|
}
|
|
614
621
|
}
|
|
@@ -628,12 +635,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
|
|
|
628
635
|
if (name === "capture_screenshot") {
|
|
629
636
|
const { platform, deviceId } = args as any
|
|
630
637
|
const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId })
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
638
|
+
const mime = (res as any).screenshot_mime || 'image/png'
|
|
639
|
+
const content: any[] = [
|
|
640
|
+
{ type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: (res as any).resolution, mimeType: mime } }, null, 2) },
|
|
641
|
+
{ type: 'image', data: (res as any).screenshot, mimeType: mime }
|
|
642
|
+
]
|
|
643
|
+
// If a jpeg fallback is available, include a small note and the fallback as an additional image block for compatibility
|
|
644
|
+
if ((res as any).screenshot_fallback) {
|
|
645
|
+
content.push({ type: 'text', text: JSON.stringify({ note: 'JPEG fallback included for compatibility', mimeType: (res as any).screenshot_fallback_mime || 'image/jpeg' }) })
|
|
646
|
+
content.push({ type: 'image', data: (res as any).screenshot_fallback, mimeType: (res as any).screenshot_fallback_mime || 'image/jpeg' })
|
|
636
647
|
}
|
|
648
|
+
return { content }
|
|
637
649
|
}
|
|
638
650
|
|
|
639
651
|
if (name === "capture_debug_snapshot") {
|
package/src/types.ts
CHANGED
|
@@ -36,9 +36,17 @@ 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;
|
|
43
51
|
}
|
|
44
52
|
|
|
@@ -50,6 +58,9 @@ export interface GetCrashResponse {
|
|
|
50
58
|
export interface CaptureAndroidScreenResponse {
|
|
51
59
|
device: DeviceInfo;
|
|
52
60
|
screenshot: string; // base64 encoded string
|
|
61
|
+
screenshot_mime?: string; // e.g. image/webp, image/jpeg, image/png
|
|
62
|
+
screenshot_fallback?: string; // optional fallback base64 (e.g., jpeg)
|
|
63
|
+
screenshot_fallback_mime?: string;
|
|
53
64
|
resolution: {
|
|
54
65
|
width: number;
|
|
55
66
|
height: number;
|
|
@@ -59,6 +70,9 @@ export interface CaptureAndroidScreenResponse {
|
|
|
59
70
|
export interface CaptureIOSScreenshotResponse {
|
|
60
71
|
device: DeviceInfo;
|
|
61
72
|
screenshot: string; // base64 encoded string
|
|
73
|
+
screenshot_mime?: string; // e.g. image/webp, image/jpeg, image/png
|
|
74
|
+
screenshot_fallback?: string; // optional fallback base64 (e.g., jpeg)
|
|
75
|
+
screenshot_fallback_mime?: string;
|
|
62
76
|
resolution: {
|
|
63
77
|
width: number;
|
|
64
78
|
height: number;
|
|
@@ -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,14 @@
|
|
|
1
|
+
export function parsePngSize(buf: Buffer): { width: number; height: number } {
|
|
2
|
+
try {
|
|
3
|
+
if (!buf || buf.length < 24) return { width: 0, height: 0 };
|
|
4
|
+
// PNG signature + IHDR checks
|
|
5
|
+
if (buf.readUInt32BE(0) !== 0x89504e47 || buf.readUInt32BE(4) !== 0x0d0a1a0a) return { width: 0, height: 0 };
|
|
6
|
+
const ihdr = buf.toString('ascii', 12, 16);
|
|
7
|
+
if (ihdr !== 'IHDR') return { width: 0, height: 0 };
|
|
8
|
+
const width = buf.readUInt32BE(16);
|
|
9
|
+
const height = buf.readUInt32BE(20);
|
|
10
|
+
return { width, height };
|
|
11
|
+
} catch {
|
|
12
|
+
return { width: 0, height: 0 };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
File without changes
|
|
@@ -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,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()
|