mobile-debug-mcp 0.30.2 → 0.32.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 +50 -5
- package/dist/interact/android.js +13 -8
- package/dist/interact/index.js +33 -22
- package/dist/interact/ios.js +5 -2
- package/dist/manage/android.js +20 -10
- package/dist/manage/ios.js +20 -11
- package/dist/observe/android.js +6 -1
- package/dist/observe/index.js +22 -5
- package/dist/observe/ios.js +7 -2
- package/dist/observe/snapshot-metadata.js +94 -15
- package/dist/server/common.js +14 -7
- package/dist/server/journey-schema.js +60 -0
- package/dist/server/tool-definitions.js +59 -6
- package/dist/server/tool-handlers.js +358 -55
- package/dist/server-core.js +1 -1
- package/dist/system/index.js +485 -48
- package/dist/system/ios.js +15 -7
- package/dist/utils/android/utils.js +4 -4
- package/dist/utils/cli/idb/check-idb.js +10 -3
- package/dist/utils/cli/idb/idb-helper.js +47 -13
- package/dist/utils/exec.js +4 -2
- package/dist/utils/ios/utils.js +10 -11
- package/dist/utils/operation-budget.js +25 -0
- package/dist/utils/resolve-device.js +12 -1
- package/dist/utils/timing.js +19 -0
- package/dist/utils/ui/index.js +2 -0
- package/docs/CHANGELOG.md +6 -0
- package/docs/ROADMAP.md +4 -4
- package/docs/benchmarks/interaction-latency/README.md +26 -0
- package/docs/benchmarks/interaction-latency/final.json +1810 -0
- package/docs/benchmarks/interaction-latency/final.md +17 -0
- package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
- package/docs/specs/baseline-spec-v0.md +1 -1
- package/docs/specs-deltas/016-environment-tools.md +533 -0
- package/docs/specs-deltas/018-interaction-latency-and-batched-journeys.md +301 -0
- package/docs/tools/TOOLS.md +2 -0
- package/docs/tools/interact.md +30 -0
- package/docs/tools/manage.md +4 -2
- package/docs/tools/system.md +9 -2
- package/package.json +1 -1
- package/src/interact/android.ts +13 -8
- package/src/interact/index.ts +32 -23
- package/src/interact/ios.ts +5 -2
- package/src/manage/android.ts +19 -10
- package/src/manage/ios.ts +19 -11
- package/src/observe/android.ts +5 -1
- package/src/observe/index.ts +21 -5
- package/src/observe/ios.ts +6 -2
- package/src/observe/snapshot-metadata.ts +94 -15
- package/src/server/common.ts +13 -6
- package/src/server/journey-schema.ts +63 -0
- package/src/server/tool-definitions.ts +60 -7
- package/src/server/tool-handlers.ts +324 -53
- package/src/server-core.ts +1 -1
- package/src/system/index.ts +572 -50
- package/src/system/ios.ts +14 -7
- package/src/types.ts +31 -2
- package/src/utils/android/utils.ts +3 -3
- package/src/utils/cli/idb/check-idb.ts +11 -2
- package/src/utils/cli/idb/idb-helper.ts +49 -12
- package/src/utils/exec.ts +4 -2
- package/src/utils/ios/utils.ts +9 -10
- package/src/utils/operation-budget.ts +22 -0
- package/src/utils/resolve-device.ts +13 -1
- package/src/utils/timing.ts +15 -0
- package/src/utils/ui/index.ts +2 -0
- package/test/device/automated/latency/benchmark.ts +136 -0
- package/test/device/automated/latency/report.ts +6 -0
- package/test/fixtures/latency-journey-app/AndroidManifest.xml +6 -0
- package/test/fixtures/latency-journey-app/README.md +10 -0
- package/test/fixtures/latency-journey-app/build.ts +26 -0
- package/test/fixtures/latency-journey-app/src/MainActivity.java +48 -0
- package/test/unit/interact/tap_element.test.ts +1 -1
- package/test/unit/manage/restart_budget.test.ts +37 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
- package/test/unit/observe/snapshot_metadata.test.ts +20 -1
- package/test/unit/observe/state_delta.test.ts +15 -0
- package/test/unit/server/benchmark_report.test.ts +4 -0
- package/test/unit/server/contract.test.ts +1 -1
- package/test/unit/server/interaction_modes.test.ts +60 -0
- package/test/unit/server/operation_budget.test.ts +32 -0
- package/test/unit/server/response_shapes.test.ts +3 -3
- package/test/unit/server/run_journey.test.ts +90 -0
- package/test/unit/server/tap_selector.test.ts +68 -0
- package/test/unit/system/get_system_status.test.ts +3 -0
- package/test/unit/system/system_status.test.ts +40 -0
- package/test/unit/utils/idb_health.test.ts +74 -0
package/src/interact/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remainingBudget } from '../utils/operation-budget.js'
|
|
1
2
|
import { createHash } from 'crypto'
|
|
2
3
|
import { AndroidInteract } from './android.js';
|
|
3
4
|
import { iOSInteract } from './ios.js';
|
|
@@ -1226,22 +1227,22 @@ export class ToolsInteract {
|
|
|
1226
1227
|
return { interact: interact as any, resolved, platform: effectivePlatform }
|
|
1227
1228
|
}
|
|
1228
1229
|
|
|
1229
|
-
static async tapHandler({ platform, x, y, deviceId }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string }) {
|
|
1230
|
+
static async tapHandler({ platform, x, y, deviceId, timeoutMs }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string, timeoutMs?: number }) {
|
|
1230
1231
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
1231
|
-
return await interact.tap(x, y, resolved.id)
|
|
1232
|
+
return await interact.tap(x, y, resolved.id, timeoutMs)
|
|
1232
1233
|
}
|
|
1233
1234
|
|
|
1234
|
-
static async tapElementHandler({ elementId }: { elementId: string }): Promise<TapElementResponse> {
|
|
1235
|
+
static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }: { elementId: string, verificationMode?: 'none' | 'light' | 'full', timeoutMs?: number, platform?: 'android' | 'ios', deviceId?: string, freshTree?: any }): Promise<TapElementResponse> {
|
|
1235
1236
|
const actionType = 'tap_element'
|
|
1236
1237
|
const selector = { elementId }
|
|
1237
1238
|
const resolved = ToolsInteract._resolvedUiElements.get(elementId)
|
|
1238
|
-
if (!resolved) {
|
|
1239
|
+
if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId)) {
|
|
1239
1240
|
return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null)
|
|
1240
1241
|
}
|
|
1241
1242
|
|
|
1242
|
-
const fingerprintBefore = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
|
|
1243
|
+
const fingerprintBefore = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null
|
|
1243
1244
|
|
|
1244
|
-
const tree = await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId }) as any
|
|
1245
|
+
const tree = freshTree ?? await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId }) as any
|
|
1245
1246
|
const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : resolved.platform
|
|
1246
1247
|
const treeDeviceId = tree?.device?.id || resolved.deviceId
|
|
1247
1248
|
const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
|
|
@@ -1272,14 +1273,14 @@ export class ToolsInteract {
|
|
|
1272
1273
|
|
|
1273
1274
|
const x = Math.floor((bounds[0] + bounds[2]) / 2)
|
|
1274
1275
|
const y = Math.floor((bounds[1] + bounds[3]) / 2)
|
|
1275
|
-
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId })
|
|
1276
|
+
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId, timeoutMs })
|
|
1276
1277
|
|
|
1277
1278
|
if (!tapResult.success) {
|
|
1278
|
-
const fingerprintAfterFailure = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
|
|
1279
|
-
return ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure)
|
|
1279
|
+
const fingerprintAfterFailure = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null
|
|
1280
|
+
return { ...ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure), error: tapResult.error }
|
|
1280
1281
|
}
|
|
1281
1282
|
|
|
1282
|
-
const fingerprintAfter = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
|
|
1283
|
+
const fingerprintAfter = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null
|
|
1283
1284
|
return buildActionExecutionResult({
|
|
1284
1285
|
actionType,
|
|
1285
1286
|
device: tree?.device,
|
|
@@ -2204,19 +2205,19 @@ export class ToolsInteract {
|
|
|
2204
2205
|
return buildFailure('CONTROL_CONVERGENCE_FAILED', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter)
|
|
2205
2206
|
}
|
|
2206
2207
|
|
|
2207
|
-
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string }) {
|
|
2208
|
+
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId, timeoutMs }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string, timeoutMs?: number }) {
|
|
2208
2209
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
2209
|
-
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id)
|
|
2210
|
+
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id, timeoutMs)
|
|
2210
2211
|
}
|
|
2211
2212
|
|
|
2212
|
-
static async typeTextHandler({ text, deviceId }: { text: string, deviceId?: string }) {
|
|
2213
|
+
static async typeTextHandler({ text, deviceId, timeoutMs }: { text: string, deviceId?: string, timeoutMs?: number }) {
|
|
2213
2214
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
|
|
2214
|
-
return await new AndroidInteract().typeText(text, resolved.id)
|
|
2215
|
+
return await new AndroidInteract().typeText(text, resolved.id, timeoutMs)
|
|
2215
2216
|
}
|
|
2216
2217
|
|
|
2217
|
-
static async pressBackHandler({ deviceId }: { deviceId?: string }) {
|
|
2218
|
+
static async pressBackHandler({ deviceId, timeoutMs }: { deviceId?: string, timeoutMs?: number }) {
|
|
2218
2219
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
|
|
2219
|
-
return await new AndroidInteract().pressBack(resolved.id)
|
|
2220
|
+
return await new AndroidInteract().pressBack(resolved.id, timeoutMs)
|
|
2220
2221
|
}
|
|
2221
2222
|
|
|
2222
2223
|
static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }: { platform: 'android' | 'ios', selector: { text?: string, resourceId?: string, contentDesc?: string, className?: string }, direction?: 'down' | 'up', maxScrolls?: number, scrollAmount?: number, deviceId?: string }) {
|
|
@@ -2490,7 +2491,7 @@ export class ToolsInteract {
|
|
|
2490
2491
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal, resolution }
|
|
2491
2492
|
}
|
|
2492
2493
|
|
|
2493
|
-
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string }) {
|
|
2494
|
+
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId, singleObservation = false, singleAttempt = false, rejectAmbiguous = false }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string, singleObservation?: boolean, singleAttempt?: boolean, rejectAmbiguous?: boolean }) {
|
|
2494
2495
|
const overallStart = Date.now()
|
|
2495
2496
|
const requestedIndex = typeof match?.index === 'number' ? match.index : null
|
|
2496
2497
|
const requested = {
|
|
@@ -2538,7 +2539,7 @@ export class ToolsInteract {
|
|
|
2538
2539
|
let lastConditionSatisfied = false
|
|
2539
2540
|
let matchedAt: number | null = null
|
|
2540
2541
|
let stableMatchCount = 0
|
|
2541
|
-
const stableObservationCount = 2
|
|
2542
|
+
const stableObservationCount = singleObservation ? 1 : 2
|
|
2542
2543
|
const snapshotStaleThresholdMs = 500
|
|
2543
2544
|
|
|
2544
2545
|
// Precompute normalized selector values and helpers (constant across polls)
|
|
@@ -2554,7 +2555,8 @@ export class ToolsInteract {
|
|
|
2554
2555
|
const attemptStart = Date.now()
|
|
2555
2556
|
const deadline = attemptStart + (timeout_ms || 0)
|
|
2556
2557
|
|
|
2557
|
-
while (Date.now() <= deadline) {
|
|
2558
|
+
while (Date.now() <= deadline || (singleAttempt && totalPollCount === 0)) {
|
|
2559
|
+
remainingBudget()
|
|
2558
2560
|
totalPollCount++
|
|
2559
2561
|
try {
|
|
2560
2562
|
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
|
|
@@ -2601,6 +2603,7 @@ export class ToolsInteract {
|
|
|
2601
2603
|
|
|
2602
2604
|
// Evaluate condition
|
|
2603
2605
|
const matchedCount = matches.length
|
|
2606
|
+
if (rejectAmbiguous && matchedCount > 1 && match?.index === undefined) return { status: 'timeout', error: { code: 'AMBIGUOUS_TARGET' } }
|
|
2604
2607
|
const pickIndex = (typeof match?.index === 'number') ? match!.index as number : undefined
|
|
2605
2608
|
let chosen: { el: any, idx: number } | null = null
|
|
2606
2609
|
if (matches.length > 0) {
|
|
@@ -2659,6 +2662,7 @@ export class ToolsInteract {
|
|
|
2659
2662
|
|
|
2660
2663
|
return {
|
|
2661
2664
|
status: 'success',
|
|
2665
|
+
...(singleObservation ? { _tree: tree } : {}),
|
|
2662
2666
|
matched: matchedCount,
|
|
2663
2667
|
element: outEl,
|
|
2664
2668
|
metrics: { latency_ms, poll_count: totalPollCount, attempts },
|
|
@@ -2677,10 +2681,12 @@ export class ToolsInteract {
|
|
|
2677
2681
|
}
|
|
2678
2682
|
|
|
2679
2683
|
} catch (e) {
|
|
2684
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
|
|
2680
2685
|
// Non-fatal per-poll error; record and continue
|
|
2681
2686
|
console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e))
|
|
2682
2687
|
}
|
|
2683
2688
|
|
|
2689
|
+
if (singleAttempt) break
|
|
2684
2690
|
// Sleep until next poll
|
|
2685
2691
|
await new Promise(r => setTimeout(r, effectivePoll || 50))
|
|
2686
2692
|
}
|
|
@@ -2712,6 +2718,7 @@ export class ToolsInteract {
|
|
|
2712
2718
|
}
|
|
2713
2719
|
|
|
2714
2720
|
} catch (err) {
|
|
2721
|
+
if (err instanceof Error && err.message === 'ACTION_TIMEOUT') throw err
|
|
2715
2722
|
const elapsed = Date.now() - overallStart
|
|
2716
2723
|
return {
|
|
2717
2724
|
status: 'timeout',
|
|
@@ -3139,7 +3146,8 @@ export class ToolsInteract {
|
|
|
3139
3146
|
stabilization_window_ms = 1000,
|
|
3140
3147
|
stable_observation_count = 2,
|
|
3141
3148
|
snapshot_stale_threshold_ms = 500,
|
|
3142
|
-
poll_interval_ms = 150
|
|
3149
|
+
poll_interval_ms = 150,
|
|
3150
|
+
exact_budget = false
|
|
3143
3151
|
}: {
|
|
3144
3152
|
selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean },
|
|
3145
3153
|
element_id?: string,
|
|
@@ -3150,16 +3158,17 @@ export class ToolsInteract {
|
|
|
3150
3158
|
stabilization_window_ms?: number,
|
|
3151
3159
|
stable_observation_count?: number,
|
|
3152
3160
|
snapshot_stale_threshold_ms?: number,
|
|
3153
|
-
poll_interval_ms?: number
|
|
3161
|
+
poll_interval_ms?: number,
|
|
3162
|
+
exact_budget?: boolean
|
|
3154
3163
|
}): Promise<ExpectStateResponse> {
|
|
3155
3164
|
const compareBoolean = (value: unknown) => typeof value === 'boolean' ? value : null
|
|
3156
3165
|
const compareString = (value: unknown) => typeof value === 'string' ? value : null
|
|
3157
3166
|
const compareNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
3158
3167
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
3159
3168
|
const start = Date.now()
|
|
3160
|
-
const deadline = start + Math.max(500, stabilization_window_ms)
|
|
3169
|
+
const deadline = start + (exact_budget ? stabilization_window_ms : Math.max(500, stabilization_window_ms))
|
|
3161
3170
|
const stableTarget = Math.max(1, Math.floor(stable_observation_count || 2))
|
|
3162
|
-
const pollDelay = Math.max(100, Math.min(poll_interval_ms || 150, 200))
|
|
3171
|
+
const pollDelay = exact_budget ? Math.max(50, Math.min(poll_interval_ms || 150, 1000)) : Math.max(100, Math.min(poll_interval_ms || 150, 200))
|
|
3163
3172
|
const staleThreshold = Math.max(300, Math.min(snapshot_stale_threshold_ms || 500, 800))
|
|
3164
3173
|
|
|
3165
3174
|
let attempts = 0
|
package/src/interact/ios.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remainingBudget, markDispatchStarted } from '../utils/operation-budget.js'
|
|
1
2
|
import { spawn } from "child_process"
|
|
2
3
|
import { TapResponse, SwipeResponse } from "../types.js"
|
|
3
4
|
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
@@ -31,7 +32,8 @@ export class iOSInteract {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
await new Promise<void>((resolve, reject) => {
|
|
34
|
-
|
|
35
|
+
markDispatchStarted()
|
|
36
|
+
const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
|
|
35
37
|
let stderr = '';
|
|
36
38
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
37
39
|
proc.on('close', code => {
|
|
@@ -72,7 +74,8 @@ export class iOSInteract {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
await new Promise<void>((resolve, reject) => {
|
|
75
|
-
|
|
77
|
+
markDispatchStarted()
|
|
78
|
+
const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
|
|
76
79
|
let stderr = '';
|
|
77
80
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
78
81
|
proc.on('close', code => {
|
package/src/manage/android.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js'
|
|
1
2
|
import { promises as fs } from 'fs'
|
|
2
3
|
import { spawn } from 'child_process'
|
|
3
4
|
import path from 'path'
|
|
@@ -149,16 +150,18 @@ export class AndroidManage {
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
|
|
152
|
-
async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
|
|
153
|
+
async startApp(appId: string, deviceId?: string, timeoutMs?: number): Promise<StartAppResponse> {
|
|
154
|
+
const started = performance.now()
|
|
153
155
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
154
156
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
155
157
|
try {
|
|
156
|
-
|
|
158
|
+
markDispatchStarted()
|
|
159
|
+
const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs })
|
|
157
160
|
const current = await new AndroidObserve().getCurrentScreen(deviceId).catch(() => null)
|
|
158
161
|
return {
|
|
159
162
|
device: deviceInfo,
|
|
160
163
|
appStarted: true,
|
|
161
|
-
launchTimeMs:
|
|
164
|
+
launchTimeMs: performance.now() - started,
|
|
162
165
|
output,
|
|
163
166
|
observedApp: {
|
|
164
167
|
appId,
|
|
@@ -169,8 +172,8 @@ export class AndroidManage {
|
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
} catch (e: unknown) {
|
|
172
|
-
const
|
|
173
|
-
return { device: deviceInfo, appStarted: false, launchTimeMs:
|
|
175
|
+
const diagnostics = timeoutMs === undefined ? execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId) : undefined
|
|
176
|
+
return { device: deviceInfo, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) }
|
|
174
177
|
}
|
|
175
178
|
}
|
|
176
179
|
|
|
@@ -178,17 +181,22 @@ export class AndroidManage {
|
|
|
178
181
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
179
182
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
180
183
|
try {
|
|
184
|
+
markDispatchStarted()
|
|
181
185
|
await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
|
|
182
186
|
return { device: deviceInfo, appTerminated: true }
|
|
183
187
|
} catch (e: unknown) {
|
|
184
|
-
const
|
|
185
|
-
return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics:
|
|
188
|
+
const diagnostics = hasFiniteBudget() ? undefined : execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId)
|
|
189
|
+
return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) }
|
|
186
190
|
}
|
|
187
191
|
}
|
|
188
192
|
|
|
189
|
-
async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
|
|
190
|
-
const
|
|
191
|
-
const
|
|
193
|
+
async restartApp(appId: string, deviceId?: string, bounded = false, timeoutMs = 15000): Promise<RestartAppResponse> {
|
|
194
|
+
const started = performance.now()
|
|
195
|
+
const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(appId, deviceId)) : await this.terminateApp(appId, deviceId)
|
|
196
|
+
const remaining = timeoutMs - (performance.now() - started)
|
|
197
|
+
if (bounded && remaining <= 0) throw new Error('ACTION_TIMEOUT')
|
|
198
|
+
const launchBudget = remainingBudget(remaining)
|
|
199
|
+
const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(appId, deviceId, remainingBudget(launchBudget))) : await this.startApp(appId, deviceId)
|
|
192
200
|
return {
|
|
193
201
|
device: startResult.device,
|
|
194
202
|
appRestarted: startResult.appStarted,
|
|
@@ -206,6 +214,7 @@ export class AndroidManage {
|
|
|
206
214
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
207
215
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
208
216
|
try {
|
|
217
|
+
markDispatchStarted()
|
|
209
218
|
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
|
|
210
219
|
return { device: deviceInfo, dataCleared: output === 'Success' }
|
|
211
220
|
} catch (e: unknown) {
|
package/src/manage/ios.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js'
|
|
1
2
|
import { promises as fs } from "fs"
|
|
2
3
|
import { spawn, spawnSync } from "child_process"
|
|
3
4
|
import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, InstallAppResponse } from "../types.js"
|
|
@@ -283,7 +284,7 @@ export class iOSManage {
|
|
|
283
284
|
// Gather diagnostics for simctl failure
|
|
284
285
|
const diag = execCommandWithDiagnostics(['simctl', 'install', deviceId, toInstall], deviceId)
|
|
285
286
|
try {
|
|
286
|
-
const child = spawn(getIdbCmd(), ['--
|
|
287
|
+
const child = spawn(getIdbCmd(), ['list-targets', '--json'])
|
|
287
288
|
const idbExists = await new Promise<boolean>((resolve) => {
|
|
288
289
|
child.on('error', () => resolve(false));
|
|
289
290
|
child.on('close', (code) => resolve(code === 0));
|
|
@@ -310,17 +311,19 @@ export class iOSManage {
|
|
|
310
311
|
}
|
|
311
312
|
}
|
|
312
313
|
|
|
313
|
-
async startApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
|
|
314
|
+
async startApp(bundleId: string, deviceId: string = "booted", observe = true): Promise<StartAppResponse> {
|
|
315
|
+
const started = performance.now()
|
|
314
316
|
validateBundleId(bundleId)
|
|
315
317
|
try {
|
|
318
|
+
markDispatchStarted()
|
|
316
319
|
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
317
320
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
318
|
-
const fingerprint = await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null)
|
|
321
|
+
const fingerprint = observe ? await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null) : null
|
|
319
322
|
const pidMatch = result.output.match(/:\s*(\d+)\s*$/)
|
|
320
323
|
return {
|
|
321
324
|
device,
|
|
322
325
|
appStarted: !!result.output,
|
|
323
|
-
launchTimeMs:
|
|
326
|
+
launchTimeMs: performance.now() - started,
|
|
324
327
|
output: result.output,
|
|
325
328
|
observedApp: {
|
|
326
329
|
appId: bundleId,
|
|
@@ -330,28 +333,33 @@ export class iOSManage {
|
|
|
330
333
|
}
|
|
331
334
|
}
|
|
332
335
|
} catch (e: unknown) {
|
|
333
|
-
const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
334
336
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
335
|
-
|
|
337
|
+
const diagnostics = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
338
|
+
return { device, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) } as any
|
|
336
339
|
}
|
|
337
340
|
}
|
|
338
341
|
|
|
339
342
|
async terminateApp(bundleId: string, deviceId: string = "booted"): Promise<TerminateAppResponse> {
|
|
340
343
|
validateBundleId(bundleId)
|
|
341
344
|
try {
|
|
345
|
+
markDispatchStarted()
|
|
342
346
|
await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
|
|
343
347
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
344
348
|
return { device, appTerminated: true }
|
|
345
349
|
} catch (e: unknown) {
|
|
346
|
-
const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
|
|
350
|
+
const diag = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
|
|
347
351
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
348
|
-
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
|
|
352
|
+
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diag ? { diagnostics: diag } : {}) } as any
|
|
349
353
|
}
|
|
350
354
|
}
|
|
351
355
|
|
|
352
|
-
async restartApp(bundleId: string, deviceId: string = "booted"): Promise<RestartAppResponse> {
|
|
353
|
-
const
|
|
354
|
-
const
|
|
356
|
+
async restartApp(bundleId: string, deviceId: string = "booted", observe = true, bounded = false, timeoutMs = 15000): Promise<RestartAppResponse> {
|
|
357
|
+
const started = performance.now()
|
|
358
|
+
const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(bundleId, deviceId)) : await this.terminateApp(bundleId, deviceId)
|
|
359
|
+
const remaining = timeoutMs - (performance.now() - started)
|
|
360
|
+
if (bounded && remaining <= 0) throw new Error('ACTION_TIMEOUT')
|
|
361
|
+
const launchBudget = remainingBudget(remaining)
|
|
362
|
+
const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(bundleId, deviceId, observe)) : await this.startApp(bundleId, deviceId, observe)
|
|
355
363
|
return {
|
|
356
364
|
device: startResult.device,
|
|
357
365
|
appRestarted: startResult.appStarted,
|
package/src/observe/android.ts
CHANGED
|
@@ -8,6 +8,7 @@ import path from "path"
|
|
|
8
8
|
import { computeScreenFingerprint } from "../utils/ui/index.js"
|
|
9
9
|
import { parsePngSize } from "../utils/image.js"
|
|
10
10
|
import { deriveSnapshotMetadata } from "./snapshot-metadata.js"
|
|
11
|
+
import { remainingBudget } from '../utils/operation-budget.js'
|
|
11
12
|
|
|
12
13
|
const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
|
|
13
14
|
|
|
@@ -33,10 +34,12 @@ export class AndroidObserve {
|
|
|
33
34
|
const maxAttempts = 3;
|
|
34
35
|
|
|
35
36
|
while (attempts < maxAttempts) {
|
|
37
|
+
remainingBudget()
|
|
36
38
|
attempts++;
|
|
37
39
|
try {
|
|
38
40
|
// Stabilization delay
|
|
39
|
-
await delay(300 + (attempts * 100)); // 300ms, 400ms, 500ms...
|
|
41
|
+
await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))); // 300ms, 400ms, 500ms...
|
|
42
|
+
remainingBudget()
|
|
40
43
|
|
|
41
44
|
// Dump UI hierarchy
|
|
42
45
|
await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
|
|
@@ -49,6 +52,7 @@ export class AndroidObserve {
|
|
|
49
52
|
break; // Success
|
|
50
53
|
}
|
|
51
54
|
} catch (e) {
|
|
55
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
|
|
52
56
|
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
53
57
|
}
|
|
54
58
|
|
package/src/observe/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { measure } from '../utils/timing.js'
|
|
1
2
|
import { resolveTargetDevice } from '../utils/resolve-device.js'
|
|
2
3
|
import { AndroidObserve } from './android.js'
|
|
3
4
|
import { iOSObserve } from './ios.js'
|
|
@@ -5,7 +6,7 @@ import type {
|
|
|
5
6
|
CaptureDebugSnapshotRawResponse,
|
|
6
7
|
SnapshotSemanticResponse
|
|
7
8
|
} from '../types.js'
|
|
8
|
-
import { deriveSnapshotMetadata } from './snapshot-metadata.js'
|
|
9
|
+
import { deriveSnapshotMetadata, getLatestStateDelta, getStateDelta } from './snapshot-metadata.js'
|
|
9
10
|
|
|
10
11
|
export { AndroidObserve } from './android.js'
|
|
11
12
|
export { iOSObserve } from './ios.js'
|
|
@@ -149,6 +150,9 @@ function deriveSnapshotSemantic(raw: CaptureDebugSnapshotRawResponse): SnapshotS
|
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
export class ToolsObserve {
|
|
153
|
+
static getLatestStateDelta(platform: 'android' | 'ios', deviceId: string) {
|
|
154
|
+
return getLatestStateDelta(`${platform}:${deviceId}`)
|
|
155
|
+
}
|
|
152
156
|
// Resolve a target device and return the appropriate observe instance and resolved info.
|
|
153
157
|
private static async resolveObserve(platform?: 'android' | 'ios', deviceId?: string, appId?: string) {
|
|
154
158
|
if (platform === 'android') {
|
|
@@ -170,9 +174,21 @@ export class ToolsObserve {
|
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
176
|
|
|
173
|
-
static async getUITreeHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string }) {
|
|
177
|
+
static async getUITreeHandler({ platform, deviceId, responseMode = 'debug', sinceSnapshotRevision }: { platform?: 'android' | 'ios', deviceId?: string, responseMode?: 'compact' | 'debug', sinceSnapshotRevision?: number }) {
|
|
174
178
|
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
|
|
175
|
-
|
|
179
|
+
const tree = await observe.getUITree(resolved.id) as any
|
|
180
|
+
if (responseMode !== 'compact') return tree
|
|
181
|
+
const revision = tree?.snapshot_revision ?? null
|
|
182
|
+
const baseRevision = sinceSnapshotRevision
|
|
183
|
+
if (!Number.isInteger(baseRevision) || revision === null) {
|
|
184
|
+
return { delta_available: false, snapshot_revision: revision }
|
|
185
|
+
}
|
|
186
|
+
const delta = getStateDelta(`${resolved.platform}:${resolved.id}`, baseRevision as number, revision)
|
|
187
|
+
return delta ? { delta_available: true, state_delta: delta, snapshot_revision: revision } : {
|
|
188
|
+
delta_available: false,
|
|
189
|
+
snapshot_revision: revision,
|
|
190
|
+
reason: 'BASE_REVISION_UNAVAILABLE'
|
|
191
|
+
}
|
|
176
192
|
}
|
|
177
193
|
|
|
178
194
|
static async getCurrentScreenHandler({ deviceId }: { deviceId?: string }) {
|
|
@@ -244,7 +260,7 @@ export class ToolsObserve {
|
|
|
244
260
|
return await (observe as any).getScreenFingerprint(resolved.id)
|
|
245
261
|
}
|
|
246
262
|
|
|
247
|
-
static async captureDebugSnapshotHandler({ reason, includeLogs = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
|
|
263
|
+
static async captureDebugSnapshotHandler({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; includeScreenshot?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
|
|
248
264
|
const timestamp = Date.now()
|
|
249
265
|
const raw: CaptureDebugSnapshotRawResponse = {
|
|
250
266
|
timestamp,
|
|
@@ -261,7 +277,7 @@ export class ToolsObserve {
|
|
|
261
277
|
// Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
|
|
262
278
|
const sid = sessionId || 'default'
|
|
263
279
|
const tasks: Record<string, Promise<any>> = {
|
|
264
|
-
screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
|
|
280
|
+
screenshot: includeScreenshot ? measure('screenshot_ms', () => ToolsObserve.captureScreenshotHandler({ platform, deviceId })) : Promise.resolve(null),
|
|
265
281
|
currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
|
|
266
282
|
fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
|
|
267
283
|
uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
|
package/src/observe/ios.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
|
+
import { remainingBudget } from '../utils/operation-budget.js'
|
|
2
3
|
import { promises as fs } from "fs"
|
|
3
4
|
import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo, UIElementSemanticMetadata, UIElementState, UIResolutionSelector, SelectorConfidence } from "../types.js"
|
|
4
5
|
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
@@ -547,9 +548,11 @@ export class iOSObserve {
|
|
|
547
548
|
const maxAttempts = 3;
|
|
548
549
|
|
|
549
550
|
while (attempts < maxAttempts) {
|
|
551
|
+
remainingBudget()
|
|
550
552
|
attempts++;
|
|
551
553
|
try {
|
|
552
|
-
await delay(300 + (attempts * 100));
|
|
554
|
+
await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100)));
|
|
555
|
+
remainingBudget()
|
|
553
556
|
|
|
554
557
|
const args = ['ui', 'describe-all', '--json'];
|
|
555
558
|
if (targetUdid) {
|
|
@@ -557,7 +560,7 @@ export class iOSObserve {
|
|
|
557
560
|
}
|
|
558
561
|
|
|
559
562
|
const output = await new Promise<string>((resolve, reject) => {
|
|
560
|
-
const child = spawn(getIdbCmd(), args);
|
|
563
|
+
const child = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
|
|
561
564
|
let stdout = '';
|
|
562
565
|
let stderr = '';
|
|
563
566
|
|
|
@@ -580,6 +583,7 @@ export class iOSObserve {
|
|
|
580
583
|
break; // Success
|
|
581
584
|
}
|
|
582
585
|
} catch (e) {
|
|
586
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
|
|
583
587
|
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
584
588
|
}
|
|
585
589
|
|
|
@@ -5,8 +5,15 @@ interface SnapshotState {
|
|
|
5
5
|
revision: number
|
|
6
6
|
signature: string | null
|
|
7
7
|
elementSignatures: Map<string, string>
|
|
8
|
+
elements: Map<string, UIElement>
|
|
9
|
+
history: Map<number, Map<string, UIElement>>
|
|
10
|
+
historyTimes: Map<number, number>
|
|
11
|
+
oversized: Map<number, number>
|
|
12
|
+
previousRevision: number | null
|
|
13
|
+
updatedAt: number
|
|
8
14
|
}
|
|
9
15
|
|
|
16
|
+
let nextRevision = 1
|
|
10
17
|
const snapshotStateByDevice = new Map<string, SnapshotState>()
|
|
11
18
|
|
|
12
19
|
function normalize(value: unknown): string {
|
|
@@ -53,18 +60,20 @@ function stableElementIdentity(element: UIElement, index: number) {
|
|
|
53
60
|
})).digest('hex')}`
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
function
|
|
63
|
+
function buildElementRecords(tree: Pick<GetUITreeResponse, 'elements'> | null | undefined) {
|
|
57
64
|
const signatures = new Map<string, string>()
|
|
65
|
+
const elementsByIdentity = new Map<string, UIElement>()
|
|
58
66
|
const elements = Array.isArray(tree?.elements) ? tree!.elements! : []
|
|
59
67
|
|
|
60
|
-
for (let index = 0; index < elements.length; index++) {
|
|
68
|
+
for (let index = 0; index < Math.min(elements.length, 500); index++) {
|
|
61
69
|
const element = elements[index]
|
|
62
70
|
if (!element) continue
|
|
63
71
|
const identity = stableElementIdentity(element, index)
|
|
64
72
|
signatures.set(identity, crypto.createHash('sha1').update(JSON.stringify(stableElementSignature(element))).digest('hex'))
|
|
73
|
+
elementsByIdentity.set(identity, structuredClone(element))
|
|
65
74
|
}
|
|
66
75
|
|
|
67
|
-
return signatures
|
|
76
|
+
return { signatures, elementsByIdentity }
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
function summarizeSnapshotDelta(previous: SnapshotState | undefined, currentElements: Map<string, string>): SnapshotDelta | null {
|
|
@@ -140,25 +149,45 @@ export function deriveSnapshotMetadata(
|
|
|
140
149
|
signatureOverride?: string | null
|
|
141
150
|
) {
|
|
142
151
|
const signature = signatureOverride ?? computeSnapshotSignature(tree)
|
|
143
|
-
const
|
|
152
|
+
const existing = snapshotStateByDevice.get(deviceKey)
|
|
153
|
+
const previous = existing && Date.now() - existing.updatedAt <= 10 * 60 * 1000 ? existing : undefined
|
|
154
|
+
if (!previous && existing) snapshotStateByDevice.delete(deviceKey)
|
|
144
155
|
const hasValidTree = !!tree && !tree.error
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
const records = hasValidTree ? buildElementRecords(tree) : null
|
|
157
|
+
const currentElementSignatures = records?.signatures ?? previous?.elementSignatures ?? new Map<string, string>()
|
|
158
|
+
const currentElements = records?.elementsByIdentity ?? previous?.elements ?? new Map<string, UIElement>()
|
|
159
|
+
|
|
160
|
+
const revision = previous && (signature === null || previous.signature === signature) ? previous.revision : nextRevision++
|
|
161
|
+
const history = previous?.history ?? new Map<number, Map<string, UIElement>>()
|
|
162
|
+
const historyTimes = previous?.historyTimes ?? new Map<number, number>()
|
|
163
|
+
const oversized = previous?.oversized ?? new Map<number, number>()
|
|
164
|
+
if (hasValidTree) {
|
|
165
|
+
history.set(revision, currentElements)
|
|
166
|
+
if (!historyTimes.has(revision)) historyTimes.set(revision, Date.now())
|
|
167
|
+
if ((tree.elements?.length ?? 0) > 500) oversized.set(revision, (tree.elements?.length ?? 0) - 500)
|
|
168
|
+
}
|
|
169
|
+
for (const [id, timestamp] of historyTimes) {
|
|
170
|
+
if (Date.now() - timestamp > 10 * 60 * 1000) {
|
|
171
|
+
history.delete(id)
|
|
172
|
+
historyTimes.delete(id)
|
|
173
|
+
oversized.delete(id)
|
|
155
174
|
}
|
|
156
175
|
}
|
|
176
|
+
while (history.size > 8) {
|
|
177
|
+
const oldest = history.keys().next().value
|
|
178
|
+
if (oldest === undefined) break
|
|
179
|
+
history.delete(oldest)
|
|
180
|
+
historyTimes.delete(oldest)
|
|
181
|
+
oversized.delete(oldest)
|
|
182
|
+
}
|
|
157
183
|
|
|
158
184
|
snapshotStateByDevice.set(deviceKey, {
|
|
159
185
|
revision,
|
|
160
186
|
signature,
|
|
161
|
-
elementSignatures: currentElementSignatures
|
|
187
|
+
elementSignatures: currentElementSignatures,
|
|
188
|
+
elements: currentElements,
|
|
189
|
+
history, historyTimes, oversized, previousRevision: previous?.revision ?? null,
|
|
190
|
+
updatedAt: Date.now()
|
|
162
191
|
})
|
|
163
192
|
|
|
164
193
|
return {
|
|
@@ -169,6 +198,56 @@ export function deriveSnapshotMetadata(
|
|
|
169
198
|
}
|
|
170
199
|
}
|
|
171
200
|
|
|
201
|
+
export function getStateDelta(deviceKey: string, baseRevision: number, currentRevision: number) {
|
|
202
|
+
const state = snapshotStateByDevice.get(deviceKey)
|
|
203
|
+
if (!state || Date.now() - (state.historyTimes.get(baseRevision) ?? 0) > 10 * 60 * 1000 || Date.now() - state.updatedAt > 10 * 60 * 1000) return null
|
|
204
|
+
const base = state.history.get(baseRevision)
|
|
205
|
+
const current = state?.history.get(currentRevision)
|
|
206
|
+
if (!base || !current) return null
|
|
207
|
+
|
|
208
|
+
const added: UIElement[] = []
|
|
209
|
+
const changed: UIElement[] = []
|
|
210
|
+
const removed: Array<{ stable_id?: string, resourceId?: string, contentDescription?: string, text?: string, index: number }> = []
|
|
211
|
+
for (const [identity, element] of current) {
|
|
212
|
+
const before = base.get(identity)
|
|
213
|
+
if (!before) added.push(element)
|
|
214
|
+
else if (JSON.stringify(stableElementSignature(before)) !== JSON.stringify(stableElementSignature(element))) changed.push(element)
|
|
215
|
+
}
|
|
216
|
+
let index = 0
|
|
217
|
+
for (const [identity, element] of base) {
|
|
218
|
+
if (!current.has(identity)) removed.push({ stable_id: element.stable_id ?? undefined, resourceId: element.resourceId ?? undefined, contentDescription: element.contentDescription ?? undefined, text: element.text ?? undefined, index })
|
|
219
|
+
index++
|
|
220
|
+
}
|
|
221
|
+
const all = added.length + changed.length + removed.length
|
|
222
|
+
const limit = 200
|
|
223
|
+
return {
|
|
224
|
+
base_snapshot_revision: baseRevision,
|
|
225
|
+
snapshot_revision: currentRevision,
|
|
226
|
+
added: added.slice(0, limit),
|
|
227
|
+
changed: changed.slice(0, Math.max(0, limit - added.length)),
|
|
228
|
+
removed: removed.slice(0, Math.max(0, limit - added.length - changed.length)),
|
|
229
|
+
truncated: all > limit || state.oversized.has(baseRevision) || state.oversized.has(currentRevision),
|
|
230
|
+
...((all > limit || state.oversized.has(baseRevision) || state.oversized.has(currentRevision))
|
|
231
|
+
? { omitted_changes: Math.max(all - limit, state.oversized.get(baseRevision) ?? 0, state.oversized.get(currentRevision) ?? 0) } : {})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function getLatestStateDelta(deviceKey: string) {
|
|
236
|
+
const state = snapshotStateByDevice.get(deviceKey)
|
|
237
|
+
if (!state || state.previousRevision === null) return null
|
|
238
|
+
return getStateDelta(deviceKey, state.previousRevision, state.revision)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function retainConnectedDeviceSnapshots(platform: string, deviceIds: string[]) {
|
|
242
|
+
const keys = new Set(deviceIds.map(id => `${platform}:${id}`))
|
|
243
|
+
for (const key of snapshotStateByDevice.keys()) if (key.startsWith(`${platform}:`) && !keys.has(key)) snapshotStateByDevice.delete(key)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function clearDeviceSnapshots(deviceKey: string) {
|
|
247
|
+
snapshotStateByDevice.delete(deviceKey)
|
|
248
|
+
}
|
|
249
|
+
|
|
172
250
|
export function resetSnapshotMetadataForTests() {
|
|
173
251
|
snapshotStateByDevice.clear()
|
|
252
|
+
nextRevision = 1
|
|
174
253
|
}
|