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,280 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
const sessionId = randomUUID();
|
|
4
|
+
const context = new AsyncLocalStorage();
|
|
5
|
+
const history = new Map();
|
|
6
|
+
const active = new Map();
|
|
7
|
+
const orders = new Map();
|
|
8
|
+
const stamp = () => ({ wall: Date.now(), mono: performance.now() });
|
|
9
|
+
const deviceKey = (d) => `${d.platform}:${d.id}`;
|
|
10
|
+
const sameDevice = (a, b) => !!a && !!b && deviceKey(a) === deviceKey(b);
|
|
11
|
+
function validDevice(d) {
|
|
12
|
+
return d && ['android', 'ios'].includes(d.platform) && typeof d.id === 'string' && d.id && !['default', 'booted'].includes(d.id)
|
|
13
|
+
? { platform: d.platform, id: d.id } : null;
|
|
14
|
+
}
|
|
15
|
+
function orderFor(device) {
|
|
16
|
+
if (!device)
|
|
17
|
+
return null;
|
|
18
|
+
const key = deviceKey(device);
|
|
19
|
+
let order = orders.get(key);
|
|
20
|
+
if (!order) {
|
|
21
|
+
order = { sequence: 0, active: 0, lastStarted: -Infinity };
|
|
22
|
+
orders.set(key, order);
|
|
23
|
+
if (orders.size > 1000) {
|
|
24
|
+
for (const [oldKey, oldOrder] of orders) {
|
|
25
|
+
if (oldKey !== key && oldOrder.active === 0)
|
|
26
|
+
orders.delete(oldKey);
|
|
27
|
+
if (orders.size <= 1000)
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return order;
|
|
33
|
+
}
|
|
34
|
+
function prune() {
|
|
35
|
+
const now = performance.now();
|
|
36
|
+
for (const [key, record] of history) {
|
|
37
|
+
if (record.completed !== null && now - record.completed >= 600000)
|
|
38
|
+
history.delete(key);
|
|
39
|
+
}
|
|
40
|
+
while (history.size > 1000)
|
|
41
|
+
history.delete(history.keys().next().value);
|
|
42
|
+
}
|
|
43
|
+
export function validateActionRef(value) {
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
return undefined;
|
|
46
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
47
|
+
Object.keys(value).some(key => !['sessionId', 'actionId'].includes(key)))
|
|
48
|
+
throw new Error('INVALID_ARGUMENT: invalid actionRef');
|
|
49
|
+
const ref = value;
|
|
50
|
+
for (const v of [ref.sessionId, ref.actionId])
|
|
51
|
+
if (typeof v !== 'string' || v.length < 1 || v.length > 128)
|
|
52
|
+
throw new Error('INVALID_ARGUMENT: invalid actionRef');
|
|
53
|
+
return { sessionId: ref.sessionId, actionId: ref.actionId };
|
|
54
|
+
}
|
|
55
|
+
export function evidenceSessionId() { return sessionId; }
|
|
56
|
+
export function currentEvidenceCorrelationId() { return context.getStore()?.correlationId; }
|
|
57
|
+
export function bindSystemTrace(traceId) {
|
|
58
|
+
const current = context.getStore();
|
|
59
|
+
if (current)
|
|
60
|
+
current.systemTraceId = traceId;
|
|
61
|
+
}
|
|
62
|
+
export function currentEvidenceActionId() { return context.getStore()?.action?.id; }
|
|
63
|
+
export function bindEvidenceDevice(device) {
|
|
64
|
+
const current = context.getStore();
|
|
65
|
+
const target = validDevice(device);
|
|
66
|
+
if (!current || !target)
|
|
67
|
+
return;
|
|
68
|
+
current.device ??= target;
|
|
69
|
+
if (current.isAction && current.action)
|
|
70
|
+
current.action.device ??= target;
|
|
71
|
+
}
|
|
72
|
+
export function markEvidenceVerification() {
|
|
73
|
+
const current = context.getStore();
|
|
74
|
+
if (current) {
|
|
75
|
+
current.verification = stamp();
|
|
76
|
+
if (current.isAction && current.action)
|
|
77
|
+
current.action.verification = current.verification;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function evidenceDispatch(device, operation) {
|
|
81
|
+
const current = context.getStore();
|
|
82
|
+
if (!current?.isAction || !current.action)
|
|
83
|
+
return operation();
|
|
84
|
+
bindEvidenceDevice(device);
|
|
85
|
+
const record = current.action;
|
|
86
|
+
const start = stamp();
|
|
87
|
+
record.dispatchStart ??= start;
|
|
88
|
+
record.dispatchEnd = null;
|
|
89
|
+
const order = orderFor(validDevice(device));
|
|
90
|
+
if (order) {
|
|
91
|
+
order.sequence++;
|
|
92
|
+
order.active++;
|
|
93
|
+
order.lastStarted = start.mono;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return await operation();
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
record.dispatchEnd = stamp();
|
|
100
|
+
if (order)
|
|
101
|
+
order.active--;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function omitEvidence(kind) {
|
|
105
|
+
const current = context.getStore();
|
|
106
|
+
if (!current)
|
|
107
|
+
return;
|
|
108
|
+
const start = stamp();
|
|
109
|
+
current.observations.push({ value: {
|
|
110
|
+
evidence_id: randomUUID(), kind, status: 'omitted', capture_started_at_ms: null,
|
|
111
|
+
capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
|
|
112
|
+
freshness: kind === 'logs' ? 'not_applicable' : 'unknown', freshness_reasons: [], device: current.device
|
|
113
|
+
}, start, end: null, order: null, startSequence: 0, endSequence: 0, overlapping: false, reused: false });
|
|
114
|
+
}
|
|
115
|
+
export async function captureEvidence(kind, device, operation) {
|
|
116
|
+
const current = context.getStore();
|
|
117
|
+
if (!current)
|
|
118
|
+
return operation();
|
|
119
|
+
const target = validDevice(device);
|
|
120
|
+
if (target)
|
|
121
|
+
bindEvidenceDevice(target);
|
|
122
|
+
const order = orderFor(target);
|
|
123
|
+
const start = stamp();
|
|
124
|
+
const acquisition = { value: {
|
|
125
|
+
evidence_id: randomUUID(), kind, status: 'failed', capture_started_at_ms: start.wall,
|
|
126
|
+
capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
|
|
127
|
+
freshness: 'unknown', freshness_reasons: [], device: target
|
|
128
|
+
}, start, end: null, order, startSequence: order?.sequence ?? 0,
|
|
129
|
+
endSequence: 0, overlapping: !!order?.active, reused: false };
|
|
130
|
+
current.observations.push(acquisition);
|
|
131
|
+
try {
|
|
132
|
+
const result = await operation();
|
|
133
|
+
const value = acquisition.value;
|
|
134
|
+
const resolved = validDevice(result?.device) ?? target;
|
|
135
|
+
value.device = resolved;
|
|
136
|
+
if (resolved)
|
|
137
|
+
bindEvidenceDevice(resolved);
|
|
138
|
+
const failure = result?.error ?? result?.meta?.error;
|
|
139
|
+
value.status = failure || result?.success === false || (kind === 'fingerprint' && !result?.fingerprint) ? 'failed' : 'captured';
|
|
140
|
+
if (failure)
|
|
141
|
+
value.error = failure;
|
|
142
|
+
const source = result?.captured_at_ms;
|
|
143
|
+
if (Number.isFinite(source) && source > 0) {
|
|
144
|
+
value.source_captured_at_ms = source;
|
|
145
|
+
acquisition.reused = source < start.wall;
|
|
146
|
+
}
|
|
147
|
+
if (kind === 'ui_tree') {
|
|
148
|
+
value.snapshot_session_id = result?.snapshot_session_id;
|
|
149
|
+
value.snapshot_revision = result?.snapshot_revision;
|
|
150
|
+
}
|
|
151
|
+
if (kind === 'logs') {
|
|
152
|
+
const entries = result?.logs ?? result?.entries ?? [];
|
|
153
|
+
const times = entries.map((e) => {
|
|
154
|
+
const t = e?.timestamp ?? e?._iso;
|
|
155
|
+
return typeof t === 'number' ? t : typeof t === 'string' ? Date.parse(t) : NaN;
|
|
156
|
+
}).filter((t) => Number.isFinite(t));
|
|
157
|
+
value.clock = 'device_or_unknown';
|
|
158
|
+
value.event_time_comparable = false;
|
|
159
|
+
value.earliest_entry_at_ms = times.length ? times.reduce((a, b) => Math.min(a, b)) : null;
|
|
160
|
+
value.latest_entry_at_ms = times.length ? times.reduce((a, b) => Math.max(a, b)) : null;
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
acquisition.value.error = error instanceof Error ? error.message : String(error);
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
acquisition.end = stamp();
|
|
170
|
+
acquisition.value.capture_completed_at_ms = acquisition.end.wall;
|
|
171
|
+
acquisition.endSequence = order?.sequence ?? 0;
|
|
172
|
+
acquisition.overlapping ||= !!order?.active || acquisition.startSequence !== acquisition.endSequence;
|
|
173
|
+
if (acquisition.value.status === 'captured' && acquisition.value.source_captured_at_ms === null && kind !== 'logs') {
|
|
174
|
+
acquisition.value.source_captured_at_ms = acquisition.end.wall;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function association(current) {
|
|
179
|
+
if (current.isAction)
|
|
180
|
+
return { association: 'self', association_reason: null };
|
|
181
|
+
if (!current.ref)
|
|
182
|
+
return { association: 'none', association_reason: null };
|
|
183
|
+
const reason = current.ref.sessionId !== sessionId ? 'SESSION_MISMATCH' : !current.action ? 'ACTION_UNAVAILABLE' :
|
|
184
|
+
!sameDevice(current.device, current.action.device) ? 'DEVICE_MISMATCH' : null;
|
|
185
|
+
return { association: reason ? 'unavailable' : 'explicit', association_reason: reason };
|
|
186
|
+
}
|
|
187
|
+
function assemble(current) {
|
|
188
|
+
const end = stamp();
|
|
189
|
+
const link = association(current);
|
|
190
|
+
const action = link.association === 'unavailable' ? null : current.action;
|
|
191
|
+
const clockChanged = end.wall < current.start.wall;
|
|
192
|
+
const observations = current.observations.map(a => {
|
|
193
|
+
const v = { ...a.value, freshness_reasons: [] };
|
|
194
|
+
if (v.status === 'omitted')
|
|
195
|
+
return v;
|
|
196
|
+
const componentAction = sameDevice(v.device, action?.device ?? null) ? action : null;
|
|
197
|
+
const sourceMono = a.end && v.source_captured_at_ms !== null ? a.end.mono + v.source_captured_at_ms - a.end.wall : a.end?.mono;
|
|
198
|
+
const reason = (freshness, code) => { v.freshness = freshness; v.freshness_reasons.push(code); };
|
|
199
|
+
if (v.kind === 'logs')
|
|
200
|
+
reason('unknown', 'DEVICE_CLOCK_UNVERIFIED');
|
|
201
|
+
else if (clockChanged || (a.end && a.end.wall < a.start.wall))
|
|
202
|
+
reason('unknown', 'CLOCK_DISCONTINUITY');
|
|
203
|
+
else if (v.source_captured_at_ms !== null && a.end && v.source_captured_at_ms > a.end.wall)
|
|
204
|
+
reason('unknown', 'CLOCK_DISCONTINUITY');
|
|
205
|
+
else if (!v.device || !a.order || orders.get(deviceKey(v.device)) !== a.order || v.status !== 'captured' || !a.end)
|
|
206
|
+
reason('unknown', 'CAPTURE_TIME_UNKNOWN');
|
|
207
|
+
else if (componentAction?.dispatchStart && sourceMono !== undefined && sourceMono < componentAction.dispatchStart.mono)
|
|
208
|
+
reason('stale', 'BEFORE_ACTION');
|
|
209
|
+
else if (a.order && (a.order.sequence > a.endSequence || (a.reused && sourceMono !== undefined && sourceMono < a.order.lastStarted)))
|
|
210
|
+
reason('stale', 'SUPERSEDED_BY_ACTION');
|
|
211
|
+
else if (a.overlapping)
|
|
212
|
+
reason('unknown', 'OVERLAPPING_ACTION');
|
|
213
|
+
else if (a.reused)
|
|
214
|
+
reason('unknown', 'REUSED_OBSERVATION');
|
|
215
|
+
else {
|
|
216
|
+
v.freshness = 'fresh';
|
|
217
|
+
}
|
|
218
|
+
if (!clockChanged && a.end && a.end.wall >= a.start.wall && v.source_captured_at_ms !== null && v.source_captured_at_ms <= end.wall) {
|
|
219
|
+
v.age_ms = Math.max(0, end.mono - a.end.mono + a.end.wall - v.source_captured_at_ms);
|
|
220
|
+
}
|
|
221
|
+
return { ...v, session_id: sessionId, correlation_id: componentAction?.correlationId ?? current.correlationId,
|
|
222
|
+
action_id: componentAction?.id ?? null, association: componentAction ? 'internal' : link.association };
|
|
223
|
+
});
|
|
224
|
+
return {
|
|
225
|
+
session_id: sessionId, correlation_id: action?.correlationId ?? current.correlationId,
|
|
226
|
+
action_id: action?.id ?? null, device: current.device, ...link,
|
|
227
|
+
...(current.ref ? { requested_action_ref: current.ref } : {}),
|
|
228
|
+
operation_started_at_ms: current.start.wall, operation_completed_at_ms: current.end?.wall ?? end.wall,
|
|
229
|
+
dispatch_started_at_ms: action?.dispatchStart?.wall ?? null,
|
|
230
|
+
dispatch_completed_at_ms: action?.dispatchEnd?.wall ?? null,
|
|
231
|
+
verification_completed_at_ms: current.verification?.wall ?? action?.verification?.wall ?? null,
|
|
232
|
+
observations, trace_context: { supported: !!current.systemTraceId, trace_id: current.systemTraceId ?? null, markers: [] }
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
export async function withEvidence(options, operation) {
|
|
236
|
+
prune();
|
|
237
|
+
const parent = context.getStore();
|
|
238
|
+
const id = randomUUID();
|
|
239
|
+
const record = options.action ? { id, correlationId: parent?.correlationId ?? id, device: null,
|
|
240
|
+
dispatchStart: null, dispatchEnd: null, verification: null, completed: null } : options.ref?.sessionId === sessionId ? active.get(options.ref.actionId) ?? history.get(options.ref.actionId) ?? null : null;
|
|
241
|
+
const current = { systemTraceId: parent?.systemTraceId, correlationId: options.action ? record.correlationId : options.journey ? id : randomUUID(),
|
|
242
|
+
action: record, ref: options.ref, device: null, start: stamp(), end: null, verification: null, observations: [], children: [], isAction: !!options.action };
|
|
243
|
+
if (options.action && record)
|
|
244
|
+
active.set(id, record);
|
|
245
|
+
if (parent && options.action)
|
|
246
|
+
parent.children.push(current);
|
|
247
|
+
try {
|
|
248
|
+
return await context.run(current, async () => {
|
|
249
|
+
const result = await operation();
|
|
250
|
+
current.end = stamp();
|
|
251
|
+
return { result, evidence: assemble(current) };
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
if (options.action && record) {
|
|
256
|
+
active.delete(id);
|
|
257
|
+
record.completed = performance.now();
|
|
258
|
+
history.set(id, record);
|
|
259
|
+
prune();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
export function refreshJourneyEvidence(steps) {
|
|
264
|
+
const current = context.getStore();
|
|
265
|
+
if (!current)
|
|
266
|
+
return;
|
|
267
|
+
for (const step of steps) {
|
|
268
|
+
const previous = step.result?.evidence;
|
|
269
|
+
const child = current.children.find(c => c.action?.id === previous?.action_id);
|
|
270
|
+
if (child && step.result)
|
|
271
|
+
step.result.evidence = assemble(child);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
export function completeJourneyVerification(actionId) {
|
|
275
|
+
const child = context.getStore()?.children.find(c => c.action?.id === actionId);
|
|
276
|
+
if (child?.action) {
|
|
277
|
+
child.verification = stamp();
|
|
278
|
+
child.action.verification = child.verification;
|
|
279
|
+
}
|
|
280
|
+
}
|
package/dist/observe/index.js
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';
|
|
@@ -136,16 +139,22 @@ export class ToolsObserve {
|
|
|
136
139
|
}
|
|
137
140
|
static async getUITreeHandler({ platform, deviceId, responseMode = 'debug', sinceSnapshotRevision }) {
|
|
138
141
|
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId);
|
|
142
|
+
const acquisitionStarted = Date.now();
|
|
139
143
|
const tree = await observe.getUITree(resolved.id);
|
|
144
|
+
tree.readiness = new ReadinessTracker(resolved.platform, resolved.id).observe(tree, acquisitionStarted);
|
|
140
145
|
if (responseMode !== 'compact')
|
|
141
146
|
return tree;
|
|
142
147
|
const revision = tree?.snapshot_revision ?? null;
|
|
148
|
+
const session = { snapshot_session_id: tree?.snapshot_session_id, readiness: tree.readiness };
|
|
149
|
+
if (tree?.error)
|
|
150
|
+
return { ...session, delta_available: false, snapshot_revision: revision, error: tree.error };
|
|
143
151
|
const baseRevision = sinceSnapshotRevision;
|
|
144
152
|
if (!Number.isInteger(baseRevision) || revision === null) {
|
|
145
|
-
return { delta_available: false, snapshot_revision: revision };
|
|
153
|
+
return { ...session, delta_available: false, snapshot_revision: revision };
|
|
146
154
|
}
|
|
147
155
|
const delta = getStateDelta(`${resolved.platform}:${resolved.id}`, baseRevision, revision);
|
|
148
|
-
return delta ? { delta_available: true, state_delta: delta, snapshot_revision: revision } : {
|
|
156
|
+
return delta ? { ...session, delta_available: true, state_delta: delta, snapshot_revision: revision } : {
|
|
157
|
+
...session,
|
|
149
158
|
delta_available: false,
|
|
150
159
|
snapshot_revision: revision,
|
|
151
160
|
reason: 'BASE_REVISION_UNAVAILABLE'
|
|
@@ -192,14 +201,23 @@ export class ToolsObserve {
|
|
|
192
201
|
return await observe.startLogStream(packageName, resolved.id, sid);
|
|
193
202
|
}
|
|
194
203
|
}
|
|
195
|
-
static async readLogStreamHandler({ platform, sessionId, limit, since }) {
|
|
204
|
+
static async readLogStreamHandler({ platform, sessionId, limit, since, deviceId }) {
|
|
196
205
|
const sid = sessionId || 'default';
|
|
197
|
-
const
|
|
206
|
+
const android = new AndroidObserve();
|
|
207
|
+
const ios = new iOSObserve();
|
|
208
|
+
const observe = platform === 'android' ? android : platform === 'ios' ? ios :
|
|
209
|
+
ios.getLogStreamDevice(sid) && !android.getLogStreamDevice(sid) ? ios : android;
|
|
210
|
+
const streamDevice = observe.getLogStreamDevice(sid);
|
|
211
|
+
if (deviceId && streamDevice && streamDevice.id !== deviceId)
|
|
212
|
+
throw new Error('LOG_STREAM_DEVICE_MISMATCH');
|
|
198
213
|
return await observe.readLogStream(sid, limit ?? 100, since);
|
|
199
214
|
}
|
|
200
215
|
static async stopLogStreamHandler({ platform, sessionId }) {
|
|
201
216
|
const sid = sessionId || 'default';
|
|
202
|
-
const
|
|
217
|
+
const android = new AndroidObserve();
|
|
218
|
+
const ios = new iOSObserve();
|
|
219
|
+
const observe = platform === 'android' ? android : platform === 'ios' ? ios :
|
|
220
|
+
ios.getLogStreamDevice(sid) && !android.getLogStreamDevice(sid) ? ios : android;
|
|
203
221
|
return await observe.stopLogStream(sid);
|
|
204
222
|
}
|
|
205
223
|
static async captureScreenshotHandler({ platform, deviceId }) {
|
|
@@ -217,6 +235,15 @@ export class ToolsObserve {
|
|
|
217
235
|
return await observe.getScreenFingerprint(resolved.id);
|
|
218
236
|
}
|
|
219
237
|
static async captureDebugSnapshotHandler({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId } = {}) {
|
|
238
|
+
const { resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId);
|
|
239
|
+
return withResolvedDevice(resolved, () => ToolsObserve.collectDebugSnapshot({ reason, includeLogs, includeScreenshot, logLines,
|
|
240
|
+
platform: resolved.platform, appId, deviceId: resolved.id, sessionId }));
|
|
241
|
+
}
|
|
242
|
+
static async collectDebugSnapshot({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId } = {}) {
|
|
243
|
+
if (!includeScreenshot)
|
|
244
|
+
omitEvidence('screenshot');
|
|
245
|
+
if (!includeLogs)
|
|
246
|
+
omitEvidence('logs');
|
|
220
247
|
const timestamp = Date.now();
|
|
221
248
|
const raw = {
|
|
222
249
|
timestamp,
|
|
@@ -236,7 +263,7 @@ export class ToolsObserve {
|
|
|
236
263
|
currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
|
|
237
264
|
fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
|
|
238
265
|
uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
|
|
239
|
-
readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
|
|
266
|
+
readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, deviceId, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
|
|
240
267
|
};
|
|
241
268
|
const results = await Promise.allSettled(Object.values(tasks));
|
|
242
269
|
const keys = Object.keys(tasks);
|
package/dist/observe/ios.js
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';
|
|
@@ -256,17 +258,21 @@ export function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
|
|
|
256
258
|
return currentIndex;
|
|
257
259
|
}
|
|
258
260
|
const iosActiveLogStreams = new Map();
|
|
259
|
-
export function _setIOSActiveLogStream(sessionId, file) {
|
|
260
|
-
iosActiveLogStreams.set(sessionId, { proc: {}, file });
|
|
261
|
+
export function _setIOSActiveLogStream(sessionId, file, device) {
|
|
262
|
+
iosActiveLogStreams.set(sessionId, { proc: {}, file, device });
|
|
261
263
|
}
|
|
262
264
|
export function _clearIOSActiveLogStream(sessionId) {
|
|
263
265
|
iosActiveLogStreams.delete(sessionId);
|
|
264
266
|
}
|
|
265
267
|
export class iOSObserve {
|
|
268
|
+
getLogStreamDevice(sessionId = 'default') { return iosActiveLogStreams.get(sessionId)?.device ?? null; }
|
|
266
269
|
async getDeviceMetadata(deviceId = "booted") {
|
|
267
270
|
return getIOSDeviceMetadata(deviceId);
|
|
268
271
|
}
|
|
269
272
|
async getLogs(filters = {}) {
|
|
273
|
+
return captureEvidence('logs', { platform: 'ios', id: filters.deviceId ?? '' }, () => this.collectGetLogs(filters));
|
|
274
|
+
}
|
|
275
|
+
async collectGetLogs(filters = {}) {
|
|
270
276
|
const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters;
|
|
271
277
|
const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog'];
|
|
272
278
|
// Default to last N seconds if no since_seconds provided; limit lines handled after parsing
|
|
@@ -418,8 +424,11 @@ export class iOSObserve {
|
|
|
418
424
|
}
|
|
419
425
|
}
|
|
420
426
|
async captureScreenshot(deviceId = "booted") {
|
|
427
|
+
return captureEvidence('screenshot', { platform: 'ios', id: deviceId ?? '' }, () => this.collectCaptureScreenshot(deviceId));
|
|
428
|
+
}
|
|
429
|
+
async collectCaptureScreenshot(deviceId = "booted") {
|
|
421
430
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
422
|
-
const tmpFile = `/tmp/mcp-ios-screenshot-${
|
|
431
|
+
const tmpFile = `/tmp/mcp-ios-screenshot-${randomUUID()}.png`;
|
|
423
432
|
try {
|
|
424
433
|
await iosExecCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId);
|
|
425
434
|
const buffer = await fs.readFile(tmpFile);
|
|
@@ -485,6 +494,9 @@ export class iOSObserve {
|
|
|
485
494
|
}
|
|
486
495
|
}
|
|
487
496
|
async getUITree(deviceId = "booted") {
|
|
497
|
+
return captureEvidence('ui_tree', { platform: 'ios', id: deviceId ?? '' }, () => this.collectGetUITree(deviceId));
|
|
498
|
+
}
|
|
499
|
+
async collectGetUITree(deviceId = "booted") {
|
|
488
500
|
const measurement = createObservationMeasurement('ios');
|
|
489
501
|
let device;
|
|
490
502
|
try {
|
|
@@ -608,11 +620,14 @@ export class iOSObserve {
|
|
|
608
620
|
}
|
|
609
621
|
}
|
|
610
622
|
async getScreenFingerprint(deviceId = 'booted') {
|
|
623
|
+
return captureEvidence('fingerprint', { platform: 'ios', id: deviceId ?? '' }, () => this.collectGetScreenFingerprint(deviceId));
|
|
624
|
+
}
|
|
625
|
+
async collectGetScreenFingerprint(deviceId = 'booted') {
|
|
611
626
|
try {
|
|
612
627
|
const tree = await this.getUITree(deviceId);
|
|
613
628
|
if (!tree || tree.error)
|
|
614
629
|
return { fingerprint: null, error: tree && tree.error };
|
|
615
|
-
return computeScreenFingerprint(tree, null, 'ios', 50);
|
|
630
|
+
return { ...computeScreenFingerprint(tree, null, 'ios', 50), captured_at_ms: tree.captured_at_ms };
|
|
616
631
|
}
|
|
617
632
|
catch (e) {
|
|
618
633
|
return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
|
|
@@ -652,9 +667,10 @@ export class iOSObserve {
|
|
|
652
667
|
});
|
|
653
668
|
proc.on('close', () => {
|
|
654
669
|
stream.end();
|
|
655
|
-
iosActiveLogStreams.
|
|
670
|
+
if (iosActiveLogStreams.get(sessionId)?.proc === proc)
|
|
671
|
+
iosActiveLogStreams.delete(sessionId);
|
|
656
672
|
});
|
|
657
|
-
iosActiveLogStreams.set(sessionId, { proc, file });
|
|
673
|
+
iosActiveLogStreams.set(sessionId, { proc, file, device: { platform: 'ios', id: deviceId ?? '' } });
|
|
658
674
|
return { success: true, stream_started: true };
|
|
659
675
|
}
|
|
660
676
|
catch {
|
|
@@ -673,6 +689,9 @@ export class iOSObserve {
|
|
|
673
689
|
return { success: true };
|
|
674
690
|
}
|
|
675
691
|
async readLogStream(sessionId = 'default', limit = 100, since) {
|
|
692
|
+
return captureEvidence('logs', this.getLogStreamDevice(sessionId), () => this.collectReadLogStream(sessionId, limit, since));
|
|
693
|
+
}
|
|
694
|
+
async collectReadLogStream(sessionId = 'default', limit = 100, since) {
|
|
676
695
|
const entry = iosActiveLogStreams.get(sessionId);
|
|
677
696
|
if (!entry)
|
|
678
697
|
return { entries: [] };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { pausedReadiness } from '../debugger/guard.js';
|
|
2
|
+
import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js';
|
|
3
|
+
import { remainingBudget, runWithBudget } from '../utils/operation-budget.js';
|
|
4
|
+
import { ToolsObserve } from './index.js';
|
|
5
|
+
import { isDeviceUnavailable, ReadinessTracker, withReadinessTracker } from './readiness.js';
|
|
6
|
+
function budgetExpired(error) {
|
|
7
|
+
if (/ACTION_TIMEOUT/.test(String(error)))
|
|
8
|
+
return true;
|
|
9
|
+
try {
|
|
10
|
+
remainingBudget();
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function resolve(target) {
|
|
18
|
+
if (target.platform)
|
|
19
|
+
return resolveTargetDevice({ ...target, platform: target.platform });
|
|
20
|
+
try {
|
|
21
|
+
return await resolveTargetDevice({ ...target, platform: 'android' });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return resolveTargetDevice({ ...target, platform: 'ios' });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function readinessDelay(ms) {
|
|
28
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(ms, remainingBudget(ms))));
|
|
29
|
+
remainingBudget();
|
|
30
|
+
}
|
|
31
|
+
export async function getAppReadiness(args, wait = false) {
|
|
32
|
+
const started = performance.now();
|
|
33
|
+
let device = null;
|
|
34
|
+
let tracker = new ReadinessTracker(args.platform ?? 'android', args.deviceId ?? '');
|
|
35
|
+
let failureCode;
|
|
36
|
+
let debugPause = null;
|
|
37
|
+
await runWithBudget(args.timeoutMs ?? (wait ? 30000 : 15000), async () => {
|
|
38
|
+
try {
|
|
39
|
+
device = await resolve(args);
|
|
40
|
+
remainingBudget();
|
|
41
|
+
tracker = new ReadinessTracker(device.platform, device.id, args.stabilityMs ?? 500);
|
|
42
|
+
await withResolvedDevice(device, async () => {
|
|
43
|
+
let count = 0;
|
|
44
|
+
while (wait || count < 2) {
|
|
45
|
+
remainingBudget();
|
|
46
|
+
debugPause = device.platform === 'android' ? pausedReadiness(device.id) : null;
|
|
47
|
+
if (debugPause) {
|
|
48
|
+
failureCode = wait ? 'DEBUGGER_PAUSED' : undefined;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const captureStarted = Date.now();
|
|
52
|
+
try {
|
|
53
|
+
const tree = await ToolsObserve.getUITreeHandler({ platform: device.platform, deviceId: device.id });
|
|
54
|
+
remainingBudget();
|
|
55
|
+
tracker.observe(tree, captureStarted);
|
|
56
|
+
if (isDeviceUnavailable(tree?.error)) {
|
|
57
|
+
failureCode = 'DEVICE_UNAVAILABLE';
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (/ACTION_TIMEOUT/.test(String(error)))
|
|
63
|
+
throw error;
|
|
64
|
+
tracker.unavailable();
|
|
65
|
+
if (isDeviceUnavailable(error)) {
|
|
66
|
+
failureCode = 'DEVICE_UNAVAILABLE';
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
count++;
|
|
71
|
+
if (tracker.summary.state === 'settled' || (!wait && tracker.summary.state === 'loading'))
|
|
72
|
+
return;
|
|
73
|
+
if (!wait && count === 2)
|
|
74
|
+
return;
|
|
75
|
+
await readinessDelay(wait ? args.pollIntervalMs ?? 500 : args.stabilityMs ?? 500);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const expired = budgetExpired(error);
|
|
81
|
+
if (expired)
|
|
82
|
+
tracker.deadline();
|
|
83
|
+
else
|
|
84
|
+
tracker.unavailable();
|
|
85
|
+
failureCode = !expired && (!device || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE';
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
if (debugPause)
|
|
89
|
+
return { success: !wait, device, readiness: debugPause, elapsed_ms: performance.now() - started, ...(wait ? { failure_code: 'DEBUGGER_PAUSED' } : {}) };
|
|
90
|
+
const state = tracker.summary.state;
|
|
91
|
+
const success = !failureCode && (wait ? state === 'settled' : state !== 'unknown');
|
|
92
|
+
return { success, device, readiness: tracker.summary, elapsed_ms: performance.now() - started,
|
|
93
|
+
...(!success ? { failure_code: failureCode ?? (wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE') } : {}) };
|
|
94
|
+
}
|
|
95
|
+
export async function withReadyWait(target, timeoutMs, operation, failure) {
|
|
96
|
+
const started = performance.now();
|
|
97
|
+
let tracker = new ReadinessTracker(target.platform ?? 'android', target.deviceId ?? '');
|
|
98
|
+
let resolved = false;
|
|
99
|
+
return runWithBudget(timeoutMs, async () => {
|
|
100
|
+
try {
|
|
101
|
+
const device = await resolve(target);
|
|
102
|
+
resolved = true;
|
|
103
|
+
remainingBudget();
|
|
104
|
+
tracker = new ReadinessTracker(device.platform, device.id);
|
|
105
|
+
const result = await withResolvedDevice(device, () => withReadinessTracker(tracker, () => operation(device)));
|
|
106
|
+
try {
|
|
107
|
+
remainingBudget();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
const alreadyFailed = result && (('status' in result && result.status !== 'success') || ('success' in result && result.success === false));
|
|
111
|
+
return { ...(alreadyFailed ? result : failure('TIMEOUT', performance.now() - started, tracker.summary.sample_count)), readiness: tracker.deadline() };
|
|
112
|
+
}
|
|
113
|
+
return { ...result, readiness: tracker.summary };
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
const expired = budgetExpired(error);
|
|
117
|
+
if (expired)
|
|
118
|
+
tracker.deadline();
|
|
119
|
+
else
|
|
120
|
+
tracker.unavailable();
|
|
121
|
+
return { ...failure(!expired && (!resolved || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : 'TIMEOUT', performance.now() - started, tracker.summary.sample_count), readiness: tracker.summary };
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { pausedReadiness } from '../debugger/guard.js';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { remainingBudget } from '../utils/operation-budget.js';
|
|
4
|
+
import { computeSnapshotSignature, getSnapshotSessionId } from './snapshot-metadata.js';
|
|
5
|
+
const baselineLimitations = ['NORMALIZED_UI_ONLY', 'FOREGROUND_APP_UNVERIFIED', 'UNRESPONSIVE_SIGNAL_UNSUPPORTED'];
|
|
6
|
+
const capabilities = { loading: true, transitioning: true, settled: true, unresponsive: false, debugger_paused: false };
|
|
7
|
+
export function isDeviceUnavailable(error) {
|
|
8
|
+
return /device.*(?:not found|offline|disconnected)|no devices found|READINESS_DEVICE_UNAVAILABLE/i.test(String(error));
|
|
9
|
+
}
|
|
10
|
+
export class ReadinessTracker {
|
|
11
|
+
platform;
|
|
12
|
+
deviceId;
|
|
13
|
+
stabilityMs;
|
|
14
|
+
signature = null;
|
|
15
|
+
stableSince = 0;
|
|
16
|
+
summary;
|
|
17
|
+
constructor(platform, deviceId, stabilityMs = 500) {
|
|
18
|
+
this.platform = platform;
|
|
19
|
+
this.deviceId = deviceId;
|
|
20
|
+
this.stabilityMs = stabilityMs;
|
|
21
|
+
this.summary = {
|
|
22
|
+
state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
|
|
23
|
+
sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
|
|
24
|
+
evidence: [], limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
unavailable(code = 'OBSERVATION_FAILED') {
|
|
28
|
+
this.signature = null;
|
|
29
|
+
this.summary = { ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
|
|
30
|
+
sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', code],
|
|
31
|
+
evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: null, snapshot_revision: null }].slice(-8) };
|
|
32
|
+
return this.summary;
|
|
33
|
+
}
|
|
34
|
+
deadline() {
|
|
35
|
+
this.summary = { ...this.summary, limitations: [...new Set([...this.summary.limitations, 'DEADLINE_EXCEEDED'])] };
|
|
36
|
+
return this.summary;
|
|
37
|
+
}
|
|
38
|
+
observe(tree, acquisitionStartedAtMs, now = performance.now()) {
|
|
39
|
+
const paused = this.platform === 'android' ? pausedReadiness(this.deviceId) : null;
|
|
40
|
+
if (paused) {
|
|
41
|
+
this.summary = paused;
|
|
42
|
+
return this.summary;
|
|
43
|
+
}
|
|
44
|
+
if (tree?.error)
|
|
45
|
+
return this.unavailable('OBSERVATION_FAILED');
|
|
46
|
+
if (!tree || tree.device?.platform !== this.platform || tree.device?.id !== this.deviceId ||
|
|
47
|
+
tree.snapshot_session_id !== getSnapshotSessionId() || !Number.isSafeInteger(tree.snapshot_revision) || tree.snapshot_revision <= 0 ||
|
|
48
|
+
!Number.isFinite(tree.captured_at_ms) || tree.captured_at_ms < acquisitionStartedAtMs || tree.captured_at_ms <= 0 ||
|
|
49
|
+
!Array.isArray(tree.elements) || !tree.elements.length || tree.elements.some((element) => !element || typeof element !== 'object') ||
|
|
50
|
+
!Number.isFinite(tree.resolution?.width) || !Number.isFinite(tree.resolution?.height) || tree.resolution.width <= 0 || tree.resolution.height <= 0)
|
|
51
|
+
return this.unavailable('OBSERVATION_INVALID');
|
|
52
|
+
const signature = computeSnapshotSignature(tree);
|
|
53
|
+
if (!signature)
|
|
54
|
+
return this.unavailable('OBSERVATION_INVALID');
|
|
55
|
+
const nativeLoading = tree.elements.some((element) => element.visible === true && ['android.widget.ProgressBar', 'ActivityIndicator', 'ProgressIndicator'].includes(element.type));
|
|
56
|
+
const textLoading = tree.elements.some((element) => element.visible === true && [element.text, element.contentDescription].some(label => typeof label === 'string' && /^(loading|please\s+wait)[.\s…]*$/i.test(label.trim())));
|
|
57
|
+
const previous = this.signature;
|
|
58
|
+
const unchanged = previous === signature;
|
|
59
|
+
if (!unchanged || nativeLoading || textLoading)
|
|
60
|
+
this.stableSince = now;
|
|
61
|
+
this.signature = signature;
|
|
62
|
+
const stableFor = unchanged && !nativeLoading && !textLoading ? Math.max(0, now - this.stableSince) : 0;
|
|
63
|
+
const state = nativeLoading || textLoading ? 'loading' : !previous ? 'unknown' : !unchanged ? 'transitioning' : stableFor >= this.stabilityMs ? 'settled' : 'unknown';
|
|
64
|
+
const code = nativeLoading ? 'NATIVE_LOADING_INDICATOR' : textLoading ? 'LOADING_LABEL' : state === 'transitioning' ? 'SNAPSHOT_CHANGED' : state === 'settled' ? 'SNAPSHOT_STABLE' : 'INSUFFICIENT_SAMPLES';
|
|
65
|
+
this.summary = {
|
|
66
|
+
state, scope: 'device_ui', basis: textLoading && !nativeLoading ? 'heuristic' : state === 'unknown' ? 'insufficient' : 'observed',
|
|
67
|
+
captured_at_ms: tree.captured_at_ms, snapshot_session_id: tree.snapshot_session_id, snapshot_revision: tree.snapshot_revision,
|
|
68
|
+
sample_count: this.summary.sample_count + 1, stable_for_ms: stableFor,
|
|
69
|
+
evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: tree.captured_at_ms, snapshot_revision: tree.snapshot_revision }].slice(-8),
|
|
70
|
+
limitations: [...baselineLimitations, this.platform === 'android' ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities, debugger_paused: this.platform === 'android' }
|
|
71
|
+
};
|
|
72
|
+
return this.summary;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const waits = new AsyncLocalStorage();
|
|
76
|
+
export function currentReadinessWait() { return waits.getStore(); }
|
|
77
|
+
export function withReadinessTracker(tracker, operation) { return waits.run(tracker, operation); }
|
|
78
|
+
export function observeReadinessWait(tree, startedAtMs) {
|
|
79
|
+
const tracker = waits.getStore();
|
|
80
|
+
if (!tracker)
|
|
81
|
+
return;
|
|
82
|
+
remainingBudget();
|
|
83
|
+
const summary = tracker.observe(tree, startedAtMs);
|
|
84
|
+
if (isDeviceUnavailable(tree?.error))
|
|
85
|
+
throw new Error('READINESS_DEVICE_UNAVAILABLE');
|
|
86
|
+
if (summary.limitations.some(code => ['OBSERVATION_INVALID', 'OBSERVATION_FAILED'].includes(code)))
|
|
87
|
+
throw new Error('READINESS_SAMPLE_INVALID');
|
|
88
|
+
}
|