mobile-debug-mcp 0.30.0 → 0.30.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.
- package/dist/interact/index.js +330 -22
- package/dist/observe/index.js +1 -0
- package/dist/observe/snapshot-metadata.js +62 -1
- package/dist/server/tool-definitions.js +7 -2
- package/dist/server/tool-handlers.js +3 -1
- package/dist/server-core.js +1 -1
- package/docs/CHANGELOG.md +6 -0
- package/docs/rfcs/013-wait-and-synchronization-reliability.md +15 -35
- package/docs/rfcs/014-actionability-resolution.md +32 -30
- package/package.json +1 -1
- package/src/interact/index.ts +427 -33
- package/src/observe/index.ts +1 -0
- package/src/observe/snapshot-metadata.ts +69 -2
- package/src/server/tool-definitions.ts +7 -2
- package/src/server/tool-handlers.ts +3 -1
- package/src/server-core.ts +1 -1
- package/src/types.ts +24 -0
- package/test/unit/interact/adjust_control.test.ts +104 -0
- package/test/unit/interact/subtree_collection.test.ts +24 -0
- package/test/unit/interact/tap_element.test.ts +71 -0
- package/test/unit/interact/wait_for_ui_change.test.ts +92 -1
- package/test/unit/observe/snapshot_metadata.test.ts +67 -0
|
@@ -244,7 +244,7 @@ Failure Handling:
|
|
|
244
244
|
},
|
|
245
245
|
{
|
|
246
246
|
name: 'capture_debug_snapshot',
|
|
247
|
-
description: 'Capture a complete debug snapshot (raw observation layer plus optional derived semantic layer). Returns structured JSON with snapshot_revision, captured_at_ms, and loading_state when detectable.',
|
|
247
|
+
description: 'Capture a complete debug snapshot (raw observation layer plus optional derived semantic layer). Returns structured JSON with snapshot_revision, captured_at_ms, snapshot_delta, and loading_state when detectable.',
|
|
248
248
|
inputSchema: {
|
|
249
249
|
type: 'object',
|
|
250
250
|
properties: {
|
|
@@ -295,7 +295,7 @@ Failure Handling:
|
|
|
295
295
|
},
|
|
296
296
|
{
|
|
297
297
|
name: 'get_ui_tree',
|
|
298
|
-
description: 'Get the current UI hierarchy from an Android device or iOS simulator. Returns a structured JSON representation of the screen content with snapshot metadata when available.',
|
|
298
|
+
description: 'Get the current UI hierarchy from an Android device or iOS simulator. Returns a structured JSON representation of the screen content with snapshot metadata and incremental delta signals when available.',
|
|
299
299
|
inputSchema: {
|
|
300
300
|
type: 'object',
|
|
301
301
|
properties: {
|
|
@@ -376,11 +376,14 @@ Inputs:
|
|
|
376
376
|
- expected_change (optional): hierarchy_diff, text_change, or state_change
|
|
377
377
|
- timeout_ms (optional)
|
|
378
378
|
- stability_window_ms (optional)
|
|
379
|
+
- scope (optional): screen or subtree
|
|
380
|
+
- target (optional): element_id when scope=subtree
|
|
379
381
|
|
|
380
382
|
Guidance:
|
|
381
383
|
- Prefer wait_for_screen_change for navigation transitions.
|
|
382
384
|
- Prefer wait_for_ui_change for in-place mutations and non-navigation updates.
|
|
383
385
|
- Use the returned snapshot_revision as the observed synchronization point when available.
|
|
386
|
+
- Scoped waits return scope-aware stability metadata and a lightweight change summary.
|
|
384
387
|
|
|
385
388
|
Failure Handling:
|
|
386
389
|
- TIMEOUT means the UI did not change in a stable way within the allotted time.`,
|
|
@@ -390,6 +393,8 @@ Failure Handling:
|
|
|
390
393
|
platform: { type: 'string', enum: ['android', 'ios'], description: 'Optional platform override (android|ios)' },
|
|
391
394
|
deviceId: { type: 'string', description: 'Optional device id/udid to target' },
|
|
392
395
|
expected_change: { type: 'string', enum: ['hierarchy_diff', 'text_change', 'state_change'], description: 'Optional type of UI change to wait for' },
|
|
396
|
+
scope: { type: 'string', enum: ['screen', 'subtree'], default: 'screen', description: 'Synchronization scope for the wait' },
|
|
397
|
+
target: { type: 'string', description: 'Target element_id when scope is subtree' },
|
|
393
398
|
timeout_ms: { type: 'number', description: 'Timeout in ms to wait for change (default 60000)', default: 60000 },
|
|
394
399
|
stability_window_ms: { type: 'number', description: 'How long the change must remain stable before success (default 300)', default: 300 }
|
|
395
400
|
}
|
|
@@ -321,7 +321,9 @@ async function handleWaitForUIChange(args: ToolCallArgs) {
|
|
|
321
321
|
const timeout_ms = getNumberArg(args, 'timeout_ms') ?? 60000
|
|
322
322
|
const stability_window_ms = getNumberArg(args, 'stability_window_ms') ?? 300
|
|
323
323
|
const expected_change = getStringArg(args, 'expected_change') as 'hierarchy_diff' | 'text_change' | 'state_change' | undefined
|
|
324
|
-
const
|
|
324
|
+
const scope = getStringArg(args, 'scope') as 'screen' | 'subtree' | undefined
|
|
325
|
+
const target = getStringArg(args, 'target')
|
|
326
|
+
const res = await ToolsInteract.waitForUIChangeHandler({ platform, deviceId, timeout_ms, stability_window_ms, expected_change, scope, target })
|
|
325
327
|
return wrapResponse(res)
|
|
326
328
|
}
|
|
327
329
|
|
package/src/server-core.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -152,6 +152,14 @@ export interface LoadingState {
|
|
|
152
152
|
source: string;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
export interface SnapshotDelta {
|
|
156
|
+
previous_snapshot_revision: number | null;
|
|
157
|
+
added_elements: number;
|
|
158
|
+
removed_elements: number;
|
|
159
|
+
mutated_elements: number;
|
|
160
|
+
total_elements: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
155
163
|
export interface CaptureAndroidScreenResponse {
|
|
156
164
|
device: DeviceInfo;
|
|
157
165
|
screenshot: string; // base64 encoded string
|
|
@@ -207,6 +215,7 @@ export interface GetUITreeResponse {
|
|
|
207
215
|
elements: UIElement[];
|
|
208
216
|
snapshot_revision: number;
|
|
209
217
|
captured_at_ms: number;
|
|
218
|
+
snapshot_delta?: SnapshotDelta | null;
|
|
210
219
|
loading_state?: LoadingState | null;
|
|
211
220
|
error?: string;
|
|
212
221
|
}
|
|
@@ -231,6 +240,7 @@ export interface CaptureDebugSnapshotRawResponse {
|
|
|
231
240
|
timestamp: number;
|
|
232
241
|
snapshot_revision: number;
|
|
233
242
|
captured_at_ms: number;
|
|
243
|
+
snapshot_delta?: SnapshotDelta | null;
|
|
234
244
|
reason: string;
|
|
235
245
|
activity: string | null;
|
|
236
246
|
fingerprint: string | null;
|
|
@@ -497,11 +507,25 @@ export interface WaitForUIChangeResponse {
|
|
|
497
507
|
success: boolean;
|
|
498
508
|
observed_change: 'hierarchy_diff' | 'text_change' | 'state_change' | null;
|
|
499
509
|
snapshot_revision?: number;
|
|
510
|
+
snapshot_freshness_ms?: number | null;
|
|
511
|
+
scope?: 'screen' | 'subtree';
|
|
512
|
+
target?: string | null;
|
|
513
|
+
stability_state?: 'transient' | 'stable';
|
|
514
|
+
change_summary?: {
|
|
515
|
+
total_elements: number;
|
|
516
|
+
added_elements: number;
|
|
517
|
+
removed_elements: number;
|
|
518
|
+
mutated_elements: number;
|
|
519
|
+
} | null;
|
|
500
520
|
timeout: boolean;
|
|
501
521
|
elapsed_ms: number;
|
|
502
522
|
expected_change?: 'hierarchy_diff' | 'text_change' | 'state_change';
|
|
503
523
|
reason?: string;
|
|
504
524
|
loading_state?: LoadingState | null;
|
|
525
|
+
error?: {
|
|
526
|
+
code: 'INVALID_SCOPE' | 'ELEMENT_NOT_FOUND';
|
|
527
|
+
message: string;
|
|
528
|
+
};
|
|
505
529
|
}
|
|
506
530
|
|
|
507
531
|
export interface SwipeResponse {
|
|
@@ -263,6 +263,110 @@ async function run() {
|
|
|
263
263
|
assert.strictEqual(swipeCalls.length, 0)
|
|
264
264
|
assert.ok(tapCalls[3].x > 2750, 'wide, high-range control should not be clamped to a 3% endpoint margin')
|
|
265
265
|
|
|
266
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
267
|
+
device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
268
|
+
screen: '',
|
|
269
|
+
resolution: { width: 1080, height: 2400 },
|
|
270
|
+
elements: [
|
|
271
|
+
{
|
|
272
|
+
text: 'Duration',
|
|
273
|
+
type: 'android.widget.SeekBar',
|
|
274
|
+
contentDescription: null,
|
|
275
|
+
clickable: true,
|
|
276
|
+
enabled: false,
|
|
277
|
+
visible: true,
|
|
278
|
+
bounds: [0, 0, 200, 40],
|
|
279
|
+
resourceId: 'seek_duration',
|
|
280
|
+
state: {
|
|
281
|
+
value: 10,
|
|
282
|
+
raw_value: 10,
|
|
283
|
+
value_range: { min: 0, max: 20 }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
]
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
const disabledAdjust = await ToolsInteract.adjustControlHandler({
|
|
290
|
+
element_id: wait.element.elementId,
|
|
291
|
+
property: 'value',
|
|
292
|
+
targetValue: 8,
|
|
293
|
+
tolerance: 0.5,
|
|
294
|
+
maxAttempts: 1,
|
|
295
|
+
platform: 'android'
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
assert.strictEqual(disabledAdjust.success, false)
|
|
299
|
+
assert.strictEqual(disabledAdjust.failure_code, 'ELEMENT_NOT_INTERACTABLE')
|
|
300
|
+
|
|
301
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
302
|
+
device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
303
|
+
screen: '',
|
|
304
|
+
resolution: { width: 1080, height: 2400 },
|
|
305
|
+
elements: [
|
|
306
|
+
{
|
|
307
|
+
text: 'Stable slider',
|
|
308
|
+
type: 'android.widget.SeekBar',
|
|
309
|
+
contentDescription: null,
|
|
310
|
+
clickable: true,
|
|
311
|
+
enabled: true,
|
|
312
|
+
visible: true,
|
|
313
|
+
bounds: [0, 0, 200, 40],
|
|
314
|
+
resourceId: 'seek_stable',
|
|
315
|
+
stable_id: 'stable-1',
|
|
316
|
+
state: {
|
|
317
|
+
value: 4,
|
|
318
|
+
raw_value: 4,
|
|
319
|
+
value_range: { min: 0, max: 10 }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
]
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
const stableWait = await ToolsInteract.waitForUIHandler({
|
|
326
|
+
selector: { text: 'Stable slider' },
|
|
327
|
+
condition: 'clickable',
|
|
328
|
+
timeout_ms: 200,
|
|
329
|
+
poll_interval_ms: 50,
|
|
330
|
+
platform: 'android'
|
|
331
|
+
})
|
|
332
|
+
assert.strictEqual(stableWait.status, 'success')
|
|
333
|
+
|
|
334
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
335
|
+
device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
336
|
+
screen: '',
|
|
337
|
+
resolution: { width: 1080, height: 2400 },
|
|
338
|
+
elements: [
|
|
339
|
+
{
|
|
340
|
+
text: 'Stable slider',
|
|
341
|
+
type: 'android.widget.SeekBar',
|
|
342
|
+
contentDescription: null,
|
|
343
|
+
clickable: true,
|
|
344
|
+
enabled: true,
|
|
345
|
+
visible: true,
|
|
346
|
+
bounds: [0, 0, 200, 40],
|
|
347
|
+
resourceId: 'seek_stable',
|
|
348
|
+
stable_id: 'stable-2',
|
|
349
|
+
state: {
|
|
350
|
+
value: 4,
|
|
351
|
+
raw_value: 4,
|
|
352
|
+
value_range: { min: 0, max: 10 }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
]
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
const staleAdjust = await ToolsInteract.adjustControlHandler({
|
|
359
|
+
element_id: stableWait.element.elementId,
|
|
360
|
+
property: 'value',
|
|
361
|
+
targetValue: 6,
|
|
362
|
+
tolerance: 0.5,
|
|
363
|
+
maxAttempts: 1,
|
|
364
|
+
platform: 'android'
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
assert.strictEqual(staleAdjust.success, false)
|
|
368
|
+
assert.strictEqual(staleAdjust.failure_code, 'STALE_REFERENCE')
|
|
369
|
+
|
|
266
370
|
let treeFetches = 0
|
|
267
371
|
let retryVerificationCount = 0
|
|
268
372
|
;(Observe as any).ToolsObserve.getUITreeHandler = async () => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
3
|
+
|
|
4
|
+
async function run() {
|
|
5
|
+
console.log('Starting subtree_collection unit tests...')
|
|
6
|
+
|
|
7
|
+
const elements = [
|
|
8
|
+
{ text: 'Root', resourceId: 'root-node', stable_id: 'root-stable', bounds: [0, 0, 100, 100], visible: true, enabled: true, clickable: true },
|
|
9
|
+
{ text: 'Numeric child', parentId: '0', stable_id: 'numeric-child', bounds: [0, 0, 50, 50], visible: true, enabled: true, clickable: true },
|
|
10
|
+
{ text: 'Resource child', parentId: 'root-node', stable_id: 'resource-child', bounds: [0, 50, 50, 100], visible: true, enabled: true, clickable: true },
|
|
11
|
+
{ text: 'Stable child', parentId: 'root-stable', stable_id: 'stable-child', bounds: [50, 0, 100, 50], visible: true, enabled: true, clickable: true },
|
|
12
|
+
{ text: 'Grandchild', parentId: 'resource-child', bounds: [50, 50, 100, 100], visible: true, enabled: true, clickable: true }
|
|
13
|
+
] as any[]
|
|
14
|
+
|
|
15
|
+
const indices = (ToolsInteract as any)._collectSubtreeIndices(elements, 0)
|
|
16
|
+
assert.deepStrictEqual(indices, [0, 1, 2, 3, 4])
|
|
17
|
+
|
|
18
|
+
console.log('subtree_collection unit tests passed')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
run().catch((error) => {
|
|
22
|
+
console.error(error)
|
|
23
|
+
process.exit(1)
|
|
24
|
+
})
|
|
@@ -84,6 +84,77 @@ async function run() {
|
|
|
84
84
|
assert.strictEqual(disabledResult.success, false)
|
|
85
85
|
assert.strictEqual(disabledResult.failure_code, 'ELEMENT_NOT_INTERACTABLE')
|
|
86
86
|
|
|
87
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
88
|
+
device: { platform: 'android', id: 'mock-device' },
|
|
89
|
+
elements: [
|
|
90
|
+
{ text: 'Plain label', resourceId: 'txt_plain', bounds: [0, 0, 20, 20], visible: true, enabled: true, clickable: false }
|
|
91
|
+
]
|
|
92
|
+
})
|
|
93
|
+
const waitPlain = await ToolsInteract.waitForUIHandler({
|
|
94
|
+
selector: { text: 'Plain label' },
|
|
95
|
+
condition: 'exists',
|
|
96
|
+
timeout_ms: 200,
|
|
97
|
+
poll_interval_ms: 50,
|
|
98
|
+
platform: 'android'
|
|
99
|
+
})
|
|
100
|
+
const plainResult = await ToolsInteract.tapElementHandler({ elementId: waitPlain.element.elementId })
|
|
101
|
+
assert.strictEqual(plainResult.success, false)
|
|
102
|
+
assert.strictEqual(plainResult.failure_code, 'ELEMENT_NOT_INTERACTABLE')
|
|
103
|
+
|
|
104
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
105
|
+
device: { platform: 'ios', id: 'ios-mock' },
|
|
106
|
+
elements: [
|
|
107
|
+
{
|
|
108
|
+
text: 'Semantic tap',
|
|
109
|
+
resourceId: 'ios_semantic_tap',
|
|
110
|
+
bounds: [10, 10, 30, 30],
|
|
111
|
+
visible: true,
|
|
112
|
+
enabled: true,
|
|
113
|
+
clickable: false,
|
|
114
|
+
semantic: {
|
|
115
|
+
is_clickable: true,
|
|
116
|
+
is_container: false,
|
|
117
|
+
supported_actions: ['tap']
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
})
|
|
122
|
+
const iosSemanticWait = await ToolsInteract.waitForUIHandler({
|
|
123
|
+
selector: { text: 'Semantic tap' },
|
|
124
|
+
condition: 'exists',
|
|
125
|
+
timeout_ms: 200,
|
|
126
|
+
poll_interval_ms: 50,
|
|
127
|
+
platform: 'ios'
|
|
128
|
+
})
|
|
129
|
+
const iosSemanticTap = await ToolsInteract.tapElementHandler({ elementId: iosSemanticWait.element.elementId })
|
|
130
|
+
assert.strictEqual(iosSemanticTap.success, true)
|
|
131
|
+
assert.strictEqual(iosSemanticTap.target.resolved?.elementId, iosSemanticWait.element.elementId)
|
|
132
|
+
assert.deepStrictEqual(tapped, { platform: 'ios', x: 20, y: 20, deviceId: 'ios-mock' })
|
|
133
|
+
|
|
134
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
135
|
+
device: { platform: 'android', id: 'mock-device' },
|
|
136
|
+
elements: [
|
|
137
|
+
{ text: 'Stable target', resourceId: 'btn_stable', bounds: [0, 0, 20, 20], visible: true, enabled: true, clickable: true, stable_id: 'stable-1' }
|
|
138
|
+
]
|
|
139
|
+
})
|
|
140
|
+
const waitStable = await ToolsInteract.waitForUIHandler({
|
|
141
|
+
selector: { text: 'Stable target' },
|
|
142
|
+
condition: 'exists',
|
|
143
|
+
timeout_ms: 200,
|
|
144
|
+
poll_interval_ms: 50,
|
|
145
|
+
platform: 'android'
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
149
|
+
device: { platform: 'android', id: 'mock-device' },
|
|
150
|
+
elements: [
|
|
151
|
+
{ text: 'Stable target', resourceId: 'btn_stable', bounds: [0, 0, 20, 20], visible: true, enabled: true, clickable: true, stable_id: 'stable-2' }
|
|
152
|
+
]
|
|
153
|
+
})
|
|
154
|
+
const stableMismatchResult = await ToolsInteract.tapElementHandler({ elementId: waitStable.element.elementId })
|
|
155
|
+
assert.strictEqual(stableMismatchResult.success, false)
|
|
156
|
+
assert.strictEqual(stableMismatchResult.failure_code, 'STALE_REFERENCE')
|
|
157
|
+
|
|
87
158
|
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
88
159
|
device: { platform: 'android', id: 'mock-device' },
|
|
89
160
|
elements: []
|
|
@@ -6,7 +6,18 @@ type UiTree = {
|
|
|
6
6
|
device: { platform: 'android', id: string, osVersion: string, model: string, simulator: boolean }
|
|
7
7
|
screen: string
|
|
8
8
|
resolution: { width: number, height: number }
|
|
9
|
-
elements: Array<{
|
|
9
|
+
elements: Array<{
|
|
10
|
+
text: string
|
|
11
|
+
type: string
|
|
12
|
+
bounds: number[]
|
|
13
|
+
visible: boolean
|
|
14
|
+
enabled?: boolean
|
|
15
|
+
clickable?: boolean
|
|
16
|
+
stable_id?: string
|
|
17
|
+
parentId?: number
|
|
18
|
+
children?: number[]
|
|
19
|
+
state?: Record<string, unknown> | null
|
|
20
|
+
}>
|
|
10
21
|
snapshot_revision: number
|
|
11
22
|
captured_at_ms: number
|
|
12
23
|
}
|
|
@@ -22,6 +33,21 @@ function makeTree(screen: string, revision: number): UiTree {
|
|
|
22
33
|
}
|
|
23
34
|
}
|
|
24
35
|
|
|
36
|
+
function makeScopedTree(title: string, status: string, revision: number): UiTree {
|
|
37
|
+
return {
|
|
38
|
+
device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
|
|
39
|
+
screen: 'Scoped',
|
|
40
|
+
resolution: { width: 1080, height: 2400 },
|
|
41
|
+
elements: [
|
|
42
|
+
{ text: 'Root', type: 'FrameLayout', bounds: [0, 0, 1080, 2400], visible: true, enabled: true, stable_id: 'root', children: [1, 2] },
|
|
43
|
+
{ text: title, type: 'TextView', bounds: [0, 0, 1080, 200], visible: true, enabled: true, stable_id: 'title', parentId: 0, state: { text_value: title } },
|
|
44
|
+
{ text: status, type: 'TextView', bounds: [0, 200, 1080, 260], visible: true, enabled: true, stable_id: 'status', parentId: 0, state: { text_value: status } }
|
|
45
|
+
],
|
|
46
|
+
snapshot_revision: revision,
|
|
47
|
+
captured_at_ms: 1000 + revision
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
async function runScenario({
|
|
26
52
|
snapshots,
|
|
27
53
|
expectedChange,
|
|
@@ -72,6 +98,62 @@ async function runScenario({
|
|
|
72
98
|
}
|
|
73
99
|
}
|
|
74
100
|
|
|
101
|
+
async function runScopedScenario() {
|
|
102
|
+
const snapshots = [
|
|
103
|
+
makeScopedTree('Title', 'Status 1', 1),
|
|
104
|
+
makeScopedTree('Title', 'Status 2', 2),
|
|
105
|
+
makeScopedTree('Title Ready', 'Status 2', 3),
|
|
106
|
+
makeScopedTree('Title Ready', 'Status 2', 4)
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
const originalGetUITreeHandler = (ToolsObserve as any).getUITreeHandler
|
|
110
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
111
|
+
const originalDateNow = Date.now
|
|
112
|
+
let now = 0
|
|
113
|
+
let calls = 0
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
;(Date as any).now = () => now
|
|
117
|
+
;(globalThis as any).setTimeout = (callback: (...args: any[]) => void) => {
|
|
118
|
+
now += 1
|
|
119
|
+
callback()
|
|
120
|
+
return 0
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
;(ToolsObserve as any).getUITreeHandler = async () => snapshots[Math.min(calls++, snapshots.length - 1)]
|
|
124
|
+
|
|
125
|
+
const resolution = await ToolsInteract.waitForUIHandler({
|
|
126
|
+
selector: { text: 'Title' },
|
|
127
|
+
condition: 'exists',
|
|
128
|
+
timeout_ms: 1000,
|
|
129
|
+
poll_interval_ms: 1,
|
|
130
|
+
platform: 'android',
|
|
131
|
+
deviceId: 'mock'
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const targetId = resolution?.element?.elementId
|
|
135
|
+
assert.ok(targetId)
|
|
136
|
+
|
|
137
|
+
calls = 0
|
|
138
|
+
now = 0
|
|
139
|
+
const result = await ToolsInteract.waitForUIChangeHandler({
|
|
140
|
+
platform: 'android',
|
|
141
|
+
deviceId: 'mock',
|
|
142
|
+
expected_change: 'text_change',
|
|
143
|
+
scope: 'subtree',
|
|
144
|
+
target: targetId,
|
|
145
|
+
timeout_ms: 2000,
|
|
146
|
+
stability_window_ms: 1
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
return { result, targetId, calls }
|
|
150
|
+
} finally {
|
|
151
|
+
;(ToolsObserve as any).getUITreeHandler = originalGetUITreeHandler
|
|
152
|
+
;(globalThis as any).setTimeout = originalSetTimeout
|
|
153
|
+
;(Date as any).now = originalDateNow
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
75
157
|
async function run() {
|
|
76
158
|
const success = await runScenario({
|
|
77
159
|
snapshots: [makeTree('Loading', 1), makeTree('Loaded', 2)],
|
|
@@ -109,6 +191,15 @@ async function run() {
|
|
|
109
191
|
assert.strictEqual(defaultWindow.calls, 4)
|
|
110
192
|
assert.deepStrictEqual(defaultWindow.delays, [300, 300, 300])
|
|
111
193
|
|
|
194
|
+
const scoped = await runScopedScenario()
|
|
195
|
+
assert.strictEqual(scoped.result.success, true)
|
|
196
|
+
assert.strictEqual(scoped.result.scope, 'subtree')
|
|
197
|
+
assert.strictEqual(scoped.result.target, scoped.targetId)
|
|
198
|
+
assert.strictEqual(scoped.result.observed_change, 'text_change')
|
|
199
|
+
assert.strictEqual(scoped.result.change_summary?.added_elements, 0)
|
|
200
|
+
assert.strictEqual(scoped.result.change_summary?.removed_elements, 0)
|
|
201
|
+
assert.strictEqual(scoped.result.stability_state, 'stable')
|
|
202
|
+
|
|
112
203
|
const resetWindow = await runScenario({
|
|
113
204
|
snapshots: [makeTree('Loading', 1), makeTree('Loaded', 2), makeTree('Loaded-again', 3), makeTree('Loaded-again', 4), makeTree('Loaded-again', 5)],
|
|
114
205
|
expectedChange: 'text_change',
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { deriveSnapshotMetadata, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
|
|
3
|
+
|
|
4
|
+
async function run() {
|
|
5
|
+
console.log('Starting snapshot_metadata unit tests...')
|
|
6
|
+
|
|
7
|
+
resetSnapshotMetadataForTests()
|
|
8
|
+
|
|
9
|
+
const deviceKey = 'android:mock'
|
|
10
|
+
const first = deriveSnapshotMetadata(deviceKey, {
|
|
11
|
+
screen: 'Home',
|
|
12
|
+
resolution: { width: 100, height: 200 },
|
|
13
|
+
elements: [
|
|
14
|
+
{
|
|
15
|
+
text: 'Alpha',
|
|
16
|
+
contentDescription: null,
|
|
17
|
+
resourceId: 'row_1',
|
|
18
|
+
type: 'TextView',
|
|
19
|
+
clickable: false,
|
|
20
|
+
enabled: true,
|
|
21
|
+
visible: true,
|
|
22
|
+
bounds: [0, 0, 10, 10],
|
|
23
|
+
state: null,
|
|
24
|
+
stable_id: 'stable-row'
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}, 'ui_tree')
|
|
28
|
+
|
|
29
|
+
assert.strictEqual(first.snapshot_revision, 1)
|
|
30
|
+
assert.strictEqual(first.snapshot_delta, null)
|
|
31
|
+
|
|
32
|
+
const second = deriveSnapshotMetadata(deviceKey, {
|
|
33
|
+
screen: 'Home',
|
|
34
|
+
resolution: { width: 100, height: 200 },
|
|
35
|
+
elements: [
|
|
36
|
+
{
|
|
37
|
+
text: 'Beta',
|
|
38
|
+
contentDescription: null,
|
|
39
|
+
resourceId: 'row_1',
|
|
40
|
+
type: 'TextView',
|
|
41
|
+
clickable: false,
|
|
42
|
+
enabled: true,
|
|
43
|
+
visible: true,
|
|
44
|
+
bounds: [0, 0, 10, 10],
|
|
45
|
+
state: null,
|
|
46
|
+
stable_id: 'stable-row'
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}, 'ui_tree')
|
|
50
|
+
|
|
51
|
+
assert.strictEqual(second.snapshot_revision, 2)
|
|
52
|
+
assert.deepStrictEqual(second.snapshot_delta, {
|
|
53
|
+
previous_snapshot_revision: 1,
|
|
54
|
+
added_elements: 0,
|
|
55
|
+
removed_elements: 0,
|
|
56
|
+
mutated_elements: 1,
|
|
57
|
+
total_elements: 1
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
resetSnapshotMetadataForTests()
|
|
61
|
+
console.log('snapshot_metadata unit tests passed')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
run().catch((error) => {
|
|
65
|
+
console.error(error)
|
|
66
|
+
process.exit(1)
|
|
67
|
+
})
|