mobile-debug-mcp 0.23.0 → 0.24.1

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.
@@ -1,10 +1,20 @@
1
1
  # Observe (logs, screenshots, UI trees)
2
2
 
3
- Tools that retrieve device state, logs, screenshots and UI hierarchies.
3
+ Tools that retrieve device state, logs, screenshots, fingerprints, and UI hierarchies.
4
+
5
+ These tools are primarily for:
6
+
7
+ - building context before an action
8
+ - supporting synchronization
9
+ - diagnostics when verification fails
10
+
11
+ They are **not** the primary success signal when an applicable `expect_*` tool exists.
4
12
 
5
13
  ## get_logs
6
14
 
7
- Fetch recent logs as structured entries optimized for AI agents. Use logs as a debugging aid only — prefer UI validation (wait_for_ui) first.
15
+ Fetch recent logs as structured entries optimized for AI agents.
16
+
17
+ Use logs as a debugging aid only. Prefer `expect_*` for verification, and use logs after verification fails or when an error is suspected.
8
18
 
9
19
  Input (example):
10
20
 
@@ -18,7 +28,7 @@ Defaults:
18
28
 
19
29
  When to use get_logs:
20
30
 
21
- - After a UI validation (wait_for_ui) fails to confirm the expected outcome.
31
+ - After deterministic verification fails.
22
32
  - When you suspect a crash, error, or silent failure that the UI doesn't expose.
23
33
  - To provide additional debugging context correlated with an action.
24
34
 
@@ -41,7 +51,7 @@ Notes:
41
51
  - Errors are returned as structured objects with `error.code` and `error.message`. Possible codes: LOGS_UNAVAILABLE, INVALID_FILTER, PLATFORM_NOT_SUPPORTED, INTERNAL_ERROR.
42
52
 
43
53
  ## capture_screenshot
44
- Capture screen. Returns JSON metadata then an image/png block with base64 PNG data.
54
+ Capture the current screen. Returns JSON metadata followed by one or more image blocks.
45
55
 
46
56
  Input:
47
57
 
@@ -52,13 +62,17 @@ Input:
52
62
  Response (metadata):
53
63
 
54
64
  ```json
55
- { "device": { "platform": "android", "id": "emulator-5554" }, "width": 1080, "height": 2400 }
65
+ { "device": { "platform": "android", "id": "emulator-5554" }, "result": { "resolution": { "width": 1080, "height": 2400 }, "mimeType": "image/webp" } }
56
66
  ```
57
67
 
68
+ Notes:
69
+ - The image block may use WebP, PNG, or a compatibility fallback such as JPEG.
70
+ - Best used for inspection and debugging, not as a primary verification mechanism.
71
+
58
72
  ---
59
73
 
60
74
  ## get_ui_tree
61
- Returns parsed UI hierarchy.
75
+ Return the parsed UI hierarchy for the current screen.
62
76
 
63
77
  Input:
64
78
 
@@ -69,9 +83,13 @@ Input:
69
83
  Response (example):
70
84
 
71
85
  ```json
72
- { "device": { "platform": "android", "id": "emulator-5554" }, "elements": [ { "text": "Sign in", "type": "android.widget.Button", "resourceId": "com.example:id/signin", "clickable": true, "bounds": [0,0,100,50] } ] }
86
+ { "device": { "platform": "android", "id": "emulator-5554" }, "screen": "", "resolution": { "width": 1080, "height": 2400 }, "elements": [ { "text": "Sign in", "type": "android.widget.Button", "resourceId": "com.example:id/signin", "clickable": true, "bounds": [0,0,100,50] } ] }
73
87
  ```
74
88
 
89
+ Notes:
90
+ - Useful for inspection, selector development, and fallback debugging.
91
+ - Prefer `wait_for_ui` for deterministic element resolution in interactive flows.
92
+
75
93
  ---
76
94
 
77
95
  ## get_current_screen
