mobile-debug-mcp 0.32.4 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.cursor-plugin/marketplace.json +16 -0
  4. package/.github/copilot-instructions.md +7 -0
  5. package/.github/workflows/ci.yml +21 -0
  6. package/AGENTS.md +21 -10
  7. package/README.md +69 -4
  8. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  9. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  10. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  11. package/agent-plugin/.mcp.json +14 -0
  12. package/agent-plugin/README.md +9 -3
  13. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  14. package/agent-plugin/mcp.json +1 -1
  15. package/agent-plugin/plugin.json +1 -1
  16. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
  17. package/dist/debugger/android.js +78 -0
  18. package/dist/debugger/guard.js +30 -0
  19. package/dist/debugger/sessions.js +262 -0
  20. package/dist/debugger/worker.js +165 -0
  21. package/dist/interact/android.js +10 -4
  22. package/dist/interact/conditional-action.js +68 -0
  23. package/dist/interact/index.js +70 -14
  24. package/dist/interact/ios.js +14 -8
  25. package/dist/manage/android.js +4 -3
  26. package/dist/manage/ios.js +3 -2
  27. package/dist/observe/android.js +25 -4
  28. package/dist/observe/evidence.js +280 -0
  29. package/dist/observe/index.js +33 -6
  30. package/dist/observe/ios.js +25 -6
  31. package/dist/observe/readiness-service.js +124 -0
  32. package/dist/observe/readiness.js +88 -0
  33. package/dist/observe/recording.js +165 -0
  34. package/dist/observe/snapshot-metadata.js +10 -2
  35. package/dist/observe/system-trace-android.js +135 -0
  36. package/dist/observe/system-trace.js +285 -0
  37. package/dist/server/common.js +9 -0
  38. package/dist/server/condition-schema.js +32 -0
  39. package/dist/server/debugger-handlers.js +38 -0
  40. package/dist/server/debugger-schema.js +42 -0
  41. package/dist/server/journey-schema.js +4 -2
  42. package/dist/server/readiness-schema.js +12 -0
  43. package/dist/server/system-trace-schema.js +17 -0
  44. package/dist/server/tool-definitions.js +51 -0
  45. package/dist/server/tool-handlers.js +294 -105
  46. package/dist/server-core.js +3 -3
  47. package/dist/server.js +34 -0
  48. package/dist/system/index.js +5 -4
  49. package/dist/utils/exec.js +2 -0
  50. package/dist/utils/ios/utils.js +4 -4
  51. package/dist/utils/java.js +14 -12
  52. package/dist/utils/operation-budget.js +26 -2
  53. package/dist/utils/resolve-device.js +9 -0
  54. package/docs/CHANGELOG.md +15 -0
  55. package/docs/specs/020-conditional-actions/intent.md +62 -0
  56. package/docs/specs/020-conditional-actions/plan.md +214 -0
  57. package/docs/specs/020-conditional-actions/spec.md +301 -0
  58. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  59. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  60. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  61. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  62. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  63. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  64. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  65. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  66. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  67. package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
  68. package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
  69. package/docs/specs/023-android-system-traces/intent.md +40 -0
  70. package/docs/specs/023-android-system-traces/plan.md +117 -0
  71. package/docs/specs/023-android-system-traces/spec.md +120 -0
  72. package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
  73. package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
  74. package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
  75. package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
  76. package/docs/specs/process.md +213 -0
  77. package/docs/tools/TOOLS.md +5 -0
  78. package/docs/tools/debugger.md +104 -0
  79. package/docs/tools/evidence.md +82 -0
  80. package/docs/tools/interact.md +125 -2
  81. package/docs/tools/observe.md +70 -0
  82. package/docs/tools/system-traces.md +126 -0
  83. package/package.json +1 -1
  84. package/scripts/agent-plugin.mjs +68 -8
  85. package/src/debugger/android.ts +65 -0
  86. package/src/debugger/guard.ts +26 -0
  87. package/src/debugger/sessions.ts +168 -0
  88. package/src/debugger/worker.ts +122 -0
  89. package/src/interact/android.ts +10 -4
  90. package/src/interact/conditional-action.ts +75 -0
  91. package/src/interact/index.ts +57 -28
  92. package/src/interact/ios.ts +14 -8
  93. package/src/manage/android.ts +4 -3
  94. package/src/manage/ios.ts +3 -2
  95. package/src/observe/android.ts +33 -6
  96. package/src/observe/evidence.ts +262 -0
  97. package/src/observe/index.ts +32 -8
  98. package/src/observe/ios.ts +32 -8
  99. package/src/observe/readiness-service.ts +94 -0
  100. package/src/observe/readiness.ts +84 -0
  101. package/src/observe/snapshot-metadata.ts +12 -1
  102. package/src/observe/system-trace-android.ts +133 -0
  103. package/src/observe/system-trace.ts +218 -0
  104. package/src/server/common.ts +7 -0
  105. package/src/server/condition-schema.ts +32 -0
  106. package/src/server/debugger-handlers.ts +29 -0
  107. package/src/server/debugger-schema.ts +37 -0
  108. package/src/server/journey-schema.ts +4 -2
  109. package/src/server/readiness-schema.ts +12 -0
  110. package/src/server/system-trace-schema.ts +17 -0
  111. package/src/server/tool-definitions.ts +49 -0
  112. package/src/server/tool-handlers.ts +182 -21
  113. package/src/server-core.ts +3 -3
  114. package/src/server.ts +24 -0
  115. package/src/system/index.ts +7 -5
  116. package/src/types.ts +30 -1
  117. package/src/utils/exec.ts +2 -0
  118. package/src/utils/ios/utils.ts +4 -4
  119. package/src/utils/java.ts +13 -12
  120. package/src/utils/operation-budget.ts +24 -2
  121. package/src/utils/resolve-device.ts +10 -0
  122. package/test/device/automated/debugger/runtime.android.test.ts +203 -0
  123. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  124. package/test/device/automated/observe/system-trace.smoke.ts +104 -0
  125. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  126. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  127. package/test/device/manual/interact/readiness.manual.ts +99 -0
  128. package/test/fixtures/debugger-app/MainActivity.java +46 -0
  129. package/test/fixtures/debugger-app/NoLocals.java +7 -0
  130. package/test/fixtures/debugger-app/README.md +13 -0
  131. package/test/fixtures/debugger-app/build.ts +29 -0
  132. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  133. package/test/fixtures/readiness-app/README.md +12 -0
  134. package/test/fixtures/readiness-app/build.ts +37 -0
  135. package/test/fixtures/readiness-app/main.m +27 -0
  136. package/test/helpers/perfetto.ts +59 -0
  137. package/test/unit/debugger/guards.test.ts +46 -0
  138. package/test/unit/debugger/native.test.ts +33 -0
  139. package/test/unit/debugger/sessions.test.ts +83 -0
  140. package/test/unit/observe/android_tree_acquisition.test.ts +2 -0
  141. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  142. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  143. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  144. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  145. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  146. package/test/unit/observe/readiness.test.ts +41 -0
  147. package/test/unit/observe/readiness_native.test.ts +68 -0
  148. package/test/unit/observe/system-trace-android.test.ts +52 -0
  149. package/test/unit/observe/system-trace.test.ts +166 -0
  150. package/test/unit/server/conditional_actions.test.ts +299 -0
  151. package/test/unit/server/conditional_schema.test.ts +59 -0
  152. package/test/unit/server/correlated-evidence.test.ts +194 -0
  153. package/test/unit/server/readiness.test.ts +170 -0
  154. package/test/unit/server/selector_tap_target.test.ts +79 -0
  155. package/test/unit/server/system-trace.test.ts +64 -0
  156. package/test/unit/server/tap_selector.test.ts +2 -0
  157. package/worker/DebuggerWorker.java +160 -0
  158. package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
  159. /package/{skills → .agents/skills}/README.md +0 -0
  160. /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
  161. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  162. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  163. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  164. /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
  165. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  166. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  167. /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
  168. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  169. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  170. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -0,0 +1,104 @@
