mobile-debug-mcp 0.32.4 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.cursor-plugin/marketplace.json +16 -0
  4. package/.github/copilot-instructions.md +7 -0
  5. package/.github/workflows/ci.yml +21 -0
  6. package/AGENTS.md +21 -10
  7. package/README.md +69 -4
  8. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  9. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  10. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  11. package/agent-plugin/.mcp.json +14 -0
  12. package/agent-plugin/README.md +9 -3
  13. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  14. package/agent-plugin/mcp.json +1 -1
  15. package/agent-plugin/plugin.json +1 -1
  16. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
  17. package/dist/debugger/android.js +78 -0
  18. package/dist/debugger/guard.js +30 -0
  19. package/dist/debugger/sessions.js +262 -0
  20. package/dist/debugger/worker.js +165 -0
  21. package/dist/interact/android.js +10 -4
  22. package/dist/interact/conditional-action.js +68 -0
  23. package/dist/interact/index.js +70 -14
  24. package/dist/interact/ios.js +14 -8
  25. package/dist/manage/android.js +4 -3
  26. package/dist/manage/ios.js +3 -2
  27. package/dist/observe/android.js +25 -4
  28. package/dist/observe/evidence.js +280 -0
  29. package/dist/observe/index.js +33 -6
  30. package/dist/observe/ios.js +25 -6
  31. package/dist/observe/readiness-service.js +124 -0
  32. package/dist/observe/readiness.js +88 -0
  33. package/dist/observe/recording.js +165 -0
  34. package/dist/observe/snapshot-metadata.js +10 -2
  35. package/dist/observe/system-trace-android.js +135 -0
  36. package/dist/observe/system-trace.js +285 -0
  37. package/dist/server/common.js +9 -0
  38. package/dist/server/condition-schema.js +32 -0
  39. package/dist/server/debugger-handlers.js +38 -0
  40. package/dist/server/debugger-schema.js +42 -0
  41. package/dist/server/journey-schema.js +4 -2
  42. package/dist/server/readiness-schema.js +12 -0
  43. package/dist/server/system-trace-schema.js +17 -0
  44. package/dist/server/tool-definitions.js +51 -0
  45. package/dist/server/tool-handlers.js +294 -105
  46. package/dist/server-core.js +3 -3
  47. package/dist/server.js +34 -0
  48. package/dist/system/index.js +5 -4
  49. package/dist/utils/exec.js +2 -0
  50. package/dist/utils/ios/utils.js +4 -4
  51. package/dist/utils/java.js +14 -12
  52. package/dist/utils/operation-budget.js +26 -2
  53. package/dist/utils/resolve-device.js +9 -0
  54. package/docs/CHANGELOG.md +15 -0
  55. package/docs/specs/020-conditional-actions/intent.md +62 -0
  56. package/docs/specs/020-conditional-actions/plan.md +214 -0
  57. package/docs/specs/020-conditional-actions/spec.md +301 -0
  58. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  59. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  60. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  61. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  62. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  63. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  64. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  65. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  66. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  67. package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
  68. package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
  69. package/docs/specs/023-android-system-traces/intent.md +40 -0
  70. package/docs/specs/023-android-system-traces/plan.md +117 -0
  71. package/docs/specs/023-android-system-traces/spec.md +120 -0
  72. package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
  73. package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
  74. package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
  75. package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
  76. package/docs/specs/process.md +213 -0
  77. package/docs/tools/TOOLS.md +5 -0
  78. package/docs/tools/debugger.md +104 -0
  79. package/docs/tools/evidence.md +82 -0
  80. package/docs/tools/interact.md +125 -2
  81. package/docs/tools/observe.md +70 -0
  82. package/docs/tools/system-traces.md +126 -0
  83. package/package.json +1 -1
  84. package/scripts/agent-plugin.mjs +68 -8
  85. package/src/debugger/android.ts +65 -0
  86. package/src/debugger/guard.ts +26 -0
  87. package/src/debugger/sessions.ts +168 -0
  88. package/src/debugger/worker.ts +122 -0
  89. package/src/interact/android.ts +10 -4
  90. package/src/interact/conditional-action.ts +75 -0
  91. package/src/interact/index.ts +57 -28
  92. package/src/interact/ios.ts +14 -8
  93. package/src/manage/android.ts +4 -3
  94. package/src/manage/ios.ts +3 -2
  95. package/src/observe/android.ts +33 -6
  96. package/src/observe/evidence.ts +262 -0
  97. package/src/observe/index.ts +32 -8
  98. package/src/observe/ios.ts +32 -8
  99. package/src/observe/readiness-service.ts +94 -0
  100. package/src/observe/readiness.ts +84 -0
  101. package/src/observe/snapshot-metadata.ts +12 -1
  102. package/src/observe/system-trace-android.ts +133 -0
  103. package/src/observe/system-trace.ts +218 -0
  104. package/src/server/common.ts +7 -0
  105. package/src/server/condition-schema.ts +32 -0
  106. package/src/server/debugger-handlers.ts +29 -0
  107. package/src/server/debugger-schema.ts +37 -0
  108. package/src/server/journey-schema.ts +4 -2
  109. package/src/server/readiness-schema.ts +12 -0
  110. package/src/server/system-trace-schema.ts +17 -0
  111. package/src/server/tool-definitions.ts +49 -0
  112. package/src/server/tool-handlers.ts +182 -21
  113. package/src/server-core.ts +3 -3
  114. package/src/server.ts +24 -0
  115. package/src/system/index.ts +7 -5
  116. package/src/types.ts +30 -1
  117. package/src/utils/exec.ts +2 -0
  118. package/src/utils/ios/utils.ts +4 -4
  119. package/src/utils/java.ts +13 -12
  120. package/src/utils/operation-budget.ts +24 -2
  121. package/src/utils/resolve-device.ts +10 -0
  122. package/test/device/automated/debugger/runtime.android.test.ts +203 -0
  123. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  124. package/test/device/automated/observe/system-trace.smoke.ts +104 -0
  125. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  126. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  127. package/test/device/manual/interact/readiness.manual.ts +99 -0
  128. package/test/fixtures/debugger-app/MainActivity.java +46 -0
  129. package/test/fixtures/debugger-app/NoLocals.java +7 -0
  130. package/test/fixtures/debugger-app/README.md +13 -0
  131. package/test/fixtures/debugger-app/build.ts +29 -0
  132. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  133. package/test/fixtures/readiness-app/README.md +12 -0
  134. package/test/fixtures/readiness-app/build.ts +37 -0
  135. package/test/fixtures/readiness-app/main.m +27 -0
  136. package/test/helpers/perfetto.ts +59 -0
  137. package/test/unit/debugger/guards.test.ts +46 -0
  138. package/test/unit/debugger/native.test.ts +33 -0
  139. package/test/unit/debugger/sessions.test.ts +83 -0
  140. package/test/unit/observe/android_tree_acquisition.test.ts +2 -0
  141. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  142. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  143. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  144. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  145. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  146. package/test/unit/observe/readiness.test.ts +41 -0
  147. package/test/unit/observe/readiness_native.test.ts +68 -0
  148. package/test/unit/observe/system-trace-android.test.ts +52 -0
  149. package/test/unit/observe/system-trace.test.ts +166 -0
  150. package/test/unit/server/conditional_actions.test.ts +299 -0
  151. package/test/unit/server/conditional_schema.test.ts +59 -0
  152. package/test/unit/server/correlated-evidence.test.ts +194 -0
  153. package/test/unit/server/readiness.test.ts +170 -0
  154. package/test/unit/server/selector_tap_target.test.ts +79 -0
  155. package/test/unit/server/system-trace.test.ts +64 -0
  156. package/test/unit/server/tap_selector.test.ts +2 -0
  157. package/worker/DebuggerWorker.java +160 -0
  158. package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
  159. /package/{skills → .agents/skills}/README.md +0 -0
  160. /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
  161. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  162. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  163. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  164. /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
  165. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  166. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  167. /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
  168. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  169. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  170. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ // Minimal local protobuf reader for format/content evidence, not trace analysis.