@@ -136,7 +154,7 @@ Notes:
136
154
  ---
137
155
 
138
156
  ## get_screen_fingerprint
139
- Generate a stable fingerprint representing the visible screen. Useful for detecting navigation changes, preventing loops, and synchronisation.
157
+ Generate a stable fingerprint representing the visible screen. Useful for detecting navigation changes, preventing loops, and synchronization.
140
158
 
141
159
  Input (optional):
142
160
 
@@ -157,6 +175,10 @@ Notes:
157
175
  - Sorts deterministically (top-to-bottom, left-to-right) and limits elements to 50.
158
176
  - Returns fingerprint: null and an error message if the UI tree or activity cannot be retrieved.
159
177
 
178
+ Guidance:
179
+ - Use as a baseline for `wait_for_screen_change`.
180
+ - Use fingerprints to define expected screens for `expect_screen`.
181
+
160
182
  ---
161
183
 
162
184
  ## start_log_stream / read_log_stream / stop_log_stream
@@ -44,5 +44,5 @@ Behavior notes:
44
44
 
45
45
  Usage guidance:
46
46
  - Call before build/install flows to avoid wasted build attempts on misconfigured systems.
47
+ - Call early in a session when device or toolchain availability is uncertain.
47
48
  - If `success: false`, attempt recovery steps or report issues to the user.
48
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,7 +5,14 @@ export { AndroidInteract, iOSInteract };
5
5
 
6
6
  import { resolveTargetDevice } from '../utils/resolve-device.js'
7
7
  import { ToolsObserve } from '../observe/index.js'
8
- import type { TapElementResponse } from '../types.js'
8
+ import { nextActionId } from '../server/common.js'
9
+ import type {
10
+ ActionFailureCode,
11
+ ActionTargetResolved,
12
+ ExpectElementVisibleResponse,
13
+ ExpectScreenResponse,
14
+ TapElementResponse
15
+ } from '../types.js'
9
16
 
10
17
  interface ScreenFingerprintResponse { fingerprint: string | null }
11
18
 
@@ -112,6 +119,55 @@ export class ToolsInteract {
112
119
  }
113
120
  }
114
121
 
