mobile-debug-mcp 0.24.8 → 0.25.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Mobile Debug Tools
2
2
 
3
- A minimal, secure MCP server for AI-assisted mobile development. Build, install, and inspect Android/iOS apps from an MCP-compatible client.
3
+ A minimal, secure MCP server for AI-assisted mobile development. Build, install, interact and inspect Android/iOS apps from an MCP-compatible client.
4
4
 
5
5
  > **Support:**
6
6
  > * KMP
@@ -34,6 +34,43 @@ export class ToolsInteract {
34
34
  return null;
35
35
  return normalized;
36
36
  }
37
+ static _matchesSelector(el, selector) {
38
+ if (!selector)
39
+ return false;
40
+ const normalize = ToolsInteract._normalize;
41
+ const containsFlag = !!selector.contains;
42
+ const text = normalize(el.text ?? el.label ?? el.value ?? '');
43
+ const resourceId = normalize(el.resourceId ?? el.resourceID ?? el.id ?? '');
44
+ const accessibilityId = normalize(el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? el.label ?? '');
45
+ if (selector.text !== undefined && selector.text !== null) {
46
+ const q = normalize(selector.text);
47
+ if (containsFlag ? !text.includes(q) : text !== q)
48
+ return false;
49
+ }
50
+ if (selector.resource_id !== undefined && selector.resource_id !== null) {
51
+ const q = normalize(selector.resource_id);
52
+ if (containsFlag ? !resourceId.includes(q) : resourceId !== q)
53
+ return false;
54
+ }
55
+ if (selector.accessibility_id !== undefined && selector.accessibility_id !== null) {
56
+ const q = normalize(selector.accessibility_id);
57
+ if (containsFlag ? !accessibilityId.includes(q) : accessibilityId !== q)
58
+ return false;
59
+ }
60
+ return true;
61
+ }
62
+ static _findFirstMatchingElement(elements, selector) {
63
+ if (!selector)
64
+ return null;
65
+ for (let i = 0; i < elements.length; i++) {
66
+ const el = elements[i];
67
+ if (!el)
68
+ continue;
69
+ if (ToolsInteract._matchesSelector(el, selector))
70
+ return { el, idx: i };
71
+ }
72
+ return null;
73
+ }
37
74
  static _isVisibleElement(el) {
38
75
  const bounds = ToolsInteract._normalizeBounds(el.bounds);
39
76
  return !!el.visible && !!bounds && bounds[2] > bounds[0] && bounds[3] > bounds[1];
@@ -100,7 +137,8 @@ export class ToolsInteract {
100
137
  accessibility_id: element.contentDescription ?? element.contentDesc ?? element.accessibilityLabel ?? element.label ?? null,
101
138
  class: element.type ?? element.class ?? null,
102
139
  bounds: ToolsInteract._normalizeBounds(element.bounds),
103
- index
140
+ index,
141
+ state: element.state ?? null
104
142
  };
105
143
  }
106
144
  static _actionFailure(actionId, timestamp, actionType, selector, resolved, failureCode, retryable, uiFingerprintBefore, uiFingerprintAfter) {
@@ -901,7 +939,8 @@ export class ToolsInteract {
901
939
  accessibility_id: result.element.accessibility_id ?? null,
902
940
  class: result.element.class ?? null,
903
941
  bounds: result.element.bounds ?? null,
904
- index: typeof result.element.index === 'number' ? result.element.index : null
942
+ index: typeof result.element.index === 'number' ? result.element.index : null,
943
+ state: result.element.state ?? null
905
944
  },
906
945
  observed: {
907
946
  status: result.status,
@@ -915,7 +954,8 @@ export class ToolsInteract {
915
954
  accessibility_id: result.element.accessibility_id ?? null,
916
955
  class: result.element.class ?? null,
917
956
  bounds: result.element.bounds ?? null,
918
- index: typeof result.element.index === 'number' ? result.element.index : null
957
+ index: typeof result.element.index === 'number' ? result.element.index : null,
958
+ state: result.element.state ?? null
919
959
  }
920
960
  },
921
961
  reason: 'selector is visible'
@@ -939,6 +979,176 @@ export class ToolsInteract {
939
979
  retryable: errorCode === 'TIMEOUT'
940
980
  };
941
981
  }
