mobile-debug-mcp 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintignore +5 -0
- package/.eslintrc.cjs +18 -0
- package/.github/workflows/.gitkeep +0 -0
- package/.github/workflows/ci.yml +63 -0
- package/README.md +5 -16
- package/dist/android/interact.js +1 -123
- package/dist/android/manage.js +137 -0
- package/dist/android/observe.js +133 -88
- package/dist/android/run.js +187 -0
- package/dist/android/utils.js +181 -236
- package/dist/ios/interact.js +1 -168
- package/dist/ios/manage.js +145 -0
- package/dist/ios/observe.js +112 -5
- package/dist/ios/run.js +200 -0
- package/dist/ios/utils.js +19 -118
- package/dist/server.js +44 -42
- package/dist/tools/install.js +1 -1
- package/dist/tools/interact.js +39 -0
- package/dist/tools/logs.js +2 -2
- package/dist/tools/manage.js +180 -0
- package/dist/tools/observe.js +80 -0
- package/dist/tools/run.js +180 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/java.js +76 -0
- package/docs/CHANGELOG.md +25 -6
- package/eslint.config.cjs +36 -0
- package/eslint.config.js +60 -0
- package/package.json +8 -2
- package/src/android/interact.ts +2 -136
- package/src/android/manage.ts +135 -0
- package/src/android/observe.ts +129 -97
- package/src/android/utils.ts +199 -229
- package/src/ios/interact.ts +2 -175
- package/src/ios/manage.ts +143 -0
- package/src/ios/observe.ts +113 -5
- package/src/ios/utils.ts +20 -122
- package/src/server.ts +48 -58
- package/src/tools/interact.ts +45 -0
- package/src/tools/manage.ts +171 -0
- package/src/tools/observe.ts +82 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/java.ts +69 -0
- package/test/integration/install.integration.ts +3 -3
- package/test/integration/logstream-real.ts +5 -4
- package/test/integration/run-install-android.ts +1 -1
- package/test/integration/run-install-ios.ts +1 -1
- package/test/integration/smoke-test.ts +1 -1
- package/test/integration/test-dist.ts +1 -1
- package/test/integration/test-ui-tree.ts +1 -1
- package/test/integration/wait_for_element_real.ts +1 -1
- package/test/unit/build.test.ts +84 -0
- package/test/unit/build_and_install.test.ts +132 -0
- package/test/unit/detect-java.test.ts +22 -0
- package/test/unit/install.test.ts +2 -8
- package/test/unit/logstream.test.ts +8 -9
- package/src/tools/app.ts +0 -46
- package/src/tools/devices.ts +0 -6
- package/src/tools/install.ts +0 -43
- package/src/tools/logs.ts +0 -62
- package/src/tools/screenshot.ts +0 -18
- package/src/tools/ui.ts +0 -62
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { promises as fs } from 'fs'
|
|
2
|
+
import { spawn } from 'child_process'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { existsSync } from 'fs'
|
|
5
|
+
import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from './utils.js'
|
|
6
|
+
import { detectJavaHome } from '../utils/java.js'
|
|
7
|
+
import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
|
|
8
|
+
|
|
9
|
+
export class AndroidManage {
|
|
10
|
+
async build(projectPath: string, _variant?: string): Promise<{ artifactPath: string, output?: string } | { error: string }> {
|
|
11
|
+
void _variant
|
|
12
|
+
try {
|
|
13
|
+
// Always use the shared prepareGradle utility for consistent env/setup
|
|
14
|
+
const { execCmd, gradleArgs, spawnOpts } = await (await import('./utils.js')).prepareGradle(projectPath)
|
|
15
|
+
await new Promise<void>((resolve, reject) => {
|
|
16
|
+
const proc = spawn(execCmd, gradleArgs, spawnOpts)
|
|
17
|
+
let stderr = ''
|
|
18
|
+
proc.stderr?.on('data', d => stderr += d.toString())
|
|
19
|
+
proc.on('close', code => {
|
|
20
|
+
if (code === 0) resolve()
|
|
21
|
+
else reject(new Error(stderr || `Gradle failed with code ${code}`))
|
|
22
|
+
})
|
|
23
|
+
proc.on('error', err => reject(err))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const apk = await findApk(projectPath)
|
|
27
|
+
if (!apk) return { error: 'Could not find APK after build' }
|
|
28
|
+
return { artifactPath: apk }
|
|
29
|
+
} catch (e) {
|
|
30
|
+
return { error: e instanceof Error ? e.message : String(e) }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async installApp(apkPath: string, deviceId?: string): Promise<InstallAppResponse> {
|
|
35
|
+
const metadata = await getAndroidDeviceMetadata('', deviceId)
|
|
36
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
let apkToInstall = apkPath
|
|
40
|
+
const stat = await fs.stat(apkPath).catch(() => null)
|
|
41
|
+
if (stat && stat.isDirectory()) {
|
|
42
|
+
const detectedJavaHome = await detectJavaHome().catch(() => undefined)
|
|
43
|
+
const env = Object.assign({}, process.env)
|
|
44
|
+
if (detectedJavaHome) {
|
|
45
|
+
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
46
|
+
env.JAVA_HOME = detectedJavaHome
|
|
47
|
+
env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`
|
|
48
|
+
console.debug('[android-run] Overriding JAVA_HOME with detected path:', detectedJavaHome)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
try { delete env.SHELL } catch {}
|
|
52
|
+
|
|
53
|
+
const gradleArgs = ['assembleDebug']
|
|
54
|
+
if (detectedJavaHome) {
|
|
55
|
+
gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`)
|
|
56
|
+
gradleArgs.push('--no-daemon')
|
|
57
|
+
env.GRADLE_JAVA_HOME = detectedJavaHome
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const wrapperPath = path.join(apkPath, 'gradlew')
|
|
61
|
+
const useWrapper = existsSync(wrapperPath)
|
|
62
|
+
const execCmd = useWrapper ? wrapperPath : 'gradle'
|
|
63
|
+
const spawnOpts: any = { cwd: apkPath, env }
|
|
64
|
+
if (useWrapper) {
|
|
65
|
+
await fs.chmod(wrapperPath, 0o755).catch(() => {})
|
|
66
|
+
spawnOpts.shell = false
|
|
67
|
+
} else spawnOpts.shell = true
|
|
68
|
+
|
|
69
|
+
const proc = spawn(execCmd, gradleArgs, spawnOpts)
|
|
70
|
+
let stderr = ''
|
|
71
|
+
await new Promise<void>((resolve, reject) => {
|
|
72
|
+
proc.stderr?.on('data', d => stderr += d.toString())
|
|
73
|
+
proc.on('close', code => {
|
|
74
|
+
if (code === 0) resolve()
|
|
75
|
+
else reject(new Error(stderr || `Gradle build failed with code ${code}`))
|
|
76
|
+
})
|
|
77
|
+
proc.on('error', err => reject(err))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const built = await findApk(apkPath)
|
|
81
|
+
if (!built) throw new Error('Could not locate built APK after running Gradle')
|
|
82
|
+
apkToInstall = built
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const res = await spawnAdb(['install', '-r', apkToInstall], deviceId)
|
|
87
|
+
if (res.code === 0) {
|
|
88
|
+
return { device: deviceInfo, installed: true, output: res.stdout }
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.debug('[android-run] adb install failed, attempting push+pm fallback:', e instanceof Error ? e.message : String(e))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const basename = path.basename(apkToInstall)
|
|
95
|
+
const remotePath = `/data/local/tmp/${basename}`
|
|
96
|
+
await execAdb(['push', apkToInstall, remotePath], deviceId)
|
|
97
|
+
const pmOut = await execAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId)
|
|
98
|
+
try { await execAdb(['shell', 'rm', remotePath], deviceId) } catch {}
|
|
99
|
+
return { device: deviceInfo, installed: true, output: pmOut }
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
|
|
106
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
107
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
108
|
+
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
|
|
109
|
+
return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async terminateApp(appId: string, deviceId?: string): Promise<TerminateAppResponse> {
|
|
113
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
114
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
115
|
+
await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
|
|
116
|
+
return { device: deviceInfo, appTerminated: true }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
|
|
120
|
+
await this.terminateApp(appId, deviceId)
|
|
121
|
+
const startResult = await this.startApp(appId, deviceId)
|
|
122
|
+
return {
|
|
123
|
+
device: startResult.device,
|
|
124
|
+
appRestarted: startResult.appStarted,
|
|
125
|
+
launchTimeMs: startResult.launchTimeMs
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async resetAppData(appId: string, deviceId?: string): Promise<ResetAppDataResponse> {
|
|
130
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
131
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
132
|
+
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
|
|
133
|
+
return { device: deviceInfo, dataCleared: output === 'Success' }
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/android/observe.ts
CHANGED
|
@@ -1,104 +1,14 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
2
|
import { XMLParser } from "fast-xml-parser"
|
|
3
3
|
import { GetLogsResponse, CaptureAndroidScreenResponse, GetUITreeResponse, GetCurrentScreenResponse, UIElement, DeviceInfo } from "../types.js"
|
|
4
|
-
import { ADB, execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js"
|
|
4
|
+
import { ADB, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "./utils.js"
|
|
5
|
+
import { createWriteStream } from "fs"
|
|
6
|
+
import { promises as fsPromises } from "fs"
|
|
7
|
+
import path from "path"
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
|
|
7
10
|
|
|
8
|
-
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
9
11
|
|
|
10
|
-
function parseBounds(bounds: string): [number, number, number, number] {
|
|
11
|
-
const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
|
12
|
-
if (match) {
|
|
13
|
-
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4])];
|
|
14
|
-
}
|
|
15
|
-
return [0, 0, 0, 0];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function getCenter(bounds: [number, number, number, number]): [number, number] {
|
|
19
|
-
const [x1, y1, x2, y2] = bounds;
|
|
20
|
-
return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async function getScreenResolution(deviceId?: string): Promise<{ width: number; height: number }> {
|
|
24
|
-
try {
|
|
25
|
-
const output = await execAdb(['shell', 'wm', 'size'], deviceId);
|
|
26
|
-
const match = output.match(/Physical size: (\d+)x(\d+)/);
|
|
27
|
-
if (match) {
|
|
28
|
-
return { width: parseInt(match[1]), height: parseInt(match[2]) };
|
|
29
|
-
}
|
|
30
|
-
} catch (e) {
|
|
31
|
-
// ignore
|
|
32
|
-
}
|
|
33
|
-
return { width: 0, height: 0 };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function traverseNode(node: any, elements: UIElement[], parentIndex: number = -1, depth: number = 0): number {
|
|
37
|
-
if (!node) return -1;
|
|
38
|
-
|
|
39
|
-
let currentIndex = -1;
|
|
40
|
-
|
|
41
|
-
// Check if it's a valid node with attributes we care about
|
|
42
|
-
if (node['@_class']) {
|
|
43
|
-
const text = node['@_text'] || null;
|
|
44
|
-
const contentDescription = node['@_content-desc'] || null;
|
|
45
|
-
const clickable = node['@_clickable'] === 'true';
|
|
46
|
-
const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
|
|
47
|
-
|
|
48
|
-
// Filtering Logic:
|
|
49
|
-
// Keep if clickable OR has visible text OR has content description
|
|
50
|
-
const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
|
|
51
|
-
|
|
52
|
-
if (isUseful) {
|
|
53
|
-
const element: UIElement = {
|
|
54
|
-
text,
|
|
55
|
-
contentDescription,
|
|
56
|
-
type: node['@_class'] || 'unknown',
|
|
57
|
-
resourceId: node['@_resource-id'] || null,
|
|
58
|
-
clickable,
|
|
59
|
-
enabled: node['@_enabled'] === 'true',
|
|
60
|
-
visible: true,
|
|
61
|
-
bounds,
|
|
62
|
-
center: getCenter(bounds),
|
|
63
|
-
depth
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
if (parentIndex !== -1) {
|
|
67
|
-
element.parentId = parentIndex;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
elements.push(element);
|
|
71
|
-
currentIndex = elements.length - 1;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// If current node was skipped (not useful or no class), children inherit parentIndex
|
|
76
|
-
// If current node was added, children use currentIndex
|
|
77
|
-
const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
|
|
78
|
-
const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
|
|
79
|
-
|
|
80
|
-
const childrenIndices: number[] = [];
|
|
81
|
-
|
|
82
|
-
// Traverse children
|
|
83
|
-
if (node.node) {
|
|
84
|
-
if (Array.isArray(node.node)) {
|
|
85
|
-
node.node.forEach((child: any) => {
|
|
86
|
-
const childIndex = traverseNode(child, elements, nextParentIndex, nextDepth);
|
|
87
|
-
if (childIndex !== -1) childrenIndices.push(childIndex);
|
|
88
|
-
});
|
|
89
|
-
} else {
|
|
90
|
-
const childIndex = traverseNode(node.node, elements, nextParentIndex, nextDepth);
|
|
91
|
-
if (childIndex !== -1) childrenIndices.push(childIndex);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Update current element with children if it was added
|
|
96
|
-
if (currentIndex !== -1 && childrenIndices.length > 0) {
|
|
97
|
-
elements[currentIndex].children = childrenIndices;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return currentIndex;
|
|
101
|
-
}
|
|
102
12
|
|
|
103
13
|
export class AndroidObserve {
|
|
104
14
|
async getDeviceMetadata(appId: string, deviceId?: string): Promise<DeviceInfo> {
|
|
@@ -137,8 +47,8 @@ export class AndroidObserve {
|
|
|
137
47
|
if (xmlContent && xmlContent.trim().length > 0 && !xmlContent.includes("ERROR:")) {
|
|
138
48
|
break; // Success
|
|
139
49
|
}
|
|
140
|
-
} catch (
|
|
141
|
-
console.error(`Attempt ${attempts} failed: ${
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
142
52
|
}
|
|
143
53
|
|
|
144
54
|
if (attempts === maxAttempts) {
|
|
@@ -357,4 +267,126 @@ export class AndroidObserve {
|
|
|
357
267
|
};
|
|
358
268
|
}
|
|
359
269
|
}
|
|
270
|
+
|
|
271
|
+
async startLogStream(packageName: string, level: 'error' | 'warn' | 'info' | 'debug' = 'error', deviceId?: string, sessionId: string = 'default') {
|
|
272
|
+
try {
|
|
273
|
+
const pidOutput = await execAdb(['shell', 'pidof', packageName], deviceId).catch(() => '')
|
|
274
|
+
const pid = (pidOutput || '').trim()
|
|
275
|
+
if (!pid) return { success: false, error: 'app_not_running' }
|
|
276
|
+
|
|
277
|
+
const levelMap: Record<string, string> = { error: '*:E', warn: '*:W', info: '*:I', debug: '*:D' }
|
|
278
|
+
const filter = levelMap[level] || levelMap['error']
|
|
279
|
+
|
|
280
|
+
if (activeLogStreams.has(sessionId)) {
|
|
281
|
+
try { activeLogStreams.get(sessionId)!.proc.kill() } catch {}
|
|
282
|
+
activeLogStreams.delete(sessionId)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const args = ['logcat', `--pid=${pid}`, filter]
|
|
286
|
+
const proc = spawn(ADB, args)
|
|
287
|
+
|
|
288
|
+
const tmpDir = process.env.TMPDIR || '/tmp'
|
|
289
|
+
const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`)
|
|
290
|
+
const stream = createWriteStream(file, { flags: 'a' })
|
|
291
|
+
|
|
292
|
+
proc.stdout.on('data', (chunk) => {
|
|
293
|
+
const text = chunk.toString()
|
|
294
|
+
const lines = text.split(/\r?\n/).filter(Boolean)
|
|
295
|
+
for (const l of lines) {
|
|
296
|
+
const entry = parseLogLine(l)
|
|
297
|
+
stream.write(JSON.stringify(entry) + '\n')
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
proc.stderr.on('data', (chunk) => {
|
|
302
|
+
const text = chunk.toString()
|
|
303
|
+
const lines = text.split(/\r?\n/).filter(Boolean)
|
|
304
|
+
for (const l of lines) {
|
|
305
|
+
const entry = { timestamp: '', level: 'E', tag: 'adb', message: l }
|
|
306
|
+
stream.write(JSON.stringify(entry) + '\n')
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
proc.on('close', () => {
|
|
311
|
+
stream.end()
|
|
312
|
+
activeLogStreams.delete(sessionId)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
activeLogStreams.set(sessionId, { proc, file })
|
|
316
|
+
return { success: true, stream_started: true }
|
|
317
|
+
} catch (e) {
|
|
318
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async stopLogStream(sessionId: string = 'default') {
|
|
323
|
+
const entry = activeLogStreams.get(sessionId)
|
|
324
|
+
if (!entry) return { success: true }
|
|
325
|
+
try { entry.proc.kill() } catch {}
|
|
326
|
+
activeLogStreams.delete(sessionId)
|
|
327
|
+
return { success: true }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async readLogStream(sessionId: string = 'default', limit: number = 100, since?: string) {
|
|
331
|
+
// Prefer active stream if present, otherwise fall back to a well-known NDJSON file for the session
|
|
332
|
+
const entry = activeLogStreams.get(sessionId)
|
|
333
|
+
let file: string | undefined
|
|
334
|
+
if (entry && entry.file) file = entry.file
|
|
335
|
+
else {
|
|
336
|
+
const tmpDir = process.env.TMPDIR || '/tmp'
|
|
337
|
+
const candidate = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`)
|
|
338
|
+
file = candidate
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const data = await fsPromises.readFile(file, 'utf8').catch(() => '')
|
|
343
|
+
if (!data) return { entries: [], crash_summary: { crash_detected: false } }
|
|
344
|
+
const lines = data.split(/\r?\n/).filter(Boolean)
|
|
345
|
+
|
|
346
|
+
const parsed = lines.map(l => {
|
|
347
|
+
try {
|
|
348
|
+
const obj: any = JSON.parse(l)
|
|
349
|
+
if (typeof obj._iso === 'undefined') {
|
|
350
|
+
let iso: string | null = null
|
|
351
|
+
if (obj.timestamp) {
|
|
352
|
+
const d = new Date(obj.timestamp)
|
|
353
|
+
if (!isNaN(d.getTime())) iso = d.toISOString()
|
|
354
|
+
}
|
|
355
|
+
obj._iso = iso
|
|
356
|
+
}
|
|
357
|
+
if (typeof obj.crash === 'undefined') {
|
|
358
|
+
const msg = (obj.message || '').toString()
|
|
359
|
+
const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/)
|
|
360
|
+
if (/FATAL EXCEPTION/i.test(msg) || exMatch) {
|
|
361
|
+
obj.crash = true
|
|
362
|
+
if (exMatch) obj.exception = exMatch[1]
|
|
363
|
+
} else {
|
|
364
|
+
obj.crash = false
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return obj
|
|
368
|
+
} catch {
|
|
369
|
+
return { message: l, _iso: null, crash: false }
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
let filtered = parsed
|
|
374
|
+
if (since) {
|
|
375
|
+
let sinceMs: number | null = null
|
|
376
|
+
if (/^\d+$/.test(since)) sinceMs = Number(since)
|
|
377
|
+
else {
|
|
378
|
+
const sDate = new Date(since)
|
|
379
|
+
if (!isNaN(sDate.getTime())) sinceMs = sDate.getTime()
|
|
380
|
+
}
|
|
381
|
+
if (sinceMs !== null) filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs))
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const entries = filtered.slice(-Math.max(0, limit))
|
|
385
|
+
const crashEntry = entries.find(e => e.crash)
|
|
386
|
+
const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false }
|
|
387
|
+
return { entries, crash_summary }
|
|
388
|
+
} catch {
|
|
389
|
+
return { entries: [], crash_summary: { crash_detected: false } }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
360
392
|
}
|