mobile-debug-mcp 0.32.4 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.github/copilot-instructions.md +7 -0
- package/AGENTS.md +21 -10
- package/README.md +62 -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 +5 -3
- package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +28 -0
- package/dist/interact/android.js +10 -4
- package/dist/interact/conditional-action.js +68 -0
- package/dist/interact/index.js +54 -14
- package/dist/interact/ios.js +14 -8
- package/dist/manage/android.js +4 -3
- package/dist/manage/ios.js +3 -2
- package/dist/observe/android.js +25 -4
- package/dist/observe/evidence.js +273 -0
- package/dist/observe/index.js +33 -6
- package/dist/observe/ios.js +25 -6
- package/dist/observe/readiness-service.js +115 -0
- package/dist/observe/readiness.js +81 -0
- package/dist/observe/recording.js +165 -0
- package/dist/observe/snapshot-metadata.js +10 -2
- package/dist/server/common.js +9 -0
- package/dist/server/condition-schema.js +32 -0
- package/dist/server/journey-schema.js +3 -2
- package/dist/server/readiness-schema.js +12 -0
- package/dist/server/tool-definitions.js +46 -0
- package/dist/server/tool-handlers.js +99 -17
- package/dist/server-core.js +1 -1
- package/dist/utils/ios/utils.js +4 -4
- package/dist/utils/operation-budget.js +24 -2
- package/dist/utils/resolve-device.js +7 -0
- package/docs/CHANGELOG.md +11 -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/process.md +213 -0
- package/docs/tools/TOOLS.md +3 -0
- package/docs/tools/evidence.md +79 -0
- package/docs/tools/interact.md +125 -2
- package/docs/tools/observe.md +63 -0
- package/package.json +1 -1
- package/scripts/agent-plugin.mjs +68 -8
- package/src/interact/android.ts +10 -4
- package/src/interact/conditional-action.ts +75 -0
- package/src/interact/index.ts +42 -14
- package/src/interact/ios.ts +14 -8
- package/src/manage/android.ts +4 -3
- package/src/manage/ios.ts +3 -2
- package/src/observe/android.ts +33 -6
- package/src/observe/evidence.ts +255 -0
- package/src/observe/index.ts +32 -8
- package/src/observe/ios.ts +32 -8
- package/src/observe/readiness-service.ts +89 -0
- package/src/observe/readiness.ts +78 -0
- package/src/observe/snapshot-metadata.ts +12 -1
- package/src/server/common.ts +7 -0
- package/src/server/condition-schema.ts +32 -0
- package/src/server/journey-schema.ts +3 -2
- package/src/server/readiness-schema.ts +12 -0
- package/src/server/tool-definitions.ts +44 -0
- package/src/server/tool-handlers.ts +93 -17
- package/src/server-core.ts +1 -1
- package/src/types.ts +30 -1
- package/src/utils/ios/utils.ts +4 -4
- package/src/utils/operation-budget.ts +22 -2
- package/src/utils/resolve-device.ts +8 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
- package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
- package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
- package/test/device/manual/interact/readiness.manual.ts +99 -0
- package/test/fixtures/readiness-app/MainActivity.java +23 -0
- package/test/fixtures/readiness-app/README.md +12 -0
- package/test/fixtures/readiness-app/build.ts +37 -0
- package/test/fixtures/readiness-app/main.m +27 -0
- package/test/unit/observe/android_tree_acquisition.test.ts +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 +39 -0
- package/test/unit/observe/readiness_native.test.ts +68 -0
- package/test/unit/server/conditional_actions.test.ts +299 -0
- package/test/unit/server/conditional_schema.test.ts +59 -0
- package/test/unit/server/correlated-evidence.test.ts +194 -0
- package/test/unit/server/readiness.test.ts +170 -0
- package/test/unit/server/selector_tap_target.test.ts +79 -0
- package/test/unit/server/tap_selector.test.ts +2 -0
- package/skills/mobile-debug-tools-usage/SKILL.md +0 -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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bindEvidenceDevice } from '../observe/evidence.js'
|
|
1
2
|
import { retainConnectedDeviceSnapshots } from '../observe/snapshot-metadata.js'
|
|
2
3
|
import { measure } from './timing.js'
|
|
3
4
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
@@ -50,10 +51,17 @@ export async function listDevices(platform?: "android" | "ios", appId?: string):
|
|
|
50
51
|
const deviceContext = new AsyncLocalStorage<DeviceInfo>()
|
|
51
52
|
|
|
52
53
|
export function withResolvedDevice<T>(device: DeviceInfo, operation: () => Promise<T>): Promise<T> {
|
|
54
|
+
bindEvidenceDevice(device)
|
|
53
55
|
return deviceContext.run(device, operation)
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export async function resolveTargetDevice(opts: ResolveOptions): Promise<DeviceInfo> {
|
|
59
|
+
const device = await resolveDevice(opts)
|
|
60
|
+
bindEvidenceDevice(device)
|
|
61
|
+
return device
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function resolveDevice(opts: ResolveOptions): Promise<DeviceInfo> {
|
|
57
65
|
const { platform, appId, prefer, deviceId } = opts
|
|
58
66
|
const current = deviceContext.getStore()
|
|
59
67
|
if (current && current.platform === platform && (!deviceId || current.id === deviceId)) return current
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
3
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
4
|
+
|
|
5
|
+
// Exercises the rebuilt stdio server, not the connected/published MCP instance.
|
|
6
|
+
async function run() {
|
|
7
|
+
if (process.env.SKIP_DEVICE_TESTS === '1') {
|
|
8
|
+
console.log('Correlated evidence device smoke: SKIPPED (SKIP_DEVICE_TESTS=1)')
|
|
9
|
+
return
|
|
10
|
+
}
|
|
11
|
+
const client = new Client({ name: 'correlated-evidence-smoke', version: '1.0.0' })
|
|
12
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
|
|
13
|
+
const parse = (response: any) => JSON.parse(response.content.find((item: any) => item.type === 'text').text)
|
|
14
|
+
const call = (name: string, args: Record<string, unknown> = {}) => client.callTool({ name, arguments: args }, undefined, { timeout: 120000 })
|
|
15
|
+
try {
|
|
16
|
+
await client.connect(transport)
|
|
17
|
+
const discovery = parse(await call('list_devices'))
|
|
18
|
+
for (const platform of ['android', 'ios'] as const) {
|
|
19
|
+
const device = discovery.devices.find((d: any) => d.platform === platform && d.simulator &&
|
|
20
|
+
(platform === 'android' || d.booted === true))
|
|
21
|
+
assert.ok(device, `A running ${platform} emulator/simulator is required`)
|
|
22
|
+
const target = { platform, deviceId: device.id }
|
|
23
|
+
const appId = platform === 'android' ? 'com.android.settings' : 'com.apple.Preferences'
|
|
24
|
+
const action = parse(await call('start_app', { ...target, appId, verificationMode: 'none', responseMode: 'compact' }))
|
|
25
|
+
assert.equal(action.success, true, JSON.stringify(action))
|
|
26
|
+
assert.equal(action.action_id, action.evidence.action_id)
|
|
27
|
+
assert.ok(action.evidence.dispatch_started_at_ms)
|
|
28
|
+
assert.ok(action.evidence.dispatch_completed_at_ms >= action.evidence.dispatch_started_at_ms)
|
|
29
|
+
assert.equal(action.evidence.verification_completed_at_ms, null)
|
|
30
|
+
const actionRef = { sessionId: action.evidence.session_id, actionId: action.evidence.action_id }
|
|
31
|
+
const screenResponse: any = await call('capture_screenshot', { ...target, actionRef })
|
|
32
|
+
const screen = parse(screenResponse)
|
|
33
|
+
assert.equal(screen.evidence.action_id, action.action_id)
|
|
34
|
+
assert.equal(screen.evidence.correlation_id, action.evidence.correlation_id)
|
|
35
|
+
assert.equal(screen.evidence.association, 'explicit')
|
|
36
|
+
assert.ok(screenResponse.content.some((item: any) => item.type === 'image' && item.data.length > 0))
|
|
37
|
+
const screenshot = screen.evidence.observations.find((v: any) => v.kind === 'screenshot')
|
|
38
|
+
assert.equal(screenshot.status, 'captured')
|
|
39
|
+
assert.equal(screenshot.freshness, 'fresh')
|
|
40
|
+
assert.ok(screenshot.capture_started_at_ms >= action.evidence.dispatch_completed_at_ms)
|
|
41
|
+
const logsResponse: any = await call('get_logs', { ...target, appId, limit: 5, actionRef })
|
|
42
|
+
const logs = parse(logsResponse)
|
|
43
|
+
assert.equal(logs.evidence.action_id, action.action_id)
|
|
44
|
+
assert.ok(Array.isArray(JSON.parse(logsResponse.content[1].text).logs))
|
|
45
|
+
const log = logs.evidence.observations.find((v: any) => v.kind === 'logs')
|
|
46
|
+
assert.equal(log.status, 'captured', log.error)
|
|
47
|
+
assert.equal(log.event_time_comparable, false)
|
|
48
|
+
assert.equal(log.freshness, 'unknown')
|
|
49
|
+
const snapshot = parse(await call('capture_debug_snapshot', { ...target, actionRef, includeScreenshot: false, includeLogs: false }))
|
|
50
|
+
assert.equal(snapshot.evidence.action_id, action.action_id)
|
|
51
|
+
assert.equal(snapshot.raw.screenshot, null)
|
|
52
|
+
assert.equal(snapshot.evidence.observations.find((v: any) => v.kind === 'screenshot').status, 'omitted')
|
|
53
|
+
assert.equal(snapshot.evidence.observations.find((v: any) => v.kind === 'logs').status, 'omitted')
|
|
54
|
+
assert.ok(snapshot.evidence.observations.every((v: any) => !v.device || v.device.id === device.id))
|
|
55
|
+
const tree = snapshot.evidence.observations.find((v: any) => v.kind === 'ui_tree')
|
|
56
|
+
assert.equal(tree.status, 'captured', tree.error)
|
|
57
|
+
const unassociated = parse(await call('get_ui_tree', { ...target, responseMode: 'compact', actionRef: { ...actionRef, sessionId: 'previous-process' } }))
|
|
58
|
+
assert.equal(unassociated.evidence.association_reason, 'SESSION_MISMATCH')
|
|
59
|
+
assert.equal(unassociated.evidence.action_id, null)
|
|
60
|
+
console.log(JSON.stringify({ status: 'PASS', server: client.getServerVersion(), platform, device: device.id,
|
|
61
|
+
osVersion: device.osVersion, startingApp: appId, actionId: action.action_id,
|
|
62
|
+
phases: { dispatchStarted: action.evidence.dispatch_started_at_ms, dispatchCompleted: action.evidence.dispatch_completed_at_ms,
|
|
63
|
+
screenshotStarted: screenshot.capture_started_at_ms, screenshotCompleted: screenshot.capture_completed_at_ms },
|
|
64
|
+
screenshot: screenshot.status, logs: log.status, uiTree: tree.status,
|
|
65
|
+
disabledComponents: 'omitted', foreignReference: 'unavailable',
|
|
66
|
+
cleanup: 'No settings, app data or streams changed; Settings remains foreground. No content artifacts saved.' }))
|
|
67
|
+
}
|
|
68
|
+
} finally { await client.close() }
|
|
69
|
+
}
|
|
70
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Run against a built checkout and an explicitly selected simulator in system Settings.
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { writeFile } from 'node:fs/promises'
|
|
6
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
7
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
8
|
+
|
|
9
|
+
function arg(name: string) { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined }
|
|
10
|
+
async function run() {
|
|
11
|
+
const platform = arg('--platform')
|
|
12
|
+
const deviceId = arg('--id')
|
|
13
|
+
const targetText = arg('--target-text')
|
|
14
|
+
const destinationText = arg('--destination-text')
|
|
15
|
+
const output = arg('--output')
|
|
16
|
+
assert.ok(platform === 'android' || platform === 'ios', '--platform android|ios is required')
|
|
17
|
+
assert.ok(deviceId && targetText && destinationText && output, '--id, --target-text, --destination-text and --output are required')
|
|
18
|
+
const repository = fileURLToPath(new URL('../../../../', import.meta.url))
|
|
19
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: [path.join(repository, 'dist/server.js')], cwd: repository, stderr: 'pipe' })
|
|
20
|
+
const client = new Client({ name: 'conditional-device-verification', version: '1.0.0' })
|
|
21
|
+
const evidence: any = { platform, deviceId, checked_at: new Date().toISOString(), source: 'built dist/server.js over MCP stdio', scenarios: [] }
|
|
22
|
+
transport.stderr?.on('data', chunk => process.stderr.write(chunk))
|
|
23
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
24
|
+
const result = await client.callTool({ name, arguments: { platform, deviceId, ...args } }, undefined, { timeout: 120000 })
|
|
25
|
+
const item: any = result.content.find((item: any) => item.type === 'text')
|
|
26
|
+
return JSON.parse(item.text)
|
|
27
|
+
}
|
|
28
|
+
const observe = async () => {
|
|
29
|
+
const result = await call('get_ui_tree', {})
|
|
30
|
+
assert.equal(result.error, undefined, JSON.stringify(result))
|
|
31
|
+
assert.ok(result.resolution.width > 0 && result.elements.length > 0, 'nonempty usable hierarchy required')
|
|
32
|
+
return result
|
|
33
|
+
}
|
|
34
|
+
const contains = (tree: any, text: string) => tree.elements.some((element: any) => element.text === text || element.contentDescription === text || element.resourceId === text)
|
|
35
|
+
try {
|
|
36
|
+
await client.connect(transport)
|
|
37
|
+
evidence.server = client.getServerVersion()
|
|
38
|
+
const tools = await client.listTools()
|
|
39
|
+
assert.ok((tools.tools.find(tool => tool.name === 'tap')?.inputSchema.properties as any)?.expectedState, 'newly built server advertises the condition')
|
|
40
|
+
const baseline = await observe()
|
|
41
|
+
assert.ok(contains(baseline, targetText), `starting screen must contain ${targetText}`)
|
|
42
|
+
assert.ok(!contains(baseline, destinationText), 'destination must not already be present on the starting screen')
|
|
43
|
+
const expectedState = { snapshotSessionId: baseline.snapshot_session_id, snapshotRevision: baseline.snapshot_revision, timeoutMs: 30000 }
|
|
44
|
+
const target = baseline.elements.find((element: any) => element.text === targetText)
|
|
45
|
+
assert.ok(target?.bounds, `target ${targetText} needs bounds`)
|
|
46
|
+
const [left, top, right, bottom] = target.bounds
|
|
47
|
+
const matched = await call('tap', { x: Math.floor((left + right) / 2), y: Math.floor((top + bottom) / 2), expectedState, verificationMode: 'none', responseMode: 'compact', actionTimeoutMs: 30000 })
|
|
48
|
+
evidence.scenarios.push({ name: 'matching snapshot dispatch', result: matched })
|
|
49
|
+
assert.equal(matched.success, true, JSON.stringify(matched))
|
|
50
|
+
assert.equal(matched.condition.status, 'matched')
|
|
51
|
+
const destination = await observe()
|
|
52
|
+
assert.ok(contains(destination, destinationText), `destination must contain ${destinationText}`)
|
|
53
|
+
evidence.scenarios[0].observed_destination = { snapshot_revision: destination.snapshot_revision, labels: destination.elements.map((e: any) => e.text || e.contentDescription).filter(Boolean) }
|
|
54
|
+
evidence.scenarios[0].status = 'pass'
|
|
55
|
+
const back = destination.elements.find((element: any) => element.contentDescription === 'Navigate up' || element.contentDescription === 'Back' || (element.text === 'Settings' && element.clickable))
|
|
56
|
+
assert.ok(back, 'safe back-navigation target required on destination')
|
|
57
|
+
const [bl, bt, br, bb] = back.bounds
|
|
58
|
+
const selector = back.resourceId ? { resource_id: back.resourceId } :
|
|
59
|
+
back.text ? { text: back.text } : { accessibility_id: back.contentDescription }
|
|
60
|
+
for (const responseMode of ['compact', 'debug']) {
|
|
61
|
+
const mismatch = await call('tap', { x: Math.floor((bl + br) / 2), y: Math.floor((bt + bb) / 2), expectedState, verificationMode: 'none', responseMode, actionTimeoutMs: 30000 })
|
|
62
|
+
evidence.scenarios.push({ name: `mismatch blocks back navigation (${responseMode})`, result: mismatch })
|
|
63
|
+
assert.equal(mismatch.failure_code, 'PRECONDITION_FAILED', JSON.stringify(mismatch))
|
|
64
|
+
assert.equal(mismatch.dispatch_started, false)
|
|
65
|
+
assert.equal(mismatch.delivery_status, 'not_delivered')
|
|
66
|
+
assert.ok(contains(mismatch.condition.observed, destinationText))
|
|
67
|
+
const after = await observe()
|
|
68
|
+
assert.ok(contains(after, destinationText), 'blocked back navigation must leave destination visible')
|
|
69
|
+
evidence.scenarios.at(-1).status = 'pass'
|
|
70
|
+
const journey = await call('run_journey', { responseMode, defaults: { verificationMode: 'none', actionTimeoutMs: 30000 }, steps: [
|
|
71
|
+
{ id: 'destination-visible', type: 'wait', selector: { text: destinationText }, condition: 'visible', timeoutMs: 30000 },
|
|
72
|
+
{ id: 'conditional-back', type: 'tap', selector, waitFor: { condition: 'visible', timeoutMs: 10000 }, expectedState },
|
|
73
|
+
{ id: 'must-not-run', type: 'tap', selector, waitFor: { condition: 'visible', timeoutMs: 10000 } }
|
|
74
|
+
] })
|
|
75
|
+
evidence.scenarios.push({ name: `conditional journey stops (${responseMode})`, result: journey })
|
|
76
|
+
assert.equal(journey.stopped_at_step_id, 'conditional-back', JSON.stringify(journey))
|
|
77
|
+
assert.equal(journey.steps[0].status, 'passed')
|
|
78
|
+
assert.equal(journey.steps[1].result.failure_code, 'PRECONDITION_FAILED')
|
|
79
|
+
assert.ok(contains(journey.steps[1].result.condition.observed, destinationText))
|
|
80
|
+
assert.equal(journey.steps[2].status, 'not_run')
|
|
81
|
+
assert.ok(contains(await observe(), destinationText))
|
|
82
|
+
evidence.scenarios.at(-1).status = 'pass'
|
|
83
|
+
}
|
|
84
|
+
evidence.status = 'pass'
|
|
85
|
+
console.log(JSON.stringify({ platform, deviceId, server: evidence.server, status: evidence.status, scenarios: evidence.scenarios.map((scenario: any) => ({ name: scenario.name, status: scenario.status })), output }, null, 2))
|
|
86
|
+
} catch (error) {
|
|
87
|
+
evidence.status = 'fail'
|
|
88
|
+
evidence.error = String(error)
|
|
89
|
+
throw error
|
|
90
|
+
} finally {
|
|
91
|
+
await writeFile(output!, JSON.stringify(evidence, null, 2))
|
|
92
|
+
await client.close()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { writeFileSync } from 'node:fs'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
6
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
7
|
+
|
|
8
|
+
// Explicit Modul8 fixture: run after npm run build with the Android emulator booted.
|
|
9
|
+
async function run() {
|
|
10
|
+
const client = new Client({ name: 'modul8-selector-tap-verification', version: '1' })
|
|
11
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
|
|
12
|
+
const target = { platform: 'android', deviceId: 'emulator-5554' }
|
|
13
|
+
const report: any = { verified_on: new Date().toISOString(), device: target, package: 'com.ideamechanics.modul8.dev',
|
|
14
|
+
source: 'rebuilt local MCP stdio server', build_sha256: createHash('sha256').update(readFileSync('dist/server/tool-handlers.js')).digest('hex'),
|
|
15
|
+
steps: [], status: 'UNRUN', cleanup: 'UNRUN' }
|
|
16
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
17
|
+
const response: any = await client.callTool({ name, arguments: { ...target, ...args } }, undefined, { timeout: 120000 })
|
|
18
|
+
return JSON.parse(response.content.find((item: any) => item.type === 'text').text)
|
|
19
|
+
}
|
|
20
|
+
const assertVisible = async (selector: Record<string, string>) => {
|
|
21
|
+
const result = await call('expect_element_visible', { selector, timeout_ms: 10000, poll_interval_ms: 100 })
|
|
22
|
+
assert.equal(result.success, true, `Visible fixture landmark: ${JSON.stringify(selector)}`)
|
|
23
|
+
}
|
|
24
|
+
const tap = async (text: string, condition?: string) => {
|
|
25
|
+
const args: Record<string, unknown> = { selector: { text }, verificationMode: 'none', responseMode: condition ? 'debug' : 'compact' }
|
|
26
|
+
if (condition) { args.waitFor = { condition, timeoutMs: 10000 }; args.actionTimeoutMs = 15000 }
|
|
27
|
+
const result = await call('tap_element', args)
|
|
28
|
+
report.steps.push({ selector: { text }, waitForCondition: condition ?? 'default', status: result.success ? 'PASS' : 'FAIL',
|
|
29
|
+
failure_code: result.failure_code ?? null, resolved_target: result.target?.resolved, evidence: result.evidence })
|
|
30
|
+
assert.equal(result.success, true, `tap_element ${text}: ${result.failure_code ?? result.error?.message}`)
|
|
31
|
+
assert.ok(result.evidence.dispatch_started_at_ms)
|
|
32
|
+
assert.ok(result.evidence.dispatch_completed_at_ms >= result.evidence.dispatch_started_at_ms)
|
|
33
|
+
assert.equal(result.evidence.verification_completed_at_ms, null)
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
await client.connect(transport)
|
|
37
|
+
report.server = client.getServerVersion()
|
|
38
|
+
const launch = await call('start_app', { appId: report.package, verificationMode: 'none', responseMode: 'compact' })
|
|
39
|
+
assert.equal(launch.success, true)
|
|
40
|
+
await assertVisible({ text: 'Subscription' })
|
|
41
|
+
report.starting_state = 'Subscription'
|
|
42
|
+
await tap('FAVOURITES')
|
|
43
|
+
await assertVisible({ accessibility_id: 'Favourites' })
|
|
44
|
+
await tap('GENER8')
|
|
45
|
+
await assertVisible({ text: 'Personalised breathing' })
|
|
46
|
+
await tap('SETTINGS', 'exists')
|
|
47
|
+
await assertVisible({ text: 'Subscription' })
|
|
48
|
+
await tap('FAVOURITES', 'visible')
|
|
49
|
+
await assertVisible({ accessibility_id: 'Favourites' })
|
|
50
|
+
await tap('SETTINGS')
|
|
51
|
+
await assertVisible({ text: 'Subscription' })
|
|
52
|
+
report.cleanup = 'PASS: restored Subscription; no settings, account data, favourites or sessions changed; no streams or screenshots created'
|
|
53
|
+
report.status = 'PASS'
|
|
54
|
+
} catch (error) {
|
|
55
|
+
report.status = 'FAIL'; report.error = String(error)
|
|
56
|
+
throw error
|
|
57
|
+
} finally {
|
|
58
|
+
writeFileSync('docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json', JSON.stringify(report, null, 2) + '\n')
|
|
59
|
+
console.log(JSON.stringify({ status: report.status, server: report.server, device: target, steps: report.steps.map((s: any) => ({ selector: s.selector, condition: s.waitForCondition, status: s.status })), cleanup: report.cleanup }))
|
|
60
|
+
await client.close()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { execFileSync } from 'node:child_process'
|
|
3
|
+
import { rmSync } from 'node:fs'
|
|
4
|
+
import { writeFile } from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
8
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
9
|
+
import { buildReadinessFixture, readinessAppId } from '../../../fixtures/readiness-app/build.js'
|
|
10
|
+
|
|
11
|
+
const arg = (name: string) => { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined }
|
|
12
|
+
async function run() {
|
|
13
|
+
const platform = arg('--platform')
|
|
14
|
+
const deviceId = arg('--id')
|
|
15
|
+
const output = arg('--output')
|
|
16
|
+
assert.ok(platform === 'android' || platform === 'ios')
|
|
17
|
+
assert.ok(deviceId && output, '--id and --output are required')
|
|
18
|
+
const repository = fileURLToPath(new URL('../../../../', import.meta.url))
|
|
19
|
+
const client = new Client({ name: 'readiness-device-verification', version: '1.0.0' })
|
|
20
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: [path.join(repository, 'dist/server.js')], cwd: repository, stderr: 'pipe' })
|
|
21
|
+
transport.stderr?.on('data', chunk => process.stderr.write(chunk))
|
|
22
|
+
const evidence: any = { platform, deviceId, source: 'built dist/server.js over MCP stdio', checked_at: new Date().toISOString(), scenarios: [] }
|
|
23
|
+
let fixture: ReturnType<typeof buildReadinessFixture> | undefined
|
|
24
|
+
let installed = false
|
|
25
|
+
let restoreApp: string | undefined
|
|
26
|
+
const call = async (name: string, args: any = {}) => {
|
|
27
|
+
const result = await client.callTool({ name, arguments: { platform, deviceId, ...args } }, undefined, { timeout: 120000 })
|
|
28
|
+
const item: any = result.content.find((item: any) => item.type === 'text')
|
|
29
|
+
return JSON.parse(item.text)
|
|
30
|
+
}
|
|
31
|
+
const record = (name: string, result: any) => { evidence.scenarios.push({ name, result }); return result }
|
|
32
|
+
try {
|
|
33
|
+
await client.connect(transport)
|
|
34
|
+
evidence.server = client.getServerVersion()
|
|
35
|
+
const devices = await call('list_devices', { appId: readinessAppId })
|
|
36
|
+
const device = devices.devices.find((device: any) => device.id === deviceId)
|
|
37
|
+
assert.ok(device?.simulator, 'fixture mutations require an explicitly selected simulator')
|
|
38
|
+
assert.ok(!device.appInstalled, 'refuse to replace a pre-existing fixture app')
|
|
39
|
+
if (platform === 'android') restoreApp = (await call('get_current_screen')).package
|
|
40
|
+
fixture = buildReadinessFixture(platform)
|
|
41
|
+
const install = await call('install_app', { projectType: 'native', appPath: fixture.artifact })
|
|
42
|
+
assert.equal(install.installed, true, JSON.stringify(install))
|
|
43
|
+
installed = true
|
|
44
|
+
assert.equal((await call('start_app', { appId: readinessAppId, verificationMode: 'none', responseMode: 'compact' })).success, true)
|
|
45
|
+
const tree = await call('get_ui_tree')
|
|
46
|
+
assert.equal(tree.readiness.state, 'unknown', 'single-sample tree never settles')
|
|
47
|
+
evidence.initial_tree = tree
|
|
48
|
+
const target = tree.elements.find((element: any) => element.contentDescription === 'show-loader' || element.resourceId === 'show-loader' || element.text?.toLowerCase() === 'show loader')
|
|
49
|
+
assert.ok(target?.bounds)
|
|
50
|
+
const [left, top, right, bottom] = target.bounds
|
|
51
|
+
const trigger = () => call('tap', { x: (left + right) / 2, y: (top + bottom) / 2, verificationMode: 'none', responseMode: 'compact' })
|
|
52
|
+
const stable = record('stable foreground UI', await call('get_app_readiness'))
|
|
53
|
+
assert.equal(stable.success, true, JSON.stringify(stable))
|
|
54
|
+
assert.equal(stable.readiness.state, 'settled')
|
|
55
|
+
assert.equal(stable.readiness.capabilities.unresponsive, false)
|
|
56
|
+
evidence.scenarios.at(-1).status = 'pass'
|
|
57
|
+
assert.equal((await trigger()).success, true)
|
|
58
|
+
const loading = record('controlled loader observation', await call('get_app_readiness'))
|
|
59
|
+
assert.equal(loading.readiness.state, 'loading', JSON.stringify(loading))
|
|
60
|
+
evidence.scenarios.at(-1).status = 'pass'
|
|
61
|
+
const bounded = record('bounded wait cannot claim readiness', await call('wait_for_app_ready', { timeoutMs: 100, pollIntervalMs: 100 }))
|
|
62
|
+
assert.equal(bounded.success, false)
|
|
63
|
+
assert.ok(bounded.readiness.limitations.includes('DEADLINE_EXCEEDED'))
|
|
64
|
+
assert.notEqual(bounded.readiness.state, 'unresponsive')
|
|
65
|
+
evidence.scenarios.at(-1).status = 'pass'
|
|
66
|
+
const predicate = record('visible predicate waits for loader completion', await call('wait_for_ui', { selector: { text: 'Ready' }, condition: 'visible', requireReady: true, timeout_ms: 30000, poll_interval_ms: 500 }))
|
|
67
|
+
assert.equal(predicate.status, 'success', JSON.stringify(predicate))
|
|
68
|
+
assert.equal(predicate.readiness.state, 'settled')
|
|
69
|
+
const finished = await call('get_ui_tree')
|
|
70
|
+
assert.ok(finished.elements.some((element: any) => element.text === 'Done'))
|
|
71
|
+
evidence.scenarios.at(-1).status = 'pass'
|
|
72
|
+
assert.equal((await trigger()).success, true)
|
|
73
|
+
const changed = record('changed UI settles after loader', await call('wait_for_ui_change', { requireReady: true, timeout_ms: 30000, stability_window_ms: 0 }))
|
|
74
|
+
assert.equal(changed.success, true, JSON.stringify(changed))
|
|
75
|
+
assert.equal(changed.readiness.state, 'settled')
|
|
76
|
+
evidence.scenarios.at(-1).status = 'pass'
|
|
77
|
+
const wait = record('readiness wait succeeds on settled content', await call('wait_for_app_ready', { timeoutMs: 15000 }))
|
|
78
|
+
assert.equal(wait.success, true)
|
|
79
|
+
assert.equal(wait.readiness.state, 'settled')
|
|
80
|
+
evidence.scenarios.at(-1).status = 'pass'
|
|
81
|
+
evidence.status = 'pass'
|
|
82
|
+
console.log(JSON.stringify({ platform, deviceId, status: 'pass', scenarios: evidence.scenarios.map((entry: any) => ({ name: entry.name, status: entry.status })), output }, null, 2))
|
|
83
|
+
} catch (error) { evidence.status = 'fail'; evidence.error = String(error); throw error }
|
|
84
|
+
finally {
|
|
85
|
+
if (installed) {
|
|
86
|
+
await call('terminate_app', { appId: readinessAppId }).catch(() => null)
|
|
87
|
+
try {
|
|
88
|
+
if (platform === 'android') execFileSync(process.env.ADB_PATH || path.join(process.env.HOME!, 'Library/Android/sdk/platform-tools/adb'), ['-s', deviceId!, 'uninstall', readinessAppId], { stdio: 'pipe', timeout: 30000 })
|
|
89
|
+
else execFileSync('xcrun', ['simctl', 'uninstall', deviceId!, readinessAppId], { stdio: 'pipe', timeout: 30000 })
|
|
90
|
+
evidence.cleanup = 'fixture uninstalled'
|
|
91
|
+
} catch (error) { evidence.cleanup = String(error) }
|
|
92
|
+
if (restoreApp) await call('start_app', { appId: restoreApp, verificationMode: 'none', responseMode: 'compact' }).catch(() => null)
|
|
93
|
+
}
|
|
94
|
+
if (fixture) rmSync(fixture.directory, { recursive: true, force: true })
|
|
95
|
+
await writeFile(output!, JSON.stringify(evidence, null, 2))
|
|
96
|
+
await client.close()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package tools.mobiledebug.readinessfixture;
|
|
2
|
+
import android.app.Activity;
|
|
3
|
+
import android.os.Bundle;
|
|
4
|
+
import android.os.Handler;
|
|
5
|
+
import android.widget.*;
|
|
6
|
+
import android.view.View;
|
|
7
|
+
public class MainActivity extends Activity {
|
|
8
|
+
private int generation = 0;
|
|
9
|
+
public void onCreate(Bundle state) {
|
|
10
|
+
super.onCreate(state);
|
|
11
|
+
LinearLayout layout = new LinearLayout(this);
|
|
12
|
+
layout.setOrientation(LinearLayout.VERTICAL); layout.setPadding(32, 120, 32, 32);
|
|
13
|
+
TextView ready = new TextView(this); ready.setText("Ready"); ready.setContentDescription("ready"); layout.addView(ready);
|
|
14
|
+
TextView status = new TextView(this); status.setText("Idle"); layout.addView(status);
|
|
15
|
+
ProgressBar loader = new ProgressBar(this); loader.setVisibility(View.GONE); layout.addView(loader);
|
|
16
|
+
Button start = new Button(this); start.setText("Show loader"); start.setContentDescription("show-loader"); layout.addView(start);
|
|
17
|
+
start.setOnClickListener(view -> {
|
|
18
|
+
int current = ++generation; loader.setVisibility(View.VISIBLE); status.setText("Loading");
|
|
19
|
+
new Handler(getMainLooper()).postDelayed(() -> { if (current == generation) { loader.setVisibility(View.GONE); status.setText("Done"); } }, 12000);
|
|
20
|
+
});
|
|
21
|
+
setContentView(layout);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Readiness fixture
|
|
2
|
+
|
|
3
|
+
Offline native Android/iOS simulator apps used only for explicit readiness
|
|
4
|
+
verification. Ready stays visible while Show loader starts a 12-second loader;
|
|
5
|
+
completion hides the loader and changes its status to Done. No permissions,
|
|
6
|
+
accounts, networking, or persistence are used. These are test apps, not hierarchy
|
|
7
|
+
inspection helpers.
|
|
8
|
+
|
|
9
|
+
The manual helper builds them in a temporary directory with Android SDK 36 /
|
|
10
|
+
build-tools 36.0.0 and the installed JDK, or the installed iOS simulator SDK and
|
|
11
|
+
Clang. It installs only on an explicitly selected simulator and cleans up its
|
|
12
|
+
own app and temporary artifacts afterward.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
export const readinessAppId = 'tools.mobiledebug.readinessfixture'
|
|
8
|
+
export function buildReadinessFixture(platform: 'android' | 'ios') {
|
|
9
|
+
const source = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const directory = mkdtempSync(path.join(tmpdir(), `mcp-readiness-${platform}-`))
|
|
11
|
+
const java = process.env.JAVA_HOME ?? (existsSync('/Applications/Android Studio.app/Contents/jbr/Contents/Home') ? '/Applications/Android Studio.app/Contents/jbr/Contents/Home' : undefined)
|
|
12
|
+
const run = (cmd: string, args: string[]) => execFileSync(cmd, args, { cwd: directory, encoding: 'utf8', env: { ...process.env, ...(java ? { JAVA_HOME: java } : {}) }, stdio: 'pipe' })
|
|
13
|
+
if (platform === 'ios') {
|
|
14
|
+
const sdk = run('xcrun', ['--sdk', 'iphonesimulator', '--show-sdk-path']).trim()
|
|
15
|
+
const artifact = path.join(directory, 'Readiness.app'); mkdirSync(artifact)
|
|
16
|
+
writeFileSync(path.join(artifact, 'Info.plist'), `<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleExecutable</key><string>Readiness</string><key>CFBundleIdentifier</key><string>${readinessAppId}</string><key>CFBundleName</key><string>Readiness fixture</string><key>CFBundlePackageType</key><string>APPL</string><key>CFBundleVersion</key><string>1</string><key>CFBundleShortVersionString</key><string>1.0</string><key>LSRequiresIPhoneOS</key><true/><key>MinimumOSVersion</key><string>18.0</string><key>UILaunchScreen</key><dict/></dict></plist>`)
|
|
17
|
+
run('xcrun', ['--sdk', 'iphonesimulator', 'clang', '-target', `${process.arch === 'arm64' ? 'arm64' : 'x86_64'}-apple-ios18.0-simulator`, '-isysroot', sdk, '-fobjc-arc', '-framework', 'UIKit', '-framework', 'Foundation', path.join(source, 'main.m'), '-o', path.join(artifact, 'Readiness')])
|
|
18
|
+
run('codesign', ['--force', '--sign', '-', artifact])
|
|
19
|
+
return { artifact, directory }
|
|
20
|
+
}
|
|
21
|
+
const sdk = process.env.ANDROID_HOME ?? path.join(process.env.HOME!, 'Library/Android/sdk')
|
|
22
|
+
const tools = path.join(sdk, 'build-tools/36.0.0')
|
|
23
|
+
const androidJar = path.join(sdk, 'platforms/android-36/android.jar')
|
|
24
|
+
const jdk = (name: string) => java ? path.join(java, 'bin', name) : name
|
|
25
|
+
mkdirSync(path.join(directory, 'classes'))
|
|
26
|
+
writeFileSync(path.join(directory, 'AndroidManifest.xml'), `<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="${readinessAppId}" android:versionCode="1" android:versionName="1.0"><uses-sdk android:minSdkVersion="26" android:targetSdkVersion="36"/><application android:label="Readiness fixture" android:theme="@android:style/Theme.Material.Light.NoActionBar"><activity android:name=".MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity></application></manifest>`)
|
|
27
|
+
run(jdk('javac'), ['--release', '8', '-classpath', androidJar, '-d', 'classes', path.join(source, 'MainActivity.java')])
|
|
28
|
+
run(jdk('jar'), ['cf', 'classes.jar', '-C', 'classes', '.'])
|
|
29
|
+
run(path.join(tools, 'd8'), ['--lib', androidJar, '--output', directory, 'classes.jar'])
|
|
30
|
+
run(path.join(tools, 'aapt2'), ['link', '-I', androidJar, '--manifest', 'AndroidManifest.xml', '-o', 'unsigned.apk'])
|
|
31
|
+
run('zip', ['-q', 'unsigned.apk', 'classes.dex'])
|
|
32
|
+
run(path.join(tools, 'zipalign'), ['-f', '4', 'unsigned.apk', 'aligned.apk'])
|
|
33
|
+
run(jdk('keytool'), ['-genkeypair', '-keystore', 'fixture.jks', '-storepass', 'android', '-keypass', 'android', '-alias', 'fixture', '-dname', 'CN=Readiness Fixture', '-keyalg', 'RSA', '-validity', '365'])
|
|
34
|
+
const artifact = path.join(directory, 'Readiness.apk')
|
|
35
|
+
run(path.join(tools, 'apksigner'), ['sign', '--ks', 'fixture.jks', '--ks-pass', 'pass:android', '--out', artifact, 'aligned.apk'])
|
|
36
|
+
return { artifact, directory }
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#import <UIKit/UIKit.h>
|
|
2
|
+
@interface ReadinessApp : UIResponder <UIApplicationDelegate>
|
|
3
|
+
@property(strong, nonatomic) UIWindow *window;
|
|
4
|
+
@property(strong, nonatomic) UILabel *status;
|
|
5
|
+
@property(strong, nonatomic) UIActivityIndicatorView *loader;
|
|
6
|
+
@property(nonatomic) NSInteger generation;
|
|
7
|
+
@end
|
|
8
|
+
@implementation ReadinessApp
|
|
9
|
+
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options {
|
|
10
|
+
self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds];
|
|
11
|
+
UIViewController *controller = [UIViewController new]; controller.view.backgroundColor = UIColor.whiteColor;
|
|
12
|
+
self.window.rootViewController = controller;
|
|
13
|
+
UILabel *ready = [[UILabel alloc] initWithFrame:CGRectMake(30, 140, 300, 50)]; ready.text = @"Ready"; ready.accessibilityIdentifier = @"ready";
|
|
14
|
+
[controller.view addSubview:ready];
|
|
15
|
+
self.status = [[UILabel alloc] initWithFrame:CGRectMake(30, 200, 300, 50)]; self.status.text = @"Idle"; [controller.view addSubview:self.status];
|
|
16
|
+
self.loader = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; self.loader.frame = CGRectMake(30, 260, 50, 50); [controller.view addSubview:self.loader];
|
|
17
|
+
UIButton *start = [UIButton buttonWithType:UIButtonTypeSystem]; start.frame = CGRectMake(30, 340, 300, 60);
|
|
18
|
+
[start setTitle:@"Show loader" forState:UIControlStateNormal]; start.accessibilityIdentifier = @"show-loader";
|
|
19
|
+
[start addTarget:self action:@selector(showLoader) forControlEvents:UIControlEventTouchUpInside]; [controller.view addSubview:start];
|
|
20
|
+
[self.window makeKeyAndVisible]; return YES;
|
|
21
|
+
}
|
|
22
|
+
- (void)showLoader {
|
|
23
|
+
NSInteger current = ++self.generation; self.status.text = @"Loading"; [self.loader startAnimating];
|
|
24
|
+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 12 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ if (current == self.generation) { self.status.text = @"Done"; [self.loader stopAnimating]; } });
|
|
25
|
+
}
|
|
26
|
+
@end
|
|
27
|
+
int main(int argc, char **argv) { @autoreleasepool { return UIApplicationMain(argc, argv, nil, NSStringFromClass(ReadinessApp.class)); } }
|
|
@@ -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 })
|