mobile-debug-mcp 0.33.0 → 0.34.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/.github/workflows/ci.yml +21 -0
- package/README.md +7 -0
- package/agent-plugin/.claude-plugin/plugin.json +2 -2
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/README.md +4 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +88 -4
- package/dist/debugger/android.js +78 -0
- package/dist/debugger/guard.js +30 -0
- package/dist/debugger/sessions.js +262 -0
- package/dist/debugger/worker.js +165 -0
- package/dist/interact/index.js +16 -0
- package/dist/observe/evidence.js +9 -2
- package/dist/observe/readiness-service.js +9 -0
- package/dist/observe/readiness.js +15 -8
- package/dist/observe/system-trace-android.js +135 -0
- package/dist/observe/system-trace.js +285 -0
- package/dist/server/debugger-handlers.js +38 -0
- package/dist/server/debugger-schema.js +42 -0
- package/dist/server/journey-schema.js +1 -0
- package/dist/server/system-trace-schema.js +17 -0
- package/dist/server/tool-definitions.js +6 -1
- package/dist/server/tool-handlers.js +203 -96
- package/dist/server-core.js +3 -3
- package/dist/server.js +34 -0
- package/dist/system/index.js +5 -4
- package/dist/utils/exec.js +2 -0
- package/dist/utils/java.js +14 -12
- package/dist/utils/operation-budget.js +2 -0
- package/dist/utils/resolve-device.js +2 -0
- package/docs/CHANGELOG.md +4 -0
- package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
- package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
- package/docs/specs/023-android-system-traces/intent.md +40 -0
- package/docs/specs/023-android-system-traces/plan.md +117 -0
- package/docs/specs/023-android-system-traces/spec.md +120 -0
- package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
- package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
- package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
- package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
- package/docs/tools/TOOLS.md +3 -1
- package/docs/tools/debugger.md +104 -0
- package/docs/tools/evidence.md +5 -2
- package/docs/tools/observe.md +12 -5
- package/docs/tools/system-traces.md +126 -0
- package/package.json +1 -1
- package/src/debugger/android.ts +65 -0
- package/src/debugger/guard.ts +26 -0
- package/src/debugger/sessions.ts +168 -0
- package/src/debugger/worker.ts +122 -0
- package/src/interact/index.ts +16 -15
- package/src/observe/evidence.ts +9 -2
- package/src/observe/readiness-service.ts +5 -0
- package/src/observe/readiness.ts +12 -6
- package/src/observe/system-trace-android.ts +133 -0
- package/src/observe/system-trace.ts +218 -0
- package/src/server/debugger-handlers.ts +29 -0
- package/src/server/debugger-schema.ts +37 -0
- package/src/server/journey-schema.ts +1 -0
- package/src/server/system-trace-schema.ts +17 -0
- package/src/server/tool-definitions.ts +6 -1
- package/src/server/tool-handlers.ts +92 -7
- package/src/server-core.ts +3 -3
- package/src/server.ts +24 -0
- package/src/system/index.ts +7 -5
- package/src/utils/exec.ts +2 -0
- package/src/utils/java.ts +13 -12
- package/src/utils/operation-budget.ts +2 -0
- package/src/utils/resolve-device.ts +2 -0
- package/test/device/automated/debugger/runtime.android.test.ts +203 -0
- package/test/device/automated/observe/system-trace.smoke.ts +104 -0
- package/test/fixtures/debugger-app/MainActivity.java +46 -0
- package/test/fixtures/debugger-app/NoLocals.java +7 -0
- package/test/fixtures/debugger-app/README.md +13 -0
- package/test/fixtures/debugger-app/build.ts +29 -0
- package/test/helpers/perfetto.ts +59 -0
- package/test/unit/debugger/guards.test.ts +46 -0
- package/test/unit/debugger/native.test.ts +33 -0
- package/test/unit/debugger/sessions.test.ts +83 -0
- package/test/unit/observe/readiness.test.ts +3 -1
- package/test/unit/observe/system-trace-android.test.ts +52 -0
- package/test/unit/observe/system-trace.test.ts +166 -0
- package/test/unit/server/system-trace.test.ts +64 -0
- package/worker/DebuggerWorker.java +160 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { registerDebuggerPauseProvider } from './guard.js'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { AndroidDebugNative, type DebugNative, type DebugTarget } from './android.js'
|
|
4
|
+
import { createWorker, prepareWorker, DebuggerError, withDebuggerBudget, debuggerRemaining, type DebugWorker, type WorkerEvent } from './worker.js'
|
|
5
|
+
|
|
6
|
+
type State = 'attaching' | 'running' | 'paused' | 'detaching' | 'detached' | 'disconnected' | 'failed'
|
|
7
|
+
interface Session {
|
|
8
|
+
id: string; device: string; target: DebugTarget | null; state: State; stop: any; reason: string | null
|
|
9
|
+
worker?: DebugWorker; port?: number; queue: Promise<unknown>; events: any[]; sequence: number
|
|
10
|
+
cleanup: { status: 'pending' | 'complete' | 'failed'; remaining_resources: string[] }; terminalAt?: number
|
|
11
|
+
created: number; activity: number; pausedAt?: number; timer?: ReturnType<typeof setInterval>; cleaning?: Promise<void>
|
|
12
|
+
}
|
|
13
|
+
export const debuggerCapabilities = { attach: true, breakpoints: true, stacks: true, locals: true, step: true, resume: true,
|
|
14
|
+
evaluation: false, mutation: false, coroutines: false, native: false }
|
|
15
|
+
const limitations = ['JAVA_KOTLIN_BYTECODE_ONLY', 'DEBUG_METADATA_REQUIRED', 'SHALLOW_LOCALS', 'ALL_THREADS_SUSPEND', 'DEVICE_WIDE_UI_GUARD']
|
|
16
|
+
export function debuggerExpiry(created: number, activity: number, pausedAt: number | undefined, now: number) {
|
|
17
|
+
return pausedAt !== undefined && now - pausedAt >= 60000 ? 'pause_limit' : now - activity >= 300000 ? 'idle_limit' : now - created >= 1800000 ? 'session_limit' : null
|
|
18
|
+
}
|
|
19
|
+
export class DebuggerSessions {
|
|
20
|
+
private sessions = new Map<string, Session>()
|
|
21
|
+
private closing = false
|
|
22
|
+
constructor(private native: DebugNative = new AndroidDebugNative(), private workerFactory = createWorker, private prerequisite = prepareWorker) {}
|
|
23
|
+
private prune() {
|
|
24
|
+
const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.cleanup.status === 'complete')
|
|
25
|
+
for (const s of terminal) if (Date.now() - s.terminalAt! >= 600000) this.sessions.delete(s.id)
|
|
26
|
+
const remaining = terminal.filter(s => this.sessions.has(s.id))
|
|
27
|
+
for (const s of remaining.slice(0, Math.max(0, remaining.length - 100))) this.sessions.delete(s.id)
|
|
28
|
+
}
|
|
29
|
+
private get(id: string) { this.prune(); const s = this.sessions.get(id); if (!s) throw new DebuggerError('DEBUGGER_NOT_FOUND'); s.activity = Date.now(); return s }
|
|
30
|
+
private serial<T>(s: Session, fn: () => Promise<T>) { const out = s.queue.then(fn); s.queue = out.catch(() => {}); return out }
|
|
31
|
+
private result(s: Session, payload: any = {}) {
|
|
32
|
+
const success = payload.success ?? (!['failed', 'disconnected'].includes(s.state) && s.cleanup.status !== 'failed')
|
|
33
|
+
const code = s.cleanup.status === 'failed' ? 'DEBUGGER_CLEANUP_FAILED' : s.state === 'disconnected' ? 'DEBUGGER_DISCONNECTED' : s.reason?.match(/^[A-Z_]+$/) ? s.reason : 'DEBUGGER_WORKER_FAILED'
|
|
34
|
+
return structuredClone({ success, ...(!success ? { error: { code, message: code, retryable: false } } : {}), debug_session_id: s.id,
|
|
35
|
+
device: { platform: 'android', id: s.device }, target: s.target, state: s.state, captured_at_ms: Date.now(),
|
|
36
|
+
capabilities: debuggerCapabilities, limitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload })
|
|
37
|
+
}
|
|
38
|
+
summary(id: string) { return this.result(this.get(id)) }
|
|
39
|
+
hasActiveSessions() { return [...this.sessions.values()].some(s => s.cleanup.status !== 'complete') }
|
|
40
|
+
paused(device: string) { const s = [...this.sessions.values()].find(s => s.device === device && s.state === 'paused'); return s ? this.result(s) : null }
|
|
41
|
+
assertRunning(device: string) { if (this.paused(device)) throw new DebuggerError('DEBUGGER_PAUSED') }
|
|
42
|
+
private event(s: Session, event: WorkerEvent) {
|
|
43
|
+
if (s.cleanup.status === 'complete') return
|
|
44
|
+
if (event.event === 'stopped' && !s.cleaning) { s.state = 'paused'; s.stop = event; s.pausedAt ??= Date.now() }
|
|
45
|
+
if (event.event === 'terminated' || event.event === 'worker_failed') {
|
|
46
|
+
if (!s.cleaning && s.state !== 'failed') s.reason = event.reason ?? 'worker_failed'
|
|
47
|
+
if (!s.cleaning) {
|
|
48
|
+
s.state = event.event === 'worker_failed' ? 'failed' : event.reason === 'process_exit' || event.reason === 'transport_lost' ? 'disconnected' : 'detached'
|
|
49
|
+
s.stop = null
|
|
50
|
+
// The worker has disposed or lost its transport. Cleanup remains pending until host resources are checked.
|
|
51
|
+
void this.serial(s, () => this.cleanup(s, false))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
s.events.push({ ...event, sequence: ++s.sequence, received_at_ms: Date.now() })
|
|
55
|
+
if (s.events.length > 256) s.events.shift()
|
|
56
|
+
}
|
|
57
|
+
async capabilities(device: string, app: string, pid?: number) {
|
|
58
|
+
const prerequisites: Record<string, boolean> = { jdk: false, device: false, process: false }
|
|
59
|
+
let candidates: DebugTarget[] = []
|
|
60
|
+
const reasons: string[] = []
|
|
61
|
+
try { await this.prerequisite(); prerequisites.jdk = true } catch (e) { if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code)) throw e; reasons.push('DEBUGGER_UNSUPPORTED') }
|
|
62
|
+
try { candidates = await this.native.candidates(device, app); prerequisites.device = true; prerequisites.process = candidates.some(t => pid === undefined || pid === t.pid); if (!prerequisites.process) reasons.push('PROCESS_NOT_FOUND') }
|
|
63
|
+
catch (e) { if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code)) throw e; reasons.push(e instanceof DebuggerError ? e.code : 'DEVICE_UNAVAILABLE') }
|
|
64
|
+
return { success: true, debug_session_id: null, device: { platform: 'android', id: device }, target: null, state: null,
|
|
65
|
+
captured_at_ms: Date.now(), supported: Object.values(prerequisites).every(Boolean), prerequisites, candidates,
|
|
66
|
+
capabilities: debuggerCapabilities, reasons, limitations, cleanup: { status: 'complete', remaining_resources: [] } }
|
|
67
|
+
}
|
|
68
|
+
async attach(device: string, app: string, pid?: number, timeout = 10000) {
|
|
69
|
+
this.prune()
|
|
70
|
+
const owned = [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete')
|
|
71
|
+
if (this.closing || owned.length >= 4 || owned.some(s => s.device === device)) throw new DebuggerError('DEBUGGER_BUSY')
|
|
72
|
+
const s: Session = { id: randomUUID(), device, target: null, state: 'attaching', stop: null, reason: null,
|
|
73
|
+
queue: Promise.resolve(), events: [], sequence: 0, cleanup: { status: 'pending', remaining_resources: [] }, created: Date.now(), activity: Date.now() }
|
|
74
|
+
this.sessions.set(s.id, s)
|
|
75
|
+
return this.serial(s, async () => {
|
|
76
|
+
const deadline = Date.now() + timeout
|
|
77
|
+
const remaining = () => { debuggerRemaining(timeout); const ms = deadline - Date.now(); if (ms <= 0) throw new DebuggerError('DEBUGGER_TIMEOUT'); return ms }
|
|
78
|
+
try {
|
|
79
|
+
await this.prerequisite(); remaining()
|
|
80
|
+
const candidates = (await this.native.candidates(device, app)).filter(t => pid === undefined || t.pid === pid); remaining()
|
|
81
|
+
if (!candidates.length) throw new DebuggerError('PROCESS_NOT_FOUND')
|
|
82
|
+
if (candidates.length > 1) throw new DebuggerError('PROCESS_AMBIGUOUS')
|
|
83
|
+
s.target = candidates[0]
|
|
84
|
+
s.port = await this.native.forward(device, s.target.pid); remaining()
|
|
85
|
+
s.cleanup.remaining_resources.push(`forward:tcp:${s.port}`)
|
|
86
|
+
s.worker = await this.workerFactory(event => this.event(s, event)); remaining()
|
|
87
|
+
s.cleanup.remaining_resources.push('jdi_worker')
|
|
88
|
+
await s.worker.call('attach', { port: s.port, timeoutMs: remaining() }, remaining())
|
|
89
|
+
await this.native.identity(device, s.target); remaining()
|
|
90
|
+
if (s.state === 'attaching') s.state = 'running'
|
|
91
|
+
if (['failed', 'disconnected', 'detached'].includes(s.state)) throw new DebuggerError('DEBUGGER_ATTACH_FAILED')
|
|
92
|
+
s.timer = setInterval(() => {
|
|
93
|
+
const now = Date.now()
|
|
94
|
+
const reason = debuggerExpiry(s.created, s.activity, s.pausedAt, now)
|
|
95
|
+
if (reason) { s.reason = reason; void this.serial(s, () => this.cleanup(s, true)) }
|
|
96
|
+
}, 1000); s.timer.unref()
|
|
97
|
+
} catch (e) {
|
|
98
|
+
s.state = 'failed'; s.reason = e instanceof DebuggerError ? e.code : 'DEBUGGER_ATTACH_FAILED'
|
|
99
|
+
await this.cleanup(s, true)
|
|
100
|
+
return this.result(s, { success: false, error: { code: s.reason, message: s.reason, retryable: false } })
|
|
101
|
+
}
|
|
102
|
+
return this.result(s)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
private async cleanup(s: Session, requestDetach: boolean): Promise<void> {
|
|
106
|
+
if (s.cleaning) return s.cleaning
|
|
107
|
+
s.cleaning = withDebuggerBudget(10000, undefined, async () => {
|
|
108
|
+
if (s.timer) clearInterval(s.timer)
|
|
109
|
+
const terminal = ['failed', 'disconnected'].includes(s.state) ? s.state : 'detached'
|
|
110
|
+
s.state = 'detaching'; s.stop = null; s.pausedAt = undefined
|
|
111
|
+
const remaining: string[] = []
|
|
112
|
+
if (s.worker) {
|
|
113
|
+
if (requestDetach) { try { await s.worker.call('detach', {}, 3000) } catch { /* EOF/transport closure is the fallback; execution remains unknown. */ } }
|
|
114
|
+
try { await s.worker.close(); s.worker = undefined } catch { remaining.push('jdi_worker') }
|
|
115
|
+
}
|
|
116
|
+
if (s.port !== undefined && s.target) {
|
|
117
|
+
try { await this.native.removeForward(s.device, s.port, s.target.pid); s.port = undefined }
|
|
118
|
+
catch { remaining.push(`forward:tcp:${s.port}`) }
|
|
119
|
+
}
|
|
120
|
+
s.events = []; s.cleanup = { status: remaining.length ? 'failed' : 'complete', remaining_resources: remaining }
|
|
121
|
+
s.state = terminal; s.terminalAt = Date.now()
|
|
122
|
+
})
|
|
123
|
+
try { await s.cleaning } finally { s.cleaning = undefined }
|
|
124
|
+
}
|
|
125
|
+
async command(id: string, command: string, args: Record<string, any> = {}, timeout = 10000) {
|
|
126
|
+
const s = this.get(id)
|
|
127
|
+
if (command === 'events') {
|
|
128
|
+
const deadline = Date.now() + timeout
|
|
129
|
+
const after = args.afterSequence ?? 0
|
|
130
|
+
if (after > s.sequence) throw new DebuggerError('INVALID_ARGUMENT')
|
|
131
|
+
if (args.signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
|
|
132
|
+
if (s.worker && ['running', 'paused'].includes(s.state)) await s.worker.call('heartbeat', {}, Math.min(timeout, 1000))
|
|
133
|
+
while (Date.now() < deadline && !s.events.some(e => e.sequence > after) && s.cleanup.status === 'pending') {
|
|
134
|
+
if (args.signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
|
|
135
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(50, deadline - Date.now())))
|
|
136
|
+
}
|
|
137
|
+
const events = s.events.filter(e => e.sequence > after).slice(0, 100)
|
|
138
|
+
return this.result(s, { events, next_cursor: events.at(-1)?.sequence ?? s.sequence, overflow: after < (s.events[0]?.sequence ?? s.sequence + 1) - 1, timed_out: events.length === 0 })
|
|
139
|
+
}
|
|
140
|
+
return this.serial(s, async () => {
|
|
141
|
+
if (command === 'detach') { if (s.cleanup.status !== 'complete') await this.cleanup(s, true); return this.result(s, { success: s.cleanup.status === 'complete', execution_state: 'unknown' }) }
|
|
142
|
+
if (command === 'state' && (s.cleanup.status === 'complete' || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state))) return this.result(s)
|
|
143
|
+
if (!s.worker || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state)) throw new DebuggerError('DEBUGGER_DISCONNECTED')
|
|
144
|
+
if (['stack', 'locals', 'step', 'resume'].includes(command)) {
|
|
145
|
+
if (args.stopId !== s.stop?.stop_id) throw new DebuggerError('DEBUGGER_STALE_REFERENCE')
|
|
146
|
+
if (s.state !== 'paused') throw new DebuggerError('DEBUGGER_NOT_PAUSED')
|
|
147
|
+
}
|
|
148
|
+
if (command === 'step' || command === 'resume') { s.state = 'running'; s.stop = null; s.pausedAt = undefined }
|
|
149
|
+
try {
|
|
150
|
+
const data = await s.worker.call(command, args, timeout)
|
|
151
|
+
return this.result(s, command === 'state' ? { breakpoints: data.breakpoints } : { result: data })
|
|
152
|
+
} catch (e) {
|
|
153
|
+
if (e instanceof DebuggerError && e.code === 'DEBUGGER_DISCONNECTED') {
|
|
154
|
+
s.state = 'disconnected'; s.reason = 'transport_lost'; await this.cleanup(s, false)
|
|
155
|
+
if (command === 'state') return this.result(s)
|
|
156
|
+
}
|
|
157
|
+
if ((command === 'step' || command === 'resume' || command === 'set' || command === 'remove') && e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED', 'DEBUGGER_WORKER_FAILED'].includes(e.code)) {
|
|
158
|
+
s.state = 'failed'; s.reason = e.code; await this.cleanup(s, true)
|
|
159
|
+
}
|
|
160
|
+
throw e
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
async shutdown() { this.closing = true; await Promise.allSettled([...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.serial(s, () => this.cleanup(s, true)))); return [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.result(s)) }
|
|
165
|
+
}
|
|
166
|
+
export const debuggerSessions = new DebuggerSessions()
|
|
167
|
+
|
|
168
|
+
registerDebuggerPauseProvider(device => debuggerSessions.paused(device))
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
import { readFile, mkdtemp, chmod, rm } from 'node:fs/promises'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { detectJavaHome } from '../utils/java.js'
|
|
10
|
+
|
|
11
|
+
const exec = promisify(execFile)
|
|
12
|
+
const operations = new AsyncLocalStorage<{ deadline: number; signal?: AbortSignal }>()
|
|
13
|
+
export function withDebuggerBudget<T>(timeout: number, signal: AbortSignal | undefined, operation: () => Promise<T>) {
|
|
14
|
+
return operations.run({ deadline: performance.now() + timeout, signal }, operation)
|
|
15
|
+
}
|
|
16
|
+
export function debuggerRemaining(fallback: number) {
|
|
17
|
+
const context = operations.getStore()
|
|
18
|
+
if (context?.signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
|
|
19
|
+
const remaining = Math.floor((context?.deadline ?? Infinity) - performance.now())
|
|
20
|
+
if (remaining <= 0) throw new DebuggerError('DEBUGGER_TIMEOUT')
|
|
21
|
+
return Math.min(fallback, remaining)
|
|
22
|
+
}
|
|
23
|
+
export function debuggerSignal() { return operations.getStore()?.signal }
|
|
24
|
+
export function nativeError(error: any, fallback: string): DebuggerError {
|
|
25
|
+
if (error instanceof DebuggerError) return error
|
|
26
|
+
if (debuggerSignal()?.aborted) return new DebuggerError('DEBUGGER_CANCELLED')
|
|
27
|
+
if (error?.killed) return new DebuggerError('DEBUGGER_TIMEOUT')
|
|
28
|
+
return new DebuggerError(fallback)
|
|
29
|
+
}
|
|
30
|
+
export class DebuggerError extends Error { constructor(public code: string) { super(code) } }
|
|
31
|
+
export const workerSource = fileURLToPath(new URL('../../worker/DebuggerWorker.java', import.meta.url))
|
|
32
|
+
let compiled: Promise<{ java: string; directory: string }> | undefined
|
|
33
|
+
export async function prepareWorker() {
|
|
34
|
+
compiled ??= (async () => {
|
|
35
|
+
const home = await detectJavaHome({ quiet: true })
|
|
36
|
+
const java = home ? path.join(home, 'bin', 'java') : 'java'
|
|
37
|
+
const javac = home ? path.join(home, 'bin', 'javac') : 'javac'
|
|
38
|
+
const version = await exec(java, ['-version'], { timeout: debuggerRemaining(5000), signal: debuggerSignal() })
|
|
39
|
+
const major = Number(/version "(\d+)/.exec(version.stderr)?.[1])
|
|
40
|
+
if (!(major >= 17)) throw new DebuggerError('DEBUGGER_UNSUPPORTED')
|
|
41
|
+
const source = await readFile(workerSource)
|
|
42
|
+
const key = createHash('sha256').update(source).update(version.stderr).digest('hex').slice(0, 16)
|
|
43
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), `mobile-debug-jdi-${key}-`))
|
|
44
|
+
await chmod(directory, 0o700)
|
|
45
|
+
try { await exec(javac, ['--release', '17', '--add-modules', 'jdk.jdi', '-d', directory, workerSource], { timeout: debuggerRemaining(15000), signal: debuggerSignal(), maxBuffer: 1024 * 1024 }) }
|
|
46
|
+
catch (e) { await rm(directory, { recursive: true, force: true }); throw nativeError(e, 'DEBUGGER_UNSUPPORTED') }
|
|
47
|
+
return { java, directory }
|
|
48
|
+
})().catch(error => { compiled = undefined; throw error })
|
|
49
|
+
return compiled
|
|
50
|
+
}
|
|
51
|
+
export type WorkerEvent = Record<string, any>
|
|
52
|
+
export interface DebugWorker {
|
|
53
|
+
call(command: string, args?: Record<string, unknown>, timeout?: number): Promise<any>
|
|
54
|
+
close(): Promise<void>
|
|
55
|
+
}
|
|
56
|
+
export class JdiWorker implements DebugWorker {
|
|
57
|
+
private child: ChildProcessWithoutNullStreams
|
|
58
|
+
private pending = new Map<number, { resolve(value: any): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }>()
|
|
59
|
+
private sequence = 0
|
|
60
|
+
private dead = false
|
|
61
|
+
private exited: Promise<void>
|
|
62
|
+
constructor(java: string, directory: string, onEvent: (event: WorkerEvent) => void) {
|
|
63
|
+
this.child = spawn(java, ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
64
|
+
let buffer = ''
|
|
65
|
+
const fail = () => {
|
|
66
|
+
if (this.dead) return
|
|
67
|
+
this.dead = true
|
|
68
|
+
for (const request of this.pending.values()) { clearTimeout(request.timer); request.reject(new DebuggerError('DEBUGGER_WORKER_FAILED')) }
|
|
69
|
+
this.pending.clear()
|
|
70
|
+
onEvent({ event: 'worker_failed' })
|
|
71
|
+
}
|
|
72
|
+
this.exited = new Promise(resolve => { this.child.once('exit', () => { fail(); resolve() }); this.child.once('error', () => { fail(); resolve() }) })
|
|
73
|
+
this.child.stdin.on('error', fail)
|
|
74
|
+
this.child.stderr.resume() // Never copy worker data or VM diagnostics into MCP stdout/logs.
|
|
75
|
+
this.child.stdout.setEncoding('utf8')
|
|
76
|
+
this.child.stdout.on('data', (chunk: string) => {
|
|
77
|
+
buffer += chunk
|
|
78
|
+
if (Buffer.byteLength(buffer) > 2 * 1024 * 1024) { fail(); this.child.kill('SIGKILL'); return }
|
|
79
|
+
let end: number
|
|
80
|
+
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
81
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1)
|
|
82
|
+
try {
|
|
83
|
+
if (Buffer.byteLength(line) > 1024 * 1024) throw new Error('IPC_LIMIT')
|
|
84
|
+
const msg = JSON.parse(line)
|
|
85
|
+
if (msg.event) onEvent(msg)
|
|
86
|
+
else {
|
|
87
|
+
const request = this.pending.get(msg.id)
|
|
88
|
+
if (!request) continue
|
|
89
|
+
clearTimeout(request.timer); this.pending.delete(msg.id)
|
|
90
|
+
if (msg.error) request.reject(new DebuggerError(msg.error.code ?? 'DEBUGGER_WORKER_FAILED'))
|
|
91
|
+
else request.resolve(msg.result)
|
|
92
|
+
}
|
|
93
|
+
} catch { fail(); this.child.kill('SIGKILL'); return }
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
call(command: string, args: Record<string, unknown> = {}, timeout = 10000): Promise<any> {
|
|
98
|
+
if (this.dead) return Promise.reject(new DebuggerError('DEBUGGER_WORKER_FAILED'))
|
|
99
|
+
try { timeout = Math.min(timeout, debuggerRemaining(timeout)) } catch (e) { return Promise.reject(e) }
|
|
100
|
+
const id = ++this.sequence
|
|
101
|
+
const message = JSON.stringify({ ...args, command, id }) + '\n'
|
|
102
|
+
if (Buffer.byteLength(message) > 1024 * 1024) return Promise.reject(new DebuggerError('INVALID_ARGUMENT'))
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const signal = debuggerSignal()
|
|
105
|
+
const abort = () => { this.pending.delete(id); clearTimeout(timer); reject(new DebuggerError('DEBUGGER_CANCELLED')) }
|
|
106
|
+
const done = () => signal?.removeEventListener('abort', abort)
|
|
107
|
+
const timer = setTimeout(() => { this.pending.delete(id); done(); reject(new DebuggerError('DEBUGGER_TIMEOUT')) }, timeout)
|
|
108
|
+
this.pending.set(id, { resolve: value => { done(); resolve(value) }, reject: error => { done(); reject(error) }, timer })
|
|
109
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
110
|
+
this.child.stdin.write(message)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
async close() {
|
|
114
|
+
this.child.stdin.end()
|
|
115
|
+
const kill = setTimeout(() => this.child.kill('SIGKILL'), 2000)
|
|
116
|
+
try { await this.exited } finally { clearTimeout(kill) }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function createWorker(onEvent: (event: WorkerEvent) => void) {
|
|
120
|
+
const { java, directory } = await prepareWorker()
|
|
121
|
+
return new JdiWorker(java, directory, onEvent)
|
|
122
|
+
}
|
package/src/interact/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkDebuggerGuard } from '../debugger/guard.js'
|
|
1
2
|
import { currentReadinessWait, observeReadinessWait, isDeviceUnavailable } from '../observe/readiness.js'
|
|
2
3
|
import { readinessDelay } from '../observe/readiness-service.js'
|
|
3
4
|
import { isConditionBlocked } from './conditional-action.js'
|
|
@@ -2349,13 +2350,13 @@ export class ToolsInteract {
|
|
|
2349
2350
|
break
|
|
2350
2351
|
}
|
|
2351
2352
|
}
|
|
2352
|
-
} catch (e) { console.error('Error scoring element:', e) }
|
|
2353
|
+
} catch (e) { checkDebuggerGuard(); console.error('Error scoring element:', e) }
|
|
2353
2354
|
}
|
|
2354
2355
|
if (iterationImprovedBest) {
|
|
2355
2356
|
bestIterationCandidates = iterationCandidates.slice()
|
|
2356
2357
|
}
|
|
2357
2358
|
}
|
|
2358
|
-
} catch (e) { console.error('Error fetching UI tree:', e) }
|
|
2359
|
+
} catch (e) { checkDebuggerGuard(); console.error('Error fetching UI tree:', e) }
|
|
2359
2360
|
if (shouldStop || Date.now() > deadline) break
|
|
2360
2361
|
await new Promise(r => setTimeout(r, 100))
|
|
2361
2362
|
}
|
|
@@ -2443,7 +2444,7 @@ export class ToolsInteract {
|
|
|
2443
2444
|
}
|
|
2444
2445
|
}
|
|
2445
2446
|
}
|
|
2446
|
-
} catch (e) { console.error('Error resolving ancestor:', e) }
|
|
2447
|
+
} catch (e) { checkDebuggerGuard(); console.error('Error resolving ancestor:', e) }
|
|
2447
2448
|
|
|
2448
2449
|
if (!best) return { found: false, error: 'Element not found' }
|
|
2449
2450
|
|
|
@@ -2697,7 +2698,7 @@ export class ToolsInteract {
|
|
|
2697
2698
|
matchedAt = null
|
|
2698
2699
|
}
|
|
2699
2700
|
|
|
2700
|
-
} catch (e) {
|
|
2701
|
+
} catch (e) { checkDebuggerGuard();
|
|
2701
2702
|
// Deadline expiry does not invalidate the last successfully observed state.
|
|
2702
2703
|
if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
|
|
2703
2704
|
if (readinessTracker && !String(e).includes('READINESS_')) readinessTracker.unavailable()
|
|
@@ -2737,7 +2738,7 @@ export class ToolsInteract {
|
|
|
2737
2738
|
}
|
|
2738
2739
|
}
|
|
2739
2740
|
|
|
2740
|
-
} catch (err) {
|
|
2741
|
+
} catch (err) { checkDebuggerGuard();
|
|
2741
2742
|
if (!readinessTracker && err instanceof Error && err.message === 'ACTION_TIMEOUT') throw err
|
|
2742
2743
|
if (readinessTracker && isDeviceUnavailable(err)) throw err
|
|
2743
2744
|
const elapsed = Date.now() - overallStart
|
|
@@ -2805,9 +2806,9 @@ export class ToolsInteract {
|
|
|
2805
2806
|
}
|
|
2806
2807
|
lastFingerprint = confirmFp
|
|
2807
2808
|
continue
|
|
2808
|
-
} catch (e) { console.error('Error confirming fingerprint:', e); continue }
|
|
2809
|
+
} catch (e) { checkDebuggerGuard(); console.error('Error confirming fingerprint:', e); continue }
|
|
2809
2810
|
}
|
|
2810
|
-
} catch (e) { console.error('Error getting screen fingerprint:', e) }
|
|
2811
|
+
} catch (e) { checkDebuggerGuard(); console.error('Error getting screen fingerprint:', e) }
|
|
2811
2812
|
|
|
2812
2813
|
await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
|
|
2813
2814
|
}
|
|
@@ -2940,7 +2941,7 @@ export class ToolsInteract {
|
|
|
2940
2941
|
candidateSinceMs = null
|
|
2941
2942
|
}
|
|
2942
2943
|
}
|
|
2943
|
-
} catch (error) {
|
|
2944
|
+
} catch (error) { checkDebuggerGuard();
|
|
2944
2945
|
if (readinessTracker && !String(error).includes('READINESS_')) readinessTracker.unavailable()
|
|
2945
2946
|
if (readinessTracker && isDeviceUnavailable(error)) throw error
|
|
2946
2947
|
if (readinessTracker && String(error).includes('ACTION_TIMEOUT')) { readinessTracker.deadline(); break }
|
|
@@ -3533,7 +3534,7 @@ export class ToolsInteract {
|
|
|
3533
3534
|
const msgs = ToolsInteract._logsToMessages(logsArr)
|
|
3534
3535
|
baselineLastLine = msgs.length ? msgs[msgs.length - 1] : null
|
|
3535
3536
|
}
|
|
3536
|
-
} catch (err) {
|
|
3537
|
+
} catch (err) { checkDebuggerGuard();
|
|
3537
3538
|
try { console.warn('waitForUI: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
|
|
3538
3539
|
}
|
|
3539
3540
|
|
|
@@ -3583,8 +3584,8 @@ export class ToolsInteract {
|
|
|
3583
3584
|
stableDuration = 0
|
|
3584
3585
|
lastObservedState = false
|
|
3585
3586
|
}
|
|
3586
|
-
} catch (err) { console.error('waitForUI(ui) find error:', err) }
|
|
3587
|
-
} catch (err) { console.error('waitForUI(ui) outer error:', err) }
|
|
3587
|
+
} catch (err) { checkDebuggerGuard(); console.error('waitForUI(ui) find error:', err) }
|
|
3588
|
+
} catch (err) { checkDebuggerGuard(); console.error('waitForUI(ui) outer error:', err) }
|
|
3588
3589
|
} else if (type === 'log') {
|
|
3589
3590
|
try {
|
|
3590
3591
|
// Logs: presence semantics only (match 'present'). Stability not applicable (immediate)
|
|
@@ -3614,7 +3615,7 @@ export class ToolsInteract {
|
|
|
3614
3615
|
return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true }
|
|
3615
3616
|
}
|
|
3616
3617
|
}
|
|
3617
|
-
} catch (err) { console.error('waitForUI(log) error:', err) }
|
|
3618
|
+
} catch (err) { checkDebuggerGuard(); console.error('waitForUI(log) error:', err) }
|
|
3618
3619
|
} else if (type === 'screen') {
|
|
3619
3620
|
try {
|
|
3620
3621
|
const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
@@ -3636,7 +3637,7 @@ export class ToolsInteract {
|
|
|
3636
3637
|
lastObservedState = false
|
|
3637
3638
|
}
|
|
3638
3639
|
}
|
|
3639
|
-
} catch (err) { console.error('waitForUI(screen) error:', err) }
|
|
3640
|
+
} catch (err) { checkDebuggerGuard(); console.error('waitForUI(screen) error:', err) }
|
|
3640
3641
|
} else if (type === 'idle') {
|
|
3641
3642
|
try {
|
|
3642
3643
|
const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
@@ -3655,7 +3656,7 @@ export class ToolsInteract {
|
|
|
3655
3656
|
return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: idleMs, matchSource: 'idle-stable', timestamp: now2, type: 'idle', observed_state: lastObservedState ?? null }
|
|
3656
3657
|
}
|
|
3657
3658
|
}
|
|
3658
|
-
} catch (err) { console.error('waitForUI(idle) error:', err) }
|
|
3659
|
+
} catch (err) { checkDebuggerGuard(); console.error('waitForUI(idle) error:', err) }
|
|
3659
3660
|
}
|
|
3660
3661
|
|
|
3661
3662
|
// Respect poll interval and avoid tight loop
|
|
@@ -3671,7 +3672,7 @@ export class ToolsInteract {
|
|
|
3671
3672
|
// time ensures the latest exported ToolsObserve object is used.
|
|
3672
3673
|
const Obs = await import('../observe/index.js')
|
|
3673
3674
|
snapshot = await (Obs as any).ToolsObserve.captureDebugSnapshotHandler({ reason: `wait_for_ui timeout for ${type}`, includeLogs: true, platform, deviceId })
|
|
3674
|
-
} catch (err) {
|
|
3675
|
+
} catch (err) { checkDebuggerGuard();
|
|
3675
3676
|
snapshot = { error: err instanceof Error ? err.message : String(err) }
|
|
3676
3677
|
}
|
|
3677
3678
|
}
|
package/src/observe/evidence.ts
CHANGED
|
@@ -25,6 +25,7 @@ interface Acquisition {
|
|
|
25
25
|
startSequence: number; endSequence: number; overlapping: boolean; reused: boolean
|
|
26
26
|
}
|
|
27
27
|
interface EvidenceContext {
|
|
28
|
+
systemTraceId?: string
|
|
28
29
|
correlationId: string; action: ActionRecord | null; ref?: ActionRef
|
|
29
30
|
device: EvidenceDevice | null; start: Stamp; end: Stamp | null; verification: Stamp | null
|
|
30
31
|
observations: Acquisition[]; isAction: boolean; children: EvidenceContext[]
|
|
@@ -72,6 +73,12 @@ export function validateActionRef(value: unknown): ActionRef | undefined {
|
|
|
72
73
|
for (const v of [ref.sessionId, ref.actionId]) if (typeof v !== 'string' || v.length < 1 || v.length > 128) throw new Error('INVALID_ARGUMENT: invalid actionRef')
|
|
73
74
|
return { sessionId: ref.sessionId, actionId: ref.actionId }
|
|
74
75
|
}
|
|
76
|
+
export function evidenceSessionId() { return sessionId }
|
|
77
|
+
export function currentEvidenceCorrelationId() { return context.getStore()?.correlationId }
|
|
78
|
+
export function bindSystemTrace(traceId: string) {
|
|
79
|
+
const current = context.getStore()
|
|
80
|
+
if (current) current.systemTraceId = traceId
|
|
81
|
+
}
|
|
75
82
|
export function currentEvidenceActionId() { return context.getStore()?.action?.id }
|
|
76
83
|
export function bindEvidenceDevice(device: { platform: string; id: string }) {
|
|
77
84
|
const current = context.getStore()
|
|
@@ -210,7 +217,7 @@ function assemble(current: EvidenceContext) {
|
|
|
210
217
|
dispatch_started_at_ms: action?.dispatchStart?.wall ?? null,
|
|
211
218
|
dispatch_completed_at_ms: action?.dispatchEnd?.wall ?? null,
|
|
212
219
|
verification_completed_at_ms: current.verification?.wall ?? action?.verification?.wall ?? null,
|
|
213
|
-
observations, trace_context: { supported:
|
|
220
|
+
observations, trace_context: { supported: !!current.systemTraceId, trace_id: current.systemTraceId ?? null, markers: [] }
|
|
214
221
|
}
|
|
215
222
|
}
|
|
216
223
|
export async function withEvidence<T>(options: { action?: boolean; journey?: boolean; ref?: ActionRef }, operation: () => Promise<T>): Promise<{ result: T; evidence: ReturnType<typeof assemble> }> {
|
|
@@ -219,7 +226,7 @@ export async function withEvidence<T>(options: { action?: boolean; journey?: boo
|
|
|
219
226
|
const id = randomUUID()
|
|
220
227
|
const record: ActionRecord | null = options.action ? { id, correlationId: parent?.correlationId ?? id, device: null,
|
|
221
228
|
dispatchStart: null, dispatchEnd: null, verification: null, completed: null } : options.ref?.sessionId === sessionId ? active.get(options.ref.actionId) ?? history.get(options.ref.actionId) ?? null : null
|
|
222
|
-
const current: EvidenceContext = { correlationId: options.action ? record!.correlationId : options.journey ? id : randomUUID(),
|
|
229
|
+
const current: EvidenceContext = { systemTraceId: parent?.systemTraceId, correlationId: options.action ? record!.correlationId : options.journey ? id : randomUUID(),
|
|
223
230
|
action: record, ref: options.ref, device: null, start: stamp(), end: null, verification: null, observations: [], children: [], isAction: !!options.action }
|
|
224
231
|
if (options.action && record) active.set(id, record)
|
|
225
232
|
if (parent && options.action) parent.children.push(current)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { pausedReadiness } from '../debugger/guard.js'
|
|
1
2
|
import type { DeviceInfo } from '../types.js'
|
|
2
3
|
import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js'
|
|
3
4
|
import { remainingBudget, runWithBudget } from '../utils/operation-budget.js'
|
|
@@ -24,6 +25,7 @@ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabi
|
|
|
24
25
|
let device: DeviceInfo | null = null
|
|
25
26
|
let tracker = new ReadinessTracker(args.platform ?? 'android', args.deviceId ?? '')
|
|
26
27
|
let failureCode: string | undefined
|
|
28
|
+
let debugPause: ReturnType<typeof pausedReadiness> = null
|
|
27
29
|
await runWithBudget(args.timeoutMs ?? (wait ? 30000 : 15000), async () => {
|
|
28
30
|
try {
|
|
29
31
|
device = await resolve(args)
|
|
@@ -33,6 +35,8 @@ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabi
|
|
|
33
35
|
let count = 0
|
|
34
36
|
while (wait || count < 2) {
|
|
35
37
|
remainingBudget()
|
|
38
|
+
debugPause = device!.platform === 'android' ? pausedReadiness(device!.id) : null
|
|
39
|
+
if (debugPause) { failureCode = wait ? 'DEBUGGER_PAUSED' : undefined; return }
|
|
36
40
|
const captureStarted = Date.now()
|
|
37
41
|
try {
|
|
38
42
|
const tree = await ToolsObserve.getUITreeHandler({ platform: device!.platform as 'android' | 'ios', deviceId: device!.id })
|
|
@@ -57,6 +61,7 @@ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabi
|
|
|
57
61
|
failureCode = !expired && (!device || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE'
|
|
58
62
|
}
|
|
59
63
|
})
|
|
64
|
+
if (debugPause) return { success: !wait, device, readiness: debugPause, elapsed_ms: performance.now() - started, ...(wait ? { failure_code: 'DEBUGGER_PAUSED' } : {}) }
|
|
60
65
|
const state = tracker.summary.state
|
|
61
66
|
const success = !failureCode && (wait ? state === 'settled' : state !== 'unknown')
|
|
62
67
|
return { success, device, readiness: tracker.summary, elapsed_ms: performance.now() - started,
|
package/src/observe/readiness.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { pausedReadiness } from '../debugger/guard.js'
|
|
1
2
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
3
|
import type { ReadinessSummary } from '../types.js'
|
|
3
4
|
import { remainingBudget } from '../utils/operation-budget.js'
|
|
4
5
|
import { computeSnapshotSignature, getSnapshotSessionId } from './snapshot-metadata.js'
|
|
5
6
|
|
|
6
|
-
const baselineLimitations = ['NORMALIZED_UI_ONLY', 'FOREGROUND_APP_UNVERIFIED', 'UNRESPONSIVE_SIGNAL_UNSUPPORTED'
|
|
7
|
+
const baselineLimitations = ['NORMALIZED_UI_ONLY', 'FOREGROUND_APP_UNVERIFIED', 'UNRESPONSIVE_SIGNAL_UNSUPPORTED']
|
|
7
8
|
const capabilities = { loading: true, transitioning: true, settled: true, unresponsive: false, debugger_paused: false }
|
|
8
9
|
|
|
9
10
|
export function isDeviceUnavailable(error: unknown) {
|
|
@@ -13,18 +14,21 @@ export function isDeviceUnavailable(error: unknown) {
|
|
|
13
14
|
export class ReadinessTracker {
|
|
14
15
|
private signature: string | null = null
|
|
15
16
|
private stableSince = 0
|
|
16
|
-
summary: ReadinessSummary
|
|
17
|
+
summary: ReadinessSummary
|
|
18
|
+
|
|
19
|
+
constructor(private platform: string, private deviceId: string, private stabilityMs = 500) {
|
|
20
|
+
this.summary = {
|
|
17
21
|
state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
|
|
18
22
|
sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
|
|
19
|
-
evidence: [], limitations: [...baselineLimitations, 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities }
|
|
23
|
+
evidence: [], limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
}
|
|
23
27
|
|
|
24
28
|
unavailable(code = 'OBSERVATION_FAILED'): ReadinessSummary {
|
|
25
29
|
this.signature = null
|
|
26
30
|
this.summary = { ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
|
|
27
|
-
sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, code],
|
|
31
|
+
sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', code],
|
|
28
32
|
evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: null, snapshot_revision: null }].slice(-8) }
|
|
29
33
|
return this.summary
|
|
30
34
|
}
|
|
@@ -35,6 +39,8 @@ export class ReadinessTracker {
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
observe(tree: any, acquisitionStartedAtMs: number, now = performance.now()): ReadinessSummary {
|
|
42
|
+
const paused = this.platform === 'android' ? pausedReadiness(this.deviceId) : null
|
|
43
|
+
if (paused) { this.summary = paused; return this.summary }
|
|
38
44
|
if (tree?.error) return this.unavailable('OBSERVATION_FAILED')
|
|
39
45
|
if (!tree || tree.device?.platform !== this.platform || tree.device?.id !== this.deviceId ||
|
|
40
46
|
tree.snapshot_session_id !== getSnapshotSessionId() || !Number.isSafeInteger(tree.snapshot_revision) || tree.snapshot_revision <= 0 ||
|
|
@@ -58,7 +64,7 @@ export class ReadinessTracker {
|
|
|
58
64
|
captured_at_ms: tree.captured_at_ms, snapshot_session_id: tree.snapshot_session_id, snapshot_revision: tree.snapshot_revision,
|
|
59
65
|
sample_count: this.summary.sample_count + 1, stable_for_ms: stableFor,
|
|
60
66
|
evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: tree.captured_at_ms, snapshot_revision: tree.snapshot_revision }].slice(-8),
|
|
61
|
-
limitations: [...baselineLimitations, ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities }
|
|
67
|
+
limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
|
|
62
68
|
}
|
|
63
69
|
return this.summary
|
|
64
70
|
}
|