982
+ static async expectStateHandler({ selector, element_id, property, expected, platform, deviceId }) {
983
+ const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
984
+ const elements = Array.isArray(tree?.elements) ? tree.elements : [];
985
+ const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android');
986
+ const treeDeviceId = tree?.device?.id || deviceId;
987
+ let matched = null;
988
+ if (element_id) {
989
+ const resolved = ToolsInteract._resolvedUiElements.get(element_id);
990
+ if (resolved) {
991
+ const current = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved);
992
+ if (current)
993
+ matched = { el: current.el, idx: current.index };
994
+ }
995
+ }
996
+ if (!matched && selector) {
997
+ matched = ToolsInteract._findFirstMatchingElement(elements, selector);
998
+ }
999
+ if (!matched) {
1000
+ return {
1001
+ success: false,
1002
+ selector,
1003
+ element_id: element_id ?? null,
1004
+ expected_state: { property, expected },
1005
+ reason: 'element not found',
1006
+ failure_code: 'ELEMENT_NOT_FOUND',
1007
+ retryable: true
1008
+ };
1009
+ }
1010
+ const resolvedElement = ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(treePlatform, treeDeviceId, matched.el, matched.idx), matched.el, matched.idx);
1011
+ const observedState = matched.el.state ?? null;
1012
+ const actual = observedState?.[property] ?? null;
1013
+ const compareBoolean = (value) => typeof value === 'boolean' ? value : null;
1014
+ const compareString = (value) => typeof value === 'string' ? value : null;
1015
+ const compareNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
1016
+ let success = false;
1017
+ let reason = '';
1018
+ let rawValue = null;
1019
+ let observedValue = actual;
1020
+ switch (property) {
1021
+ case 'checked':
1022
+ case 'focused':
1023
+ case 'expanded':
1024
+ case 'enabled': {
1025
+ const expectedBool = compareBoolean(expected);
1026
+ const actualBool = compareBoolean(actual);
1027
+ if (expectedBool === null) {
1028
+ reason = `expected ${property} must be boolean`;
1029
+ }
1030
+ else if (actualBool === null) {
1031
+ reason = `${property} state unavailable`;
1032
+ }
1033
+ else {
1034
+ rawValue = actualBool;
1035
+ success = actualBool === expectedBool;
1036
+ reason = success ? `${property} matches expected value` : `expected ${property}=${expectedBool} but observed ${actualBool}`;
1037
+ }
1038
+ observedValue = actualBool;
1039
+ break;
1040
+ }
1041
+ case 'value':
1042
+ case 'raw_value': {
1043
+ const expectedNumber = compareNumber(expected);
1044
+ const actualNumber = compareNumber(actual);
1045
+ if (expectedNumber !== null && actualNumber !== null) {
1046
+ success = actualNumber === expectedNumber;
1047
+ rawValue = actualNumber;
1048
+ observedValue = actualNumber;
1049
+ reason = success ? 'value matches expected value' : `expected value=${expectedNumber} but observed ${actualNumber}`;
1050
+ break;
1051
+ }
1052
+ const expectedString = typeof expected === 'string' ? expected : null;
1053
+ const actualString = compareString(actual);
1054
+ if (expectedString !== null && actualString !== null) {
1055
+ success = actualString === expectedString;
1056
+ rawValue = actualString;
1057
+ observedValue = actualString;
1058
+ reason = success ? 'value matches expected value' : `expected value=${expectedString} but observed ${actualString}`;
1059
+ }
1060
+ else {
1061
+ reason = 'value state unavailable';
1062
+ }
1063
+ break;
1064
+ }
1065
+ case 'selected': {
1066
+ const expectedBool = typeof expected === 'boolean' ? expected : null;
1067
+ const expectedString = typeof expected === 'string'
1068
+ ? expected
1069
+ : expected && typeof expected === 'object'
1070
+ ? String(expected.id ?? expected.label ?? '')
1071
+ : null;
1072
+ if (!observedState || observedState.selected === undefined || observedState.selected === null) {
1073
+ reason = 'selected state unavailable';
1074
+ break;
1075
+ }
1076
+ if (expectedBool !== null) {
1077
+ const actualBool = typeof observedState.selected === 'boolean' ? observedState.selected : null;
1078
+ if (actualBool === null) {
1079
+ reason = 'selected state is not boolean';
1080
+ break;
1081
+ }
1082
+ rawValue = actualBool;
1083
+ observedValue = actualBool;
1084
+ success = actualBool === expectedBool;
1085
+ reason = success ? 'selected matches expected value' : `expected selected=${expectedBool} but observed ${actualBool}`;
1086
+ break;
1087
+ }
1088
+ const actualSelected = typeof observedState.selected === 'object' && observedState.selected !== null
1089
+ ? String(observedState.selected.id ?? observedState.selected.label ?? '')
1090
+ : String(observedState.selected);
1091
+ const actualString = actualSelected.trim();
1092
+ if (!expectedString) {
1093
+ reason = 'expected selected must be boolean, string, or object with id/label';
1094
+ break;
1095
+ }
1096
+ rawValue = actualString;
1097
+ observedValue = actualString;
1098
+ success = actualString === expectedString;
1099
+ reason = success ? 'selected matches expected value' : `expected selected=${expectedString} but observed ${actualString}`;
1100
+ break;
1101
+ }
1102
+ case 'text_value': {
1103
+ const expectedString = typeof expected === 'string' ? expected : null;
1104
+ const actualString = compareString(actual);
1105
+ if (!expectedString) {
1106
+ reason = 'expected text_value must be string';
1107
+ }
1108
+ else if (!actualString) {
1109
+ reason = 'text_value state unavailable';
1110
+ }
1111
+ else {
1112
+ success = actualString === expectedString;
1113
+ rawValue = actualString;
1114
+ observedValue = actualString;
1115
+ reason = success ? 'text_value matches expected value' : `expected text_value=${expectedString} but observed ${actualString}`;
1116
+ }
1117
+ break;
1118
+ }
1119
+ default: {
1120
+ if (actual !== null && actual !== undefined) {
1121
+ success = actual === expected;
1122
+ observedValue = actual;
1123
+ rawValue = typeof actual === 'string' || typeof actual === 'number' || typeof actual === 'boolean' ? actual : null;
1124
+ reason = success ? `${property} matches expected value` : `expected ${property} to match but observed ${String(actual)}`;
1125
+ }
1126
+ else {
1127
+ reason = `unsupported or unavailable state property: ${property}`;
1128
+ }
1129
+ }
1130
+ }
1131
+ if (!success && !reason) {
1132
+ reason = `${property} did not match expected value`;
1133
+ }
1134
+ return {
1135
+ success,
1136
+ selector,
1137
+ element_id: element_id ?? resolvedElement.elementId,
1138
+ expected_state: { property, expected },
1139
+ element: {
1140
+ ...resolvedElement,
1141
+ state: observedState
1142
+ },
1143
+ observed_state: {
1144
+ property,
1145
+ value: observedValue,
1146
+ ...(rawValue !== null ? { raw_value: rawValue } : {})
1147
+ },
1148
+ reason,
1149
+ ...(success ? {} : { failure_code: 'UNKNOWN', retryable: false })
1150
+ };
1151
+ }
942
1152
  static async waitForUICore({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }) {
943
1153
  const start = Date.now();
944
1154
  const deadline = start + (timeoutMs || 0);
@@ -37,7 +37,59 @@ function getCenter(bounds) {
37
37
  const [x1, y1, x2, y2] = bounds;
38
38
  return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
39
39
  }
40
- function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
40
+ function parseIOSNumber(value) {
41
+ if (typeof value === 'number' && Number.isFinite(value))
42
+ return value;
43
+ if (typeof value !== 'string')
44
+ return null;
45
+ const parsed = Number(value);
46
+ return Number.isFinite(parsed) ? parsed : null;
47
+ }
48
+ function isIOSAdjustable(node, type, traits) {
49
+ return /slider|adjustable|stepper|progress/i.test(type) || traits.some((trait) => /adjustable|slider|progress/i.test(trait));
50
+ }
51
+ function extractIOSState(node, type, label, value, traits) {
52
+ const state = {};
53
+ const normalizedTraits = traits.map((trait) => String(trait).toLowerCase());
54
+ if (normalizedTraits.some((trait) => /selected/.test(trait))) {
55
+ state.selected = label || value || true;
56
+ }
57
+ if (normalizedTraits.some((trait) => /focused/.test(trait))) {
58
+ state.focused = true;
59
+ }
60
+ if (normalizedTraits.some((trait) => /enabled/.test(trait))) {
61
+ state.enabled = true;
62
+ }
63
+ if (normalizedTraits.some((trait) => /disabled/.test(trait))) {
64
+ state.enabled = false;
65
+ }
66
+ if (value && /textfield|search|text/i.test(type)) {
67
+ state.text_value = value;
68
+ }
69
+ if (isIOSAdjustable(node, type, traits)) {
70
+ const rawValue = parseIOSNumber(value);
71
+ if (rawValue !== null) {
72
+ state.raw_value = rawValue;
73
+ state.value = rawValue >= 0 && rawValue <= 1 ? Math.round(rawValue * 100) : rawValue;
74
+ }
75
+ else if (value) {
76
+ state.raw_value = value;
77
+ state.value = value;
78
+ }
79
+ }
80
+ else if (value) {
81
+ const numericValue = parseIOSNumber(value);
82
+ if (numericValue !== null) {
83
+ state.value = numericValue;
84
+ state.raw_value = numericValue;
85
+ }
86
+ else {
87
+ state.value = value;
88
+ }
89
+ }
90
+ return Object.keys(state).length > 0 ? state : null;
91
+ }
92
+ export function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
41
93
  if (!node)
42
94
  return -1;
43
95
  let currentIndex = -1;
@@ -46,6 +98,7 @@ function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
46
98
  const value = node.AXValue || null;
47
99
  const frame = node.AXFrame || node.frame;
48
100
  const traits = node.AXTraits || [];
101
+ const state = extractIOSState(node, type, label, value, traits);
49
102
  const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell";
50
103
  const isUseful = clickable || (label && label.length > 0) || (value && value.length > 0) || type === "Application" || type === "Window";
51
104
  if (isUseful) {
@@ -60,7 +113,8 @@ function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
60
113
  visible: true,
61
114
  bounds: bounds,
62
115
  center: getCenter(bounds),
63
- depth: depth
116
+ depth: depth,
117
+ state
64
118
  };
65
119
  if (parentIndex !== -1) {
66
120
  element.parentId = parentIndex;
@@ -79,7 +79,8 @@ export function normalizeResolvedTarget(value = null) {
79
79
  accessibility_id: value.accessibility_id ?? null,
80
80
  class: value.class ?? null,
81
81
  bounds: value.bounds ?? null,
82
- index: value.index ?? null
82
+ index: value.index ?? null,
83
+ state: value.state ?? null
83
84
  };
84
85
  }
85
86
  export function inferGenericFailure(message) {
@@ -468,6 +468,61 @@ Failure Handling:
468
468
  required: ['selector']
469
469
  }
470
470
  },
