mobile-debug-mcp 0.32.4 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.github/copilot-instructions.md +7 -0
- package/.github/workflows/ci.yml +21 -0
- package/AGENTS.md +21 -10
- package/README.md +69 -4
- package/agent-plugin/.claude-plugin/plugin.json +20 -0
- package/agent-plugin/.codex-plugin/plugin.json +34 -0
- package/agent-plugin/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/README.md +9 -3
- package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
- package/dist/debugger/android.js +78 -0
- package/dist/debugger/guard.js +30 -0
- package/dist/debugger/sessions.js +262 -0
- package/dist/debugger/worker.js +165 -0
- package/dist/interact/android.js +10 -4
- package/dist/interact/conditional-action.js +68 -0
- package/dist/interact/index.js +70 -14
- package/dist/interact/ios.js +14 -8
- package/dist/manage/android.js +4 -3
- package/dist/manage/ios.js +3 -2
- package/dist/observe/android.js +25 -4
- package/dist/observe/evidence.js +280 -0
- package/dist/observe/index.js +33 -6
- package/dist/observe/ios.js +25 -6
- package/dist/observe/readiness-service.js +124 -0
- package/dist/observe/readiness.js +88 -0
- package/dist/observe/recording.js +165 -0
- package/dist/observe/snapshot-metadata.js +10 -2
- package/dist/observe/system-trace-android.js +135 -0
- package/dist/observe/system-trace.js +285 -0
- package/dist/server/common.js +9 -0
- package/dist/server/condition-schema.js +32 -0
- package/dist/server/debugger-handlers.js +38 -0
- package/dist/server/debugger-schema.js +42 -0
- package/dist/server/journey-schema.js +4 -2
- package/dist/server/readiness-schema.js +12 -0
- package/dist/server/system-trace-schema.js +17 -0
- package/dist/server/tool-definitions.js +51 -0
- package/dist/server/tool-handlers.js +294 -105
- package/dist/server-core.js +3 -3
- package/dist/server.js +34 -0
- package/dist/system/index.js +5 -4
- package/dist/utils/exec.js +2 -0
- package/dist/utils/ios/utils.js +4 -4
- package/dist/utils/java.js +14 -12
- package/dist/utils/operation-budget.js +26 -2
- package/dist/utils/resolve-device.js +9 -0
- package/docs/CHANGELOG.md +15 -0
- package/docs/specs/020-conditional-actions/intent.md +62 -0
- package/docs/specs/020-conditional-actions/plan.md +214 -0
- package/docs/specs/020-conditional-actions/spec.md +301 -0
- package/docs/specs/021-app-readiness-signals/intent.md +40 -0
- package/docs/specs/021-app-readiness-signals/plan.md +102 -0
- package/docs/specs/021-app-readiness-signals/spec.md +144 -0
- package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
- package/docs/specs/022-correlated-evidence/intent.md +37 -0
- package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
- package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
- package/docs/specs/022-correlated-evidence/plan.md +171 -0
- package/docs/specs/022-correlated-evidence/spec.md +123 -0
- package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
- package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
- package/docs/specs/023-android-system-traces/intent.md +40 -0
- package/docs/specs/023-android-system-traces/plan.md +117 -0
- package/docs/specs/023-android-system-traces/spec.md +120 -0
- package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
- package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
- package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
- package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
- package/docs/specs/process.md +213 -0
- package/docs/tools/TOOLS.md +5 -0
- package/docs/tools/debugger.md +104 -0
- package/docs/tools/evidence.md +82 -0
- package/docs/tools/interact.md +125 -2
- package/docs/tools/observe.md +70 -0
- package/docs/tools/system-traces.md +126 -0
- package/package.json +1 -1
- package/scripts/agent-plugin.mjs +68 -8
- package/src/debugger/android.ts +65 -0
- package/src/debugger/guard.ts +26 -0
- package/src/debugger/sessions.ts +168 -0
- package/src/debugger/worker.ts +122 -0
- package/src/interact/android.ts +10 -4
- package/src/interact/conditional-action.ts +75 -0
- package/src/interact/index.ts +57 -28
- package/src/interact/ios.ts +14 -8
- package/src/manage/android.ts +4 -3
- package/src/manage/ios.ts +3 -2
- package/src/observe/android.ts +33 -6
- package/src/observe/evidence.ts +262 -0
- package/src/observe/index.ts +32 -8
- package/src/observe/ios.ts +32 -8
- package/src/observe/readiness-service.ts +94 -0
- package/src/observe/readiness.ts +84 -0
- package/src/observe/snapshot-metadata.ts +12 -1
- package/src/observe/system-trace-android.ts +133 -0
- package/src/observe/system-trace.ts +218 -0
- package/src/server/common.ts +7 -0
- package/src/server/condition-schema.ts +32 -0
- package/src/server/debugger-handlers.ts +29 -0
- package/src/server/debugger-schema.ts +37 -0
- package/src/server/journey-schema.ts +4 -2
- package/src/server/readiness-schema.ts +12 -0
- package/src/server/system-trace-schema.ts +17 -0
- package/src/server/tool-definitions.ts +49 -0
- package/src/server/tool-handlers.ts +182 -21
- package/src/server-core.ts +3 -3
- package/src/server.ts +24 -0
- package/src/system/index.ts +7 -5
- package/src/types.ts +30 -1
- package/src/utils/exec.ts +2 -0
- package/src/utils/ios/utils.ts +4 -4
- package/src/utils/java.ts +13 -12
- package/src/utils/operation-budget.ts +24 -2
- package/src/utils/resolve-device.ts +10 -0
- package/test/device/automated/debugger/runtime.android.test.ts +203 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
- package/test/device/automated/observe/system-trace.smoke.ts +104 -0
- package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
- package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
- package/test/device/manual/interact/readiness.manual.ts +99 -0
- package/test/fixtures/debugger-app/MainActivity.java +46 -0
- package/test/fixtures/debugger-app/NoLocals.java +7 -0
- package/test/fixtures/debugger-app/README.md +13 -0
- package/test/fixtures/debugger-app/build.ts +29 -0
- package/test/fixtures/readiness-app/MainActivity.java +23 -0
- package/test/fixtures/readiness-app/README.md +12 -0
- package/test/fixtures/readiness-app/build.ts +37 -0
- package/test/fixtures/readiness-app/main.m +27 -0
- package/test/helpers/perfetto.ts +59 -0
- package/test/unit/debugger/guards.test.ts +46 -0
- package/test/unit/debugger/native.test.ts +33 -0
- package/test/unit/debugger/sessions.test.ts +83 -0
- package/test/unit/observe/android_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
- package/test/unit/observe/compact_tree_error.test.ts +24 -0
- package/test/unit/observe/conditional_snapshots.test.ts +33 -0
- package/test/unit/observe/correlated-evidence.test.ts +128 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/readiness.test.ts +41 -0
- package/test/unit/observe/readiness_native.test.ts +68 -0
- package/test/unit/observe/system-trace-android.test.ts +52 -0
- package/test/unit/observe/system-trace.test.ts +166 -0
- package/test/unit/server/conditional_actions.test.ts +299 -0
- package/test/unit/server/conditional_schema.test.ts +59 -0
- package/test/unit/server/correlated-evidence.test.ts +194 -0
- package/test/unit/server/readiness.test.ts +170 -0
- package/test/unit/server/selector_tap_target.test.ts +79 -0
- package/test/unit/server/system-trace.test.ts +64 -0
- package/test/unit/server/tap_selector.test.ts +2 -0
- package/worker/DebuggerWorker.java +160 -0
- package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
- /package/{skills → .agents/skills}/README.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
- /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
|
@@ -0,0 +1,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')
|