4
+ // Field numbers: google/perfetto protos/perfetto/trace/{trace,trace_packet}.proto
5
+ // and trace/ftrace/{ftrace_event_bundle,ftrace_event}.proto. Unknown fields skipped.
6
+ export function protobufFields(bytes: Buffer): Array<{ field: number; wire: number; value: Buffer | bigint }> {
7
+ let offset = 0
8
+ const result: Array<{ field: number; wire: number; value: Buffer | bigint }> = []
9
+ function varint() {
10
+ let value = 0n
11
+ for (let shift = 0n; shift < 70n; shift += 7n) {
12
+ assert.ok(offset < bytes.length, 'Truncated protobuf varint')
13
+ const byte = bytes[offset++]
14
+ value |= BigInt(byte & 127) << shift
15
+ if (!(byte & 128)) return value
16
+ }
17
+ throw new Error('Oversized protobuf varint')
18
+ }
19
+ while (offset < bytes.length) {
20
+ const key = Number(varint()), field = Math.floor(key / 8), wire = key % 8
21
+ assert.ok(field > 0)
22
+ let value: Buffer | bigint
23
+ if (wire === 0) value = varint()
24
+ else {
25
+ const length = wire === 2 ? Number(varint()) : wire === 1 ? 8 : wire === 5 ? 4 : -1
26
+ assert.ok(Number.isSafeInteger(length) && length >= 0 && length <= bytes.length - offset, 'Invalid protobuf length/wire')
27
+ value = bytes.subarray(offset, offset + length); offset += length
28
+ }
29
+ result.push({ field, wire, value })
30
+ }
31
+ return result
32
+ }
33
+ export function inspectPerfetto(bytes: Buffer, duration: number) {
34
+ const root = protobufFields(bytes)
35
+ assert.ok(root.length > 0)
36
+ assert.ok(root.every(f => f.field === 1 && f.wire === 2), 'Trace.packet must be repeated length-delimited messages')
37
+ const packets = root.map(f => protobufFields(f.value as Buffer))
38
+ const config = packets.flat().find(f => f.field === 33 && f.wire === 2)
39
+ assert.ok(config, 'Missing TracePacket.trace_config')
40
+ assert.equal(protobufFields(config.value as Buffer).find(f => f.field === 3)?.value, BigInt(duration))
41
+ const bundles = packets.flat().filter(f => f.field === 1 && f.wire === 2).map(f => protobufFields(f.value as Buffer))
42
+ let events = 0, compactBundles = 0
43
+ for (const bundle of bundles) {
44
+ for (const f of bundle) {
45
+ if (f.field === 2 && f.wire === 2) {
46
+ const event = protobufFields(f.value as Buffer)
47
+ assert.ok(event.some(e => e.field === 1 && e.wire === 0), 'FtraceEvent timestamp missing')
48
+ events++
49
+ }
50
+ if (f.field === 4 && f.wire === 2) {
51
+ assert.ok(protobufFields(f.value as Buffer).some(e => [1, 7].includes(e.field) && e.wire === 2))
52
+ compactBundles++
53
+ }
54
+ }
55
+ }
56
+ assert.ok(events + compactBundles > 0, 'No captured kernel/scheduler events')
57
+ return { packets: packets.length, ftrace_bundles: bundles.length, events, compact_sched_bundles: compactBundles,
58
+ validation: 'local protobuf structure, trace config duration and timestamped ftrace/compact scheduler content; not full Perfetto analysis' }
59
+ }
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict'
2
+ import { debuggerSessions } from '../../../src/debugger/sessions.js'
3
+ import { withDebuggerGuard, checkDebuggerGuard } from '../../../src/debugger/guard.js'
4
+ import { handleToolCall } from '../../../src/server-core.js'
5
+ import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
6
+ import { ToolsObserve } from '../../../src/observe/index.js'
7
+ import { ToolsInteract } from '../../../src/interact/index.js'
8
+ import { getAppReadiness } from '../../../src/observe/readiness-service.js'
9
+ const originalPause = debuggerSessions.paused
10
+ const originalActive = debuggerSessions.hasActiveSessions
11
+ const originalTree = ToolsObserve.getUITreeHandler
12
+ const originalWait = ToolsInteract.waitForUIHandler
13
+ let paused = true
14
+ const pause: any = { debug_session_id: 'session', target: { app_id: 'test.app', pid: 10 }, state: 'paused', captured_at_ms: 1000 }
15
+ debuggerSessions.paused = device => paused && device === 'fixture' ? pause : null
16
+ debuggerSessions.hasActiveSessions = () => true
17
+ _setDeviceListersForTests({ listAndroidDevices: async () => [{ id: 'fixture', platform: 'android' } as any] })
18
+ const payload = (response: any) => JSON.parse(response.content.find((c: any) => c.type === 'text').text)
19
+ try {
20
+ ToolsObserve.getUITreeHandler = async () => { throw new Error('Paused readiness must not acquire a UI tree') }
21
+ const readiness = await getAppReadiness({ platform: 'android', deviceId: 'fixture' })
22
+ assert.equal(readiness.readiness.state, 'debugger_paused')
23
+ const wait = await getAppReadiness({ platform: 'android', deviceId: 'fixture' }, true)
24
+ assert.equal(wait.failure_code, 'DEBUGGER_PAUSED')
25
+ const blocked = payload(await handleToolCall('tap', { platform: 'android', deviceId: 'fixture', x: 1, y: 1 }))
26
+ assert.equal(blocked.delivery_status, 'not_delivered')
27
+ await withDebuggerGuard('android', 'other', async () => checkDebuggerGuard(true))
28
+ await assert.rejects(withDebuggerGuard('android', 'fixture', async () => checkDebuggerGuard()), /DEBUGGER_PAUSED/)
29
+ paused = false
30
+ let calls = 0
31
+ ToolsInteract.waitForUIHandler = async () => { calls++; if (calls === 2) paused = true; return { status: 'success' } as any }
32
+ const journey = payload(await handleToolCall('run_journey', { platform: 'android', deviceId: 'fixture', responseMode: 'compact', steps: [
33
+ { id: 'first', type: 'wait', selector: { text: 'First' } },
34
+ { id: 'second', type: 'wait', selector: { text: 'Second' } },
35
+ { id: 'third', type: 'wait', selector: { text: 'Third' } }
36
+ ] }))
37
+ assert.equal(journey.steps[0].status, 'passed', 'completed steps survive a later pause')
38
+ assert.equal(journey.steps[1].success, false)
39
+ assert.equal(journey.steps[1].result.failure_code, 'DEBUGGER_PAUSED')
40
+ assert.equal(calls, 2, 'no action queued or retried after pause')
41
+ console.log('PASS debugger readiness without UI acquisition, device scoping, dispatch guard, mid-journey pause preservation')
42
+ } finally {
43
+ debuggerSessions.paused = originalPause; debuggerSessions.hasActiveSessions = originalActive
44
+ ToolsObserve.getUITreeHandler = originalTree; ToolsInteract.waitForUIHandler = originalWait
45
+ _resetDeviceListersForTests()
46
+ }
@@ -0,0 +1,33 @@
1
+ import assert from 'node:assert/strict'
2
+ import { AndroidDebugNative } from '../../../src/debugger/android.js'
3
+ import { DebuggerError, debuggerRemaining, withDebuggerBudget } from '../../../src/debugger/worker.js'
4
+ class Native extends AndroidDebugNative {
5
+ calls: string[][] = []
6
+ forwards = 'device tcp:1234 jdwp:10\nother tcp:3456 jdwp:20'
7
+ async adb(_device: string, args: string[]) {
8
+ this.calls.push(args)
9
+ if (args[0] === 'forward' && args[1] === '--list') return this.forwards
10
+ if (args.at(-1)?.endsWith('/stat')) return '10 (complex process name) S ' + Array(18).fill('0').join(' ') + ' 999 0'
11
+ if (args.at(-1)?.endsWith('/status')) return 'Name:\ttest\nUid:\t42\t42\t42\t42'
12
+ if (args.at(-1)?.endsWith('/cmdline')) return 'test.app\0'
13
+ return ''
14
+ }
15
+ }
16
+ const native = new Native()
17
+ await native.removeForward('device', 1234, 10)
18
+ assert.deepEqual(native.calls.at(-1), ['forward', '--remove', 'tcp:1234'])
19
+ await assert.rejects(native.removeForward('device', 3456, 10), /DEBUGGER_CLEANUP_FAILED/)
20
+ const before = native.calls.length
21
+ await native.removeForward('device', 9999, 10)
22
+ assert.equal(native.calls.length, before + 1, 'absent owned forward requires no removal')
23
+ const target = { app_id: 'test.app', pid: 10, process_name: 'test.app', start_identity: '', uid: 42 }
24
+ assert.equal((await native.identity('device', target)).start_identity, '999')
25
+ await assert.rejects(native.identity('device', { ...target, start_identity: 'old' }), /PROCESS_CHANGED/)
26
+ await assert.rejects(native.identity('device', { ...target, uid: 44 }), /PROCESS_CHANGED/)
27
+ await assert.rejects(native.candidates('device', 'test.app;evil'), /INVALID_ARGUMENT/)
28
+ const aborted = new AbortController(); aborted.abort()
29
+ await assert.rejects(withDebuggerBudget(100, aborted.signal, async () => debuggerRemaining(10)), /DEBUGGER_CANCELLED/)
30
+ await assert.rejects(withDebuggerBudget(-1, undefined, async () => debuggerRemaining(10)), /DEBUGGER_TIMEOUT/)
31
+ assert.equal(await withDebuggerBudget(100, undefined, async () => debuggerRemaining(10)), 10)
32
+ assert.equal(new DebuggerError('DEBUGGER_TIMEOUT').code, 'DEBUGGER_TIMEOUT')
33
+ console.log('PASS debugger native identity, PID reuse, forward ownership, deadlines and cancellation')
@@ -0,0 +1,83 @@
1
+ import assert from 'node:assert/strict'
2
+ import { DebuggerSessions, debuggerExpiry } from '../../../src/debugger/sessions.js'
3
+ import { DebuggerError, type WorkerEvent } from '../../../src/debugger/worker.js'
4
+ import type { DebugNative, DebugTarget } from '../../../src/debugger/android.js'
5
+ import { debuggerContracts, debuggerTools } from '../../../src/server/debugger-schema.js'
6
+ assert.equal(debuggerExpiry(0, 0, 0, 59999), null)
7
+ assert.equal(debuggerExpiry(0, 60000, 0, 60000), 'pause_limit', 'polling cannot extend pause expiry')
8
+ assert.equal(debuggerExpiry(0, 0, undefined, 300000), 'idle_limit')
9
+ assert.equal(debuggerExpiry(0, 1800000, undefined, 1800000), 'session_limit', 'activity cannot extend lifetime')
10
+ const target: DebugTarget = { app_id: 'test.app', pid: 123, uid: 456, process_name: 'test.app', start_identity: '100' }
11
+ let candidates = [target]
12
+ let removed = 0
13
+ let emit: (event: WorkerEvent) => void = () => {}
14
+ let close = 0
15
+ let failCleanup = false
16
+ let failCommand = ''
17
+ const native: DebugNative = {
18
+ candidates: async () => candidates, identity: async () => target, forward: async () => 12345,
19
+ removeForward: async () => { if (failCleanup) throw new Error('offline'); removed++ }
20
+ }
21
+ const sessions = new DebuggerSessions(native, async event => {
22
+ emit = event
23
+ return { call: async (command: string) => { if (command === failCommand) throw new DebuggerError('DEBUGGER_TIMEOUT'); return { state: 'running', breakpoints: [] } }, close: async () => { close++ } }
24
+ }, async () => ({ java: 'java', directory: '/unused' }))
25
+ try {
26
+ candidates = [target, { ...target, pid: 124 }]
27
+ const ambiguous = await sessions.attach('emulator', 'test.app')
28
+ assert.equal(ambiguous.error.code, 'PROCESS_AMBIGUOUS')
29
+ assert.equal(removed, 0)
30
+ candidates = [target]
31
+ const first = await sessions.attach('emulator', 'test.app')
32
+ const id = first.debug_session_id
33
+ assert.equal(first.state, 'running')
34
+ await assert.rejects(sessions.attach('emulator', 'test.app'), /DEBUGGER_BUSY/)
35
+ emit({ event: 'stopped', stop_id: 'stop-1', thread_id: 't1', stops: [] })
36
+ assert.equal(sessions.paused('emulator')?.stop.stop_id, 'stop-1')
37
+ assert.equal(sessions.paused('other'), null)
38
+ assert.throws(() => sessions.assertRunning('emulator'), /DEBUGGER_PAUSED/)
39
+ const pendingWait = sessions.command(id, 'events', { afterSequence: 1 }, 500)
40
+ await sessions.command(id, 'resume', { stopId: 'stop-1' })
41
+ assert.equal(sessions.paused('emulator'), null)
42
+ await assert.rejects(sessions.command(id, 'locals', { stopId: 'stop-1', frameId: 'old' }), /DEBUGGER_STALE_REFERENCE/)
43
+ emit({ event: 'stopped', stop_id: 'stop-2', thread_id: 't2' })
44
+ assert.equal((await pendingWait).events[0].stop_id, 'stop-2', 'event wait cannot block resume')
45
+ for (let i = 0; i < 260; i++) emit({ event: 'breakpoint', i })
46
+ const events = await sessions.command(id, 'events', { afterSequence: 0 }, 100)
47
+ assert.equal(events.overflow, true); assert.equal(events.events.length, 100)
48
+ await assert.rejects(sessions.command(id, 'events', { afterSequence: 9999 }, 100), /INVALID_ARGUMENT/)
49
+ const controller = new AbortController(); controller.abort()
50
+ await assert.rejects(sessions.command(id, 'events', { afterSequence: 262, signal: controller.signal }, 100), /DEBUGGER_CANCELLED/)
51
+ failCommand = 'locals'
52
+ await assert.rejects(sessions.command(id, 'locals', { stopId: 'stop-2' }, 100), /DEBUGGER_TIMEOUT/)
53
+ assert.equal(sessions.paused('emulator')?.state, 'paused', 'inspection timeout preserves known suspension')
54
+ failCommand = 'step'
55
+ await assert.rejects(sessions.command(id, 'step', { stopId: 'stop-2', threadId: 't2' }, 100), /DEBUGGER_TIMEOUT/)
56
+ assert.equal((await sessions.command(id, 'state')).cleanup.status, 'complete')
57
+ assert.equal(close, 1); assert.equal(removed, 1)
58
+ failCommand = ''
59
+ const second = await sessions.attach('emulator', 'test.app')
60
+ failCleanup = true
61
+ const partial = await sessions.command(second.debug_session_id, 'detach')
62
+ assert.equal(partial.success, false); assert.equal(partial.cleanup.status, 'failed')
63
+ await assert.rejects(sessions.attach('emulator', 'test.app'), /DEBUGGER_BUSY/)
64
+ failCleanup = false
65
+ const retry = await sessions.command(second.debug_session_id, 'detach')
66
+ assert.equal(retry.cleanup.status, 'complete'); assert.equal(close, 2)
67
+ assert.equal((await sessions.command(second.debug_session_id, 'detach')).cleanup.status, 'complete')
68
+ const third = await sessions.attach('emulator', 'test.app')
69
+ emit({ event: 'worker_failed' })
70
+ const crashed = await sessions.command(third.debug_session_id, 'state')
71
+ assert.equal(crashed.state, 'failed')
72
+ assert.equal(crashed.cleanup.status, 'complete')
73
+ assert.equal(crashed.error.code, 'DEBUGGER_WORKER_FAILED')
74
+ assert.equal(sessions.paused('emulator'), null)
75
+ for (const tool of debuggerTools) {
76
+ const contract = debuggerContracts[tool.name]
77
+ assert.equal(tool.inputSchema.additionalProperties, false)
78
+ assert.equal(contract.schema.safeParse({ ...Object.fromEntries(tool.inputSchema.required.map(k => [k, 'x'])), unexpected: true }).success, false)
79
+ }
80
+ assert.equal(debuggerContracts.attach_debugger.schema.safeParse({ platform: 'android', appId: 'test.app' }).success, false)
81
+ assert.equal(debuggerContracts.set_debugger_breakpoint.schema.safeParse({ debugSessionId: 's', className: '$(bad)', line: 2 }).success, false)
82
+ console.log('PASS debugger session ownership, stop epochs, event concurrency/overflow, timeout recovery, cleanup retries, strict contracts')
83
+ } finally { await sessions.shutdown() }
@@ -36,6 +36,8 @@ else process.exit(1)
36
36
  const first = await observe.getUITree('fake-a')