122
+ private static async _captureFingerprint(platform: 'android' | 'ios', deviceId?: string): Promise<string | null> {
123
+ try {
124
+ const fingerprint = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
125
+ return fingerprint?.fingerprint ?? null
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+
131
+ private static _resolvedTargetFromElement(
132
+ elementId: string,
133
+ element: UiElement,
134
+ index: number
135
+ ): ActionTargetResolved {
136
+ return {
137
+ elementId,
138
+ text: element.text ?? null,
139
+ resource_id: element.resourceId ?? element.resourceID ?? element.id ?? null,
140
+ accessibility_id: element.contentDescription ?? element.contentDesc ?? element.accessibilityLabel ?? element.label ?? null,
141
+ class: element.type ?? element.class ?? null,
142
+ bounds: ToolsInteract._normalizeBounds(element.bounds),
143
+ index
144
+ }
145
+ }
146
+
147
+ private static _actionFailure(
148
+ actionId: string,
149
+ timestamp: number,
150
+ actionType: string,
151
+ selector: Record<string, unknown> | null,
152
+ resolved: ActionTargetResolved | null,
153
+ failureCode: ActionFailureCode,
154
+ retryable: boolean,
155
+ uiFingerprintBefore: string | null,
156
+ uiFingerprintAfter?: string | null
157
+ ): TapElementResponse {
158
+ return {
159
+ action_id: actionId,
160
+ timestamp,
161
+ action_type: actionType,
162
+ target: { selector, resolved },
163
+ success: false,
164
+ failure_code: failureCode,
165
+ retryable,
166
+ ui_fingerprint_before: uiFingerprintBefore,
167
+ ui_fingerprint_after: uiFingerprintAfter
168
+ }
169
+ }
170
+
115
171
  static _resetResolvedUiElementsForTests() {
116
172
  ToolsInteract._resolvedUiElements.clear()
117
173
  }
@@ -198,20 +254,17 @@ export class ToolsInteract {
198
254
  }
199
255
 
200
256
  static async tapElementHandler({ elementId }: { elementId: string }): Promise<TapElementResponse> {
201
- const action = 'tap' as const
257
+ const timestamp = Date.now()
258
+ const actionType = 'tap_element'
259
+ const actionId = nextActionId(actionType, timestamp)
260
+ const selector = { elementId }
202
261
  const resolved = ToolsInteract._resolvedUiElements.get(elementId)
203
262
  if (!resolved) {
204
- return {
205
- success: false,
206
- elementId,
207
- action,
208
- error: {
209
- code: 'element_not_found',
210
- message: 'Element ID was not found in the current UI context'
211
- }
212
- }
263
+ return ToolsInteract._actionFailure(actionId, timestamp, actionType, selector, null, 'STALE_REFERENCE', true, null)
213
264
  }
214
265
 
266
+ const fingerprintBefore = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
267
+
215
268
  const tree = await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId }) as any
216
269
  const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : resolved.platform
217
270
  const treeDeviceId = tree?.device?.id || resolved.deviceId
@@ -219,52 +272,22 @@ export class ToolsInteract {
219
272
  const currentMatch = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved)
220
273
 
221
274
  if (!currentMatch) {
222
- return {
223
- success: false,
224
- elementId,
225
- action,
226
- error: {
227
- code: 'element_not_found',
228
- message: 'Element ID is not present in the current UI context'
229
- }
230
- }
275
+ return ToolsInteract._actionFailure(actionId, timestamp, actionType, selector, null, 'STALE_REFERENCE', true, fingerprintBefore)
231
276
  }
232
277
 
278
+ const resolvedTarget = ToolsInteract._resolvedTargetFromElement(resolved.elementId, currentMatch.el, currentMatch.index)
279
+
233
280
  if (!ToolsInteract._isVisibleElement(currentMatch.el)) {
234
- return {
235
- success: false,
236
- elementId,
237
- action,
238
- error: {
239
- code: 'element_not_visible',
240
- message: 'Element is not visible'
241
- }
242
- }
281
+ return ToolsInteract._actionFailure(actionId, timestamp, actionType, selector, resolvedTarget, 'ELEMENT_NOT_INTERACTABLE', true, fingerprintBefore)
243
282
  }
244
283
 
245
284
  if (currentMatch.el.enabled === false) {
246
- return {
247
- success: false,
248
- elementId,
249
- action,
250
- error: {
251
- code: 'element_not_enabled',
252
- message: 'Element is not enabled'
253
- }
254
- }
285
+ return ToolsInteract._actionFailure(actionId, timestamp, actionType, selector, resolvedTarget, 'ELEMENT_NOT_INTERACTABLE', true, fingerprintBefore)
255
286
  }
256
287
 
257
288
  const bounds = ToolsInteract._normalizeBounds(currentMatch.el.bounds) ?? resolved.bounds
258
289
  if (!bounds || bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) {
259
- return {
260
- success: false,
261
- elementId,
262
- action,
263
- error: {
264
- code: 'element_not_visible',
265
- message: 'Element does not have valid visible bounds'
266
- }
267
- }
290
+ return ToolsInteract._actionFailure(actionId, timestamp, actionType, selector, resolvedTarget, 'ELEMENT_NOT_INTERACTABLE', true, fingerprintBefore)
268
291
  }
269
292
 
270
293
  const x = Math.floor((bounds[0] + bounds[2]) / 2)
@@ -272,21 +295,22 @@ export class ToolsInteract {
272
295
  const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId })
273
296
 
