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.
@@ -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: string = "booted"): Promise<GetLogsResponse> {
124
- const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m']
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
- const result = await execCommand(args, deviceId)
131
- const device = await getIOSDeviceMetadata(deviceId)
132
- const logs = result.output ? result.output.split('\n') : []
133
- return {
134
- device,
135
- logs,
136
- logCount: logs.length,
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
- resolution: { width: 0, height: 0 },
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 the log output.",
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: "Number of log lines (android only)"
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: { lines: res.logs.length, crashLines: res.crashLines.length > 0 ? res.crashLines : undefined } }, null, 2) },
611
- { type: 'text', text: (res.logs || []).join('\n') }
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
- return {
632
- content: [
633
- { type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: (res as any).resolution } }, null, 2) },
634
- { type: 'image', data: (res as any).screenshot, mimeType: 'image/png' }
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: string[];
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, undefined);
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()