mobile-debug-mcp 0.32.2 → 0.33.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/{skills → .agents/skills}/mcp-builder/SKILL.md +1 -2
- package/{skills → .agents/skills}/spec-review/SKILL.md +1 -1
- package/{skills → .agents/skills}/test-authoring/SKILL.md +1 -1
- package/.claude-plugin/marketplace.json +14 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.github/copilot-instructions.md +7 -0
- package/AGENTS.md +21 -10
- package/README.md +67 -5
- 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/LICENSE +201 -0
- package/agent-plugin/README.md +29 -0
- package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
- package/agent-plugin/mcp.json +11 -0
- package/agent-plugin/plugin.json +29 -0
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +124 -0
- package/dist/interact/android.js +10 -4
- package/dist/interact/conditional-action.js +68 -0
- package/dist/interact/index.js +54 -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 +27 -22
- package/dist/observe/evidence.js +273 -0
- package/dist/observe/index.js +33 -6
- package/dist/observe/ios.js +25 -6
- package/dist/observe/readiness-service.js +115 -0
- package/dist/observe/readiness.js +81 -0
- package/dist/observe/recording.js +165 -0
- package/dist/observe/snapshot-metadata.js +10 -2
- package/dist/server/common.js +9 -0
- package/dist/server/condition-schema.js +32 -0
- package/dist/server/journey-schema.js +3 -2
- package/dist/server/readiness-schema.js +12 -0
- package/dist/server/tool-definitions.js +46 -0
- package/dist/server/tool-handlers.js +99 -17
- package/dist/server-core.js +1 -1
- package/dist/utils/ios/utils.js +4 -4
- package/dist/utils/operation-budget.js +24 -2
- package/dist/utils/resolve-device.js +7 -0
- package/docs/CHANGELOG.md +16 -2
- package/docs/benchmarks/ui-tree-performance/README.md +4 -3
- 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/process.md +213 -0
- package/docs/tools/TOOLS.md +3 -0
- package/docs/tools/evidence.md +79 -0
- package/docs/tools/interact.md +125 -2
- package/docs/tools/observe.md +64 -2
- package/package.json +4 -2
- package/scripts/agent-plugin.mjs +98 -0
- package/src/interact/android.ts +10 -4
- package/src/interact/conditional-action.ts +75 -0
- package/src/interact/index.ts +42 -14
- 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 +35 -21
- package/src/observe/evidence.ts +255 -0
- package/src/observe/index.ts +32 -8
- package/src/observe/ios.ts +32 -8
- package/src/observe/readiness-service.ts +89 -0
- package/src/observe/readiness.ts +78 -0
- package/src/observe/snapshot-metadata.ts +12 -1
- package/src/server/common.ts +7 -0
- package/src/server/condition-schema.ts +32 -0
- package/src/server/journey-schema.ts +3 -2
- package/src/server/readiness-schema.ts +12 -0
- package/src/server/tool-definitions.ts +44 -0
- package/src/server/tool-handlers.ts +93 -17
- package/src/server-core.ts +1 -1
- package/src/types.ts +30 -1
- package/src/utils/ios/utils.ts +4 -4
- package/src/utils/operation-budget.ts +22 -2
- package/src/utils/resolve-device.ts +8 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -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/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/unit/observe/android_tree_acquisition.test.ts +6 -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-getlogs.test.ts +3 -3
- package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/readiness.test.ts +39 -0
- package/test/unit/observe/readiness_native.test.ts +68 -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/tap_selector.test.ts +2 -0
- package/skills/mobile-debug-tools-usage/SKILL.md +0 -74
- package/src/observe/android-tree-bridge.ts +0 -178
- package/test/device/automated/ui-tree-performance/bridge-probe.ts +0 -197
- package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +0 -6
- package/test/fixtures/ui-tree-bridge/build.ts +0 -34
- package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +0 -126
- package/test/unit/observe/android_tree_bridge_cleanup.test.ts +0 -95
- /package/{skills → .agents/skills}/README.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/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/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
package/dist/observe/android.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureEvidence } from './evidence.js';
|
|
1
2
|
import { spawn } from "child_process";
|
|
2
3
|
import { XMLParser } from "fast-xml-parser";
|
|
3
4
|
import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "../utils/android/utils.js";
|
|
@@ -9,13 +10,16 @@ import { parsePngSize } from "../utils/image.js";
|
|
|
9
10
|
import { deriveSnapshotMetadata } from "./snapshot-metadata.js";
|
|
10
11
|
import { remainingBudget } from '../utils/operation-budget.js';
|
|
11
12
|
import { createObservationMeasurement } from './performance-metrics.js';
|
|
12
|
-
import { androidTreeBridgeEnabled, readAndroidTreeFromBridge } from './android-tree-bridge.js';
|
|
13
13
|
const activeLogStreams = new Map();
|
|
14
14
|
export class AndroidObserve {
|
|
15
|
+
getLogStreamDevice(sessionId = 'default') { return activeLogStreams.get(sessionId)?.device ?? null; }
|
|
15
16
|
async getDeviceMetadata(appId, deviceId) {
|
|
16
17
|
return getAndroidDeviceMetadata(appId, deviceId);
|
|
17
18
|
}
|
|
18
19
|
async getUITree(deviceId) {
|
|
20
|
+
return captureEvidence('ui_tree', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetUITree(deviceId));
|
|
21
|
+
}
|
|
22
|
+
async collectGetUITree(deviceId) {
|
|
19
23
|
const measurement = createObservationMeasurement('android');
|
|
20
24
|
let metadata;
|
|
21
25
|
try {
|
|
@@ -47,22 +51,7 @@ export class AndroidObserve {
|
|
|
47
51
|
await measurement.timed('observation_wait_ms', () => delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))));
|
|
48
52
|
}
|
|
49
53
|
remainingBudget();
|
|
50
|
-
|
|
51
|
-
if (androidTreeBridgeEnabled()) {
|
|
52
|
-
try {
|
|
53
|
-
bridgeContent = await measurement.physicalRead(async () => {
|
|
54
|
-
const content = await readAndroidTreeFromBridge(deviceInfo.id);
|
|
55
|
-
if (!content)
|
|
56
|
-
throw new Error('Android UI tree bridge unavailable');
|
|
57
|
-
return content;
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
if (error instanceof Error && (error.message === 'ACTION_TIMEOUT' || error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED')))
|
|
62
|
-
throw error;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
xmlContent = bridgeContent ?? await measurement.physicalRead(async () => {
|
|
54
|
+
xmlContent = await measurement.physicalRead(async () => {
|
|
66
55
|
await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
|
|
67
56
|
const content = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId);
|
|
68
57
|
if (!content || !content.trim() || content.includes('ERROR:'))
|
|
@@ -72,7 +61,7 @@ export class AndroidObserve {
|
|
|
72
61
|
break;
|
|
73
62
|
}
|
|
74
63
|
catch (e) {
|
|
75
|
-
if (e instanceof Error &&
|
|
64
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
|
|
76
65
|
throw e;
|
|
77
66
|
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
78
67
|
}
|
|
@@ -129,6 +118,9 @@ export class AndroidObserve {
|
|
|
129
118
|
}
|
|
130
119
|
}
|
|
131
120
|
async getLogs(filters = {}) {
|
|
121
|
+
return captureEvidence('logs', { platform: 'android', id: filters.deviceId ?? '' }, () => this.collectGetLogs(filters));
|
|
122
|
+
}
|
|
123
|
+
async collectGetLogs(filters = {}) {
|
|
132
124
|
const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters;
|
|
133
125
|
const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
|
|
134
126
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
@@ -229,6 +221,9 @@ export class AndroidObserve {
|
|
|
229
221
|
}
|
|
230
222
|
}
|
|
231
223
|
async captureScreen(deviceId) {
|
|
224
|
+
return captureEvidence('screenshot', { platform: 'android', id: deviceId ?? '' }, () => this.collectCaptureScreen(deviceId));
|
|
225
|
+
}
|
|
226
|
+
async collectCaptureScreen(deviceId) {
|
|
232
227
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
233
228
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
234
229
|
return new Promise((resolve, reject) => {
|
|
@@ -347,6 +342,9 @@ export class AndroidObserve {
|
|
|
347
342
|
});
|
|
348
343
|
}
|
|
349
344
|
async getCurrentScreen(deviceId) {
|
|
345
|
+
return captureEvidence('activity', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetCurrentScreen(deviceId));
|
|
346
|
+
}
|
|
347
|
+
async collectGetCurrentScreen(deviceId) {
|
|
350
348
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
351
349
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
352
350
|
try {
|
|
@@ -401,12 +399,15 @@ export class AndroidObserve {
|
|
|
401
399
|
}
|
|
402
400
|
}
|
|
403
401
|
async getScreenFingerprint(deviceId) {
|
|
402
|
+
return captureEvidence('fingerprint', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetScreenFingerprint(deviceId));
|
|
403
|
+
}
|
|
404
|
+
async collectGetScreenFingerprint(deviceId) {
|
|
404
405
|
try {
|
|
405
406
|
const tree = await this.getUITree(deviceId);
|
|
406
407
|
if (!tree || tree.error)
|
|
407
408
|
return { fingerprint: null, error: tree.error };
|
|
408
409
|
const current = await this.getCurrentScreen(deviceId).catch(() => null);
|
|
409
|
-
return computeScreenFingerprint(tree, current, 'android', 50);
|
|
410
|
+
return { ...computeScreenFingerprint(tree, current, 'android', 50), captured_at_ms: tree.captured_at_ms };
|
|
410
411
|
}
|
|
411
412
|
catch (e) {
|
|
412
413
|
return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
|
|
@@ -427,7 +428,7 @@ export class AndroidObserve {
|
|
|
427
428
|
catch { }
|
|
428
429
|
activeLogStreams.delete(sessionId);
|
|
429
430
|
}
|
|
430
|
-
const args = ['logcat', `--pid=${pid}`, filter];
|
|
431
|
+
const args = [...(deviceId ? ['-s', deviceId] : []), 'logcat', `--pid=${pid}`, filter];
|
|
431
432
|
const proc = spawn(getAdbCmd(), args);
|
|
432
433
|
const tmpDir = process.env.TMPDIR || '/tmp';
|
|
433
434
|
const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`);
|
|
@@ -450,9 +451,10 @@ export class AndroidObserve {
|
|
|
450
451
|
});
|
|
451
452
|
proc.on('close', () => {
|
|
452
453
|
stream.end();
|
|
453
|
-
activeLogStreams.
|
|
454
|
+
if (activeLogStreams.get(sessionId)?.proc === proc)
|
|
455
|
+
activeLogStreams.delete(sessionId);
|
|
454
456
|
});
|
|
455
|
-
activeLogStreams.set(sessionId, { proc, file });
|
|
457
|
+
activeLogStreams.set(sessionId, { proc, file, device: { platform: 'android', id: deviceId ?? '' } });
|
|
456
458
|
return { success: true, stream_started: true };
|
|
457
459
|
}
|
|
458
460
|
catch (e) {
|
|
@@ -471,6 +473,9 @@ export class AndroidObserve {
|
|
|
471
473
|
return { success: true };
|
|
472
474
|
}
|
|
473
475
|
async readLogStream(sessionId = 'default', limit = 100, since) {
|
|
476
|
+
return captureEvidence('logs', this.getLogStreamDevice(sessionId), () => this.collectReadLogStream(sessionId, limit, since));
|
|
477
|
+
}
|
|
478
|
+
async collectReadLogStream(sessionId = 'default', limit = 100, since) {
|
|
474
479
|
const entry = activeLogStreams.get(sessionId);
|
|
475
480
|
let file;
|
|
476
481
|
if (entry && entry.file)
|
|
@@ -0,0 +1,273 @@
|
|
|
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 currentEvidenceActionId() { return context.getStore()?.action?.id; }
|
|
56
|
+
export function bindEvidenceDevice(device) {
|
|
57
|
+
const current = context.getStore();
|
|
58
|
+
const target = validDevice(device);
|
|
59
|
+
if (!current || !target)
|
|
60
|
+
return;
|
|
61
|
+
current.device ??= target;
|
|
62
|
+
if (current.isAction && current.action)
|
|
63
|
+
current.action.device ??= target;
|
|
64
|
+
}
|
|
65
|
+
export function markEvidenceVerification() {
|
|
66
|
+
const current = context.getStore();
|
|
67
|
+
if (current) {
|
|
68
|
+
current.verification = stamp();
|
|
69
|
+
if (current.isAction && current.action)
|
|
70
|
+
current.action.verification = current.verification;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export async function evidenceDispatch(device, operation) {
|
|
74
|
+
const current = context.getStore();
|
|
75
|
+
if (!current?.isAction || !current.action)
|
|
76
|
+
return operation();
|
|
77
|
+
bindEvidenceDevice(device);
|
|
78
|
+
const record = current.action;
|
|
79
|
+
const start = stamp();
|
|
80
|
+
record.dispatchStart ??= start;
|
|
81
|
+
record.dispatchEnd = null;
|
|
82
|
+
const order = orderFor(validDevice(device));
|
|
83
|
+
if (order) {
|
|
84
|
+
order.sequence++;
|
|
85
|
+
order.active++;
|
|
86
|
+
order.lastStarted = start.mono;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return await operation();
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
record.dispatchEnd = stamp();
|
|
93
|
+
if (order)
|
|
94
|
+
order.active--;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function omitEvidence(kind) {
|
|
98
|
+
const current = context.getStore();
|
|
99
|
+
if (!current)
|
|
100
|
+
return;
|
|
101
|
+
const start = stamp();
|
|
102
|
+
current.observations.push({ value: {
|
|
103
|
+
evidence_id: randomUUID(), kind, status: 'omitted', capture_started_at_ms: null,
|
|
104
|
+
capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
|
|
105
|
+
freshness: kind === 'logs' ? 'not_applicable' : 'unknown', freshness_reasons: [], device: current.device
|
|
106
|
+
}, start, end: null, order: null, startSequence: 0, endSequence: 0, overlapping: false, reused: false });
|
|
107
|
+
}
|
|
108
|
+
export async function captureEvidence(kind, device, operation) {
|
|
109
|
+
const current = context.getStore();
|
|
110
|
+
if (!current)
|
|
111
|
+
return operation();
|
|
112
|
+
const target = validDevice(device);
|
|
113
|
+
if (target)
|
|
114
|
+
bindEvidenceDevice(target);
|
|
115
|
+
const order = orderFor(target);
|
|
116
|
+
const start = stamp();
|
|
117
|
+
const acquisition = { value: {
|
|
118
|
+
evidence_id: randomUUID(), kind, status: 'failed', capture_started_at_ms: start.wall,
|
|
119
|
+
capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
|
|
120
|
+
freshness: 'unknown', freshness_reasons: [], device: target
|
|
121
|
+
}, start, end: null, order, startSequence: order?.sequence ?? 0,
|
|
122
|
+
endSequence: 0, overlapping: !!order?.active, reused: false };
|
|
123
|
+
current.observations.push(acquisition);
|
|
124
|
+
try {
|
|
125
|
+
const result = await operation();
|
|
126
|
+
const value = acquisition.value;
|
|
127
|
+
const resolved = validDevice(result?.device) ?? target;
|
|
128
|
+
value.device = resolved;
|
|
129
|
+
if (resolved)
|
|
130
|
+
bindEvidenceDevice(resolved);
|
|
131
|
+
const failure = result?.error ?? result?.meta?.error;
|
|
132
|
+
value.status = failure || result?.success === false || (kind === 'fingerprint' && !result?.fingerprint) ? 'failed' : 'captured';
|
|
133
|
+
if (failure)
|
|
134
|
+
value.error = failure;
|
|
135
|
+
const source = result?.captured_at_ms;
|
|
136
|
+
if (Number.isFinite(source) && source > 0) {
|
|
137
|
+
value.source_captured_at_ms = source;
|
|
138
|
+
acquisition.reused = source < start.wall;
|
|
139
|
+
}
|
|
140
|
+
if (kind === 'ui_tree') {
|
|
141
|
+
value.snapshot_session_id = result?.snapshot_session_id;
|
|
142
|
+
value.snapshot_revision = result?.snapshot_revision;
|
|
143
|
+
}
|
|
144
|
+
if (kind === 'logs') {
|
|
145
|
+
const entries = result?.logs ?? result?.entries ?? [];
|
|
146
|
+
const times = entries.map((e) => {
|
|
147
|
+
const t = e?.timestamp ?? e?._iso;
|
|
148
|
+
return typeof t === 'number' ? t : typeof t === 'string' ? Date.parse(t) : NaN;
|
|
149
|
+
}).filter((t) => Number.isFinite(t));
|
|
150
|
+
value.clock = 'device_or_unknown';
|
|
151
|
+
value.event_time_comparable = false;
|
|
152
|
+
value.earliest_entry_at_ms = times.length ? times.reduce((a, b) => Math.min(a, b)) : null;
|
|
153
|
+
value.latest_entry_at_ms = times.length ? times.reduce((a, b) => Math.max(a, b)) : null;
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
acquisition.value.error = error instanceof Error ? error.message : String(error);
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
acquisition.end = stamp();
|
|
163
|
+
acquisition.value.capture_completed_at_ms = acquisition.end.wall;
|
|
164
|
+
acquisition.endSequence = order?.sequence ?? 0;
|
|
165
|
+
acquisition.overlapping ||= !!order?.active || acquisition.startSequence !== acquisition.endSequence;
|
|
166
|
+
if (acquisition.value.status === 'captured' && acquisition.value.source_captured_at_ms === null && kind !== 'logs') {
|
|
167
|
+
acquisition.value.source_captured_at_ms = acquisition.end.wall;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function association(current) {
|
|
172
|
+
if (current.isAction)
|
|
173
|
+
return { association: 'self', association_reason: null };
|
|
174
|
+
if (!current.ref)
|
|
175
|
+
return { association: 'none', association_reason: null };
|
|
176
|
+
const reason = current.ref.sessionId !== sessionId ? 'SESSION_MISMATCH' : !current.action ? 'ACTION_UNAVAILABLE' :
|
|
177
|
+
!sameDevice(current.device, current.action.device) ? 'DEVICE_MISMATCH' : null;
|
|
178
|
+
return { association: reason ? 'unavailable' : 'explicit', association_reason: reason };
|
|
179
|
+
}
|
|
180
|
+
function assemble(current) {
|
|
181
|
+
const end = stamp();
|
|
182
|
+
const link = association(current);
|
|
183
|
+
const action = link.association === 'unavailable' ? null : current.action;
|
|
184
|
+
const clockChanged = end.wall < current.start.wall;
|
|
185
|
+
const observations = current.observations.map(a => {
|
|
186
|
+
const v = { ...a.value, freshness_reasons: [] };
|
|
187
|
+
if (v.status === 'omitted')
|
|
188
|
+
return v;
|
|
189
|
+
const componentAction = sameDevice(v.device, action?.device ?? null) ? action : null;
|
|
190
|
+
const sourceMono = a.end && v.source_captured_at_ms !== null ? a.end.mono + v.source_captured_at_ms - a.end.wall : a.end?.mono;
|
|
191
|
+
const reason = (freshness, code) => { v.freshness = freshness; v.freshness_reasons.push(code); };
|
|
192
|
+
if (v.kind === 'logs')
|
|
193
|
+
reason('unknown', 'DEVICE_CLOCK_UNVERIFIED');
|
|
194
|
+
else if (clockChanged || (a.end && a.end.wall < a.start.wall))
|
|
195
|
+
reason('unknown', 'CLOCK_DISCONTINUITY');
|
|
196
|
+
else if (v.source_captured_at_ms !== null && a.end && v.source_captured_at_ms > a.end.wall)
|
|
197
|
+
reason('unknown', 'CLOCK_DISCONTINUITY');
|
|
198
|
+
else if (!v.device || !a.order || orders.get(deviceKey(v.device)) !== a.order || v.status !== 'captured' || !a.end)
|
|
199
|
+
reason('unknown', 'CAPTURE_TIME_UNKNOWN');
|
|
200
|
+
else if (componentAction?.dispatchStart && sourceMono !== undefined && sourceMono < componentAction.dispatchStart.mono)
|
|
201
|
+
reason('stale', 'BEFORE_ACTION');
|
|
202
|
+
else if (a.order && (a.order.sequence > a.endSequence || (a.reused && sourceMono !== undefined && sourceMono < a.order.lastStarted)))
|
|
203
|
+
reason('stale', 'SUPERSEDED_BY_ACTION');
|
|
204
|
+
else if (a.overlapping)
|
|
205
|
+
reason('unknown', 'OVERLAPPING_ACTION');
|
|
206
|
+
else if (a.reused)
|
|
207
|
+
reason('unknown', 'REUSED_OBSERVATION');
|
|
208
|
+
else {
|
|
209
|
+
v.freshness = 'fresh';
|
|
210
|
+
}
|
|
211
|
+
if (!clockChanged && a.end && a.end.wall >= a.start.wall && v.source_captured_at_ms !== null && v.source_captured_at_ms <= end.wall) {
|
|
212
|
+
v.age_ms = Math.max(0, end.mono - a.end.mono + a.end.wall - v.source_captured_at_ms);
|
|
213
|
+
}
|
|
214
|
+
return { ...v, session_id: sessionId, correlation_id: componentAction?.correlationId ?? current.correlationId,
|
|
215
|
+
action_id: componentAction?.id ?? null, association: componentAction ? 'internal' : link.association };
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
session_id: sessionId, correlation_id: action?.correlationId ?? current.correlationId,
|
|
219
|
+
action_id: action?.id ?? null, device: current.device, ...link,
|
|
220
|
+
...(current.ref ? { requested_action_ref: current.ref } : {}),
|
|
221
|
+
operation_started_at_ms: current.start.wall, operation_completed_at_ms: current.end?.wall ?? end.wall,
|
|
222
|
+
dispatch_started_at_ms: action?.dispatchStart?.wall ?? null,
|
|
223
|
+
dispatch_completed_at_ms: action?.dispatchEnd?.wall ?? null,
|
|
224
|
+
verification_completed_at_ms: current.verification?.wall ?? action?.verification?.wall ?? null,
|
|
225
|
+
observations, trace_context: { supported: false, trace_id: null, markers: [] }
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
export async function withEvidence(options, operation) {
|
|
229
|
+
prune();
|
|
230
|
+
const parent = context.getStore();
|
|
231
|
+
const id = randomUUID();
|
|
232
|
+
const record = options.action ? { id, correlationId: parent?.correlationId ?? id, device: null,
|
|
233
|
+
dispatchStart: null, dispatchEnd: null, verification: null, completed: null } : options.ref?.sessionId === sessionId ? active.get(options.ref.actionId) ?? history.get(options.ref.actionId) ?? null : null;
|
|
234
|
+
const current = { correlationId: options.action ? record.correlationId : options.journey ? id : randomUUID(),
|
|
235
|
+
action: record, ref: options.ref, device: null, start: stamp(), end: null, verification: null, observations: [], children: [], isAction: !!options.action };
|
|
236
|
+
if (options.action && record)
|
|
237
|
+
active.set(id, record);
|
|
238
|
+
if (parent && options.action)
|
|
239
|
+
parent.children.push(current);
|
|
240
|
+
try {
|
|
241
|
+
return await context.run(current, async () => {
|
|
242
|
+
const result = await operation();
|
|
243
|
+
current.end = stamp();
|
|
244
|
+
return { result, evidence: assemble(current) };
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
if (options.action && record) {
|
|
249
|
+
active.delete(id);
|
|
250
|
+
record.completed = performance.now();
|
|
251
|
+
history.set(id, record);
|
|
252
|
+
prune();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
export function refreshJourneyEvidence(steps) {
|
|
257
|
+
const current = context.getStore();
|
|
258
|
+
if (!current)
|
|
259
|
+
return;
|
|
260
|
+
for (const step of steps) {
|
|
261
|
+
const previous = step.result?.evidence;
|
|
262
|
+
const child = current.children.find(c => c.action?.id === previous?.action_id);
|
|
263
|
+
if (child && step.result)
|
|
264
|
+
step.result.evidence = assemble(child);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
export function completeJourneyVerification(actionId) {
|
|
268
|
+
const child = context.getStore()?.children.find(c => c.action?.id === actionId);
|
|
269
|
+
if (child?.action) {
|
|
270
|
+
child.verification = stamp();
|
|
271
|
+
child.action.verification = child.verification;
|
|
272
|
+
}
|
|
273
|
+
}
|
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: [] };
|