274
297
  if (!tapResult.success) {
275
- return {
276
- success: false,
277
- elementId,
278
- action,
279
- error: {
280
- code: 'tap_failed',
281
- message: tapResult.error || 'Tap failed'
282
- }
283
- }
298
+ const fingerprintAfterFailure = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
299
+ return ToolsInteract._actionFailure(actionId, timestamp, actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure)
284
300
  }
285
301
 
302
+ const fingerprintAfter = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
286
303
  return {
304
+ action_id: actionId,
305
+ timestamp,
306
+ action_type: actionType,
307
+ target: {
308
+ selector,
309
+ resolved: resolvedTarget
310
+ },
287
311
  success: true,
288
- elementId,
289
- action
312
+ ui_fingerprint_before: fingerprintBefore,
313
+ ui_fingerprint_after: fingerprintAfter
290
314
  }
291
315
  }
292
316
 
@@ -692,6 +716,110 @@ export class ToolsInteract {
692
716
  return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
693
717
  }
694
718
 
719
+ static async expectScreenHandler({
720
+ platform,
721
+ fingerprint,
722
+ screen,
723
+ deviceId
724
+ }: {
725
+ platform?: 'android' | 'ios',
726
+ fingerprint?: string,
727
+ screen?: string,
728
+ deviceId?: string
729
+ }): Promise<ExpectScreenResponse> {
730
+ const observedFingerprint = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as any
731
+ const observedScreen = {
732
+ fingerprint: observedFingerprint?.fingerprint ?? null,
733
+ screen: observedFingerprint?.activity ?? null
734
+ }
735
+
736
+ let observedScreenLabel = observedScreen.screen
737
+ if (!fingerprint && screen && platform !== 'ios') {
738
+ try {
739
+ const current = await ToolsObserve.getCurrentScreenHandler({ deviceId }) as any
740
+ observedScreenLabel = current?.shortActivity || current?.activity || observedScreenLabel
741
+ } catch {
742
+ // Keep fingerprint-derived activity when current-screen lookup is unavailable.
743
+ }
744
+ }
745
+
746
+ const expectedScreen = {
747
+ fingerprint: fingerprint ?? null,
748
+ screen: screen ?? null
749
+ }
750
+
751
+ let success = false
752
+ if (fingerprint) {
753
+ success = observedScreen.fingerprint === fingerprint
754
+ } else if (screen) {
755
+ const candidates = new Set<string>()
756
+ if (observedScreen.screen) candidates.add(observedScreen.screen)
757
+ if (observedScreenLabel) candidates.add(observedScreenLabel)
758
+ success = candidates.has(screen)
759
+ }
760
+
761
+ return {
762
+ success,
763
+ observed_screen: {
764
+ fingerprint: observedScreen.fingerprint,
765
+ screen: observedScreenLabel
766
+ },
767
+ expected_screen: expectedScreen,
768
+ confidence: success ? 1 : 0
769
+ }
770
+ }
771
+
772
+ static async expectElementVisibleHandler({
773
+ selector,
774
+ element_id,
775
+ timeout_ms = 5000,
776
+ poll_interval_ms = 300,
777
+ platform,
778
+ deviceId
779
+ }: {
780
+ selector: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean },
781
+ element_id?: string,
782
+ timeout_ms?: number,
783
+ poll_interval_ms?: number,
784
+ platform?: 'android' | 'ios',
785
+ deviceId?: string
786
+ }): Promise<ExpectElementVisibleResponse> {
787
+ const result = await ToolsInteract.waitForUIHandler({
788
+ selector,
789
+ condition: 'visible',
790
+ timeout_ms,
791
+ poll_interval_ms,
792
+ platform,
793
+ deviceId
794
+ }) as any
795
+
796
+ if (result?.status === 'success' && result?.element) {
797
+ return {
798
+ success: true,
799
+ selector,
800
+ element_id: result.element.elementId ?? element_id ?? null,
801
+ element: {
802
+ elementId: result.element.elementId ?? null,
803
+ text: result.element.text ?? null,
804
+ resource_id: result.element.resource_id ?? null,
805
+ accessibility_id: result.element.accessibility_id ?? null,
806
+ class: result.element.class ?? null,
807
+ bounds: result.element.bounds ?? null,
808
+ index: typeof result.element.index === 'number' ? result.element.index : null
809
+ }
810
+ }
811
+ }
812
+
813
+ const errorCode = result?.error?.code === 'INTERNAL_ERROR' ? 'UNKNOWN' : 'TIMEOUT'
814
+ return {
815
+ success: false,
816
+ selector,
817
+ element_id: element_id ?? null,
818
+ failure_code: errorCode,
819
+ retryable: errorCode === 'TIMEOUT'
820
+ }
821
+ }
822
+
695
823
  static async waitForUICore({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }: { type?: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, includeSnapshotOnFailure?: boolean, match?: 'present'|'absent', stability_ms?: number, observationDelayMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
696
824
  const start = Date.now()
697
825
  const deadline = start + (timeoutMs || 0)
@@ -8,6 +8,10 @@ import { detectJavaHome } from '../utils/java.js'
8
8
  import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
9
9
 
10
10
  export class AndroidManage {
11
+ private isTestOnlyInstallFailure(output: string | undefined): boolean {
12
+ return typeof output === 'string' && output.includes('INSTALL_FAILED_TEST_ONLY')
13
+ }
14
+
11
15
  async build(projectPath: string, _variant?: string): Promise<{ artifactPath: string, output?: string } | { error: string }> {
12
16
  void _variant
13
17
  try {
@@ -92,6 +96,14 @@ export class AndroidManage {
92
96
  if (res.code === 0) {
93
97
  return { device: deviceInfo, installed: true, output: res.stdout }
94
98
  }
99
+
100
+ const installOutput = `${res.stdout}\n${res.stderr}`.trim()
101
+ if (this.isTestOnlyInstallFailure(installOutput)) {
102
+ const retryRes = await spawnAdb(['install', '-r', '-t', apkToInstall], deviceId)
103
+ if (retryRes.code === 0) {
104
+ return { device: deviceInfo, installed: true, output: retryRes.stdout }
105
+ }
106
+ }
95
107
  } catch (e) {
96
108
  console.debug('[android-run] adb install failed, attempting push+pm fallback:', e instanceof Error ? e.message : String(e))
97
109
  }
@@ -99,9 +111,21 @@ export class AndroidManage {
99
111
  const basename = path.basename(apkToInstall)
100
112
  const remotePath = `/data/local/tmp/${basename}`
101
113
  await execAdb(['push', apkToInstall, remotePath], deviceId)
102
- const pmOut = await execAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId)
103
- try { await execAdb(['shell', 'rm', remotePath], deviceId) } catch {}
104
- return { device: deviceInfo, installed: true, output: pmOut }
114
+ let finalPmRes = await spawnAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId)
115
+ try {
116
+ if (finalPmRes.code === 0) {
117
+ return { device: deviceInfo, installed: true, output: finalPmRes.stdout }
118
+ }
119
+ if (this.isTestOnlyInstallFailure(`${finalPmRes.stdout}\n${finalPmRes.stderr}`)) {
120
+ finalPmRes = await spawnAdb(['shell', 'pm', 'install', '-r', '-t', remotePath], deviceId)
121
+ if (finalPmRes.code === 0) {
122
+ return { device: deviceInfo, installed: true, output: finalPmRes.stdout }
123
+ }
124
+ }
125
+ throw new Error(finalPmRes.stderr || finalPmRes.stdout || 'pm install failed')
126
+ } finally {
127
+ try { await execAdb(['shell', 'rm', remotePath], deviceId) } catch {}
128
+ }
105
129
  } catch (e) {
106
130
  // gather diagnostics for attempted adb operations
107
131
  const basename = path.basename(apkToInstall)
@@ -0,0 +1,95 @@
1
+ import type {
2
+ ActionExecutionResult,
3
+ ActionFailureCode,
4
+ ActionTargetResolved
5
+ } from '../types.js'
6
+ import { ToolsObserve } from '../observe/index.js'
7
+
8
+ export function wrapResponse<T>(data: T) {
9
+ return {
10
+ content: [{
11
+ type: 'text' as const,
12
+ text: JSON.stringify(data, null, 2)
13
+ }]
14
+ }
15
+ }
16
+
17
+ export type ToolCallArgs = Record<string, unknown>
18
+ export type ToolCallResult = Awaited<ReturnType<typeof wrapResponse>> | {
19
+ content: Array<{ type: 'text' | 'image'; text?: string; data?: string; mimeType?: string }>
20
+ }
21
+ export type ToolHandler = (args: ToolCallArgs) => Promise<ToolCallResult>
22
+
23
+ let actionSequence = 0
24
+
25
+ export function nextActionId(actionType: string, timestamp: number) {
26
+ actionSequence += 1
27
+ return `${actionType}_${timestamp}_${actionSequence}`
28
+ }
29
+
30
+ export async function captureActionFingerprint(platform?: 'android' | 'ios', deviceId?: string): Promise<string | null> {
31
+ if (!platform) return null
32
+ try {
33
+ const result = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as any
34
+ return result?.fingerprint ?? null
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ export function normalizeResolvedTarget(value: Partial<ActionTargetResolved> | null = null): ActionTargetResolved | null {
41
+ if (!value) return null
42
+ return {
43
+ elementId: value.elementId ?? null,
44
+ text: value.text ?? null,
45
+ resource_id: value.resource_id ?? null,
46
+ accessibility_id: value.accessibility_id ?? null,
47
+ class: value.class ?? null,
48
+ bounds: value.bounds ?? null,
49
+ index: value.index ?? null
50
+ }
51
+ }
52
+
53
+ export function inferGenericFailure(message: string | undefined): { failureCode: ActionFailureCode; retryable: boolean } {
54
+ if (message && /timeout/i.test(message)) return { failureCode: 'TIMEOUT', retryable: true }
55
+ return { failureCode: 'UNKNOWN', retryable: false }
56
+ }
57
+
58
+ export function inferScrollFailure(message: string | undefined): { failureCode: ActionFailureCode; retryable: boolean } {
59
+ if (message && /unchanged|no change|end of list/i.test(message)) return { failureCode: 'NAVIGATION_NO_CHANGE', retryable: true }
60
+ if (message && /timeout/i.test(message)) return { failureCode: 'TIMEOUT', retryable: true }
61
+ return { failureCode: 'UNKNOWN', retryable: false }
62
+ }
63
+
64
+ export function buildActionExecutionResult({
65
+ actionType,
66
+ selector,
67
+ resolved,
68
+ success,
69
+ uiFingerprintBefore,
70
+ uiFingerprintAfter,
71
+ failure
72
+ }: {
73
+ actionType: string
74
+ selector: Record<string, unknown> | null
75
+ resolved?: Partial<ActionTargetResolved> | null
76
+ success: boolean
77
+ uiFingerprintBefore: string | null
78
+ uiFingerprintAfter: string | null
79
+ failure?: { failureCode: ActionFailureCode; retryable: boolean }
80
+ }): ActionExecutionResult {
81
+ const timestamp = Date.now()
82
+ return {
83
+ action_id: nextActionId(actionType, timestamp),
84
+ timestamp,
85
+ action_type: actionType,
86
+ target: {
87
+ selector,
88
+ resolved: normalizeResolvedTarget(resolved)
89
+ },
90
+ success,
91
+ ...(failure ? { failure_code: failure.failureCode, retryable: failure.retryable } : {}),
92
+ ui_fingerprint_before: uiFingerprintBefore,
93
+ ui_fingerprint_after: uiFingerprintAfter
94
+ }
95
+ }