mobile-debug-mcp 0.35.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/.cursor-plugin/marketplace.json +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/README.md +96 -24
- package/agent-plugin/.antigravity-plugin/README.md +48 -0
- package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
- package/agent-plugin/.claude-plugin/plugin.json +2 -2
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/README.md +37 -8
- package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/cursor/LICENSE +201 -0
- package/agent-plugin/cursor/README.md +9 -0
- package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +261 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/mcp_config.json +13 -0
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +46 -18
- package/dist/contracts/device-readiness.js +1 -0
- package/dist/debugger/guard.js +4 -4
- package/dist/debugger/ios-worker.js +62 -0
- package/dist/debugger/ios.js +57 -0
- package/dist/debugger/sessions.js +33 -16
- package/dist/debugger/worker.js +6 -4
- package/dist/interact/index.js +12 -2
- 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/manage/ios.js +2 -0
- package/dist/observe/device-readiness.js +116 -0
- package/dist/observe/evidence.js +31 -0
- package/dist/observe/ios-trace/artifact.js +63 -0
- package/dist/observe/ios-trace/capabilities.js +86 -0
- package/dist/observe/ios-trace/native.js +93 -0
- package/dist/observe/ios-trace/notification.js +35 -0
- package/dist/observe/ios-trace/process.js +98 -0
- package/dist/observe/ios-trace/sessions.js +246 -0
- package/dist/observe/readiness-service.js +22 -10
- package/dist/observe/readiness.js +34 -4
- package/dist/observe/screenshot-encoding.js +4 -0
- package/dist/observe/snapshot-collection.js +7 -2
- package/dist/observe/system-trace-error.js +7 -0
- package/dist/observe/system-trace-router.js +81 -0
- package/dist/observe/system-trace.js +4 -8
- 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/debugger-handlers.js +27 -8
- package/dist/server/handlers/diagnostic.js +12 -4
- 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 +28 -14
- package/dist/server/schemas/debugger-schema.js +10 -7
- package/dist/server/schemas/interaction-diagnostics.js +9 -0
- package/dist/server/schemas/system-trace-schema.js +14 -9
- package/dist/server/tool-definitions.js +9 -4
- package/dist/server-core.js +5 -2
- package/dist/server.js +5 -5
- 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 +7 -11
- package/dist/utils/resolve-device.js +3 -2
- package/dist/utils/timing.js +3 -3
- package/docs/CHANGELOG.md +48 -1
- package/docs/agent-plugin-installation-verification.md +39 -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/code-structure.md +3 -1
- package/docs/specs/031-ios-system-traces/intent.md +38 -0
- package/docs/specs/031-ios-system-traces/investigation.md +92 -0
- package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
- package/docs/specs/031-ios-system-traces/plan.md +166 -0
- package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
- package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
- package/docs/specs/031-ios-system-traces/spec.md +135 -0
- package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
- package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
- package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
- package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
- package/docs/specs/032-ios-runtime-debugger/spec.md +153 -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 +3 -2
- package/docs/tools/compact-interactions.md +56 -0
- package/docs/tools/correlated-evidence.md +6 -0
- package/docs/tools/debugger.md +45 -5
- 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-traces.md +81 -14
- package/docs/tools/system.md +6 -0
- package/package.json +2 -1
- package/scripts/agent-plugin.mjs +55 -5
- package/src/contracts/device-readiness.ts +8 -0
- package/src/debugger/android.ts +1 -1
- package/src/debugger/guard.ts +5 -5
- package/src/debugger/ios-worker.ts +49 -0
- package/src/debugger/ios.ts +45 -0
- package/src/debugger/lldb/breakpoints.py +51 -0
- package/src/debugger/lldb/inspection.py +109 -0
- package/src/debugger/lldb/worker.py +203 -0
- package/src/debugger/sessions.ts +24 -12
- package/src/debugger/worker.ts +7 -4
- package/src/interact/index.ts +13 -2
- 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/manage/ios.ts +2 -0
- package/src/observe/device-readiness.ts +96 -0
- package/src/observe/evidence.ts +30 -1
- package/src/observe/ios-trace/artifact.ts +55 -0
- package/src/observe/ios-trace/capabilities.ts +74 -0
- package/src/observe/ios-trace/native.ts +72 -0
- package/src/observe/ios-trace/notification.ts +30 -0
- package/src/observe/ios-trace/process.ts +82 -0
- package/src/observe/ios-trace/sessions.ts +206 -0
- package/src/observe/readiness-service.ts +22 -10
- package/src/observe/readiness.ts +37 -4
- package/src/observe/screenshot-encoding.ts +4 -0
- package/src/observe/snapshot-collection.ts +7 -2
- package/src/observe/system-trace-error.ts +3 -0
- package/src/observe/system-trace-router.ts +61 -0
- package/src/observe/system-trace.ts +4 -4
- 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/debugger-handlers.ts +28 -9
- package/src/server/handlers/diagnostic.ts +11 -4
- 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 +29 -14
- package/src/server/schemas/debugger-schema.ts +10 -7
- package/src/server/schemas/interaction-diagnostics.ts +10 -0
- package/src/server/schemas/system-trace-schema.ts +15 -9
- package/src/server/tool-definitions.ts +8 -4
- package/src/server-core.ts +5 -2
- package/src/server.ts +4 -4
- 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 +7 -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 +227 -0
- 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 +119 -0
- package/test/device/automated/observe/system-trace.smoke.ts +2 -2
- package/test/fixtures/contracts/tool-definitions.json +243 -47
- package/test/fixtures/ios-debugger-app/Main.swift +95 -0
- package/test/fixtures/ios-debugger-app/README.md +27 -0
- package/test/fixtures/ios-debugger-app/build.ts +21 -0
- package/test/fixtures/latency-journey-app/build.ts +3 -3
- package/test/helpers/ios-trace-shutdown.ts +29 -0
- package/test/unit/debugger/handle-guards.test.ts +89 -0
- package/test/unit/debugger/ios-preparation.test.ts +57 -0
- package/test/unit/debugger/ios.test.ts +104 -0
- package/test/unit/debugger/lldb-worker.py +166 -0
- package/test/unit/debugger/lldb-worker.test.ts +10 -0
- 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-native.test.ts +90 -0
- package/test/unit/observe/ios-trace-sessions.test.ts +154 -0
- package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
- package/test/unit/observe/readiness.test.ts +1 -1
- package/test/unit/observe/system-trace-router.test.ts +49 -0
- package/test/unit/quality/agent-plugin-export.test.ts +38 -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 +18 -7
- 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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { SystemTraceRouter } from '../../../src/observe/system-trace-router.js'
|
|
3
|
+
import type { SystemTraceSessions, TraceResult } from '../../../src/observe/system-trace.js'
|
|
4
|
+
import type { IosTraceSessions, IosTraceResult } from '../../../src/observe/ios-trace/sessions.js'
|
|
5
|
+
import type { IosTraceNative } from '../../../src/observe/ios-trace/native.js'
|
|
6
|
+
function backend(platform:'android'|'ios') {
|
|
7
|
+
let next=0,starts=0
|
|
8
|
+
const results=new Map<string,TraceResult|IosTraceResult>()
|
|
9
|
+
const api={
|
|
10
|
+
capabilities:async()=>({}),
|
|
11
|
+
start:async(device:string|{id:string})=>{
|
|
12
|
+
starts++
|
|
13
|
+
const result={trace_id:platform+ ++next,device:{platform,id:typeof device==='string'?device:device.id},state:'recording',cleanup:{status:'pending'}} as TraceResult|IosTraceResult
|
|
14
|
+
results.set(result.trace_id,result);return result
|
|
15
|
+
},
|
|
16
|
+
control:async(id:string,op:string)=>{
|
|
17
|
+
const result=results.get(id);if(!result)throw new Error('TRACE_NOT_FOUND')
|
|
18
|
+
if(op==='cancel'){result.cleanup.status='complete';result.state='cancelled'}
|
|
19
|
+
return result
|
|
20
|
+
},
|
|
21
|
+
link:async()=>async()=>({trace_id:'linked',coverage:'covered',clock_alignment:'unknown'}),
|
|
22
|
+
shutdown:async()=>[]
|
|
23
|
+
}
|
|
24
|
+
return {api,results,count:()=>starts}
|
|
25
|
+
}
|
|
26
|
+
async function run(){
|
|
27
|
+
const android=backend('android'),ios=backend('ios')
|
|
28
|
+
let release!:()=>void,gate=Promise.resolve()
|
|
29
|
+
const native={shutdown:()=>{},capabilities:async(device:string)=>{await gate;return {supported:true,device:{platform:'ios',id:device,simulator:true}}}} as unknown as IosTraceNative
|
|
30
|
+
const router=new SystemTraceRouter(android.api as unknown as SystemTraceSessions,ios.api as unknown as IosTraceSessions,native)
|
|
31
|
+
const a=await router.start('a'),b=await router.start('b',30000,{appId:'fixture'})
|
|
32
|
+
await assert.rejects(()=>router.link(b.trace_id,{platform:'android',id:'b'},'bad'),/TRACE_NOT_FOUND/)
|
|
33
|
+
await assert.rejects(()=>router.link(b.trace_id,{platform:'ios',id:'other'},'bad'),/TRACE_NOT_FOUND/)
|
|
34
|
+
await assert.rejects(()=>router.start('b',30000,{appId:'fixture'}),/TRACE_BUSY/)
|
|
35
|
+
await router.start('c');await router.start('d',30000,{appId:'fixture'})
|
|
36
|
+
await assert.rejects(()=>router.start('fifth'),/TRACE_BUSY/)
|
|
37
|
+
await router.control(a.trace_id,'cancel')
|
|
38
|
+
await router.start('freed')
|
|
39
|
+
await router.control(b.trace_id,'cancel')
|
|
40
|
+
gate=new Promise<void>(r=>{release=r})
|
|
41
|
+
const first=router.start('pending',30000,{appId:'fixture'})
|
|
42
|
+
await new Promise(r=>setImmediate(r))
|
|
43
|
+
await assert.rejects(()=>router.start('pending',30000,{appId:'fixture'}),/TRACE_BUSY/)
|
|
44
|
+
release();await first
|
|
45
|
+
assert.equal(ios.count(),3)
|
|
46
|
+
await router.shutdown();await assert.rejects(()=>router.start('closed'),/TRACE_BUSY/)
|
|
47
|
+
console.log('PASS cross-platform trace routing, pinned linkage, shared reservations and shutdown')
|
|
48
|
+
}
|
|
49
|
+
run().catch(error=>{console.error(error);process.exitCode=1})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
const root = fileURLToPath(new URL('../../../', import.meta.url))
|
|
9
|
+
const temporary = await mkdtemp(path.join(os.tmpdir(), 'agent-plugin-export-'))
|
|
10
|
+
const output = path.join(temporary, 'plugin')
|
|
11
|
+
const exportPlugin = () => spawnSync(process.execPath, ['scripts/agent-plugin.mjs', '--export-antigravity', output], { cwd: root, encoding: 'utf8', timeout: 10000 })
|
|
12
|
+
try {
|
|
13
|
+
const exported = exportPlugin()
|
|
14
|
+
assert.equal(exported.status, 0, exported.stderr)
|
|
15
|
+
assert.deepEqual((await readdir(output)).sort(), ['LICENSE', 'README.md', 'assets', 'mcp_config.json', 'plugin.json', 'skills'])
|
|
16
|
+
for (const directory of ['skills', 'assets']) {
|
|
17
|
+
const files = await readdir(path.join(output, directory), { recursive: true })
|
|
18
|
+
assert.ok(files.every(file => path.basename(file) !== '.DS_Store' && !path.basename(file).startsWith('._')), 'Export must exclude Finder metadata')
|
|
19
|
+
}
|
|
20
|
+
const manifest = JSON.parse(await readFile(path.join(output, 'plugin.json'), 'utf8'))
|
|
21
|
+
assert.equal(manifest.$schema, 'https://antigravity.google/schemas/v1/plugin.json')
|
|
22
|
+
assert.ok((await readFile(path.join(output, manifest.logo))).length > 0)
|
|
23
|
+
const config = JSON.parse(await readFile(path.join(output, 'mcp_config.json'), 'utf8'))
|
|
24
|
+
assert.equal(Object.keys(config.mcpServers).length, 1)
|
|
25
|
+
const server = config.mcpServers['mobile-debug-mcp']
|
|
26
|
+
assert.equal(server.command, 'npx')
|
|
27
|
+
assert.equal(server.cwd, '${PLUGIN_DATA}')
|
|
28
|
+
assert.equal('type' in server, false, 'Antigravity infers stdio transport; type causes a strict warning')
|
|
29
|
+
const skill = 'skills/mobile-debug-mcp/SKILL.md'
|
|
30
|
+
assert.deepEqual(await readFile(path.join(output, skill)), await readFile(path.join(root, 'agent-plugin', skill)))
|
|
31
|
+
assert.deepEqual(await readFile(path.join(output, 'LICENSE')), await readFile(path.join(root, 'LICENSE')))
|
|
32
|
+
await writeFile(path.join(output, 'keep.txt'), 'existing user data')
|
|
33
|
+
assert.notEqual(exportPlugin().status, 0, 'Existing destinations must not be overwritten or merged')
|
|
34
|
+
assert.equal(await readFile(path.join(output, 'keep.txt'), 'utf8'), 'existing user data')
|
|
35
|
+
console.log('PASS Antigravity export: native inventory, shared skill/license, no foreign manifests, overwrite refusal')
|
|
36
|
+
} finally {
|
|
37
|
+
await rm(temporary, { recursive: true, force: true })
|
|
38
|
+
}
|
|
@@ -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 })
|