37
37
  const second = await observe.getUITree('fake-b')
38
38
  assert.equal(first.error, undefined)
39
+ assert.equal(typeof first.snapshot_session_id, 'string')
40
+ assert.equal(first.snapshot_session_id, second.snapshot_session_id)
39
41
  assert.equal(second.error, undefined)
40
42
  assert.equal(first.snapshot_revision, 1)
41
43
  assert.notEqual(first.snapshot_revision, second.snapshot_revision)
@@ -1,5 +1,6 @@
1
1
  import { ToolsObserve } from '../../../src/observe/index.js'
2
2
  import assert from 'assert'
3
+ import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
3
4
 
4
5
  async function run() {
5
6
  console.log('Starting capture_debug_snapshot unit tests...')
@@ -12,24 +13,41 @@ async function run() {
12
13
  const origReadLogStreamHandler = (ToolsObserve as any).readLogStreamHandler
13
14
  const origGetLogsHandler = (ToolsObserve as any).getLogsHandler
14
15
 
16
+ const device = { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true }
17
+ let resolutions = 0
18
+ _setDeviceListersForTests({
19
+ listAndroidDevices: async () => { resolutions++; return [device] },
20
+ listIOSDevices: async () => { throw new Error('Android snapshot must not discover iOS devices') }
21
+ })
22
+ const assertTarget = (args: any) => {
23
+ assert.strictEqual(args.platform, 'android')
24
+ assert.strictEqual(args.deviceId, device.id, 'Every component must use the resolved fixture device')
25
+ }
26
+
15
27
  try {
16
28
  // --- Test 1: all components succeed and logs come from stream ---
17
- ;(ToolsObserve as any).captureScreenshotHandler = async function() {
29
+ ;(ToolsObserve as any).captureScreenshotHandler = async function(args: any) {
30
+ assertTarget(args)
18
31
  return { device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true }, screenshot: 'BASE64PNG', resolution: { width: 1080, height: 1920 } }
19
32
  }
20
- ;(ToolsObserve as any).getCurrentScreenHandler = async function() {
33
+ ;(ToolsObserve as any).getCurrentScreenHandler = async function(args: any) {
34
+ assert.strictEqual(args.deviceId, device.id)
21
35
  return { device: { platform: 'android', id: 'mock' }, package: 'com.example', activity: 'com.example.Main', shortActivity: 'Main' }
22
36
  }
23
- ;(ToolsObserve as any).getScreenFingerprintHandler = async function() {
37
+ ;(ToolsObserve as any).getScreenFingerprintHandler = async function(args: any) {
38
+ assertTarget(args)
24
39
  return { fingerprint: 'abc123', activity: 'Main' }
25
40
  }
26
- ;(ToolsObserve as any).getUITreeHandler = async function() {
41
+ ;(ToolsObserve as any).getUITreeHandler = async function(args: any) {
42
+ assertTarget(args)
27
43
  return { device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true }, screen: '', resolution: { width: 1080, height: 1920 }, elements: [] }
28
44
  }
29
- ;(ToolsObserve as any).readLogStreamHandler = async function() {
45
+ ;(ToolsObserve as any).readLogStreamHandler = async function(args: any) {
46
+ assertTarget(args)
30
47
  return { entries: [ { timestamp: '2026-03-23T20:00:00.000Z', level: 'ERROR', message: 'Boom' } ], crash_summary: { crash_detected: true } }
31
48
  }
32
- ;(ToolsObserve as any).getLogsHandler = async function() {
49
+ ;(ToolsObserve as any).getLogsHandler = async function(args: any) {
50
+ assertTarget(args)
33
51
  return { device: { platform: 'android', id: 'mock' }, logs: [], logCount: 0 }
34
52
  }
35
53
 
@@ -41,20 +59,13 @@ async function run() {
41
59
  assert.strictEqual(res1.semantic.confidence >= 0.7, true)
42
60
  assert.deepStrictEqual(res1.semantic.actions_available, null)
43
61
  console.log('Test 1:', pass1 ? 'PASS' : 'FAIL')
44
-
45
- // Restore handlers before next test
46
- ;(ToolsObserve as any).captureScreenshotHandler = origCaptureHandler
47
- ;(ToolsObserve as any).getCurrentScreenHandler = origGetCurrentHandler
48
- ;(ToolsObserve as any).getScreenFingerprintHandler = origGetFpHandler
49
- ;(ToolsObserve as any).getUITreeHandler = origGetTreeHandler
50
- ;(ToolsObserve as any).readLogStreamHandler = origReadLogStreamHandler
51
- ;(ToolsObserve as any).getLogsHandler = origGetLogsHandler
62
+ assert.strictEqual(resolutions, 1, 'Resolve one device for the entire snapshot')
52
63
 
53
64
  // --- Test 2: screenshot and ui tree fail; logs fallback to getLogs ---
54
- ;(ToolsObserve as any).captureScreenshotHandler = async function() { throw new Error('screencap failed') }
55
- ;(ToolsObserve as any).getUITreeHandler = async function() { throw new Error('uie_error') }
56
- ;(ToolsObserve as any).readLogStreamHandler = async function() { return { entries: [] } }
57
- ;(ToolsObserve as any).getLogsHandler = async function() { return { device: { platform: 'android', id: 'mock' }, logs: ['INFO starting','ERROR crash here'], logCount: 2 } }
65
+ ;(ToolsObserve as any).captureScreenshotHandler = async function(args: any) { assertTarget(args); throw new Error('screencap failed') }
66
+ ;(ToolsObserve as any).getUITreeHandler = async function(args: any) { assertTarget(args); throw new Error('uie_error') }
67
+ ;(ToolsObserve as any).readLogStreamHandler = async function(args: any) { assertTarget(args); return { entries: [] } }
68
+ ;(ToolsObserve as any).getLogsHandler = async function(args: any) { assertTarget(args); return { device: { platform: 'android', id: 'mock' }, logs: ['INFO starting','ERROR crash here'], logCount: 2 } }
58
69
 
59
70
  const res2: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: true, logLines: 10, appId: 'com.example' })
60
71
  console.log('res2:', JSON.stringify(res2, null, 2))
@@ -62,20 +73,12 @@ async function run() {
62
73
  assert.ok(pass2, 'captureDebugSnapshot should surface partial failures and fallback logs')
63
74
  console.log('Test 2:', pass2 ? 'PASS' : 'FAIL')
64
75
 
65
- // Restore handlers before next test
66
- ;(ToolsObserve as any).captureScreenshotHandler = origCaptureHandler
67
- ;(ToolsObserve as any).getCurrentScreenHandler = origGetCurrentHandler
68
- ;(ToolsObserve as any).getScreenFingerprintHandler = origGetFpHandler
69
- ;(ToolsObserve as any).getUITreeHandler = origGetTreeHandler
70
- ;(ToolsObserve as any).readLogStreamHandler = origReadLogStreamHandler
71
- ;(ToolsObserve as any).getLogsHandler = origGetLogsHandler
72
-
73
76
  // --- Test 3: includeLogs=false should omit logs ---
74
- ;(ToolsObserve as any).captureScreenshotHandler = async function() { return { device: { platform: 'android', id: 'mock' }, screenshot: null } }
75
- ;(ToolsObserve as any).getCurrentScreenHandler = async function() { return { device: { platform: 'android', id: 'mock' }, package: '', activity: '', shortActivity: '' } }
76
- ;(ToolsObserve as any).getScreenFingerprintHandler = async function() { return { fingerprint: null } }
77
- ;(ToolsObserve as any).getUITreeHandler = async function() { return { device: { platform: 'android', id: 'mock' }, screen: '', resolution: { width: 0, height: 0 }, elements: [] } }
78
- ;(ToolsObserve as any).readLogStreamHandler = async function() { return { entries: [] } }
77
+ ;(ToolsObserve as any).captureScreenshotHandler = async function(args: any) { assertTarget(args); return { device: { platform: 'android', id: 'mock' }, screenshot: null } }
78
+ ;(ToolsObserve as any).getCurrentScreenHandler = async function(args: any) { assert.strictEqual(args.deviceId, device.id); return { device: { platform: 'android', id: 'mock' }, package: '', activity: '', shortActivity: '' } }
79
+ ;(ToolsObserve as any).getScreenFingerprintHandler = async function(args: any) { assertTarget(args); return { fingerprint: null } }
80
+ ;(ToolsObserve as any).getUITreeHandler = async function(args: any) { assertTarget(args); return { device: { platform: 'android', id: 'mock' }, screen: '', resolution: { width: 0, height: 0 }, elements: [] } }
81
+ ;(ToolsObserve as any).readLogStreamHandler = async function(args: any) { assertTarget(args); return { entries: [] } }
79
82
 
80
83
  const res3: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: false })
81
84
  console.log('res3:', JSON.stringify(res3, null, 2))
@@ -84,12 +87,19 @@ async function run() {
84
87
  console.log('Test 3:', pass3 ? 'PASS' : 'FAIL')
85
88
 
86
89
  let screenshotCalls = 0
87
- ;(ToolsObserve as any).captureScreenshotHandler = async function() { screenshotCalls++; return { screenshot: 'unexpected' } }
90
+ ;(ToolsObserve as any).captureScreenshotHandler = async function(args: any) { assertTarget(args); screenshotCalls++; return { screenshot: 'unexpected' } }
88
91
  const res4: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: false, includeScreenshot: false })
89
92
  assert.strictEqual(screenshotCalls, 0, 'includeScreenshot=false must not invoke screenshot capture')
90
93
  assert.strictEqual(res4.raw.screenshot, null)
94
+ assert.strictEqual(resolutions, 4, 'Each snapshot resolves its own target once')
95
+
96
+ // Discovery failure must not collect from an arbitrary/default device.
97
+ _setDeviceListersForTests({ listAndroidDevices: async () => { throw new Error('fixture discovery unavailable') } })
98
+ await assert.rejects(() => ToolsObserve.captureDebugSnapshotHandler({ platform: 'android' }), /fixture discovery unavailable/)
99
+ assert.strictEqual(screenshotCalls, 0, 'No component capture should run without a resolved target')
91
100
 
92
101
  } finally {
102
+ _resetDeviceListersForTests()
93
103
  ;(ToolsObserve as any).captureScreenshotHandler = origCaptureHandler
94
104
  ;(ToolsObserve as any).getCurrentScreenHandler = origGetCurrentHandler
95
105
  ;(ToolsObserve as any).getScreenFingerprintHandler = origGetFpHandler
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict'
2
+ import { AndroidObserve, ToolsObserve } from '../../../src/observe/index.js'
3
+ import { deriveSnapshotMetadata, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
4
+ import { withResolvedDevice } from '../../../src/utils/resolve-device.js'
5
+
6
+ async function run() {
7
+ const original = AndroidObserve.prototype.getUITree
8
+ const device = { platform: 'android', id: 'fixture', model: 'Fixture', osVersion: '16', simulator: true }
9
+ const body = { screen: '', resolution: { width: 100, height: 200 }, elements: [] }
10
+ const metadata = deriveSnapshotMetadata('android:fixture', body, 'ui_tree')
11
+ AndroidObserve.prototype.getUITree = async () => ({ ...body, device, ...metadata, error: 'observation unavailable' })
12
+ try {
13
+ const compact: any = await withResolvedDevice(device, () => ToolsObserve.getUITreeHandler({ platform: 'android', deviceId: 'fixture', responseMode: 'compact', sinceSnapshotRevision: metadata.snapshot_revision }))
14
+ assert.equal(compact.error, 'observation unavailable', 'compact observations must not conceal an error behind a retained revision')
15
+ assert.equal(compact.delta_available, false)
16
+ assert.equal(compact.snapshot_session_id, metadata.snapshot_session_id)
17
+ assert.equal(compact.state_delta, undefined)
18
+ console.log('Compact UI-tree error evidence: PASS')
19
+ } finally {
20
+ AndroidObserve.prototype.getUITree = original
21
+ resetSnapshotMetadataForTests()
22
+ }
23
+ }
24
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,33 @@
1
+ import assert from 'node:assert/strict'
2
+ import { deriveSnapshotMetadata, hasSnapshotBaseline, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
3
+
4
+ const originalNow = Date.now
5
+ try {
6
+ resetSnapshotMetadataForTests()
7
+ let now = originalNow()
8
+ Date.now = () => now
9
+ const body = { screen: '', resolution: { width: 100, height: 200 }, elements: Array.from({ length: 501 }, (_, index) => ({ text: `Item ${index}`, stable_id: `item-${index}`, visible: true, bounds: [0, index, 20, index + 1] })) }
10
+ const first = deriveSnapshotMetadata('android:fixture', body, 'ui_tree')
11
+ assert.equal(hasSnapshotBaseline('android:other', first.snapshot_revision), false)
12
+ assert.equal(hasSnapshotBaseline('android:fixture', first.snapshot_revision), true)
13
+ now += 9 * 60 * 1000
14
+ const repeat = deriveSnapshotMetadata('android:fixture', body, 'ui_tree')
15
+ assert.equal(repeat.snapshot_revision, first.snapshot_revision)
16
+ now += 2 * 60 * 1000
17
+ assert.equal(hasSnapshotBaseline('android:fixture', repeat.snapshot_revision), true, 'successful re-observation refreshes retention')
18
+ const changed = structuredClone(body)
19
+ changed.elements[500].text = 'Changed beyond delta history limit'
20
+ const second = deriveSnapshotMetadata('android:fixture', changed, 'ui_tree')
21
+ assert.notEqual(second.snapshot_revision, first.snapshot_revision, 'every element contributes to conditional revision')
22
+ assert.equal(second.snapshot_session_id, first.snapshot_session_id)
23
+ now += 11 * 60 * 1000
24
+ assert.equal(hasSnapshotBaseline('android:fixture', second.snapshot_revision), false)
25
+ const fresh = deriveSnapshotMetadata('android:fixture', changed, 'ui_tree')
26
+ assert.equal(hasSnapshotBaseline('android:fixture', fresh.snapshot_revision), true)
27
+ const failed = deriveSnapshotMetadata('android:empty', null, 'ui_tree')
28
+ assert.equal(hasSnapshotBaseline('android:empty', failed.snapshot_revision), false, 'error metadata never creates a baseline')
29
+ console.log('Conditional snapshot retention/session coverage: PASS')
30
+ } finally {
31
+ Date.now = originalNow
32
+ resetSnapshotMetadataForTests()
33
+ }
@@ -0,0 +1,128 @@
1
+ import assert from 'node:assert/strict'
2
+ import { withEvidence, captureEvidence, evidenceDispatch, currentEvidenceActionId, markEvidenceVerification, omitEvidence, validateActionRef, type EvidenceDevice } from '../../../src/observe/evidence.js'
3
+
4
+ async function run() {
5
+ const android = { platform: 'android' as const, id: 'evidence-unit-android' }
6
+ const ios = { platform: 'ios' as const, id: 'evidence-unit-ios' }
7
+ const originalWall = Date.now
8
+ const originalMono = Object.getOwnPropertyDescriptor(performance, 'now')
9
+ let wall = 1000000
10
+ let mono = 0
11
+ Date.now = () => wall
12
+ Object.defineProperty(performance, 'now', { configurable: true, value: () => mono })
13
+ const tick = (n = 10) => { wall += n; mono += n }
14
+ const observe = (device: EvidenceDevice = android) => captureEvidence('ui_tree', device, async () => {
15
+ tick()
16
+ return { device, captured_at_ms: wall, snapshot_session_id: 'tree-session', snapshot_revision: 1 }
17
+ })
18
+ const act = async (device: EvidenceDevice = android) => withEvidence({ action: true }, async () => {
19
+ const id = currentEvidenceActionId()
20
+ await evidenceDispatch(device, async () => { tick() })
21
+ return id
22
+ })
23
+ try {
24
+ const a = await withEvidence({ action: true }, async () => {
25
+ await observe()
26
+ tick()
27
+ const id = currentEvidenceActionId()
28
+ await evidenceDispatch(android, async () => { tick() })
29
+ await observe()
30
+ markEvidenceVerification()
31
+ return id
32
+ })
33
+ assert.equal(a.evidence.action_id, a.result)
34
+ assert.equal(a.evidence.correlation_id, a.result)
35
+ assert.deepEqual(a.evidence.observations.map(v => v.freshness), ['stale', 'fresh'])
36
+ assert.deepEqual(a.evidence.observations[0].freshness_reasons, ['BEFORE_ACTION'])
37
+ assert.ok(a.evidence.dispatch_started_at_ms! < a.evidence.dispatch_completed_at_ms!)
38
+ assert.ok(a.evidence.verification_completed_at_ms! >= a.evidence.dispatch_completed_at_ms!)
39
+ assert.deepEqual(a.evidence.trace_context, { supported: false, trace_id: null, markers: [] })
40
+ const ref = { sessionId: a.evidence.session_id, actionId: a.evidence.action_id! }
41
+ const linked = await withEvidence({ ref }, () => observe())
42
+ assert.equal(linked.evidence.association, 'explicit')
43
+ assert.equal(linked.evidence.correlation_id, a.evidence.correlation_id)
44
+ assert.equal(linked.evidence.verification_completed_at_ms, a.evidence.verification_completed_at_ms)
45
+ assert.equal(linked.evidence.observations[0].snapshot_revision, 1)
46
+ assert.ok(linked.evidence.observations[0].age_ms! >= 0)
47
+ const mismatch = await withEvidence({ ref }, () => observe(ios))
48
+ assert.equal(mismatch.evidence.association_reason, 'DEVICE_MISMATCH')
49
+ assert.equal(mismatch.evidence.action_id, null)
50
+ assert.notEqual(mismatch.evidence.correlation_id, a.evidence.correlation_id)
51
+ const foreign = await withEvidence({ ref: { ...ref, sessionId: 'previous-process' } }, () => observe())
52
+ assert.equal(foreign.evidence.association_reason, 'SESSION_MISMATCH')
53
+ assert.equal(foreign.evidence.action_id, null)
54
+
55
+ const oldSource = wall - 100
56
+ const reused = await withEvidence({}, () => captureEvidence('ui_tree', ios, async () => ({ device: ios, captured_at_ms: oldSource })))
57
+ assert.equal(reused.evidence.observations[0].source_captured_at_ms, oldSource)
58
+ assert.deepEqual(reused.evidence.observations[0].freshness_reasons, ['REUSED_OBSERVATION'])
59
+ const cachedBeforeAction = await withEvidence({ ref }, () => captureEvidence('ui_tree', android, async () => ({ captured_at_ms: a.evidence.operation_started_at_ms - 10 })))
60
+ assert.deepEqual(cachedBeforeAction.evidence.observations[0].freshness_reasons, ['BEFORE_ACTION'])
61
+
62
+ let release!: () => void
63
+ let entered!: () => void
64
+ const ready = new Promise<void>(resolve => { entered = resolve })
65
+ const gate = new Promise<void>(resolve => { release = resolve })
66
+ const pending = withEvidence({ action: true }, () => evidenceDispatch(android, async () => { entered(); await gate; tick() }))
67
+ await ready
68
+ const overlap = await withEvidence({}, () => observe())
69
+ assert.deepEqual(overlap.evidence.observations[0].freshness_reasons, ['OVERLAPPING_ACTION'])
70
+ const separate = await withEvidence({}, () => observe(ios))
71
+ assert.equal(separate.evidence.observations[0].freshness, 'fresh')
72
+ release(); await pending
73
+ const superseded = await withEvidence({}, async () => { await observe(); tick(); await act() })
74
+ assert.deepEqual(superseded.evidence.observations[0].freshness_reasons, ['SUPERSEDED_BY_ACTION'])
75
+ const [one, two] = await Promise.all([act(), act(ios)])
76
+ assert.notEqual(one.evidence.action_id, two.evidence.action_id)
77
+ assert.equal(one.evidence.device?.platform, 'android')
78
+ assert.equal(two.evidence.device?.platform, 'ios')
79
+
80
+ const logs = await withEvidence({}, () => captureEvidence('logs', android, async () => ({ logs: [{ timestamp: null, message: 'untimed' }, { timestamp: 42 }, { timestamp: '2020-01-01T00:00:00Z' }] })))
81
+ const log = logs.evidence.observations[0]
82
+ assert.equal(log.freshness, 'unknown')
83
+ assert.equal(log.earliest_entry_at_ms, 42)
84
+ assert.equal(log.event_time_comparable, false)
85
+ assert.equal(log.age_ms, null)
86
+ const partial = await withEvidence({}, async () => {
87
+ omitEvidence('logs'); omitEvidence('screenshot')
88
+ await captureEvidence('ui_tree', android, async () => { throw new Error('offline') }).catch(() => null)
89
+ })
90
+ assert.deepEqual(partial.evidence.observations.map(v => v.status), ['omitted', 'omitted', 'failed'])
91
+ assert.equal(partial.evidence.observations[0].freshness, 'not_applicable')
92
+ assert.equal(partial.evidence.observations[2].error, 'offline')
93
+ const unknown = await withEvidence({}, () => captureEvidence('screenshot', null, async () => ({})))
94
+ assert.equal(unknown.evidence.observations[0].freshness, 'unknown')
95
+ const reversal = await withEvidence({}, async () => {
96
+ await observe(); wall -= 1000; mono += 10
97
+ })
98
+ assert.deepEqual(reversal.evidence.observations[0].freshness_reasons, ['CLOCK_DISCONTINUITY'])
99
+ assert.equal(reversal.evidence.observations[0].age_ms, null)
100
+
101
+ tick(600001)
102
+ const expired = await withEvidence({ ref }, () => observe())
103
+ assert.equal(expired.evidence.association_reason, 'ACTION_UNAVAILABLE')
104
+ const first = await act()
105
+ for (let i = 0; i < 1000; i++) await act()
106
+ const evicted = await withEvidence({ ref: { sessionId: first.evidence.session_id, actionId: first.evidence.action_id! } }, () => observe())
107
+ assert.equal(evicted.evidence.association_reason, 'ACTION_UNAVAILABLE')
108
+ const noDispatch = await withEvidence({ action: true }, async () => undefined)
109
+ assert.equal(noDispatch.evidence.dispatch_started_at_ms, null)
110
+ assert.equal(noDispatch.evidence.verification_completed_at_ms, null)
111
+ const multi = await withEvidence({ action: true }, async () => {
112
+ await evidenceDispatch(android, async () => tick())
113
+ tick()
114
+ await evidenceDispatch(android, async () => tick())
115
+ })
116
+ assert.equal(multi.evidence.dispatch_completed_at_ms! - multi.evidence.dispatch_started_at_ms!, 30)
117
+ for (const invalid of [null, [], {}, { sessionId: 'a', actionId: '' }, { sessionId: 'a', actionId: 'x'.repeat(129) }, { ...ref, extra: true }]) {
118
+ assert.throws(() => validateActionRef(invalid), /INVALID_ARGUMENT/)
119
+ }
120
+ assert.deepEqual(validateActionRef(ref), ref)
121
+ } finally {
122
+ Date.now = originalWall
123
+ if (originalMono) Object.defineProperty(performance, 'now', originalMono)
124
+ else delete (performance as any).now
125
+ }
126
+ console.log('Correlated evidence clocks, freshness, references, isolation and bounds: PASS')
127
+ }
128
+ run().catch(error => { console.error(error); process.exit(1) })
@@ -43,6 +43,8 @@ console.log(JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-
43
43
  const first = await observe.getUITree('fake-a')
44
44
  const second = await observe.getUITree('fake-b')
45
45
  assert.equal(first.error, undefined)
46
+ assert.equal(typeof first.snapshot_session_id, 'string')
47
+ assert.equal(first.snapshot_session_id, second.snapshot_session_id)
46
48
  assert.equal(second.error, undefined)
47
49
  assert.equal(first.elements[0].text, 'Ready')
48
50
  assert.equal(second.snapshot_delta, null, 'different simulators keep independent delta history')
@@ -0,0 +1,41 @@
1
+ import assert from 'node:assert/strict'
2
+ import { ReadinessTracker } from '../../../src/observe/readiness.js'
3
+ import { getSnapshotSessionId } from '../../../src/observe/snapshot-metadata.js'
4
+
5
+ const device = { platform: 'android', id: 'fixture' }
6
+ const sample = (label = 'Ready', extra: any = {}) => ({ device, snapshot_session_id: getSnapshotSessionId(), snapshot_revision: 1,
7
+ captured_at_ms: 10000, screen: '', resolution: { width: 100, height: 200 },
8
+ elements: [{ type: 'Button', text: label, visible: true, enabled: true, bounds: [0, 0, 50, 50] }], ...extra })
9
+ const tracker = new ReadinessTracker('android', 'fixture')
10
+ assert.equal(tracker.observe(sample(), 10000, 0).state, 'unknown')
11
+ assert.equal(tracker.observe(sample(), 10000, 499).state, 'unknown')
12
+ assert.equal(tracker.observe(sample(), 10000, 500).state, 'settled')
13
+ assert.equal(tracker.observe(sample('Changed'), 10000, 1000).state, 'transitioning')
14
+ assert.equal(tracker.observe(sample('Changed'), 10000, 1500).state, 'settled')
15
+ for (const label of [' Loading… ', 'Please wait...', 'please wait']) {
16
+ const state = tracker.observe(sample(label), 10000, 2000)
17
+ assert.equal(state.state, 'loading')
18
+ assert.equal(state.basis, 'heuristic')
19
+ assert.equal(tracker.observe(sample(label), 10000, 3000).state, 'loading', 'identical loaders never settle')
20
+ }
21
+ for (const type of ['android.widget.ProgressBar', 'ActivityIndicator', 'ProgressIndicator']) {
22
+ const tree = sample('Progress', { elements: [{ type, visible: true }] })
23
+ assert.equal(tracker.observe(tree, 10000, 4000).state, 'loading')
24
+ assert.equal(tracker.summary.basis, 'observed')
25
+ }
26
+ assert.notEqual(tracker.observe(sample('Progress'), 10000, 5000).state, 'loading', 'legacy broad keywords are not loaders')
27
+ for (const extra of [{ error: 'offline' }, { elements: [] }, { captured_at_ms: 9999 }, { snapshot_session_id: 'old' }, { device: { ...device, id: 'other' } }, { resolution: { width: Infinity, height: 2 } }]) {
28
+ assert.equal(tracker.observe(sample('Ready', extra), 10000, 6000).state, 'unknown')
29
+ assert.equal(tracker.observe(sample(), 10000, 7000).state, 'unknown', 'failure breaks continuity')
30
+ assert.equal(tracker.observe(sample(), 10000, 7500).state, 'settled')
31
+ }
32
+ for (let index = 0; index < 20; index++) tracker.observe(sample(), 10000, 8000 + index)
33
+ assert.equal(tracker.summary.evidence.length, 8)
34
+ assert.equal(tracker.summary.capabilities.unresponsive, false)
35
+ assert.equal(tracker.summary.capabilities.debugger_paused, true)
36
+ assert.ok(tracker.deadline().limitations.includes('DEADLINE_EXCEEDED'))
37
+ assert.equal(new ReadinessTracker('android', 'fixture').observe(sample(), 10000, 9000).state, 'unknown', 'no cross-request history')
38
+ assert.ok(!JSON.stringify(tracker.summary).includes('Ready'), 'evidence contains codes rather than private labels')
39
+ assert.equal(new ReadinessTracker('ios', 'fixture').summary.capabilities.debugger_paused, false)
40
+ assert.ok(tracker.summary.limitations.includes('MANAGED_DEBUGGER_ONLY'))
41
+ console.log('Readiness classifier: PASS')