471
+ {
472
+ name: 'expect_state',
473
+ description: `Purpose:
474
+ Verify a readable UI state property on the currently visible element.
475
+
476
+ Inputs:
477
+ - selector or element_id
478
+ - property
479
+ - expected
480
+ - platform/deviceId (optional)
481
+
482
+ Supported properties:
483
+ - checked, selected, focused, expanded, enabled, text_value, value, raw_value
484
+
485
+ Verification Guidance:
486
+ - Use this when the UI element is visible but its state must also be confirmed
487
+ - Prefer the canonical property names above
488
+ - The tool compares the normalized readable state and returns the observed value when available
489
+
490
+ Constraints:
491
+ - Returns structured success/failure only
492
+ - Does not infer a state when the property is unavailable
493
+
494
+ Failure Handling:
495
+ - ELEMENT_NOT_FOUND → re-resolve the element or wait for UI stabilization
496
+ - UNKNOWN → capture a snapshot and stop`,
497
+ inputSchema: {
498
+ type: 'object',
499
+ properties: {
500
+ selector: {
501
+ type: 'object',
502
+ properties: {
503
+ text: { type: 'string' },
504
+ resource_id: { type: 'string' },
505
+ accessibility_id: { type: 'string' },
506
+ contains: { type: 'boolean', default: false }
507
+ }
508
+ },
509
+ element_id: { type: 'string', description: 'Optional previously resolved element identifier.' },
510
+ property: { type: 'string', description: 'Readable state property to verify.' },
511
+ expected: {
512
+ description: 'Expected normalized state value.',
513
+ oneOf: [
514
+ { type: 'boolean' },
515
+ { type: 'number' },
516
+ { type: 'string' },
517
+ { type: 'object' }
518
+ ]
519
+ },
520
+ platform: { type: 'string', enum: ['android', 'ios'], description: 'Optional platform override' },
521
+ deviceId: { type: 'string', description: 'Optional device serial/udid' }
522
+ },
523
+ required: ['property', 'expected']
524
+ }
525
+ },
471
526
  {
472
527
  name: 'wait_for_ui',
473
528
  description: `Purpose:
@@ -208,6 +208,22 @@ async function handleExpectElementVisible(args) {
208
208
  const res = await ToolsInteract.expectElementVisibleHandler({ selector, element_id, timeout_ms, poll_interval_ms, platform, deviceId });
209
209
  return wrapResponse(res);
210
210
  }
211
+ async function handleExpectState(args) {
212
+ const selector = getObjectArg(args, 'selector');
213
+ const element_id = getStringArg(args, 'element_id');
214
+ const property = requireStringArg(args, 'property');
215
+ const platform = getStringArg(args, 'platform');
216
+ const deviceId = getStringArg(args, 'deviceId');
217
+ if (!selector && !element_id) {
218
+ throw new Error('Missing selector or element_id argument');
219
+ }
220
+ if (!Object.prototype.hasOwnProperty.call(args, 'expected')) {
221
+ throw new Error('Missing expected argument');
222
+ }
223
+ const expected = args.expected;
224
+ const res = await ToolsInteract.expectStateHandler({ selector: selector ?? undefined, element_id: element_id ?? undefined, property, expected, platform, deviceId });
225
+ return wrapResponse(res);
226
+ }
211
227
  async function handleWaitForUI(args) {
212
228
  const selector = getObjectArg(args, 'selector');
213
229
  const condition = getStringArg(args, 'condition') ?? 'exists';
@@ -395,6 +411,7 @@ export const toolHandlers = {
395
411
  wait_for_screen_change: handleWaitForScreenChange,
396
412
  expect_screen: handleExpectScreen,
397
413
  expect_element_visible: handleExpectElementVisible,
414
+ expect_state: handleExpectState,
398
415
  wait_for_ui: handleWaitForUI,
399
416
  find_element: handleFindElement,
400
417
  tap: handleTap,
@@ -6,7 +6,7 @@ import { handleToolCall } from './server/tool-handlers.js';
6
6
  export { wrapResponse, toolDefinitions, handleToolCall };
7
7
  export const serverInfo = {
8
8
  name: 'mobile-debug-mcp',
9
- version: '0.24.8'
9
+ version: '0.25.0'
10
10
  };
11
11
  export function createServer() {
12
12
  const server = new Server(serverInfo, {
@@ -341,6 +341,70 @@ export function getCenter(bounds) {
341
341
  const [x1, y1, x2, y2] = bounds;
342
342
  return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
343
343
  }
344
+ function parseBooleanAttr(value) {
345
+ if (value === true || value === 'true')
346
+ return true;
347
+ if (value === false || value === 'false')
348
+ return false;
349
+ return null;
350
+ }
351
+ function parseNumberAttr(value) {
352
+ if (typeof value === 'number' && Number.isFinite(value))
353
+ return value;
354
+ if (typeof value !== 'string')
355
+ return null;
356
+ const parsed = Number(value);
357
+ return Number.isFinite(parsed) ? parsed : null;
358
+ }
359
+ function isSliderLikeAndroid(node) {
360
+ const className = String(node['@_class'] || '').toLowerCase();
361
+ return /seekbar|slider|range|progress/i.test(className);
362
+ }
363
+ function extractAndroidState(node) {
364
+ const checked = parseBooleanAttr(node['@_checked']);
365
+ const selectedFlag = parseBooleanAttr(node['@_selected']);
366
+ const focused = parseBooleanAttr(node['@_focused']);
367
+ const expanded = parseBooleanAttr(node['@_expanded']);
368
+ const enabled = parseBooleanAttr(node['@_enabled']);
369
+ const textValue = typeof node['@_text'] === 'string' && node['@_text'].trim().length > 0 ? node['@_text'] : null;
370
+ const state = {};
371
+ if (checked !== null)
372
+ state.checked = checked;
373
+ if (selectedFlag !== null) {
374
+ state.selected = textValue || node['@_content-desc'] || true;
375
+ }
376
+ if (focused !== null)
377
+ state.focused = focused;
378
+ if (expanded !== null)
379
+ state.expanded = expanded;
380
+ if (enabled !== null)
381
+ state.enabled = enabled;
382
+ if (textValue && /edittext|textfield|search/i.test(String(node['@_class'] || ''))) {
383
+ state.text_value = textValue;
384
+ }
385
+ if (isSliderLikeAndroid(node)) {
386
+ const rawProgress = parseNumberAttr(node['@_progress']);
387
+ const max = parseNumberAttr(node['@_max']);
388
+ const fallbackValue = rawProgress ?? parseNumberAttr(node['@_value']) ?? parseNumberAttr(node['@_content-desc']);
389
+ const numericValue = rawProgress ?? fallbackValue;
390
+ if (numericValue !== null) {
391
+ state.raw_value = numericValue;
392
+ state.value_range = max !== null && max > 0 ? { min: 0, max } : null;
393
+ state.value = max !== null && max > 0 ? Math.round((numericValue / max) * 100) : numericValue;
394
+ }
395
+ }
396
+ else {
397
+ const numericValue = parseNumberAttr(node['@_value']);
398
+ if (numericValue !== null) {
399
+ state.value = numericValue;
400
+ state.raw_value = numericValue;
401
+ }
402
+ else if (textValue) {
403
+ state.value = textValue;
404
+ }
405
+ }
406
+ return Object.keys(state).length > 0 ? state : null;
407
+ }
344
408
  export async function getScreenResolution(deviceId) {
345
409
  try {
346
410
  const output = await execAdb(['shell', 'wm', 'size'], deviceId);
@@ -363,6 +427,7 @@ export function traverseNode(node, elements, parentIndex = -1, depth = 0) {
363
427
  const contentDescription = node['@_content-desc'] || null;
364
428
  const clickable = node['@_clickable'] === 'true';
365
429
  const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
430
+ const state = extractAndroidState(node);
366
431
  const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
367
432
  if (isUseful) {
368
433
  const element = {
@@ -375,7 +440,8 @@ export function traverseNode(node, elements, parentIndex = -1, depth = 0) {
375
440
  visible: true,
376
441
  bounds,
377
442
  center: getCenter(bounds),
378
- depth
443
+ depth,
444
+ state
379
445
  };
380
446
  if (parentIndex !== -1) {
381
447
  element.parentId = parentIndex;
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.25.0]
6
+ - Introduces the `expect_state` tool and a standardized state object for UI elements across Android and iOS.
7
+
5
8
  ## [0.24.8]
6
9
  - Improved slider interaction
7
10