mobile-debug-mcp 0.36.0 → 0.37.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/README.md +10 -0
- package/agent-plugin/.antigravity-plugin/plugin.json +1 -1
- package/agent-plugin/.claude-plugin/plugin.json +2 -2
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/cursor/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +10 -2
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/mcp_config.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +10 -2
- package/dist/contracts/device-readiness.js +1 -0
- package/dist/interact/waits/ui-change-wait.js +9 -2
- package/dist/interact/waits/ui-wait.js +7 -3
- package/dist/manage/index.js +3 -0
- package/dist/observe/device-readiness.js +116 -0
- package/dist/observe/evidence.js +31 -0
- package/dist/observe/ios-trace/sessions.js +5 -3
- package/dist/observe/readiness-service.js +21 -9
- package/dist/observe/readiness.js +30 -0
- package/dist/observe/screenshot-encoding.js +4 -0
- package/dist/observe/snapshot-collection.js +7 -2
- package/dist/server/definitions/manage.js +3 -2
- package/dist/server/handlers/action-execution.js +23 -14
- package/dist/server/handlers/compact-interaction.js +106 -0
- package/dist/server/handlers/compact-state.js +47 -0
- package/dist/server/handlers/input.js +2 -0
- package/dist/server/handlers/interact.js +2 -0
- package/dist/server/handlers/interaction-diagnostics.js +62 -0
- package/dist/server/handlers/journey.js +18 -20
- package/dist/server/handlers/observe.js +2 -1
- package/dist/server/handlers/tool-handlers.js +9 -5
- package/dist/server/schemas/interaction-diagnostics.js +9 -0
- package/dist/server/tool-definitions.js +9 -4
- package/dist/server-core.js +5 -2
- package/dist/server.js +1 -1
- package/dist/system/android.js +19 -15
- package/dist/system/index.js +2 -1
- package/dist/utils/android/utils.js +7 -16
- package/dist/utils/app-installation.js +77 -0
- package/dist/utils/benchmark-metrics.js +43 -0
- package/dist/utils/exec.js +4 -0
- package/dist/utils/ios/utils.js +4 -11
- package/dist/utils/resolve-device.js +3 -2
- package/dist/utils/timing.js +3 -3
- package/docs/CHANGELOG.md +25 -0
- package/docs/benchmarks/README.md +1 -0
- package/docs/benchmarks/end-to-end-latency/README.md +98 -0
- package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
- package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
- package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
- package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
- package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
- package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
- package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
- package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
- package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
- package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
- package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
- package/docs/specs/031-ios-system-traces/plan.md +28 -0
- package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
- package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
- package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
- package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
- package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
- package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
- package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
- package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
- package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
- package/docs/specs/034-app-installation-status/intent.md +41 -0
- package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
- package/docs/specs/034-app-installation-status/plan.md +73 -0
- package/docs/specs/034-app-installation-status/spec.md +114 -0
- package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
- package/docs/specs/035-compact-interaction-results/intent.md +38 -0
- package/docs/specs/035-compact-interaction-results/plan.md +43 -0
- package/docs/specs/035-compact-interaction-results/spec.md +118 -0
- package/docs/specs/035-compact-interaction-results/verification.md +111 -0
- package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
- package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
- package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
- package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
- package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
- package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
- package/docs/tools/TOOLS.md +1 -0
- package/docs/tools/compact-interactions.md +56 -0
- package/docs/tools/correlated-evidence.md +6 -0
- package/docs/tools/interact.md +17 -8
- package/docs/tools/manage.md +10 -2
- package/docs/tools/observe.md +42 -2
- package/docs/tools/system.md +6 -0
- package/package.json +1 -1
- package/src/contracts/device-readiness.ts +8 -0
- package/src/interact/waits/ui-change-wait.ts +7 -2
- package/src/interact/waits/ui-wait.ts +6 -3
- package/src/manage/index.ts +2 -0
- package/src/observe/device-readiness.ts +96 -0
- package/src/observe/evidence.ts +30 -1
- package/src/observe/ios-trace/sessions.ts +4 -2
- package/src/observe/readiness-service.ts +21 -9
- package/src/observe/readiness.ts +33 -0
- package/src/observe/screenshot-encoding.ts +4 -0
- package/src/observe/snapshot-collection.ts +7 -2
- package/src/server/common.ts +2 -2
- package/src/server/definitions/manage.ts +3 -2
- package/src/server/handlers/action-execution.ts +22 -14
- package/src/server/handlers/compact-interaction.ts +91 -0
- package/src/server/handlers/compact-state.ts +43 -0
- package/src/server/handlers/input.ts +2 -0
- package/src/server/handlers/interact.ts +2 -0
- package/src/server/handlers/interaction-diagnostics.ts +50 -0
- package/src/server/handlers/journey.ts +15 -17
- package/src/server/handlers/observe.ts +2 -1
- package/src/server/handlers/payload.ts +2 -1
- package/src/server/handlers/tool-handlers.ts +9 -5
- package/src/server/schemas/interaction-diagnostics.ts +10 -0
- package/src/server/tool-definitions.ts +8 -4
- package/src/server-core.ts +5 -2
- package/src/server.ts +1 -1
- package/src/system/android.ts +18 -11
- package/src/system/index.ts +2 -1
- package/src/types.ts +2 -0
- package/src/utils/android/utils.ts +8 -10
- package/src/utils/app-installation.ts +73 -0
- package/src/utils/benchmark-metrics.ts +42 -0
- package/src/utils/exec.ts +5 -0
- package/src/utils/ios/utils.ts +4 -10
- package/src/utils/resolve-device.ts +4 -2
- package/src/utils/timing.ts +3 -2
- package/test/device/automated/debugger/runtime.android.test.ts +5 -0
- package/test/device/automated/debugger/runtime.ios.test.ts +7 -1
- package/test/device/automated/interact/compact-results.smoke.ts +58 -0
- package/test/device/automated/latency/end-to-end-environment.ts +61 -0
- package/test/device/automated/latency/end-to-end-report.ts +90 -0
- package/test/device/automated/latency/end-to-end-session.ts +98 -0
- package/test/device/automated/latency/end-to-end.ts +149 -0
- package/test/device/automated/latency/owned-transport.ts +81 -0
- package/test/device/automated/latency/snapshot-pair.ts +143 -0
- package/test/device/automated/latency-ios/benchmark.ts +126 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
- package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
- package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
- package/test/device/automated/observe/ios-system-trace.smoke.ts +2 -2
- package/test/device/automated/observe/system-trace.smoke.ts +2 -2
- package/test/fixtures/contracts/tool-definitions.json +51 -15
- package/test/fixtures/latency-journey-app/build.ts +3 -3
- package/test/unit/debugger/handle-guards.test.ts +2 -1
- package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
- package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
- package/test/unit/observe/device-readiness.test.ts +97 -0
- package/test/unit/observe/ios-trace-sessions.test.ts +23 -0
- package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
- package/test/unit/server/compact-interaction.test.ts +87 -0
- package/test/unit/server/compact-journey-evidence.test.ts +40 -0
- package/test/unit/server/compact-review-regressions.test.ts +78 -0
- package/test/unit/server/conditional_actions.test.ts +7 -3
- package/test/unit/server/correlated-evidence.test.ts +18 -6
- package/test/unit/server/end-to-end-report.test.ts +46 -0
- package/test/unit/server/input-device-readiness.test.ts +55 -0
- package/test/unit/server/interaction-diagnostics.test.ts +45 -0
- package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
- package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
- package/test/unit/server/readiness.test.ts +24 -1
- package/test/unit/server/run_journey.test.ts +2 -1
- package/test/unit/server/selector_tap_target.test.ts +2 -1
- package/test/unit/server/system-trace.test.ts +6 -4
- package/test/unit/utils/app-installation.test.ts +78 -0
- package/test/unit/utils/benchmark-metrics.test.ts +36 -0
- package/test/unit/utils/resolve_device.test.ts +11 -0
|
@@ -124,6 +124,29 @@ async function run() {
|
|
|
124
124
|
assert.equal(result.completion_reason,'duration_limit');assert.ok(result.artifact)
|
|
125
125
|
}finally{await f.cleanup()}
|
|
126
126
|
}
|
|
127
|
+
const cancelledStopFailure=fixture()
|
|
128
|
+
try {
|
|
129
|
+
const started=await cancelledStopFailure.sessions.start(target,'fixture')
|
|
130
|
+
const directory=path.dirname(cancelledStopFailure.recordings[0].file)
|
|
131
|
+
cancelledStopFailure.flags({stop:true})
|
|
132
|
+
const failed=await cancelledStopFailure.sessions.control(started.trace_id,'cancel')
|
|
133
|
+
assert.equal(failed.cleanup.status,'failed')
|
|
134
|
+
await fs.stat(directory) // Never remove a directory while the recorder may still write.
|
|
135
|
+
cancelledStopFailure.flags({})
|
|
136
|
+
assert.equal((await cancelledStopFailure.sessions.control(started.trace_id,'cancel')).cleanup.status,'complete')
|
|
137
|
+
} finally {await cancelledStopFailure.cleanup()}
|
|
138
|
+
const exitedStopFailure=fixture()
|
|
139
|
+
try {
|
|
140
|
+
const started=await exitedStopFailure.sessions.start(target,'fixture')
|
|
141
|
+
const directory=path.dirname(exitedStopFailure.recordings[0].file)
|
|
142
|
+
exitedStopFailure.flags({stop:true})
|
|
143
|
+
assert.deepEqual(await exitedStopFailure.sessions.shutdown(),[], 'a stop failure after confirmed recorder exit must not strand discarded files')
|
|
144
|
+
const result=await exitedStopFailure.sessions.control(started.trace_id,'status')
|
|
145
|
+
assert.equal(result.state,'failed', 'cleanup must not hide the recorder failure')
|
|
146
|
+
assert.equal(result.reason,'ACTION_TIMEOUT')
|
|
147
|
+
assert.equal(result.cleanup.status,'complete')
|
|
148
|
+
await assert.rejects(()=>fs.stat(directory),{code:'ENOENT'})
|
|
149
|
+
} finally {await exitedStopFailure.cleanup()}
|
|
127
150
|
const shutdown=fixture()
|
|
128
151
|
try{const s=await shutdown.sessions.start(target,'fixture');assert.deepEqual(await shutdown.sessions.shutdown(),[]);assert.equal((await shutdown.sessions.control(s.trace_id,'status')).state,'cancelled');await assert.rejects(()=>shutdown.sessions.start(target,'fixture'),/TRACE_BUSY/)}finally{await shutdown.cleanup()}
|
|
129
152
|
console.log('PASS iOS trace lifecycle, linkage, failure cleanup, retries and expiry')
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { ReadinessTracker } from '../../../src/observe/readiness.js'
|
|
3
|
+
import { withDeviceReadinessProbe } from '../../../src/observe/device-readiness.js'
|
|
4
|
+
import { runWithBudget } from '../../../src/utils/operation-budget.js'
|
|
5
|
+
|
|
6
|
+
async function run() {
|
|
7
|
+
let power = 'Asleep', lock = 'true', fail = false
|
|
8
|
+
const tracker = new ReadinessTracker('android', 'fixture')
|
|
9
|
+
const observe = () => withDeviceReadinessProbe(async (_command, args) => {
|
|
10
|
+
if (fail) throw new Error('native failure')
|
|
11
|
+
return { exitCode: 0, stderr: '', stdout: args.includes('power')
|
|
12
|
+
? (power ? `mWakefulness=${power}\nmWakefulnessChanging=false` : '')
|
|
13
|
+
: (lock ? `KeyguardServiceDelegate\n showing=${lock}\n mIsShowing=${lock}` : '') }
|
|
14
|
+
}, () => tracker.observeDevice())
|
|
15
|
+
await observe()
|
|
16
|
+
const blockedEvidence = structuredClone(tracker.deviceReadiness.evidence)
|
|
17
|
+
fail = true
|
|
18
|
+
await observe()
|
|
19
|
+
assert.deepEqual(tracker.deviceReadiness.blockers, ['DEVICE_ASLEEP', 'DEVICE_LOCKED'])
|
|
20
|
+
assert.deepEqual(tracker.deviceReadiness.evidence, blockedEvidence, 'retained evidence keeps its original timestamps')
|
|
21
|
+
assert.ok(tracker.deviceReadiness.limitations.includes('DEVICE_POWER_OBSERVATION_FAILED'))
|
|
22
|
+
assert.ok(tracker.deviceReadiness.limitations.includes('DEVICE_LOCK_OBSERVATION_FAILED'))
|
|
23
|
+
assert.ok(tracker.deviceReadiness.limitations.includes('DEVICE_BLOCKER_EVIDENCE_RETAINED'))
|
|
24
|
+
await runWithBudget(0, observe)
|
|
25
|
+
tracker.deadline()
|
|
26
|
+
assert.deepEqual(tracker.deviceReadiness.blockers, ['DEVICE_ASLEEP', 'DEVICE_LOCKED'])
|
|
27
|
+
assert.deepEqual(tracker.deviceReadiness.evidence, blockedEvidence)
|
|
28
|
+
fail = false; power = 'Awake'; lock = ''
|
|
29
|
+
await observe()
|
|
30
|
+
assert.deepEqual(tracker.deviceReadiness.blockers, ['DEVICE_LOCKED'], 'fresh awake evidence clears only the power blocker')
|
|
31
|
+
assert.equal(tracker.deviceReadiness.input_ready, false)
|
|
32
|
+
power = ''; lock = 'false'
|
|
33
|
+
await observe()
|
|
34
|
+
assert.deepEqual(tracker.deviceReadiness.blockers, [], 'fresh unlocked evidence clears the remaining lock blocker')
|
|
35
|
+
assert.equal(tracker.deviceReadiness.wakefulness, 'unknown', 'do not retain previously clear observations')
|
|
36
|
+
assert.equal(tracker.deviceReadiness.input_ready, null)
|
|
37
|
+
assert.ok(!tracker.deviceReadiness.limitations.includes('DEVICE_BLOCKER_EVIDENCE_RETAINED'))
|
|
38
|
+
console.log('PASS inconclusive/expired probes retain blockers per axis; only fresh clear signals remove them')
|
|
39
|
+
}
|
|
40
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { actionHandler } from '../../../src/server/handlers/action-execution.js'
|
|
4
|
+
import { toolHandlers, handleToolCall } from '../../../src/server/handlers/tool-handlers.js'
|
|
5
|
+
import { wrapResponse } from '../../../src/server/common.js'
|
|
6
|
+
import { withDeviceReadinessProbe } from '../../../src/observe/device-readiness.js'
|
|
7
|
+
import { captureEvidence, evidenceDispatch } from '../../../src/observe/evidence.js'
|
|
8
|
+
import { markDispatchStarted } from '../../../src/utils/operation-budget.js'
|
|
9
|
+
import { observedState } from '../../../src/server/handlers/compact-state.js'
|
|
10
|
+
import { finalizeCompactInteraction } from '../../../src/server/handlers/compact-interaction.js'
|
|
11
|
+
|
|
12
|
+
const device = { platform: 'android' as const, id: 'fixture-emulator' }
|
|
13
|
+
const original = toolHandlers.tap
|
|
14
|
+
let dispatches = 0
|
|
15
|
+
const decode = (r: Awaited<ReturnType<typeof handleToolCall>>) => JSON.parse(r.content[0].text!)
|
|
16
|
+
const lookup = async (value: ReturnType<typeof decode>) => decode(await handleToolCall('get_interaction_diagnostics', { sessionId: value.diagnostics.session_id, resultId: value.diagnostics.result_id }))
|
|
17
|
+
try {
|
|
18
|
+
const baselines = [1129, 1639, 1157, 1106]
|
|
19
|
+
for (const [index, kind] of ['success-none', 'success-observed', 'failed-dispatch', 'blocked-before-dispatch'].entries()) {
|
|
20
|
+
toolHandlers.tap = actionHandler('tap', async () => {
|
|
21
|
+
if (kind !== 'blocked-before-dispatch') await evidenceDispatch(device, async () => { markDispatchStarted(); dispatches++ })
|
|
22
|
+
if (kind === 'success-observed') await captureEvidence('fingerprint', device, async () => ({ device, fingerprint: 'screen-after' }))
|
|
23
|
+
return wrapResponse({ device, success: kind.startsWith('success'), lifecycle_state: kind === 'success-observed' ? 'verified' : 'pending_verification',
|
|
24
|
+
...(kind === 'failed-dispatch' ? { failure_code: 'ACTION_TIMEOUT', retryable: false, details: { error: 'original timeout details' } } : {}),
|
|
25
|
+
...(kind === 'blocked-before-dispatch' ? { failure_code: 'ELEMENT_NOT_FOUND', retryable: true } : {}) })
|
|
26
|
+
})
|
|
27
|
+
const response = await withDeviceReadinessProbe(async (_cmd, args) => ({ exitCode: 0, stderr: '', stdout: args.includes('power') ? 'mWakefulness=Awake\nmWakefulnessChanging=false' : 'KeyguardServiceDelegate\n showing=false' }), () => handleToolCall('tap', { platform: 'android', deviceId: device.id, responseMode: 'compact', verificationMode: index === 1 ? 'full' : 'none' }))
|
|
28
|
+
const result = decode(response), size = Buffer.byteLength(response.content[0].text!)
|
|
29
|
+
assert.ok(size <= 1280 + Buffer.byteLength(device.id), `${kind} exceeds budget: ${size}`)
|
|
30
|
+
assert.ok(size <= baselines[index] * .8, `${kind} needs 20% reduction: ${size}`)
|
|
31
|
+
assert.equal(result.observed_state.status, index === 1 ? 'observed' : 'unknown')
|
|
32
|
+
assert.equal(result.device_readiness.input_ready, null)
|
|
33
|
+
assert.equal(result.evidence.observations, undefined)
|
|
34
|
+
const before = dispatches, diagnostic = await lookup(result)
|
|
35
|
+
assert.equal(diagnostic.success, true)
|
|
36
|
+
assert.equal(dispatches, before, 'lookup must not replay input')
|
|
37
|
+
assert.equal(diagnostic.result.evidence.action_id, result.action_id)
|
|
38
|
+
if (index === 2) { assert.equal(result.delivery_status, 'unknown'); assert.equal(result.retryable, false); assert.equal(diagnostic.result.details.error, 'original timeout details') }
|
|
39
|
+
if (index === 3) assert.equal(result.dispatch_started, false)
|
|
40
|
+
console.log(`${kind}: ${size} bytes, ~${Math.ceil(size / 4)} tokens (bytes/4 estimate)`)
|
|
41
|
+
}
|
|
42
|
+
} finally { toolHandlers.tap = original }
|
|
43
|
+
const stamp = Date.now(), evidence = { session_id: randomUUID(), action_id: randomUUID(), correlation_id: randomUUID(), device, dispatch_completed_at_ms: stamp,
|
|
44
|
+
observations: [{ kind: 'ui_tree', status: 'captured', freshness: 'fresh', device, capture_started_at_ms: stamp + 1, source_captured_at_ms: stamp + 2, snapshot_session_id: randomUUID(), snapshot_revision: 4 }] }
|
|
45
|
+
assert.equal(observedState(evidence, true).status, 'observed')
|
|
46
|
+
for (const patch of [{ freshness: 'stale' }, { freshness: 'unknown' }, { source_captured_at_ms: null }, { status: 'failed' }, { device: { ...device, id: 'other' } }, { capture_started_at_ms: stamp - 1 }]) {
|
|
47
|
+
assert.equal(observedState({ ...evidence, observations: [{ ...evidence.observations[0], ...patch }] }, true).status, 'unknown')
|
|
48
|
+
}
|
|
49
|
+
const heavy = { success: false, failure_code: 'PRECONDITION_FAILED', dispatch_started: false, delivery_status: 'not_delivered', verification: { status: 'not_requested' }, evidence,
|
|
50
|
+
device_readiness: { wakefulness: 'asleep', lock_state: 'locked', input_ready: false, blockers: ['DEVICE_ASLEEP', 'DEVICE_LOCKED'], limitations: [] },
|
|
51
|
+
condition: { status: 'mismatch', reason: 'STATE_CHANGED', observed: { elements: Array(1000).fill({ text: '私'.repeat(100) }) } },
|
|
52
|
+
state_delta: { added: Array(1000).fill({ text: 'private' }), changed: [], removed: [], snapshot_revision: 4, truncated: true }, actual_state: '😀'.repeat(100) }
|
|
53
|
+
const response = finalizeCompactInteraction('tap', { responseMode: 'compact' }, wrapResponse(heavy)), compact = decode(response)
|
|
54
|
+
assert.ok(Buffer.byteLength(response.content[0].text!) <= 1280 + device.id.length)
|
|
55
|
+
assert.deepEqual(compact.device_readiness.blockers, ['DEVICE_ASLEEP', 'DEVICE_LOCKED'])
|
|
56
|
+
assert.equal(compact.condition.observed?.elements, undefined)
|
|
57
|
+
assert.equal((await lookup(compact)).result.condition.observed.elements.length, 1000)
|
|
58
|
+
const debug = wrapResponse(heavy), unchanged = debug.content[0].text
|
|
59
|
+
finalizeCompactInteraction('tap', { responseMode: 'debug' }, debug)
|
|
60
|
+
assert.equal(debug.content[0].text, unchanged)
|
|
61
|
+
const steps = Array.from({ length: 50 }, (_, i) => ({ id: `${i}-😀`.repeat(100), type: 'tap', status: 'failed', success: false, result: heavy }))
|
|
62
|
+
const journeyResponse = finalizeCompactInteraction('run_journey', {}, wrapResponse({ success: false, stopped_at_step_id: steps[0].id, steps, evidence })), journey = decode(journeyResponse)
|
|
63
|
+
assert.equal(journey.steps.length, 50)
|
|
64
|
+
assert.deepEqual(journey.steps.map((s: { id: string }) => s.id), steps.map(s => s.id))
|
|
65
|
+
assert.ok(Buffer.byteLength(journeyResponse.content[0].text!) <= 1280 + 1280 * 50 + steps.reduce((n, s) => n + 2 * Buffer.byteLength(s.id), 0))
|
|
66
|
+
assert.equal(journey.diagnostics.available, false, 'oversized original journey reports unavailable explicitly')
|
|
67
|
+
console.log('PASS final compact budgets, freshness, no replay, diagnostic fidelity, huge conditions/deltas, multibyte values, 50-step IDs and debug compatibility')
|
|
68
|
+
const controlId = '\u0001'.repeat(3000)
|
|
69
|
+
const escaped = finalizeCompactInteraction('run_journey', {}, wrapResponse({ success: false, stopped_at_step_id: controlId,
|
|
70
|
+
steps: [{ id: controlId, type: 'assert', status: 'failed', success: false, result: { success: false, observed_state: { property: 'checked', value: false }, evidence } }], evidence }))
|
|
71
|
+
const escapedJourney = decode(escaped)
|
|
72
|
+
assert.equal(escapedJourney.steps[0].id, controlId)
|
|
73
|
+
assert.deepEqual(escapedJourney.steps[0].result.observed, { property: 'checked', value: false })
|
|
74
|
+
assert.ok(Buffer.byteLength(escaped.content[0].text!) <= 2560 + 2 * (Buffer.byteLength(JSON.stringify(controlId)) - 2) + 2 * device.id.length)
|
|
75
|
+
const maximal = { ...heavy, action_id: randomUUID(), action_type: 'scroll_to_element', lifecycle_state: 'pending_verification', retryable: false,
|
|
76
|
+
verification: { mode: 'light', status: 'unavailable' }, verification_diagnostic: { code: 'VERIFICATION_UNAVAILABLE', retryable: true },
|
|
77
|
+
device_readiness: { wakefulness: 'transitioning', lock_state: 'unknown', input_ready: false,
|
|
78
|
+
blockers: ['DEVICE_ASLEEP', 'DEVICE_LOCKED', 'DEVICE_TRANSITIONING', 'DEBUGGER_PAUSED'],
|
|
79
|
+
limitations: ['DEVICE_WAKEFULNESS_UNVERIFIED', 'DEVICE_LOCK_OBSERVATION_UNAVAILABLE', 'DEVICE_LOCK_OBSERVATION_FAILED'] },
|
|
80
|
+
evidence: { ...evidence, trace_context: { trace_id: randomUUID() } }, error: { code: 'UNKNOWN', message: '\u0001'.repeat(1000) } }
|
|
81
|
+
const maximalResponse = finalizeCompactInteraction('tap', { responseMode: 'compact' }, wrapResponse(maximal))
|
|
82
|
+
assert.ok(Buffer.byteLength(maximalResponse.content[0].text!) <= 1280 + device.id.length, maximalResponse.content[0].text!)
|
|
83
|
+
assert.deepEqual(decode(maximalResponse).device_readiness.blockers, maximal.device_readiness.blockers)
|
|
84
|
+
const maximalSteps = Array.from({ length: 50 }, (_, i) => ({ id: `max-${i}`, type: 'tap', status: 'failed', success: false, result: maximal }))
|
|
85
|
+
const maximalJourney = finalizeCompactInteraction('run_journey', {}, wrapResponse({ success: false, stopped_at_step_id: 'max-0', steps: maximalSteps, evidence }))
|
|
86
|
+
assert.ok(Buffer.byteLength(maximalJourney.content[0].text!) <= 1280 + 1280 * 50 + maximalSteps.reduce((n, s) => n + Buffer.byteLength(s.id), 0) + Buffer.byteLength('max-0') + 51 * device.id.length,
|
|
87
|
+
`maximal 50-step response exceeded budget: ${Buffer.byteLength(maximalJourney.content[0].text!)}`)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { withEvidence, evidenceDispatch, captureEvidence, completeJourneyVerification, withJourneyStepEvidence, refreshJourneyEvidence } from '../../../src/observe/evidence.js'
|
|
3
|
+
import { observedState } from '../../../src/server/handlers/compact-state.js'
|
|
4
|
+
import { finalizeCompactInteraction } from '../../../src/server/handlers/compact-interaction.js'
|
|
5
|
+
import { wrapResponse } from '../../../src/server/common.js'
|
|
6
|
+
import { interactionDiagnostics } from '../../../src/server/handlers/interaction-diagnostics.js'
|
|
7
|
+
|
|
8
|
+
const device = { platform: 'android' as const, id: 'fixture' }
|
|
9
|
+
await withEvidence({ journey: true }, async () => {
|
|
10
|
+
const child = await withEvidence({ action: true }, async () => {
|
|
11
|
+
await evidenceDispatch(device, async () => {})
|
|
12
|
+
return { success: true, dispatch_started: true, verification: { mode: 'light', status: 'observed' } }
|
|
13
|
+
})
|
|
14
|
+
const assertion = await withJourneyStepEvidence(() => captureEvidence('ui_tree', device, async () => ({ success: true, device, snapshot_session_id: 'snapshot', snapshot_revision: 2 })))
|
|
15
|
+
completeJourneyVerification(child.evidence.action_id!)
|
|
16
|
+
const steps = [{ id: 'tap', type: 'tap', status: 'passed', success: true, result: { ...child.result, evidence: child.evidence } },
|
|
17
|
+
{ id: 'assert', type: 'assert', status: 'passed', success: true, result: assertion }]
|
|
18
|
+
refreshJourneyEvidence(steps)
|
|
19
|
+
assert.equal(observedState(steps[0].result.evidence, true).status, 'observed', 'following assertion supplies actual fresh evidence')
|
|
20
|
+
const response = finalizeCompactInteraction('run_journey', {}, wrapResponse({ success: true, steps }))
|
|
21
|
+
const compact = JSON.parse(response.content[0].text!)
|
|
22
|
+
const ref = compact.steps[0].result.diagnostics
|
|
23
|
+
const retained = interactionDiagnostics.lookup(ref.session_id, ref.result_id)
|
|
24
|
+
assert.equal(retained.success, true)
|
|
25
|
+
assert.equal(compact.steps[0].result.observed_state.status, 'observed')
|
|
26
|
+
// A later action invalidates the first tap and assertion summaries at finalization.
|
|
27
|
+
await withEvidence({ action: true }, () => evidenceDispatch(device, async () => {}))
|
|
28
|
+
refreshJourneyEvidence(steps)
|
|
29
|
+
assert.equal(observedState(steps[0].result.evidence, true).status, 'unknown')
|
|
30
|
+
assert.equal(observedState(steps[1].result.evidence).status, 'unknown')
|
|
31
|
+
const final = finalizeCompactInteraction('run_journey', {}, wrapResponse({ success: true, steps }))
|
|
32
|
+
const result = JSON.parse(final.content[0].text!)
|
|
33
|
+
assert.equal(result.steps[0].result.observed_state.freshness, 'stale')
|
|
34
|
+
assert.equal(result.steps[1].result.observed_state.freshness, 'stale')
|
|
35
|
+
})
|
|
36
|
+
const error = { ...wrapResponse({ error: { code: 'TRACE_NOT_FOUND', message: 'Original failure' } }), isError: true }
|
|
37
|
+
finalizeCompactInteraction('run_journey', {}, error)
|
|
38
|
+
assert.equal(error.isError, true)
|
|
39
|
+
assert.equal(JSON.parse(error.content[0].text!).success, false)
|
|
40
|
+
console.log('PASS shared journey observation, final supersession, child diagnostics and MCP error signalling')
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { handleToolCall } from '../../../src/server-core.js'
|
|
3
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
4
|
+
import { captureEvidence, withJourneyStepEvidence } from '../../../src/observe/evidence.js'
|
|
5
|
+
import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
|
|
6
|
+
import { runWithBudget, remainingBudget } from '../../../src/utils/operation-budget.js'
|
|
7
|
+
import { finalizeCompactInteraction } from '../../../src/server/handlers/compact-interaction.js'
|
|
8
|
+
import { wrapResponse } from '../../../src/server/common.js'
|
|
9
|
+
|
|
10
|
+
const device = { platform: 'android' as const, id: 'fixture', model: 'Fixture', osVersion: '16', simulator: true }
|
|
11
|
+
const originalWait = ToolsInteract.waitForUIHandler
|
|
12
|
+
const decode = (response: Awaited<ReturnType<typeof handleToolCall>>) => JSON.parse(response.content[0].text!)
|
|
13
|
+
const diagnostics = async (value: ReturnType<typeof decode>) => {
|
|
14
|
+
const response = decode(await handleToolCall('get_interaction_diagnostics', {
|
|
15
|
+
sessionId: value.diagnostics.session_id, resultId: value.diagnostics.result_id
|
|
16
|
+
}))
|
|
17
|
+
assert.equal(response.success, true)
|
|
18
|
+
return response.result
|
|
19
|
+
}
|
|
20
|
+
_setDeviceListersForTests({ listAndroidDevices: async () => [device] })
|
|
21
|
+
try {
|
|
22
|
+
for (const responseMode of ['compact', 'debug']) for (const type of ['assert', 'wait']) for (const thrown of [true, false]) {
|
|
23
|
+
let reads = 0
|
|
24
|
+
ToolsInteract.waitForUIHandler = async () => {
|
|
25
|
+
reads++
|
|
26
|
+
await captureEvidence('ui_tree', device, async () => ({ device, snapshot_session_id: 'captured-before-timeout', snapshot_revision: 7 }))
|
|
27
|
+
if (thrown) return runWithBudget(0, async () => { remainingBudget(); throw new Error('unreachable') })
|
|
28
|
+
return { status: 'timeout', error: { code: 'TIMEOUT', message: 'returned timeout' }, observed: { matched_count: 0 } }
|
|
29
|
+
}
|
|
30
|
+
const result = decode(await handleToolCall('run_journey', { platform: 'android', deviceId: device.id, responseMode, steps: [
|
|
31
|
+
{ id: 'fails', type, ...(type === 'assert' ? { assertion: { kind: 'element_visible', selector: { text: 'Ready' } } } : { selector: { text: 'Ready' } }) },
|
|
32
|
+
{ id: 'later', type: 'wait', selector: { text: 'Later' } }
|
|
33
|
+
] }))
|
|
34
|
+
assert.equal(result.success, false)
|
|
35
|
+
assert.equal(result.stopped_at_step_id, 'fails')
|
|
36
|
+
assert.equal(result.steps[1].status, 'not_run')
|
|
37
|
+
assert.equal(reads, 1, 'neither a failure nor diagnostics lookup replays the observation')
|
|
38
|
+
const step = responseMode === 'compact' ? await diagnostics(result.steps[0].result) : result.steps[0].result
|
|
39
|
+
if (thrown) {
|
|
40
|
+
assert.equal(step.failure_code, type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED')
|
|
41
|
+
assert.equal(step.error, 'ACTION_TIMEOUT')
|
|
42
|
+
} else if (type === 'assert') {
|
|
43
|
+
assert.equal(step.failure_code, 'ASSERTION_TIMEOUT')
|
|
44
|
+
assert.equal(step.reason, 'returned timeout')
|
|
45
|
+
assert.equal(step.observed.matched_count, 0)
|
|
46
|
+
} else {
|
|
47
|
+
assert.equal(step.status, 'timeout')
|
|
48
|
+
assert.deepEqual(step.error, { code: 'TIMEOUT', message: 'returned timeout' })
|
|
49
|
+
}
|
|
50
|
+
if (responseMode === 'compact') {
|
|
51
|
+
const observation = step.evidence.observations[0]
|
|
52
|
+
assert.equal(observation.kind, 'ui_tree')
|
|
53
|
+
assert.equal(observation.status, 'captured')
|
|
54
|
+
assert.equal(observation.snapshot_revision, 7)
|
|
55
|
+
assert.equal(observation.snapshot_session_id, 'captured-before-timeout')
|
|
56
|
+
assert.deepEqual(observation.device, { platform: device.platform, id: device.id })
|
|
57
|
+
const journey = await diagnostics(result)
|
|
58
|
+
assert.deepEqual(journey.steps[0].result.evidence, step.evidence, 'journey and child diagnostics retain the same final evidence')
|
|
59
|
+
} else assert.equal(step.evidence, undefined, 'debug step shape is unchanged')
|
|
60
|
+
assert.equal(reads, 1)
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
ToolsInteract.waitForUIHandler = originalWait
|
|
64
|
+
_resetDeviceListersForTests()
|
|
65
|
+
}
|
|
66
|
+
const originalError = new Error('outside evidence context')
|
|
67
|
+
await assert.rejects(withJourneyStepEvidence(async () => { throw originalError }), error => error === originalError)
|
|
68
|
+
for (const message of ['short error', 'a'.repeat(128), 'a'.repeat(129), '😀'.repeat(33)]) {
|
|
69
|
+
const response = finalizeCompactInteraction('run_journey', {}, wrapResponse({ error: { code: 'FIXTURE_ERROR', message } }))
|
|
70
|
+
const result = decode(response)
|
|
71
|
+
const shortened = Buffer.byteLength(message) > 128
|
|
72
|
+
assert.equal(result.truncated, shortened ? true : undefined)
|
|
73
|
+
assert.equal(result.error.code, 'FIXTURE_ERROR')
|
|
74
|
+
assert.ok(Buffer.byteLength(result.error.message) <= 128)
|
|
75
|
+
assert.ok(Buffer.byteLength(response.content[0].text!) < 1280, 'truncation must be marked even without overall budget pressure')
|
|
76
|
+
assert.equal((await diagnostics(result)).error.message, message)
|
|
77
|
+
}
|
|
78
|
+
console.log('PASS thrown journey evidence, returned-timeout/debug compatibility, and ASCII/multibyte journey error truncation')
|
|
@@ -70,6 +70,9 @@ else process.exit(1)
|
|
|
70
70
|
ToolsNetwork.notifyActionStart = () => { networkStarts++ }
|
|
71
71
|
_setDeviceListersForTests({ listAndroidDevices: async () => [device()], listIOSDevices: async () => [device()] })
|
|
72
72
|
const payload = (response: any) => JSON.parse(response.content[0].text)
|
|
73
|
+
const retained = async (result: any) => result.diagnostics
|
|
74
|
+
? payload(await handleToolCall('get_interaction_diagnostics', { sessionId: result.diagnostics.session_id, resultId: result.diagnostics.result_id })).result
|
|
75
|
+
: result
|
|
73
76
|
const nativeInputs = () => readFileSync(eventsPath, 'utf8').split('\n').filter(line => /shell input|ui tap|ui swipe/.test(line))
|
|
74
77
|
const baseline = () => {
|
|
75
78
|
uiState = 'Ready'; propertyChange = {}; observationMode = 'normal'; readDelay = 0
|
|
@@ -129,7 +132,8 @@ else process.exit(1)
|
|
|
129
132
|
assert.equal(result.retryable, false)
|
|
130
133
|
assert.equal(result.delivery_status, 'not_delivered')
|
|
131
134
|
assert.equal(result.verification.status, 'not_requested')
|
|
132
|
-
assert.deepEqual(result.condition.observed.elements[0], tree().elements[0])
|
|
135
|
+
assert.deepEqual((await retained(result)).condition.observed.elements[0], tree().elements[0])
|
|
136
|
+
if (responseMode === 'compact') assert.equal(result.observed_state.status, 'unknown')
|
|
133
137
|
assert.equal(reads, 1, 'no post-dispatch tree read after guard failure')
|
|
134
138
|
assert.equal(nativeInputs().length, inputs)
|
|
135
139
|
assert.equal(networkStarts, starts)
|
|
@@ -239,7 +243,7 @@ else process.exit(1)
|
|
|
239
243
|
assert.equal(journey.stopped_at_step_id, 'guarded')
|
|
240
244
|
assert.equal(journey.steps[0].status, 'passed')
|
|
241
245
|
assert.equal(journey.steps[1].result.failure_code, 'PRECONDITION_FAILED')
|
|
242
|
-
assert.equal(journey.steps[1].result.condition.observed.elements[0].text, 'Changed')
|
|
246
|
+
assert.equal((await retained(journey.steps[1].result)).condition.observed.elements[0].text, 'Changed')
|
|
243
247
|
assert.equal(journey.steps[1].result.verification.status, 'not_requested')
|
|
244
248
|
assert.equal(journey.steps[2].status, 'not_run')
|
|
245
249
|
assert.equal(nativeInputs().length, before)
|
|
@@ -268,7 +272,7 @@ else process.exit(1)
|
|
|
268
272
|
const before = nativeInputs().length
|
|
269
273
|
const mismatch = await call('tap', { expectedState })
|
|
270
274
|
assert.equal(mismatch.failure_code, 'PRECONDITION_FAILED', JSON.stringify(mismatch))
|
|
271
|
-
assert.equal(mismatch.condition.observed.elements[0].text, 'Changed through native hierarchy')
|
|
275
|
+
assert.equal((await retained(mismatch)).condition.observed.elements[0].text, 'Changed through native hierarchy')
|
|
272
276
|
assert.equal(nativeInputs().length, before)
|
|
273
277
|
const compact: any = await ToolsObserve.getUITreeHandler({ platform, deviceId: 'fixture', responseMode: 'compact' })
|
|
274
278
|
assert.equal(compact.snapshot_session_id, expectedState.snapshotSessionId)
|
|
@@ -59,7 +59,15 @@ else process.exit(1)
|
|
|
59
59
|
if (platform === 'android') replace('collectGetCurrentScreen', async (id: string) => ({ device: device(platform, id), activity: 'FixtureActivity', package: 'fixture.app' }))
|
|
60
60
|
}
|
|
61
61
|
const payload = (r: any) => JSON.parse(r.content[0].text)
|
|
62
|
-
const call = async (name: string, args: Record<string, unknown>) =>
|
|
62
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
63
|
+
const value = payload(await handleToolCall(name, args))
|
|
64
|
+
if (name !== 'run_journey' && value.diagnostics?.available) {
|
|
65
|
+
const retained = payload(await handleToolCall('get_interaction_diagnostics', { sessionId: value.diagnostics.session_id, resultId: value.diagnostics.result_id }))
|
|
66
|
+
assert.equal(retained.success, true)
|
|
67
|
+
return { ...value, evidence: retained.result.evidence }
|
|
68
|
+
}
|
|
69
|
+
return value
|
|
70
|
+
}
|
|
63
71
|
const refFor = (value: any) => ({ sessionId: value.evidence.session_id, actionId: value.evidence.action_id })
|
|
64
72
|
try {
|
|
65
73
|
for (const platform of ['android', 'ios'] as const) {
|
|
@@ -122,9 +130,11 @@ else process.exit(1)
|
|
|
122
130
|
verificationMode: 'none', responseMode: 'compact', actionTimeoutMs: 500 })
|
|
123
131
|
delete process.env.EVIDENCE_INPUT_SLOW
|
|
124
132
|
assert.equal(timeout.failure_code, 'ACTION_TIMEOUT')
|
|
125
|
-
|
|
126
|
-
assert.
|
|
127
|
-
assert.
|
|
133
|
+
const retainedTimeout = await call('get_interaction_diagnostics', { sessionId: timeout.diagnostics.session_id, resultId: timeout.diagnostics.result_id })
|
|
134
|
+
assert.equal(retainedTimeout.success, true)
|
|
135
|
+
assert.ok(retainedTimeout.result.evidence.dispatch_started_at_ms)
|
|
136
|
+
assert.ok(retainedTimeout.result.evidence.dispatch_completed_at_ms)
|
|
137
|
+
assert.equal(retainedTimeout.result.evidence.verification_completed_at_ms, null)
|
|
128
138
|
const before = captureCount
|
|
129
139
|
const malformed = await call('capture_screenshot', { platform: 'android', actionRef: { actionId: '' } })
|
|
130
140
|
assert.match(malformed.error.message, /INVALID_ARGUMENT/)
|
|
@@ -149,9 +159,11 @@ else process.exit(1)
|
|
|
149
159
|
] })
|
|
150
160
|
assert.equal(light.success, true)
|
|
151
161
|
const lightStep = light.steps[0].result
|
|
152
|
-
assert.
|
|
162
|
+
assert.equal(lightStep.observed_state.status, 'observed', JSON.stringify(lightStep))
|
|
163
|
+
const retainedLight = await call('get_interaction_diagnostics', { sessionId: lightStep.diagnostics.session_id, resultId: lightStep.diagnostics.result_id })
|
|
164
|
+
assert.ok(retainedLight.result.evidence.verification_completed_at_ms)
|
|
153
165
|
const afterLight = await call('capture_screenshot', { platform: 'android', deviceId: 'fixture-android', actionRef: refFor(lightStep) })
|
|
154
|
-
assert.equal(afterLight.evidence.verification_completed_at_ms,
|
|
166
|
+
assert.equal(afterLight.evidence.verification_completed_at_ms, retainedLight.result.evidence.verification_completed_at_ms)
|
|
155
167
|
|
|
156
168
|
const started = await call('start_log_stream', { platform: 'android', deviceId: 'other', sessionId: 'evidence-owned', packageName: 'fixture.app' })
|
|
157
169
|
assert.equal(started.success, true)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { parseMetric, summarizeAttempts, unionDuration, observationOverhead, type Attempt } from '../../device/automated/latency/end-to-end-report.js'
|
|
3
|
+
import { validObservation } from '../../device/automated/latency/end-to-end-session.js'
|
|
4
|
+
|
|
5
|
+
assert.equal(unionDuration([{ stage: 'parent', start_ms: 1, end_ms: 10 }, { stage: 'nested', start_ms: 2, end_ms: 5 },
|
|
6
|
+
{ stage: 'overlap', start_ms: 8, end_ms: 12 }], 11), 10)
|
|
7
|
+
const attempts: Attempt[] = [
|
|
8
|
+
{ case: 'full', iteration: 0, warmup: true, success: false, setup_ms: 1, requests: [] },
|
|
9
|
+
{ case: 'full', iteration: 1, warmup: false, success: false, setup_ms: 1, requests: [] },
|
|
10
|
+
{ case: 'full', iteration: 2, warmup: false, success: true, setup_ms: 500, requests: [
|
|
11
|
+
{ tool: 'run_journey', elapsed_ms: 100, response_bytes: 50, success: true, tree: [], metrics: [
|
|
12
|
+
{ event: 'latency_metric', request_id: 'opaque', tool: 'run_journey', handler_completed: true, elapsed_ms: 90,
|
|
13
|
+
spans: [{ stage: 'parent', start_ms: 0, end_ms: 80 }, { stage: 'child', start_ms: 10, end_ms: 70 }] }
|
|
14
|
+
] }
|
|
15
|
+
] },
|
|
16
|
+
{ case: 'empty', iteration: 1, warmup: false, success: false, setup_ms: 0, requests: [] }
|
|
17
|
+
]
|
|
18
|
+
const report = summarizeAttempts(attempts)
|
|
19
|
+
assert.equal(report.full.total_ms?.p50, 100)
|
|
20
|
+
assert.equal(report.full.failures, 1)
|
|
21
|
+
assert.equal(report.full.warmup_failures, 1)
|
|
22
|
+
assert.equal(report.full.handler_unattributed_ms?.p50, 10)
|
|
23
|
+
assert.equal(report.full.client_minus_handler_ms?.p50, 10)
|
|
24
|
+
assert.equal(report.empty.total_ms, null)
|
|
25
|
+
assert.equal(observationOverhead(attempts).tree.paired_on_minus_off_ms, null)
|
|
26
|
+
const observation = (name: string, elapsed: number, success = true): Attempt => ({ case: name, iteration: 3, warmup: false,
|
|
27
|
+
success, setup_ms: 0, requests: [{ tool: 'get_ui_tree', elapsed_ms: elapsed, success, metrics: [], tree: [] }] })
|
|
28
|
+
assert.equal(observationOverhead([observation('tree-on', 15), observation('tree-off', 20)]).tree.paired_on_minus_off_ms?.p50, -5)
|
|
29
|
+
assert.equal(observationOverhead([observation('tree-on', 15), observation('tree-off', 20, false)]).tree.paired_on_minus_off_ms, null)
|
|
30
|
+
assert.equal(parseMetric('secret stderr'), null)
|
|
31
|
+
assert.equal(parseMetric('{"event":"latency_metric","tool":"tap","spans":[]}'), null)
|
|
32
|
+
assert.equal(parseMetric('{"event":"unrecognized","secret":"credential"}'), null)
|
|
33
|
+
const filtered = parseMetric(JSON.stringify({ event: 'latency_metric', tool: 'tap', request_id: 'opaque', handler_completed: true,
|
|
34
|
+
elapsed_ms: 10, arguments: { deviceId: 'private-device', text: 'secret' }, stdout: 'secret',
|
|
35
|
+
spans: [{ stage: 'dispatch_ms', start_ms: 0, end_ms: 8, secret: 'credential' }] }))
|
|
36
|
+
assert.ok(filtered)
|
|
37
|
+
assert.ok(!JSON.stringify(filtered).includes('secret'))
|
|
38
|
+
assert.ok(!JSON.stringify(filtered).includes('private-device'))
|
|
39
|
+
const tree = { device: { id: 'emulator-fixture' }, elements: [{}] }
|
|
40
|
+
assert.equal(validObservation('get_ui_tree', tree, [], 'emulator-fixture'), true)
|
|
41
|
+
assert.equal(validObservation('get_ui_tree', tree, [], 'other-device'), false)
|
|
42
|
+
assert.equal(validObservation('get_ui_tree', {}, [], 'emulator-fixture'), false)
|
|
43
|
+
assert.equal(validObservation('capture_screenshot', { device: { id: 'emulator-fixture' } }, [], 'emulator-fixture'), false)
|
|
44
|
+
assert.equal(validObservation('capture_debug_snapshot', { raw: { ui_tree: tree, fingerprint_error: 'failed' } }, [], 'emulator-fixture'), false)
|
|
45
|
+
assert.equal(validObservation('capture_debug_snapshot', { raw: { ui_tree: tree } }, [], 'emulator-fixture'), true)
|
|
46
|
+
console.log('End-to-end benchmark accounting/privacy: PASS')
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { handleToolCall } from '../../../src/server-core.js'
|
|
3
|
+
import { AndroidInteract } from '../../../src/interact/android.js'
|
|
4
|
+
import { _setDeviceListersForTests, _resetDeviceListersForTests, withResolvedDevice } from '../../../src/utils/resolve-device.js'
|
|
5
|
+
import { withDeviceReadinessProbe } from '../../../src/observe/device-readiness.js'
|
|
6
|
+
|
|
7
|
+
async function run() {
|
|
8
|
+
const physical = { platform: 'android', id: 'physical', model: 'Fixture', osVersion: '16', simulator: false }
|
|
9
|
+
const emulator = { ...physical, id: 'emulator', simulator: true }
|
|
10
|
+
const originalTap = AndroidInteract.prototype.tap, originalSwipe = AndroidInteract.prototype.swipe
|
|
11
|
+
let dispatched = '', dispatches = 0, resolutions = 0
|
|
12
|
+
_setDeviceListersForTests({ listAndroidDevices: async () => { resolutions++; return [emulator, physical] } })
|
|
13
|
+
AndroidInteract.prototype.tap = async (x, y, id) => {
|
|
14
|
+
dispatched = id!; dispatches++
|
|
15
|
+
return { success: true, device: id === physical.id ? physical : emulator, x, y }
|
|
16
|
+
}
|
|
17
|
+
AndroidInteract.prototype.swipe = async (x1, y1, x2, y2, duration, id) => {
|
|
18
|
+
dispatched = id!; dispatches++
|
|
19
|
+
return { success: true, device: id === physical.id ? physical : emulator, start: [x1, y1], end: [x2, y2], duration }
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
for (const tool of ['tap', 'swipe']) {
|
|
23
|
+
for (const responseMode of ['compact', 'debug']) {
|
|
24
|
+
for (const explicit of [false, true]) {
|
|
25
|
+
const expected = explicit ? emulator.id : physical.id
|
|
26
|
+
const probes: string[] = []
|
|
27
|
+
const response = await withDeviceReadinessProbe(async (_cmd, args) => {
|
|
28
|
+
probes.push(args[1])
|
|
29
|
+
return { exitCode: 0, stderr: '', stdout: args.includes('power') ? 'mWakefulness=Asleep\nmWakefulnessChanging=false' : 'KeyguardServiceDelegate\n showing=false\n mIsShowing=false' }
|
|
30
|
+
}, () => handleToolCall(tool, { platform: 'android', ...(explicit ? { deviceId: emulator.id } : {}), x: 1, y: 2, x1: 1, y1: 2, x2: 3, y2: 4, duration: 100, verificationMode: 'none', responseMode }))
|
|
31
|
+
const result = JSON.parse(response.content[0].text!)
|
|
32
|
+
assert.equal(result.success, true)
|
|
33
|
+
assert.equal(dispatched, expected)
|
|
34
|
+
assert.deepEqual(probes, [expected, expected], 'readiness probes use the device actually selected for dispatch')
|
|
35
|
+
assert.deepEqual(result.device_readiness.blockers, ['DEVICE_ASLEEP'])
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
assert.equal(dispatches, 8)
|
|
40
|
+
assert.equal(resolutions, 8, 'readiness does not select another device')
|
|
41
|
+
const before = resolutions
|
|
42
|
+
await withResolvedDevice(emulator, () => withDeviceReadinessProbe(async (_cmd, args) => {
|
|
43
|
+
assert.equal(args[1], emulator.id)
|
|
44
|
+
return { exitCode: 0, stderr: '', stdout: '' }
|
|
45
|
+
}, () => handleToolCall('tap', { x: 1, y: 2, verificationMode: 'none', responseMode: 'compact' })))
|
|
46
|
+
assert.equal(dispatched, emulator.id, 'an existing resolved/guarded target is preserved')
|
|
47
|
+
assert.equal(resolutions, before)
|
|
48
|
+
console.log('PASS ordinary tap/swipe device readiness follows implicit, explicit and already-resolved targets')
|
|
49
|
+
} finally {
|
|
50
|
+
AndroidInteract.prototype.tap = originalTap
|
|
51
|
+
AndroidInteract.prototype.swipe = originalSwipe
|
|
52
|
+
_resetDeviceListersForTests()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { InteractionDiagnostics } from '../../../src/server/handlers/interaction-diagnostics.js'
|
|
4
|
+
import { handleToolCall, toolDefinitions } from '../../../src/server-core.js'
|
|
5
|
+
|
|
6
|
+
let now = 0
|
|
7
|
+
const store = new InteractionDiagnostics(() => now)
|
|
8
|
+
const value = { error: 'original private diagnostic', evidence: { observations: [1, 2] } }
|
|
9
|
+
const ref = store.retain(value)
|
|
10
|
+
assert.equal(ref.available, true)
|
|
11
|
+
assert.deepEqual(store.lookup(ref.session_id, ref.result_id), { success: true, result: value })
|
|
12
|
+
value.error = 'mutated'
|
|
13
|
+
assert.notDeepEqual(store.lookup(ref.session_id, ref.result_id), { success: true, result: value })
|
|
14
|
+
now = 599999
|
|
15
|
+
assert.equal(store.lookup(ref.session_id, ref.result_id).success, true)
|
|
16
|
+
now++
|
|
17
|
+
assert.equal(store.lookup(ref.session_id, ref.result_id).error?.code, 'RESULT_UNAVAILABLE', 'reads do not extend expiry')
|
|
18
|
+
assert.equal(store.lookup(randomUUID(), ref.result_id).error?.code, 'SESSION_MISMATCH')
|
|
19
|
+
const oldest = store.retain({ n: 0 })
|
|
20
|
+
for (let n = 1; n <= 100; n++) store.retain({ n })
|
|
21
|
+
assert.equal(store.lookup(oldest.session_id, oldest.result_id).success, false)
|
|
22
|
+
const large = store.retain('a'.repeat(1024 * 1024))
|
|
23
|
+
assert.equal(large.available, false)
|
|
24
|
+
assert.equal(large.reason, 'RESULT_TOO_LARGE')
|
|
25
|
+
const byteStore = new InteractionDiagnostics()
|
|
26
|
+
const first = byteStore.retain('a'.repeat(1024 * 1024 - 100))
|
|
27
|
+
for (let n = 0; n < 16; n++) byteStore.retain('a'.repeat(1024 * 1024 - 100))
|
|
28
|
+
assert.equal(byteStore.lookup(first.session_id, first.result_id).success, false, 'total byte limit evicts before count limit')
|
|
29
|
+
const refs = await Promise.all(Array.from({ length: 30 }, async (_, n) => store.retain({ n })))
|
|
30
|
+
assert.equal(new Set(refs.map(r => r.result_id)).size, 30)
|
|
31
|
+
assert.ok(toolDefinitions.find(t => t.name === 'get_interaction_diagnostics')?.inputSchema.additionalProperties === false)
|
|
32
|
+
for (const args of [{}, { sessionId: randomUUID(), resultId: randomUUID(), extra: true }, { sessionId: 'bad', resultId: randomUUID() }]) {
|
|
33
|
+
const response = await handleToolCall('get_interaction_diagnostics', args)
|
|
34
|
+
assert.equal(JSON.parse(response.content[0].text!).error.code, 'INVALID_ARGUMENT')
|
|
35
|
+
}
|
|
36
|
+
console.log('PASS diagnostics fidelity, expiry, count/byte bounds, size rejection, session isolation and strict dispatch schema')
|
|
37
|
+
|
|
38
|
+
let expire: (() => void) | undefined
|
|
39
|
+
let cancelled = 0
|
|
40
|
+
const automatic = new InteractionDiagnostics(() => 0, randomUUID(), callback => { expire = callback; return () => { cancelled++ } })
|
|
41
|
+
const automaticRef = automatic.retain({ original: true })
|
|
42
|
+
assert(expire)
|
|
43
|
+
expire()
|
|
44
|
+
assert.equal(automatic.lookup(automaticRef.session_id, automaticRef.result_id).success, false, 'timer purges the payload without later cache access')
|
|
45
|
+
assert.equal(cancelled, 1)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
3
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
4
|
+
import { OwnedTransport } from '../../device/automated/latency/owned-transport.js'
|
|
5
|
+
|
|
6
|
+
if (process.platform !== 'win32') {
|
|
7
|
+
// A stalled initialization with a stubborn child must time out, then both
|
|
8
|
+
// processes must be gone. This uses real processes, not mocked kill calls.
|
|
9
|
+
let childStarted = false
|
|
10
|
+
const transport = new OwnedTransport(process.env, chunk => { childStarted ||= chunk.includes('ready') }, ['-e', `
|
|
11
|
+
const {spawn}=require('node:child_process');
|
|
12
|
+
process.on('SIGTERM',()=>{});
|
|
13
|
+
const child=spawn(process.execPath,['-e', 'process.on("SIGTERM",()=>{}); setInterval(()=>{},1000)'],{stdio:'ignore'});
|
|
14
|
+
child.on('spawn',()=>process.stderr.write('ready\\n'));
|
|
15
|
+
setInterval(()=>{},1000);
|
|
16
|
+
`])
|
|
17
|
+
const client = new Client({ name: 'timeout-cleanup-test', version: '1' })
|
|
18
|
+
try { await assert.rejects(client.connect(transport, { timeout: 500 }), /timed out/i) }
|
|
19
|
+
finally { await transport.close() }
|
|
20
|
+
assert.equal(childStarted, true)
|
|
21
|
+
assert.equal(transport.cleanupVerified, true)
|
|
22
|
+
// Interrupting a pending call takes the same owned-group cleanup path.
|
|
23
|
+
const controller = new AbortController()
|
|
24
|
+
const interrupted = new OwnedTransport(process.env, () => {}, ['-e', 'process.stdin.resume(); setInterval(()=>{},1000)'])
|
|
25
|
+
const other = new Client({ name: 'interrupt-cleanup-test', version: '1' })
|
|
26
|
+
const pending = other.connect(interrupted, { timeout: 5000, signal: controller.signal })
|
|
27
|
+
await delay(50); controller.abort()
|
|
28
|
+
await assert.rejects(pending)
|
|
29
|
+
await interrupted.close()
|
|
30
|
+
assert.equal(interrupted.cleanupVerified, true)
|
|
31
|
+
}
|
|
32
|
+
console.log('Owned benchmark timeout/interrupt cleanup: PASS')
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { getAppReadiness } from '../../../src/observe/readiness-service.js'
|
|
3
|
+
import { withDeviceReadinessProbe } from '../../../src/observe/device-readiness.js'
|
|
4
|
+
import { ToolsObserve } from '../../../src/observe/index.js'
|
|
5
|
+
import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
|
|
6
|
+
|
|
7
|
+
async function run() {
|
|
8
|
+
const original = ToolsObserve.getUITreeHandler
|
|
9
|
+
let samples = 0
|
|
10
|
+
_setDeviceListersForTests({ listAndroidDevices: async () => [{ id: 'fixture', platform: 'android', osVersion: '16', model: 'Fixture', simulator: true }] })
|
|
11
|
+
ToolsObserve.getUITreeHandler = async () => { assert.fail('inconclusive probes must not clear a known device blocker') }
|
|
12
|
+
try {
|
|
13
|
+
const result = await withDeviceReadinessProbe(async (_cmd, args) => {
|
|
14
|
+
if (args.includes('power')) samples++
|
|
15
|
+
if (samples > 1) throw new Error('probe unavailable')
|
|
16
|
+
return { exitCode: 0, stderr: '', stdout: args.includes('power') ? 'mWakefulness=Asleep\nmWakefulnessChanging=false' : 'KeyguardServiceDelegate\n showing=false\n mIsShowing=false' }
|
|
17
|
+
}, () => getAppReadiness({ platform: 'android', deviceId: 'fixture', timeoutMs: 300, pollIntervalMs: 100 }, true))
|
|
18
|
+
assert.ok(samples > 1, 'exercise a later inconclusive probe in the same wait')
|
|
19
|
+
assert.equal(result.success, false)
|
|
20
|
+
assert.equal(result.failure_code, 'READINESS_TIMEOUT')
|
|
21
|
+
assert.deepEqual(result.device_readiness.blockers, ['DEVICE_ASLEEP'])
|
|
22
|
+
assert.equal(result.device_readiness.input_ready, false)
|
|
23
|
+
assert.ok(result.device_readiness.evidence.some(item => item.code === 'DEVICE_ASLEEP'))
|
|
24
|
+
assert.ok(result.device_readiness.limitations.includes('DEVICE_POWER_OBSERVATION_FAILED'))
|
|
25
|
+
assert.ok(result.readiness.limitations.includes('DEADLINE_EXCEEDED'))
|
|
26
|
+
console.log('PASS readiness deadline retains the earlier blocker and later probe failure')
|
|
27
|
+
} finally {
|
|
28
|
+
ToolsObserve.getUITreeHandler = original
|
|
29
|
+
_resetDeviceListersForTests()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|