1
+ import { execCmd } from '../../../../src/utils/exec.js'
2
+ import { getAdbCmd } from '../../../../src/utils/android/utils.js'
3
+ import { createHash } from 'node:crypto'
4
+ import assert from 'node:assert/strict'
5
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
6
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
7
+ import { promises as fs } from 'node:fs'
8
+ import path from 'node:path'
9
+ import { inspectPerfetto } from '../../../helpers/perfetto.js'
10
+
11
+ async function run() {
12
+ if (process.env.SKIP_DEVICE_TESTS === '1') { console.log('System trace smoke: SKIPPED (SKIP_DEVICE_TESTS=1)'); return }
13
+ const client = new Client({ name: 'system-trace-smoke', version: '1.0.0' })
14
+ const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
15
+ const parse = (r: any) => JSON.parse(r.content[0].text)
16
+ const call = async (name: string, args: Record<string, unknown>) => parse(await client.callTool({ name, arguments: args }, undefined, { timeout: 120000 }))
17
+ const owned: string[] = [], artifacts: string[] = []
18
+ try {
19
+ await client.connect(transport)
20
+ const devices = await call('list_devices', {})
21
+ const device = devices.devices.find((d: any) => d.platform === 'android' && d.simulator)
22
+ assert.ok(device, 'A running Android emulator is required')
23
+ const target = { platform: 'android', deviceId: device.id }
24
+ const cap = await call('get_system_trace_capabilities', target)
25
+ assert.equal(cap.supported, true, JSON.stringify(cap))
26
+ const fixtureLaunch = await call('start_app', { ...target, appId: 'com.android.settings', verificationMode: 'none', responseMode: 'compact' })
27
+ assert.equal(fixtureLaunch.success, true)
28
+ const baseline = await call('get_ui_tree', { ...target, responseMode: 'debug' })
29
+ const settingsNode = baseline.elements?.find((e: any) => e.visible && e.resourceId?.startsWith('com.android.settings:id/'))
30
+ assert.ok(settingsNode, 'Fresh baseline must contain a visible Settings-owned UI resource')
31
+ const settingsSelector = { resource_id: settingsNode.resourceId }
32
+ const start = await call('start_system_trace', { ...target, durationMs: 30000 })
33
+ owned.push(start.trace_id)
34
+ assert.equal(start.state, 'recording', JSON.stringify(start))
35
+ const journey = await call('run_journey', { ...target, systemTraceId: start.trace_id,
36
+ steps: [
37
+ { id: 'settings', type: 'start_app', appId: 'com.android.settings', verificationMode: 'none' },
38
+ { id: 'settings-visible', type: 'assert', timeoutMs: 10000, assertion: { kind: 'element_visible', selector: settingsSelector } }
39
+ ] })
40
+ assert.equal(journey.success, true, JSON.stringify(journey))
41
+ assert.equal(journey.steps[1].status, 'passed', 'The baseline Settings-owned UI element must remain semantically visible')
42
+ assert.equal(journey.evidence.trace_context.trace_id, start.trace_id)
43
+ assert.equal(journey.steps[0].result.evidence.trace_context.trace_id, start.trace_id)
44
+ assert.equal(journey.system_trace.coverage, 'covered')
45
+ const stop = await call('stop_system_trace', { traceId: start.trace_id })
46
+ if (stop.artifact) artifacts.push(stop.artifact.path)
47
+ assert.equal(stop.state, 'completed', JSON.stringify(stop)); assert.equal(stop.cleanup.status, 'complete')
48
+ assert.deepEqual(stop.correlation_ids, [journey.evidence.correlation_id])
49
+ const content = inspectPerfetto(await fs.readFile(stop.artifact.path), 30000)
50
+ assert.equal((await call('stop_system_trace', { traceId: start.trace_id })).artifact.path, stop.artifact.path)
51
+ const rejected = await call('run_journey', { ...target, systemTraceId: start.trace_id,
52
+ steps: [{ id: 'must-not-launch', type: 'start_app', appId: 'com.invalid.trace.smoke' }] })
53
+ assert.equal(rejected.error.code, 'TRACE_NOT_FOUND')
54
+ const cancel = await call('start_system_trace', { ...target, durationMs: 30000 })
55
+ owned.push(cancel.trace_id)
56
+ const cancelled = await call('cancel_system_trace', { traceId: cancel.trace_id })
57
+ assert.equal(cancelled.state, 'cancelled', JSON.stringify(cancelled))
58
+ assert.equal((await call('get_system_trace_status', { traceId: cancel.trace_id })).artifact, null)
59
+ const short = await call('start_system_trace', { ...target, durationMs: 1001 })
60
+ owned.push(short.trace_id)
61
+ assert.equal(short.state, 'recording', JSON.stringify(short))
62
+ let ended = short
63
+ for (let i = 0; i < 30 && ended.state === 'recording'; i++) {
64
+ await new Promise(resolve => setTimeout(resolve, 200))
65
+ ended = await call('get_system_trace_status', { traceId: short.trace_id })
66
+ }
67
+ if (ended.artifact) artifacts.push(ended.artifact.path)
68
+ assert.equal(ended.state, 'completed', JSON.stringify(ended)); assert.equal(ended.completion_reason, 'duration_limit')
69
+ const durationContent = inspectPerfetto(await fs.readFile(ended.artifact.path), 1001)
70
+ const shutdownClient = new Client({ name: 'system-trace-shutdown-smoke', version: '1.0.0' })
71
+ const shutdownTransport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
72
+ let shutdownTrace: any
73
+ let shutdownDiagnostics = ''
74
+ try {
75
+ await shutdownClient.connect(shutdownTransport)
76
+ shutdownTransport.stderr?.on('data', data => { shutdownDiagnostics += String(data) })
77
+ shutdownTrace = parse(await shutdownClient.callTool({ name: 'start_system_trace', arguments: { ...target, durationMs: 30000 } }, undefined, { timeout: 120000 }))
78
+ assert.equal(shutdownTrace.state, 'recording')
79
+ } finally { await shutdownClient.close() }
80
+ const remote = shutdownTrace.cleanup.remaining_resources.find((r: string) => r.startsWith('/data/misc/perfetto-traces/mcp-'))
81
+ assert.ok(remote)
82
+ const absent = await execCmd(getAdbCmd(), ['-s', device.id, 'shell', 'test', '!', '-e', remote], { timeout: 10000 })
83
+ assert.equal(absent.exitCode, 0, `Shutdown must stop and remove its owned device capture: ${shutdownTrace.trace_id}; ${shutdownDiagnostics}; ${absent.stderr}`)
84
+ const buildHash = createHash('sha256')
85
+ for (const file of ['dist/server.js', 'dist/observe/system-trace.js', 'dist/observe/system-trace-android.js', 'dist/server/tool-handlers.js', 'dist/observe/evidence.js']) buildHash.update(await fs.readFile(file))
86
+ const report = { status: 'PASS', build_sha256: buildHash.digest('hex'), runtime: process.version, server: client.getServerVersion(), device: device.id, osVersion: device.osVersion,
87
+ starting_state: 'System Settings fixture', journey: 'start Settings, assert baseline Settings-owned resource visible in UI tree; explicit trace association',
88
+ trace_id: start.trace_id, correlation_id: journey.evidence.correlation_id, phases: {
89
+ capture_started_at_ms: stop.capture_started_at_ms, capture_completed_at_ms: stop.capture_completed_at_ms },
90
+ artifact_bytes: stop.artifact.size_bytes, content, durationContent, cancel: 'PASS', expired_journey: 'PASS', active_shutdown: 'PASS',
91
+ cleanup: 'Device capture files removed; test host artifacts removed in finally. No settings or app data changed. Settings remains foreground.' }
92
+ console.log(JSON.stringify(report))
93
+ if (process.env.SYSTEM_TRACE_EVIDENCE_PATH) await fs.writeFile(process.env.SYSTEM_TRACE_EVIDENCE_PATH, JSON.stringify(report, null, 2) + '\n')
94
+ } finally {
95
+ const cleanupErrors: string[] = []
96
+ for (const traceId of owned) {
97
+ try { const r = await call('cancel_system_trace', { traceId }); if (r.artifact && !artifacts.includes(r.artifact.path)) artifacts.push(r.artifact.path); if (r.cleanup?.status !== 'complete') cleanupErrors.push(traceId) } catch { cleanupErrors.push(traceId) }
98
+ }
99
+ await client.close()
100
+ for (const file of artifacts) await fs.rm(path.dirname(file), { recursive: true, force: true })
101
+ assert.deepEqual(cleanupErrors, [], 'Owned trace cleanup failed')
102
+ }
103
+ }
104
+ 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,46 @@
1
+ package dev.mobiledebugmcp.debugfixture;
2
+ import android.app.Activity;
3
+ import android.os.Bundle;
4
+ import android.widget.Button;
5
+ import android.widget.LinearLayout;
6
+ import android.widget.TextView;
7
+
8
+ public class MainActivity extends Activity {
9
+ TextView status;
10
+ @Override public void onCreate(Bundle saved) {
11
+ super.onCreate(saved);
12
+ LinearLayout layout = new LinearLayout(this); layout.setOrientation(1); layout.setPadding(0, 160, 0, 0);
13
+ status = new TextView(this); status.setText("Ready"); layout.addView(status);
14
+ Button button = new Button(this); button.setText("Calculate");
15
+ button.setOnClickListener(v -> calculate(7)); layout.addView(button);
16
+ Button late = new Button(this); late.setText("Late class");
17
+ late.setOnClickListener(v -> {
18
+ try { Class.forName("dev.mobiledebugmcp.debugfixture.LateWork").getDeclaredMethod("work").invoke(null); }
19
+ catch (Exception e) { status.setText("Late failed"); }
20
+ }); layout.addView(late);
21
+ Button noLocals = new Button(this); noLocals.setText("No locals");
22
+ noLocals.setOnClickListener(v -> {
23
+ try { Class.forName("dev.mobiledebugmcp.debugfixture.NoLocals").getDeclaredMethod("work").invoke(null); }
24
+ catch (Exception e) { status.setText("Metadata failed"); }
25
+ }); layout.addView(noLocals);
26
+ setContentView(layout);
27
+ }
28
+ void calculate(int seed) {
29
+ int known = seed + 35;
30
+ String longText = new String(new char[300]).replace('\0', 'x');
31
+ Object object = new Object();
32
+ int answer = increment(known); // BREAK_CALCULATE
33
+ status.setText("Result " + answer); // BREAK_RESULT
34
+ }
35
+ int increment(int value) {
36
+ int result = value + 1; // BREAK_INCREMENT
37
+ return result;
38
+ }
39
+ }
40
+
41
+ class LateWork {
42
+ public static void work() {
43
+ int local = 99; // BREAK_LATE
44
+ android.util.Log.i("DebugFixture", "late " + local);
45
+ }
46
+ }
@@ -0,0 +1,7 @@
1
+ package dev.mobiledebugmcp.debugfixture;
2
+ public class NoLocals {
3
+ public static void work() {
4
+ int hidden = 5; // BREAK_NO_LOCALS
5
+ android.util.Log.i("DebugFixture", "metadata " + hidden);
6
+ }
7
+ }
@@ -0,0 +1,13 @@
1
+ # Isolated Android debugger fixture
2
+
3
+ Package `dev.mobiledebugmcp.debugfixture`, built with `android:debuggable=true`
4
+ and Java source/line/local metadata. Calculate exposes `known=42`, an increment
5
+ call for into/over/out stepping, and a visible `Result 43` after resume. Late class
6
+ loads a second class reflectively to check pending source breakpoints. NoLocals
7
+ is compiled with source/line tables and no variable tables to verify explicit ART
8
+ metadata uncertainty.
9
+
10
+ Build helper uses SDK platform/build-tools 36, the existing direct javac/D8/aapt2
11
+ pattern, ephemeral signing material and temporary output. It does not modify other
12
+ apps or display settings. The automated journey installs/removes only this fixture
13
+ and checks session-owned ADB forwards. See [debugger tools](../../../docs/tools/debugger.md).
@@ -0,0 +1,29 @@
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
+ export const debuggerAppId = 'dev.mobiledebugmcp.debugfixture'
7
+ export function buildDebuggerFixture() {
8
+ const source = path.dirname(fileURLToPath(import.meta.url))
9
+ const directory = mkdtempSync(path.join(tmpdir(), 'mcp-debugger-fixture-'))
10
+ const java = process.env.JAVA_HOME ?? (existsSync('/Applications/Android Studio.app/Contents/jbr/Contents/Home') ? '/Applications/Android Studio.app/Contents/jbr/Contents/Home' : undefined)
11
+ const run = (cmd: string, args: string[]) => execFileSync(cmd, args, { cwd: directory, encoding: 'utf8', stdio: 'pipe' })
12
+ const sdk = process.env.ANDROID_HOME ?? path.join(process.env.HOME!, 'Library/Android/sdk')
13
+ const tools = path.join(sdk, 'build-tools/36.0.0')
14
+ const androidJar = path.join(sdk, 'platforms/android-36/android.jar')
15
+ const jdk = (name: string) => java ? path.join(java, 'bin', name) : name
16
+ mkdirSync(path.join(directory, 'classes'))
17
+ writeFileSync(path.join(directory, 'AndroidManifest.xml'), `<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="${debuggerAppId}" android:versionCode="1" android:versionName="1.0"><uses-sdk android:minSdkVersion="26" android:targetSdkVersion="36"/><application android:debuggable="true" android:label="Debugger 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>`)
18
+ run(jdk('javac'), ['-g', '--release', '8', '-classpath', androidJar, '-d', 'classes', path.join(source, 'MainActivity.java')])
19
+ run(jdk('javac'), ['-g:source,lines', '--release', '8', '-classpath', androidJar, '-d', 'classes', path.join(source, 'NoLocals.java')])
20
+ run(jdk('jar'), ['cf', 'classes.jar', '-C', 'classes', '.'])
21
+ run(path.join(tools, 'd8'), ['--lib', androidJar, '--output', directory, 'classes.jar'])
22
+ run(path.join(tools, 'aapt2'), ['link', '-I', androidJar, '--manifest', 'AndroidManifest.xml', '-o', 'unsigned.apk'])
23
+ run('zip', ['-q', 'unsigned.apk', 'classes.dex'])
24
+ run(path.join(tools, 'zipalign'), ['-f', '4', 'unsigned.apk', 'aligned.apk'])
25
+ run(jdk('keytool'), ['-genkeypair', '-keystore', 'fixture.jks', '-storepass', 'android', '-keypass', 'android', '-alias', 'fixture', '-dname', 'CN=Readiness Fixture', '-keyalg', 'RSA', '-validity', '365'])
26
+ const artifact = path.join(directory, 'Debugger.apk')
27
+ run(path.join(tools, 'apksigner'), ['sign', '--ks', 'fixture.jks', '--ks-pass', 'pass:android', '--out', artifact, 'aligned.apk'])
28
+ return { artifact, directory }
29
+ }
@@ -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)); } }