mobile-debug-mcp 0.32.4 → 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/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.github/copilot-instructions.md +7 -0
- package/.github/workflows/ci.yml +21 -0
- package/AGENTS.md +21 -10
- package/README.md +69 -4
- package/agent-plugin/.claude-plugin/plugin.json +20 -0
- package/agent-plugin/.codex-plugin/plugin.json +34 -0
- package/agent-plugin/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/README.md +9 -3
- package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
- 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/android.js +10 -4
- package/dist/interact/conditional-action.js +68 -0
- package/dist/interact/index.js +70 -14
- package/dist/interact/ios.js +14 -8
- package/dist/manage/android.js +4 -3
- package/dist/manage/ios.js +3 -2
- package/dist/observe/android.js +25 -4
- package/dist/observe/evidence.js +280 -0
- package/dist/observe/index.js +33 -6
- package/dist/observe/ios.js +25 -6
- package/dist/observe/readiness-service.js +124 -0
- package/dist/observe/readiness.js +88 -0
- package/dist/observe/recording.js +165 -0
- package/dist/observe/snapshot-metadata.js +10 -2
- package/dist/observe/system-trace-android.js +135 -0
- package/dist/observe/system-trace.js +285 -0
- package/dist/server/common.js +9 -0
- package/dist/server/condition-schema.js +32 -0
- package/dist/server/debugger-handlers.js +38 -0
- package/dist/server/debugger-schema.js +42 -0
- package/dist/server/journey-schema.js +4 -2
- package/dist/server/readiness-schema.js +12 -0
- package/dist/server/system-trace-schema.js +17 -0
- package/dist/server/tool-definitions.js +51 -0
- package/dist/server/tool-handlers.js +294 -105
- 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/ios/utils.js +4 -4
- package/dist/utils/java.js +14 -12
- package/dist/utils/operation-budget.js +26 -2
- package/dist/utils/resolve-device.js +9 -0
- package/docs/CHANGELOG.md +15 -0
- package/docs/specs/020-conditional-actions/intent.md +62 -0
- package/docs/specs/020-conditional-actions/plan.md +214 -0
- package/docs/specs/020-conditional-actions/spec.md +301 -0
- package/docs/specs/021-app-readiness-signals/intent.md +40 -0
- package/docs/specs/021-app-readiness-signals/plan.md +102 -0
- package/docs/specs/021-app-readiness-signals/spec.md +144 -0
- package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
- package/docs/specs/022-correlated-evidence/intent.md +37 -0
- package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
- package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
- package/docs/specs/022-correlated-evidence/plan.md +171 -0
- package/docs/specs/022-correlated-evidence/spec.md +123 -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/specs/process.md +213 -0
- package/docs/tools/TOOLS.md +5 -0
- package/docs/tools/debugger.md +104 -0
- package/docs/tools/evidence.md +82 -0
- package/docs/tools/interact.md +125 -2
- package/docs/tools/observe.md +70 -0
- package/docs/tools/system-traces.md +126 -0
- package/package.json +1 -1
- package/scripts/agent-plugin.mjs +68 -8
- 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/android.ts +10 -4
- package/src/interact/conditional-action.ts +75 -0
- package/src/interact/index.ts +57 -28
- package/src/interact/ios.ts +14 -8
- package/src/manage/android.ts +4 -3
- package/src/manage/ios.ts +3 -2
- package/src/observe/android.ts +33 -6
- package/src/observe/evidence.ts +262 -0
- package/src/observe/index.ts +32 -8
- package/src/observe/ios.ts +32 -8
- package/src/observe/readiness-service.ts +94 -0
- package/src/observe/readiness.ts +84 -0
- package/src/observe/snapshot-metadata.ts +12 -1
- package/src/observe/system-trace-android.ts +133 -0
- package/src/observe/system-trace.ts +218 -0
- package/src/server/common.ts +7 -0
- package/src/server/condition-schema.ts +32 -0
- package/src/server/debugger-handlers.ts +29 -0
- package/src/server/debugger-schema.ts +37 -0
- package/src/server/journey-schema.ts +4 -2
- package/src/server/readiness-schema.ts +12 -0
- package/src/server/system-trace-schema.ts +17 -0
- package/src/server/tool-definitions.ts +49 -0
- package/src/server/tool-handlers.ts +182 -21
- package/src/server-core.ts +3 -3
- package/src/server.ts +24 -0
- package/src/system/index.ts +7 -5
- package/src/types.ts +30 -1
- package/src/utils/exec.ts +2 -0
- package/src/utils/ios/utils.ts +4 -4
- package/src/utils/java.ts +13 -12
- package/src/utils/operation-budget.ts +24 -2
- package/src/utils/resolve-device.ts +10 -0
- package/test/device/automated/debugger/runtime.android.test.ts +203 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
- package/test/device/automated/observe/system-trace.smoke.ts +104 -0
- package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
- package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
- package/test/device/manual/interact/readiness.manual.ts +99 -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/fixtures/readiness-app/MainActivity.java +23 -0
- package/test/fixtures/readiness-app/README.md +12 -0
- package/test/fixtures/readiness-app/build.ts +37 -0
- package/test/fixtures/readiness-app/main.m +27 -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/android_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
- package/test/unit/observe/compact_tree_error.test.ts +24 -0
- package/test/unit/observe/conditional_snapshots.test.ts +33 -0
- package/test/unit/observe/correlated-evidence.test.ts +128 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/readiness.test.ts +41 -0
- package/test/unit/observe/readiness_native.test.ts +68 -0
- 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/conditional_actions.test.ts +299 -0
- package/test/unit/server/conditional_schema.test.ts +59 -0
- package/test/unit/server/correlated-evidence.test.ts +194 -0
- package/test/unit/server/readiness.test.ts +170 -0
- package/test/unit/server/selector_tap_target.test.ts +79 -0
- package/test/unit/server/system-trace.test.ts +64 -0
- package/test/unit/server/tap_selector.test.ts +2 -0
- package/worker/DebuggerWorker.java +160 -0
- package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
- /package/{skills → .agents/skills}/README.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
- /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
export interface EvidenceDevice { platform: 'android' | 'ios'; id: string }
|
|
5
|
+
export interface ActionRef { sessionId: string; actionId: string }
|
|
6
|
+
type Kind = 'screenshot' | 'ui_tree' | 'fingerprint' | 'activity' | 'logs'
|
|
7
|
+
type Freshness = 'fresh' | 'stale' | 'unknown' | 'not_applicable'
|
|
8
|
+
interface Stamp { wall: number; mono: number }
|
|
9
|
+
interface DeviceOrder { sequence: number; active: number; lastStarted: number }
|
|
10
|
+
interface ActionRecord {
|
|
11
|
+
id: string; correlationId: string; device: EvidenceDevice | null
|
|
12
|
+
dispatchStart: Stamp | null; dispatchEnd: Stamp | null; verification: Stamp | null; completed: number | null
|
|
13
|
+
}
|
|
14
|
+
interface Component {
|
|
15
|
+
evidence_id: string; kind: Kind; status: 'captured' | 'failed' | 'omitted'
|
|
16
|
+
capture_started_at_ms: number | null; capture_completed_at_ms: number | null
|
|
17
|
+
source_captured_at_ms: number | null; age_ms: number | null
|
|
18
|
+
freshness: Freshness; freshness_reasons: string[]
|
|
19
|
+
device: EvidenceDevice | null; snapshot_session_id?: string; snapshot_revision?: number
|
|
20
|
+
error?: unknown; clock?: 'device_or_unknown'; event_time_comparable?: false
|
|
21
|
+
earliest_entry_at_ms?: number | null; latest_entry_at_ms?: number | null
|
|
22
|
+
}
|
|
23
|
+
interface Acquisition {
|
|
24
|
+
value: Component; start: Stamp; end: Stamp | null; order: DeviceOrder | null
|
|
25
|
+
startSequence: number; endSequence: number; overlapping: boolean; reused: boolean
|
|
26
|
+
}
|
|
27
|
+
interface EvidenceContext {
|
|
28
|
+
systemTraceId?: string
|
|
29
|
+
correlationId: string; action: ActionRecord | null; ref?: ActionRef
|
|
30
|
+
device: EvidenceDevice | null; start: Stamp; end: Stamp | null; verification: Stamp | null
|
|
31
|
+
observations: Acquisition[]; isAction: boolean; children: EvidenceContext[]
|
|
32
|
+
}
|
|
33
|
+
const sessionId = randomUUID()
|
|
34
|
+
const context = new AsyncLocalStorage<EvidenceContext>()
|
|
35
|
+
const history = new Map<string, ActionRecord>()
|
|
36
|
+
const active = new Map<string, ActionRecord>()
|
|
37
|
+
const orders = new Map<string, DeviceOrder>()
|
|
38
|
+
const stamp = (): Stamp => ({ wall: Date.now(), mono: performance.now() })
|
|
39
|
+
const deviceKey = (d: EvidenceDevice) => `${d.platform}:${d.id}`
|
|
40
|
+
const sameDevice = (a: EvidenceDevice | null, b: EvidenceDevice | null) => !!a && !!b && deviceKey(a) === deviceKey(b)
|
|
41
|
+
function validDevice(d: any): EvidenceDevice | null {
|
|
42
|
+
return d && ['android', 'ios'].includes(d.platform) && typeof d.id === 'string' && d.id && !['default', 'booted'].includes(d.id)
|
|
43
|
+
? { platform: d.platform, id: d.id } : null
|
|
44
|
+
}
|
|
45
|
+
function orderFor(device: EvidenceDevice | null) {
|
|
46
|
+
if (!device) return null
|
|
47
|
+
const key = deviceKey(device)
|
|
48
|
+
let order = orders.get(key)
|
|
49
|
+
if (!order) {
|
|
50
|
+
order = { sequence: 0, active: 0, lastStarted: -Infinity }
|
|
51
|
+
orders.set(key, order)
|
|
52
|
+
if (orders.size > 1000) {
|
|
53
|
+
for (const [oldKey, oldOrder] of orders) {
|
|
54
|
+
if (oldKey !== key && oldOrder.active === 0) orders.delete(oldKey)
|
|
55
|
+
if (orders.size <= 1000) break
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return order
|
|
60
|
+
}
|
|
61
|
+
function prune() {
|
|
62
|
+
const now = performance.now()
|
|
63
|
+
for (const [key, record] of history) {
|
|
64
|
+
if (record.completed !== null && now - record.completed >= 600000) history.delete(key)
|
|
65
|
+
}
|
|
66
|
+
while (history.size > 1000) history.delete(history.keys().next().value!)
|
|
67
|
+
}
|
|
68
|
+
export function validateActionRef(value: unknown): ActionRef | undefined {
|
|
69
|
+
if (value === undefined) return undefined
|
|
70
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
71
|
+
Object.keys(value).some(key => !['sessionId', 'actionId'].includes(key))) throw new Error('INVALID_ARGUMENT: invalid actionRef')
|
|
72
|
+
const ref = value as ActionRef
|
|
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')
|
|
74
|
+
return { sessionId: ref.sessionId, actionId: ref.actionId }
|
|
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
|
+
}
|
|
82
|
+
export function currentEvidenceActionId() { return context.getStore()?.action?.id }
|
|
83
|
+
export function bindEvidenceDevice(device: { platform: string; id: string }) {
|
|
84
|
+
const current = context.getStore()
|
|
85
|
+
const target = validDevice(device)
|
|
86
|
+
if (!current || !target) return
|
|
87
|
+
current.device ??= target
|
|
88
|
+
if (current.isAction && current.action) current.action.device ??= target
|
|
89
|
+
}
|
|
90
|
+
export function markEvidenceVerification() {
|
|
91
|
+
const current = context.getStore()
|
|
92
|
+
if (current) {
|
|
93
|
+
current.verification = stamp()
|
|
94
|
+
if (current.isAction && current.action) current.action.verification = current.verification
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function evidenceDispatch<T>(device: EvidenceDevice, operation: () => Promise<T>): Promise<T> {
|
|
98
|
+
const current = context.getStore()
|
|
99
|
+
if (!current?.isAction || !current.action) return operation()
|
|
100
|
+
bindEvidenceDevice(device)
|
|
101
|
+
const record = current.action
|
|
102
|
+
const start = stamp()
|
|
103
|
+
record.dispatchStart ??= start
|
|
104
|
+
record.dispatchEnd = null
|
|
105
|
+
const order = orderFor(validDevice(device))
|
|
106
|
+
if (order) { order.sequence++; order.active++; order.lastStarted = start.mono }
|
|
107
|
+
try { return await operation() }
|
|
108
|
+
finally {
|
|
109
|
+
record.dispatchEnd = stamp()
|
|
110
|
+
if (order) order.active--
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function omitEvidence(kind: Kind) {
|
|
114
|
+
const current = context.getStore()
|
|
115
|
+
if (!current) return
|
|
116
|
+
const start = stamp()
|
|
117
|
+
current.observations.push({ value: {
|
|
118
|
+
evidence_id: randomUUID(), kind, status: 'omitted', capture_started_at_ms: null,
|
|
119
|
+
capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
|
|
120
|
+
freshness: kind === 'logs' ? 'not_applicable' : 'unknown', freshness_reasons: [], device: current.device
|
|
121
|
+
}, start, end: null, order: null, startSequence: 0, endSequence: 0, overlapping: false, reused: false })
|
|
122
|
+
}
|
|
123
|
+
export async function captureEvidence<T>(kind: Kind, device: EvidenceDevice | null, operation: () => Promise<T>): Promise<T> {
|
|
124
|
+
const current = context.getStore()
|
|
125
|
+
if (!current) return operation()
|
|
126
|
+
const target = validDevice(device)
|
|
127
|
+
if (target) bindEvidenceDevice(target)
|
|
128
|
+
const order = orderFor(target)
|
|
129
|
+
const start = stamp()
|
|
130
|
+
const acquisition: Acquisition = { value: {
|
|
131
|
+
evidence_id: randomUUID(), kind, status: 'failed', capture_started_at_ms: start.wall,
|
|
132
|
+
capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
|
|
133
|
+
freshness: 'unknown', freshness_reasons: [], device: target
|
|
134
|
+
}, start, end: null, order, startSequence: order?.sequence ?? 0,
|
|
135
|
+
endSequence: 0, overlapping: !!order?.active, reused: false }
|
|
136
|
+
current.observations.push(acquisition)
|
|
137
|
+
try {
|
|
138
|
+
const result = await operation() as any
|
|
139
|
+
const value = acquisition.value
|
|
140
|
+
const resolved = validDevice(result?.device) ?? target
|
|
141
|
+
value.device = resolved
|
|
142
|
+
if (resolved) bindEvidenceDevice(resolved)
|
|
143
|
+
const failure = result?.error ?? result?.meta?.error
|
|
144
|
+
value.status = failure || result?.success === false || (kind === 'fingerprint' && !result?.fingerprint) ? 'failed' : 'captured'
|
|
145
|
+
if (failure) value.error = failure
|
|
146
|
+
const source = result?.captured_at_ms
|
|
147
|
+
if (Number.isFinite(source) && source > 0) {
|
|
148
|
+
value.source_captured_at_ms = source
|
|
149
|
+
acquisition.reused = source < start.wall
|
|
150
|
+
}
|
|
151
|
+
if (kind === 'ui_tree') {
|
|
152
|
+
value.snapshot_session_id = result?.snapshot_session_id
|
|
153
|
+
value.snapshot_revision = result?.snapshot_revision
|
|
154
|
+
}
|
|
155
|
+
if (kind === 'logs') {
|
|
156
|
+
const entries = result?.logs ?? result?.entries ?? []
|
|
157
|
+
const times = entries.map((e: any) => {
|
|
158
|
+
const t = e?.timestamp ?? e?._iso
|
|
159
|
+
return typeof t === 'number' ? t : typeof t === 'string' ? Date.parse(t) : NaN
|
|
160
|
+
}).filter((t: number) => Number.isFinite(t)) as number[]
|
|
161
|
+
value.clock = 'device_or_unknown'; value.event_time_comparable = false
|
|
162
|
+
value.earliest_entry_at_ms = times.length ? times.reduce((a, b) => Math.min(a, b)) : null
|
|
163
|
+
value.latest_entry_at_ms = times.length ? times.reduce((a, b) => Math.max(a, b)) : null
|
|
164
|
+
}
|
|
165
|
+
return result
|
|
166
|
+
} catch (error) {
|
|
167
|
+
acquisition.value.error = error instanceof Error ? error.message : String(error)
|
|
168
|
+
throw error
|
|
169
|
+
} finally {
|
|
170
|
+
acquisition.end = stamp()
|
|
171
|
+
acquisition.value.capture_completed_at_ms = acquisition.end.wall
|
|
172
|
+
acquisition.endSequence = order?.sequence ?? 0
|
|
173
|
+
acquisition.overlapping ||= !!order?.active || acquisition.startSequence !== acquisition.endSequence
|
|
174
|
+
if (acquisition.value.status === 'captured' && acquisition.value.source_captured_at_ms === null && kind !== 'logs') {
|
|
175
|
+
acquisition.value.source_captured_at_ms = acquisition.end.wall
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function association(current: EvidenceContext) {
|
|
180
|
+
if (current.isAction) return { association: 'self', association_reason: null }
|
|
181
|
+
if (!current.ref) return { association: 'none', association_reason: null }
|
|
182
|
+
const reason = current.ref.sessionId !== sessionId ? 'SESSION_MISMATCH' : !current.action ? 'ACTION_UNAVAILABLE' :
|
|
183
|
+
!sameDevice(current.device, current.action.device) ? 'DEVICE_MISMATCH' : null
|
|
184
|
+
return { association: reason ? 'unavailable' : 'explicit', association_reason: reason }
|
|
185
|
+
}
|
|
186
|
+
function assemble(current: EvidenceContext) {
|
|
187
|
+
const end = stamp()
|
|
188
|
+
const link = association(current)
|
|
189
|
+
const action = link.association === 'unavailable' ? null : current.action
|
|
190
|
+
const clockChanged = end.wall < current.start.wall
|
|
191
|
+
const observations = current.observations.map(a => {
|
|
192
|
+
const v = { ...a.value, freshness_reasons: [] as string[] }
|
|
193
|
+
if (v.status === 'omitted') return v
|
|
194
|
+
const componentAction = sameDevice(v.device, action?.device ?? null) ? action : null
|
|
195
|
+
const sourceMono = a.end && v.source_captured_at_ms !== null ? a.end.mono + v.source_captured_at_ms - a.end.wall : a.end?.mono
|
|
196
|
+
const reason = (freshness: Freshness, code: string) => { v.freshness = freshness; v.freshness_reasons.push(code) }
|
|
197
|
+
if (v.kind === 'logs') reason('unknown', 'DEVICE_CLOCK_UNVERIFIED')
|
|
198
|
+
else if (clockChanged || (a.end && a.end.wall < a.start.wall)) reason('unknown', 'CLOCK_DISCONTINUITY')
|
|
199
|
+
else if (v.source_captured_at_ms !== null && a.end && v.source_captured_at_ms > a.end.wall) reason('unknown', 'CLOCK_DISCONTINUITY')
|
|
200
|
+
else if (!v.device || !a.order || orders.get(deviceKey(v.device)) !== a.order || v.status !== 'captured' || !a.end) reason('unknown', 'CAPTURE_TIME_UNKNOWN')
|
|
201
|
+
else if (componentAction?.dispatchStart && sourceMono !== undefined && sourceMono < componentAction.dispatchStart.mono) reason('stale', 'BEFORE_ACTION')
|
|
202
|
+
else if (a.order && (a.order.sequence > a.endSequence || (a.reused && sourceMono !== undefined && sourceMono < a.order.lastStarted))) reason('stale', 'SUPERSEDED_BY_ACTION')
|
|
203
|
+
else if (a.overlapping) reason('unknown', 'OVERLAPPING_ACTION')
|
|
204
|
+
else if (a.reused) reason('unknown', 'REUSED_OBSERVATION')
|
|
205
|
+
else { v.freshness = 'fresh' }
|
|
206
|
+
if (!clockChanged && a.end && a.end.wall >= a.start.wall && v.source_captured_at_ms !== null && v.source_captured_at_ms <= end.wall) {
|
|
207
|
+
v.age_ms = Math.max(0, end.mono - a.end.mono + a.end.wall - v.source_captured_at_ms)
|
|
208
|
+
}
|
|
209
|
+
return { ...v, session_id: sessionId, correlation_id: componentAction?.correlationId ?? current.correlationId,
|
|
210
|
+
action_id: componentAction?.id ?? null, association: componentAction ? 'internal' : link.association }
|
|
211
|
+
})
|
|
212
|
+
return {
|
|
213
|
+
session_id: sessionId, correlation_id: action?.correlationId ?? current.correlationId,
|
|
214
|
+
action_id: action?.id ?? null, device: current.device, ...link,
|
|
215
|
+
...(current.ref ? { requested_action_ref: current.ref } : {}),
|
|
216
|
+
operation_started_at_ms: current.start.wall, operation_completed_at_ms: current.end?.wall ?? end.wall,
|
|
217
|
+
dispatch_started_at_ms: action?.dispatchStart?.wall ?? null,
|
|
218
|
+
dispatch_completed_at_ms: action?.dispatchEnd?.wall ?? null,
|
|
219
|
+
verification_completed_at_ms: current.verification?.wall ?? action?.verification?.wall ?? null,
|
|
220
|
+
observations, trace_context: { supported: !!current.systemTraceId, trace_id: current.systemTraceId ?? null, markers: [] }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
export async function withEvidence<T>(options: { action?: boolean; journey?: boolean; ref?: ActionRef }, operation: () => Promise<T>): Promise<{ result: T; evidence: ReturnType<typeof assemble> }> {
|
|
224
|
+
prune()
|
|
225
|
+
const parent = context.getStore()
|
|
226
|
+
const id = randomUUID()
|
|
227
|
+
const record: ActionRecord | null = options.action ? { id, correlationId: parent?.correlationId ?? id, device: null,
|
|
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
|
|
229
|
+
const current: EvidenceContext = { systemTraceId: parent?.systemTraceId, correlationId: options.action ? record!.correlationId : options.journey ? id : randomUUID(),
|
|
230
|
+
action: record, ref: options.ref, device: null, start: stamp(), end: null, verification: null, observations: [], children: [], isAction: !!options.action }
|
|
231
|
+
if (options.action && record) active.set(id, record)
|
|
232
|
+
if (parent && options.action) parent.children.push(current)
|
|
233
|
+
try {
|
|
234
|
+
return await context.run(current, async () => {
|
|
235
|
+
const result = await operation()
|
|
236
|
+
current.end = stamp()
|
|
237
|
+
return { result, evidence: assemble(current) }
|
|
238
|
+
})
|
|
239
|
+
} finally {
|
|
240
|
+
if (options.action && record) {
|
|
241
|
+
active.delete(id); record.completed = performance.now(); history.set(id, record); prune()
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function refreshJourneyEvidence(steps: Array<{ result?: { evidence?: unknown } }>) {
|
|
247
|
+
const current = context.getStore()
|
|
248
|
+
if (!current) return
|
|
249
|
+
for (const step of steps) {
|
|
250
|
+
const previous = step.result?.evidence as { action_id?: string } | undefined
|
|
251
|
+
const child = current.children.find(c => c.action?.id === previous?.action_id)
|
|
252
|
+
if (child && step.result) step.result.evidence = assemble(child)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function completeJourneyVerification(actionId: string) {
|
|
257
|
+
const child = context.getStore()?.children.find(c => c.action?.id === actionId)
|
|
258
|
+
if (child?.action) {
|
|
259
|
+
child.verification = stamp()
|
|
260
|
+
child.action.verification = child.verification
|
|
261
|
+
}
|
|
262
|
+
}
|
package/src/observe/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { omitEvidence } from './evidence.js'
|
|
2
|
+
import { withResolvedDevice } from '../utils/resolve-device.js'
|
|
1
3
|
import { measure } from '../utils/timing.js'
|
|
4
|
+
import { ReadinessTracker } from './readiness.js'
|
|
2
5
|
import { resolveTargetDevice } from '../utils/resolve-device.js'
|
|
3
6
|
import { AndroidObserve } from './android.js'
|
|
4
7
|
import { iOSObserve } from './ios.js'
|
|
@@ -176,15 +179,20 @@ export class ToolsObserve {
|
|
|
176
179
|
|
|
177
180
|
static async getUITreeHandler({ platform, deviceId, responseMode = 'debug', sinceSnapshotRevision }: { platform?: 'android' | 'ios', deviceId?: string, responseMode?: 'compact' | 'debug', sinceSnapshotRevision?: number }) {
|
|
178
181
|
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
|
|
182
|
+
const acquisitionStarted = Date.now()
|
|
179
183
|
const tree = await observe.getUITree(resolved.id) as any
|
|
184
|
+
tree.readiness = new ReadinessTracker(resolved.platform, resolved.id).observe(tree, acquisitionStarted)
|
|
180
185
|
if (responseMode !== 'compact') return tree
|
|
181
186
|
const revision = tree?.snapshot_revision ?? null
|
|
187
|
+
const session = { snapshot_session_id: tree?.snapshot_session_id, readiness: tree.readiness }
|
|
188
|
+
if (tree?.error) return { ...session, delta_available: false, snapshot_revision: revision, error: tree.error }
|
|
182
189
|
const baseRevision = sinceSnapshotRevision
|
|
183
190
|
if (!Number.isInteger(baseRevision) || revision === null) {
|
|
184
|
-
return { delta_available: false, snapshot_revision: revision }
|
|
191
|
+
return { ...session, delta_available: false, snapshot_revision: revision }
|
|
185
192
|
}
|
|
186
193
|
const delta = getStateDelta(`${resolved.platform}:${resolved.id}`, baseRevision as number, revision)
|
|
187
|
-
return delta ? { delta_available: true, state_delta: delta, snapshot_revision: revision } : {
|
|
194
|
+
return delta ? { ...session, delta_available: true, state_delta: delta, snapshot_revision: revision } : {
|
|
195
|
+
...session,
|
|
188
196
|
delta_available: false,
|
|
189
197
|
snapshot_revision: revision,
|
|
190
198
|
reason: 'BASE_REVISION_UNAVAILABLE'
|
|
@@ -233,16 +241,24 @@ export class ToolsObserve {
|
|
|
233
241
|
}
|
|
234
242
|
}
|
|
235
243
|
|
|
236
|
-
static async readLogStreamHandler({ platform, sessionId, limit, since }: { platform?: 'android' | 'ios', sessionId?: string, limit?: number, since?: string }) {
|
|
244
|
+
static async readLogStreamHandler({ platform, sessionId, limit, since, deviceId }: { deviceId?: string, platform?: 'android' | 'ios', sessionId?: string, limit?: number, since?: string }) {
|
|
237
245
|
const sid = sessionId || 'default'
|
|
238
|
-
const
|
|
239
|
-
|
|
246
|
+
const android = new AndroidObserve()
|
|
247
|
+
const ios = new iOSObserve()
|
|
248
|
+
const observe = platform === 'android' ? android : platform === 'ios' ? ios :
|
|
249
|
+
ios.getLogStreamDevice(sid) && !android.getLogStreamDevice(sid) ? ios : android
|
|
250
|
+
const streamDevice = observe.getLogStreamDevice(sid)
|
|
251
|
+
if (deviceId && streamDevice && streamDevice.id !== deviceId) throw new Error('LOG_STREAM_DEVICE_MISMATCH')
|
|
252
|
+
return await observe.readLogStream(sid, limit ?? 100, since)
|
|
240
253
|
}
|
|
241
254
|
|
|
242
255
|
static async stopLogStreamHandler({ platform, sessionId }: { platform?: 'android' | 'ios', sessionId?: string }) {
|
|
243
256
|
const sid = sessionId || 'default'
|
|
244
|
-
const
|
|
245
|
-
|
|
257
|
+
const android = new AndroidObserve()
|
|
258
|
+
const ios = new iOSObserve()
|
|
259
|
+
const observe = platform === 'android' ? android : platform === 'ios' ? ios :
|
|
260
|
+
ios.getLogStreamDevice(sid) && !android.getLogStreamDevice(sid) ? ios : android
|
|
261
|
+
return await observe.stopLogStream(sid)
|
|
246
262
|
}
|
|
247
263
|
|
|
248
264
|
static async captureScreenshotHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string }) {
|
|
@@ -261,6 +277,14 @@ export class ToolsObserve {
|
|
|
261
277
|
}
|
|
262
278
|
|
|
263
279
|
static async captureDebugSnapshotHandler({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; includeScreenshot?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
|
|
280
|
+
const { resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId)
|
|
281
|
+
return withResolvedDevice(resolved, () => ToolsObserve.collectDebugSnapshot({ reason, includeLogs, includeScreenshot, logLines,
|
|
282
|
+
platform: resolved.platform as 'android' | 'ios', appId, deviceId: resolved.id, sessionId }))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private static async collectDebugSnapshot({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; includeScreenshot?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
|
|
286
|
+
if (!includeScreenshot) omitEvidence('screenshot')
|
|
287
|
+
if (!includeLogs) omitEvidence('logs')
|
|
264
288
|
const timestamp = Date.now()
|
|
265
289
|
const raw: CaptureDebugSnapshotRawResponse = {
|
|
266
290
|
timestamp,
|
|
@@ -281,7 +305,7 @@ export class ToolsObserve {
|
|
|
281
305
|
currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
|
|
282
306
|
fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
|
|
283
307
|
uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
|
|
284
|
-
readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
|
|
308
|
+
readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, deviceId, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
|
|
285
309
|
}
|
|
286
310
|
|
|
287
311
|
const results = await Promise.allSettled(Object.values(tasks))
|
package/src/observe/ios.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { captureEvidence } from './evidence.js'
|
|
1
3
|
import { spawn } from "child_process"
|
|
2
4
|
import { remainingBudget } from '../utils/operation-budget.js'
|
|
3
5
|
import { execCmd } from '../utils/exec.js'
|
|
@@ -283,10 +285,10 @@ export function traverseIDBNode(node: IDBElement, elements: UIElement[], parentI
|
|
|
283
285
|
return currentIndex;
|
|
284
286
|
}
|
|
285
287
|
|
|
286
|
-
const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string }> = new Map()
|
|
288
|
+
const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string, device?: { platform: 'android' | 'ios'; id: string } }> = new Map()
|
|
287
289
|
|
|
288
|
-
export function _setIOSActiveLogStream(sessionId: string, file: string) {
|
|
289
|
-
iosActiveLogStreams.set(sessionId, { proc: {} as any, file })
|
|
290
|
+
export function _setIOSActiveLogStream(sessionId: string, file: string, device?: { platform: 'android' | 'ios'; id: string }) {
|
|
291
|
+
iosActiveLogStreams.set(sessionId, { proc: {} as any, file, device })
|
|
290
292
|
}
|
|
291
293
|
|
|
292
294
|
export function _clearIOSActiveLogStream(sessionId: string) {
|
|
@@ -294,11 +296,17 @@ export function _clearIOSActiveLogStream(sessionId: string) {
|
|
|
294
296
|
}
|
|
295
297
|
|
|
296
298
|
export class iOSObserve {
|
|
299
|
+
getLogStreamDevice(sessionId: string = 'default') { return iosActiveLogStreams.get(sessionId)?.device ?? null }
|
|
300
|
+
|
|
297
301
|
async getDeviceMetadata(deviceId: string = "booted"): Promise<DeviceInfo> {
|
|
298
302
|
return getIOSDeviceMetadata(deviceId);
|
|
299
303
|
}
|
|
300
304
|
|
|
301
305
|
async getLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
|
|
306
|
+
return captureEvidence('logs', { platform: 'ios', id: filters.deviceId ?? '' }, () => this.collectGetLogs(filters))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private async collectGetLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
|
|
302
310
|
const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters
|
|
303
311
|
const args: string[] = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog']
|
|
304
312
|
|
|
@@ -454,8 +462,12 @@ export class iOSObserve {
|
|
|
454
462
|
}
|
|
455
463
|
|
|
456
464
|
async captureScreenshot(deviceId: string = "booted"): Promise<CaptureIOSScreenshotResponse> {
|
|
465
|
+
return captureEvidence('screenshot', { platform: 'ios', id: deviceId ?? '' }, () => this.collectCaptureScreenshot(deviceId))
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private async collectCaptureScreenshot(deviceId: string = "booted"): Promise<CaptureIOSScreenshotResponse> {
|
|
457
469
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
458
|
-
const tmpFile = `/tmp/mcp-ios-screenshot-${
|
|
470
|
+
const tmpFile = `/tmp/mcp-ios-screenshot-${randomUUID()}.png`
|
|
459
471
|
|
|
460
472
|
try {
|
|
461
473
|
await iosExecCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId)
|
|
@@ -527,6 +539,10 @@ export class iOSObserve {
|
|
|
527
539
|
}
|
|
528
540
|
|
|
529
541
|
async getUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
|
|
542
|
+
return captureEvidence('ui_tree', { platform: 'ios', id: deviceId ?? '' }, () => this.collectGetUITree(deviceId))
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private async collectGetUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
|
|
530
546
|
const measurement = createObservationMeasurement('ios')
|
|
531
547
|
let device
|
|
532
548
|
try {
|
|
@@ -653,12 +669,16 @@ export class iOSObserve {
|
|
|
653
669
|
}
|
|
654
670
|
}
|
|
655
671
|
|
|
656
|
-
async getScreenFingerprint(deviceId: string = 'booted'): Promise<{ fingerprint: string | null; activity?: string; error?: string }> {
|
|
672
|
+
async getScreenFingerprint(deviceId: string = 'booted'): Promise<{ fingerprint: string | null; activity?: string; error?: string; captured_at_ms?: number }> {
|
|
673
|
+
return captureEvidence('fingerprint', { platform: 'ios', id: deviceId ?? '' }, () => this.collectGetScreenFingerprint(deviceId))
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private async collectGetScreenFingerprint(deviceId: string = 'booted'): Promise<{ fingerprint: string | null; activity?: string; error?: string; captured_at_ms?: number }> {
|
|
657
677
|
try {
|
|
658
678
|
const tree = await this.getUITree(deviceId)
|
|
659
679
|
if (!tree || tree.error) return { fingerprint: null, error: tree && (tree as any).error }
|
|
660
680
|
|
|
661
|
-
return computeScreenFingerprint(tree, null, 'ios', 50)
|
|
681
|
+
return { ...computeScreenFingerprint(tree, null, 'ios', 50), captured_at_ms: tree.captured_at_ms }
|
|
662
682
|
} catch (e) {
|
|
663
683
|
return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
|
|
664
684
|
}
|
|
@@ -701,10 +721,10 @@ export class iOSObserve {
|
|
|
701
721
|
|
|
702
722
|
proc.on('close', () => {
|
|
703
723
|
stream.end()
|
|
704
|
-
iosActiveLogStreams.delete(sessionId)
|
|
724
|
+
if (iosActiveLogStreams.get(sessionId)?.proc === proc) iosActiveLogStreams.delete(sessionId)
|
|
705
725
|
})
|
|
706
726
|
|
|
707
|
-
iosActiveLogStreams.set(sessionId, { proc, file })
|
|
727
|
+
iosActiveLogStreams.set(sessionId, { proc, file, device: { platform: 'ios', id: deviceId ?? '' } })
|
|
708
728
|
return { success: true, stream_started: true }
|
|
709
729
|
} catch {
|
|
710
730
|
return { success: false, error: 'log_stream_start_failed' }
|
|
@@ -720,6 +740,10 @@ export class iOSObserve {
|
|
|
720
740
|
}
|
|
721
741
|
|
|
722
742
|
async readLogStream(sessionId: string = 'default', limit: number = 100, since?: string): Promise<{ entries: any[], crash_summary?: { crash_detected: boolean, exception?: string, sample?: string } }> {
|
|
743
|
+
return captureEvidence('logs', this.getLogStreamDevice(sessionId), () => this.collectReadLogStream(sessionId, limit, since))
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private async collectReadLogStream(sessionId: string = 'default', limit: number = 100, since?: string): Promise<{ entries: any[], crash_summary?: { crash_detected: boolean, exception?: string, sample?: string } }> {
|
|
723
747
|
const entry = iosActiveLogStreams.get(sessionId)
|
|
724
748
|
if (!entry) return { entries: [] }
|
|
725
749
|
try {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { pausedReadiness } from '../debugger/guard.js'
|
|
2
|
+
import type { DeviceInfo } from '../types.js'
|
|
3
|
+
import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js'
|
|
4
|
+
import { remainingBudget, runWithBudget } from '../utils/operation-budget.js'
|
|
5
|
+
import { ToolsObserve } from './index.js'
|
|
6
|
+
import { isDeviceUnavailable, ReadinessTracker, withReadinessTracker } from './readiness.js'
|
|
7
|
+
|
|
8
|
+
type Target = { platform?: 'android' | 'ios'; deviceId?: string }
|
|
9
|
+
function budgetExpired(error: unknown) {
|
|
10
|
+
if (/ACTION_TIMEOUT/.test(String(error))) return true
|
|
11
|
+
try { remainingBudget(); return false } catch { return true }
|
|
12
|
+
}
|
|
13
|
+
async function resolve(target: Target) {
|
|
14
|
+
if (target.platform) return resolveTargetDevice({ ...target, platform: target.platform })
|
|
15
|
+
try { return await resolveTargetDevice({ ...target, platform: 'android' }) }
|
|
16
|
+
catch { return resolveTargetDevice({ ...target, platform: 'ios' }) }
|
|
17
|
+
}
|
|
18
|
+
export async function readinessDelay(ms: number) {
|
|
19
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(ms, remainingBudget(ms))))
|
|
20
|
+
remainingBudget()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function getAppReadiness(args: Target & { timeoutMs?: number; stabilityMs?: number; pollIntervalMs?: number }, wait = false) {
|
|
24
|
+
const started = performance.now()
|
|
25
|
+
let device: DeviceInfo | null = null
|
|
26
|
+
let tracker = new ReadinessTracker(args.platform ?? 'android', args.deviceId ?? '')
|
|
27
|
+
let failureCode: string | undefined
|
|
28
|
+
let debugPause: ReturnType<typeof pausedReadiness> = null
|
|
29
|
+
await runWithBudget(args.timeoutMs ?? (wait ? 30000 : 15000), async () => {
|
|
30
|
+
try {
|
|
31
|
+
device = await resolve(args)
|
|
32
|
+
remainingBudget()
|
|
33
|
+
tracker = new ReadinessTracker(device.platform, device.id, args.stabilityMs ?? 500)
|
|
34
|
+
await withResolvedDevice(device, async () => {
|
|
35
|
+
let count = 0
|
|
36
|
+
while (wait || count < 2) {
|
|
37
|
+
remainingBudget()
|
|
38
|
+
debugPause = device!.platform === 'android' ? pausedReadiness(device!.id) : null
|
|
39
|
+
if (debugPause) { failureCode = wait ? 'DEBUGGER_PAUSED' : undefined; return }
|
|
40
|
+
const captureStarted = Date.now()
|
|
41
|
+
try {
|
|
42
|
+
const tree = await ToolsObserve.getUITreeHandler({ platform: device!.platform as 'android' | 'ios', deviceId: device!.id })
|
|
43
|
+
remainingBudget()
|
|
44
|
+
tracker.observe(tree, captureStarted)
|
|
45
|
+
if (isDeviceUnavailable((tree as any)?.error)) { failureCode = 'DEVICE_UNAVAILABLE'; return }
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (/ACTION_TIMEOUT/.test(String(error))) throw error
|
|
48
|
+
tracker.unavailable()
|
|
49
|
+
if (isDeviceUnavailable(error)) { failureCode = 'DEVICE_UNAVAILABLE'; return }
|
|
50
|
+
}
|
|
51
|
+
count++
|
|
52
|
+
if (tracker.summary.state === 'settled' || (!wait && tracker.summary.state === 'loading')) return
|
|
53
|
+
if (!wait && count === 2) return
|
|
54
|
+
await readinessDelay(wait ? args.pollIntervalMs ?? 500 : args.stabilityMs ?? 500)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const expired = budgetExpired(error)
|
|
59
|
+
if (expired) tracker.deadline()
|
|
60
|
+
else tracker.unavailable()
|
|
61
|
+
failureCode = !expired && (!device || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE'
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
if (debugPause) return { success: !wait, device, readiness: debugPause, elapsed_ms: performance.now() - started, ...(wait ? { failure_code: 'DEBUGGER_PAUSED' } : {}) }
|
|
65
|
+
const state = tracker.summary.state
|
|
66
|
+
const success = !failureCode && (wait ? state === 'settled' : state !== 'unknown')
|
|
67
|
+
return { success, device, readiness: tracker.summary, elapsed_ms: performance.now() - started,
|
|
68
|
+
...(!success ? { failure_code: failureCode ?? (wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE') } : {}) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function withReadyWait<T extends object | undefined, F extends object>(target: Target, timeoutMs: number, operation: (device: DeviceInfo) => Promise<T>, failure: (code: string, elapsedMs: number, samples: number) => F) {
|
|
72
|
+
const started = performance.now()
|
|
73
|
+
let tracker = new ReadinessTracker(target.platform ?? 'android', target.deviceId ?? '')
|
|
74
|
+
let resolved = false
|
|
75
|
+
return runWithBudget(timeoutMs, async () => {
|
|
76
|
+
try {
|
|
77
|
+
const device = await resolve(target)
|
|
78
|
+
resolved = true
|
|
79
|
+
remainingBudget()
|
|
80
|
+
tracker = new ReadinessTracker(device.platform, device.id)
|
|
81
|
+
const result = await withResolvedDevice(device, () => withReadinessTracker(tracker, () => operation(device)))
|
|
82
|
+
try { remainingBudget() } catch {
|
|
83
|
+
const alreadyFailed = result && (('status' in result && result.status !== 'success') || ('success' in result && result.success === false))
|
|
84
|
+
return { ...(alreadyFailed ? result : failure('TIMEOUT', performance.now() - started, tracker.summary.sample_count)), readiness: tracker.deadline() }
|
|
85
|
+
}
|
|
86
|
+
return { ...result, readiness: tracker.summary }
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const expired = budgetExpired(error)
|
|
89
|
+
if (expired) tracker.deadline()
|
|
90
|
+
else tracker.unavailable()
|
|
91
|
+
return { ...failure(!expired && (!resolved || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : 'TIMEOUT', performance.now() - started, tracker.summary.sample_count), readiness: tracker.summary }
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { pausedReadiness } from '../debugger/guard.js'
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
+
import type { ReadinessSummary } from '../types.js'
|
|
4
|
+
import { remainingBudget } from '../utils/operation-budget.js'
|
|
5
|
+
import { computeSnapshotSignature, getSnapshotSessionId } from './snapshot-metadata.js'
|
|
6
|
+
|
|
7
|
+
const baselineLimitations = ['NORMALIZED_UI_ONLY', 'FOREGROUND_APP_UNVERIFIED', 'UNRESPONSIVE_SIGNAL_UNSUPPORTED']
|
|
8
|
+
const capabilities = { loading: true, transitioning: true, settled: true, unresponsive: false, debugger_paused: false }
|
|
9
|
+
|
|
10
|
+
export function isDeviceUnavailable(error: unknown) {
|
|
11
|
+
return /device.*(?:not found|offline|disconnected)|no devices found|READINESS_DEVICE_UNAVAILABLE/i.test(String(error))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ReadinessTracker {
|
|
15
|
+
private signature: string | null = null
|
|
16
|
+
private stableSince = 0
|
|
17
|
+
summary: ReadinessSummary
|
|
18
|
+
|
|
19
|
+
constructor(private platform: string, private deviceId: string, private stabilityMs = 500) {
|
|
20
|
+
this.summary = {
|
|
21
|
+
state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
|
|
22
|
+
sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
|
|
23
|
+
evidence: [], limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
unavailable(code = 'OBSERVATION_FAILED'): ReadinessSummary {
|
|
29
|
+
this.signature = null
|
|
30
|
+
this.summary = { ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
|
|
31
|
+
sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', code],
|
|
32
|
+
evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: null, snapshot_revision: null }].slice(-8) }
|
|
33
|
+
return this.summary
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
deadline(): ReadinessSummary {
|
|
37
|
+
this.summary = { ...this.summary, limitations: [...new Set([...this.summary.limitations, 'DEADLINE_EXCEEDED'])] }
|
|
38
|
+
return this.summary
|
|
39
|
+
}
|
|
40
|
+
|
|
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 }
|
|
44
|
+
if (tree?.error) return this.unavailable('OBSERVATION_FAILED')
|
|
45
|
+
if (!tree || tree.device?.platform !== this.platform || tree.device?.id !== this.deviceId ||
|
|
46
|
+
tree.snapshot_session_id !== getSnapshotSessionId() || !Number.isSafeInteger(tree.snapshot_revision) || tree.snapshot_revision <= 0 ||
|
|
47
|
+
!Number.isFinite(tree.captured_at_ms) || tree.captured_at_ms < acquisitionStartedAtMs || tree.captured_at_ms <= 0 ||
|
|
48
|
+
!Array.isArray(tree.elements) || !tree.elements.length || tree.elements.some((element: unknown) => !element || typeof element !== 'object') ||
|
|
49
|
+
!Number.isFinite(tree.resolution?.width) || !Number.isFinite(tree.resolution?.height) || tree.resolution.width <= 0 || tree.resolution.height <= 0) return this.unavailable('OBSERVATION_INVALID')
|
|
50
|
+
const signature = computeSnapshotSignature(tree)
|
|
51
|
+
if (!signature) return this.unavailable('OBSERVATION_INVALID')
|
|
52
|
+
const nativeLoading = tree.elements.some((element: any) => element.visible === true && ['android.widget.ProgressBar', 'ActivityIndicator', 'ProgressIndicator'].includes(element.type))
|
|
53
|
+
const textLoading = tree.elements.some((element: any) => element.visible === true && [element.text, element.contentDescription].some(label =>
|
|
54
|
+
typeof label === 'string' && /^(loading|please\s+wait)[.\s…]*$/i.test(label.trim())))
|
|
55
|
+
const previous = this.signature
|
|
56
|
+
const unchanged = previous === signature
|
|
57
|
+
if (!unchanged || nativeLoading || textLoading) this.stableSince = now
|
|
58
|
+
this.signature = signature
|
|
59
|
+
const stableFor = unchanged && !nativeLoading && !textLoading ? Math.max(0, now - this.stableSince) : 0
|
|
60
|
+
const state = nativeLoading || textLoading ? 'loading' : !previous ? 'unknown' : !unchanged ? 'transitioning' : stableFor >= this.stabilityMs ? 'settled' : 'unknown'
|
|
61
|
+
const code = nativeLoading ? 'NATIVE_LOADING_INDICATOR' : textLoading ? 'LOADING_LABEL' : state === 'transitioning' ? 'SNAPSHOT_CHANGED' : state === 'settled' ? 'SNAPSHOT_STABLE' : 'INSUFFICIENT_SAMPLES'
|
|
62
|
+
this.summary = {
|
|
63
|
+
state, scope: 'device_ui', basis: textLoading && !nativeLoading ? 'heuristic' : state === 'unknown' ? 'insufficient' : 'observed',
|
|
64
|
+
captured_at_ms: tree.captured_at_ms, snapshot_session_id: tree.snapshot_session_id, snapshot_revision: tree.snapshot_revision,
|
|
65
|
+
sample_count: this.summary.sample_count + 1, stable_for_ms: stableFor,
|
|
66
|
+
evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: tree.captured_at_ms, snapshot_revision: tree.snapshot_revision }].slice(-8),
|
|
67
|
+
limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
|
|
68
|
+
}
|
|
69
|
+
return this.summary
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const waits = new AsyncLocalStorage<ReadinessTracker>()
|
|
74
|
+
export function currentReadinessWait() { return waits.getStore() }
|
|
75
|
+
export function withReadinessTracker<T>(tracker: ReadinessTracker, operation: () => Promise<T>) { return waits.run(tracker, operation) }
|
|
76
|
+
|
|
77
|
+
export function observeReadinessWait(tree: unknown, startedAtMs: number) {
|
|
78
|
+
const tracker = waits.getStore()
|
|
79
|
+
if (!tracker) return
|
|
80
|
+
remainingBudget()
|
|
81
|
+
const summary = tracker.observe(tree, startedAtMs)
|
|
82
|
+
if (isDeviceUnavailable((tree as any)?.error)) throw new Error('READINESS_DEVICE_UNAVAILABLE')
|
|
83
|
+
if (summary.limitations.some(code => ['OBSERVATION_INVALID', 'OBSERVATION_FAILED'].includes(code))) throw new Error('READINESS_SAMPLE_INVALID')
|
|
84
|
+
}
|