@mobileaidev/ai-app-bridge 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +7 -3
  2. package/bin/android-permissions.js +39 -5
  3. package/bin/command-discovery.js +8 -2
  4. package/bin/command-registry.js +26 -9
  5. package/bin/device-provider.js +11 -2
  6. package/bin/execution-host.js +18 -0
  7. package/bin/executors/android-host.js +201 -0
  8. package/bin/executors/android-port.js +114 -0
  9. package/bin/executors/automation-owner.js +42 -0
  10. package/bin/executors/command-schema.js +102 -0
  11. package/bin/executors/flutter-host.js +123 -0
  12. package/bin/executors/managed-runtime.js +85 -0
  13. package/bin/executors/playwright-host.js +146 -0
  14. package/bin/executors/playwright-worker.js +307 -0
  15. package/bin/executors/receipt-journal.js +56 -0
  16. package/bin/feedback-probe.js +27 -1
  17. package/bin/ios-device-outcome.js +17 -0
  18. package/bin/ios-execution.js +13 -1
  19. package/bin/ios-provider.js +74 -24
  20. package/bin/ios-runtime-binding.js +1 -1
  21. package/bin/ios-wda-startup.js +68 -0
  22. package/bin/runtime-directory.js +1 -1
  23. package/bin/shared-kernel/device-ownership-recovery.js +13 -0
  24. package/bin/shared-kernel/native-target.js +11 -8
  25. package/bin/shared-kernel/uia-protocol.js +1 -1
  26. package/bin/shared-kernel/uia-runtime-port.js +38 -2
  27. package/bin/ui-observation.js +29 -0
  28. package/bin/web-provider.js +6 -1
  29. package/docs/COMMAND_CONTRACT.md +73 -2
  30. package/docs/INTENT_FOREGROUND.md +1 -1
  31. package/docs/OPTIONAL_EXECUTORS.md +182 -0
  32. package/docs/RELEASE.md +36 -14
  33. package/docs/SCRIPT_AUTHORING.md +7 -0
  34. package/package.json +6 -1
  35. package/runtime/executors/playwright/package-lock.json +45 -0
  36. package/runtime/executors/playwright/package.json +8 -0
  37. package/runtime/uia/ai-app-bridge-uia.jar +0 -0
  38. package/runtime/uia/manifest.json +9 -8
@@ -4,7 +4,7 @@ const { CommandError } = require('./command-errors');
4
4
  const { currentExecution } = require('./shared-kernel/execution-scope');
5
5
 
6
6
  const schemaVersion = 'aab.ios-runtime/v1';
7
- const sdkCommands = new Set(['ios-status', 'ios-tree', 'ios-logs', 'ios-network', 'ios-state', 'ios-events',
7
+ const sdkCommands = new Set(['ios-ui-observation', 'ios-status', 'ios-tree', 'ios-logs', 'ios-network', 'ios-state', 'ios-events',
8
8
  'ios-h5-dom', 'ios-h5-eval', 'ios-h5-click', 'ios-h5-input', 'ios-h5-scroll', 'ios-flutter-tree', 'ios-flutter-nodes', 'ios-flutter-action',
9
9
  'ios-tap-flutter', 'ios-input-flutter-text', 'ios-scroll-flutter', 'ios-flutter-back', 'ios-flutter-hide-keyboard']);
10
10
  const fields = ['schemaVersion', 'bundleId', 'runtimeEpoch', 'processId', 'port'];
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { createHash } = require('node:crypto');
6
+ const { getHostFactStore } = require('./shared-kernel/host-fact-store');
7
+
8
+ const initializationFailure = 'The test runner failed to initialize for UI testing. (Underlying Error: Timed out while enabling automation mode.)';
9
+ const digest = value => createHash('sha256').update(value).digest('hex');
10
+
11
+ function initializationProof(summary, invocation, deviceId, completedAtMs = Date.now()) {
12
+ const args = invocation?.arguments;
13
+ if (!Array.isArray(args) || args[args.indexOf('-destination') + 1] !== `id=${deviceId}`
14
+ || args[args.indexOf('-scheme') + 1] !== 'WebDriverAgentRunner' || !args.includes('test-without-building')
15
+ || summary?.result !== 'Failed' || summary.totalTestCount !== 1 || summary.failedTests !== 1
16
+ || summary.passedTests !== 0 || summary.skippedTests !== 0 || summary.expectedFailures !== 0
17
+ || summary.devicesAndConfigurations?.length !== 1 || summary.devicesAndConfigurations[0].device?.deviceId !== deviceId
18
+ || summary.testFailures?.length !== 1 || summary.testFailures[0].targetName !== 'WebDriverAgentRunner'
19
+ || summary.testFailures[0].failureText !== initializationFailure
20
+ || !Number.isFinite(summary.startTime) || !Number.isFinite(summary.finishTime)
21
+ || summary.startTime * 1000 < invocation.startedAtMs || summary.finishTime < summary.startTime
22
+ || summary.finishTime * 1000 > completedAtMs) return null;
23
+ return { kind: 'ios-wda-start', settled: true, dispatched: true, ambiguous: false, invocation,
24
+ outcome: { ok: false, error: 'ios_wda_automation_confirmation_required', settled: true, dispatched: true, ambiguous: false,
25
+ message: 'XCTest ended before UI test initialization completed. Confirm Enable UI Automation on the iPhone, then explicitly run ios-setup again.' },
26
+ summary, summarySha256: digest(JSON.stringify(summary)) };
27
+ }
28
+
29
+ // Old setup markers lack a test invocation. Recovery requires the original
30
+ // public response AND its retained Host action, not a newly supplied exit code.
31
+ async function recoverLegacySetup({ pending, owner, resultPath, device, readSummary, readAction }) {
32
+ if (pending.command !== 'ios-setup' || pending.invocation || !resultPath) return null;
33
+ const bytes = await fs.promises.readFile(resultPath);
34
+ if (bytes.length > 4 * 1024 * 1024) return null;
35
+ const envelope = JSON.parse(bytes.toString('utf8'));
36
+ const result = envelope.kind === 'json' ? envelope.value : envelope;
37
+ const feedback = result?._feedback, times = feedback?.timings;
38
+ const evidence = feedback?.evidence?.filter(item => item.partition === 'action' && item.stored === true);
39
+ const steps = result?.steps?.filter(item => item.name === 'start-wda');
40
+ if (result?.error !== 'ios_wda_xcodebuild_exited' || result.device?.udid !== device.udid
41
+ || feedback?.dispatch?.command !== 'ios-setup' || feedback.target?.bundleId !== pending.target.bundleId
42
+ || evidence?.length !== 1 || !evidence[0].actionId?.startsWith(`host-action-${owner?.pid}-`)
43
+ || !Number.isSafeInteger(times?.startedAtMs) || !Number.isSafeInteger(times?.completedAtMs)
44
+ || times.startedAtMs > owner.acquiredAtMs || owner.acquiredAtMs > pending.preparedAtMs
45
+ || pending.preparedAtMs > times.completedAtMs || steps?.length !== 1
46
+ || steps[0].phase !== 'device-test' || steps[0].exitCode !== 65) return null;
47
+ const page = (readAction || (actionId => getHostFactStore().read({ partitions: ['action'], actionId, limit: 2 })))(evidence[0].actionId);
48
+ const fact = page.items?.[0];
49
+ if (page.ok !== true || page.items?.length !== 1 || fact.globalSeq !== evidence[0].globalSeq
50
+ || fact.payload?.command !== 'ios-setup' || fact.payload.status !== 'failed' || fact.payload.args?.startWda !== true
51
+ || fact.payload.args.deviceId !== result.device.identifier || fact.payload.args.bundleId !== pending.target.bundleId
52
+ || fact.payload.result?.error !== result.error || fact.timestamps?.occurredAtMs !== times.startedAtMs
53
+ || fact.timestamps.observedAtMs !== times.completedAtMs) return null;
54
+ const project = steps[0].prepared?.projectPath;
55
+ if (typeof project !== 'string' || path.basename(project) !== 'WebDriverAgent.xcodeproj') return null;
56
+ const directory = path.dirname(path.dirname(project));
57
+ if (steps[0].logFile !== path.join(directory, 'xcodebuild.log')) return null;
58
+ const resultsDirectory = path.join(directory, 'build', 'Logs', 'Test');
59
+ const bundles = (await fs.promises.readdir(resultsDirectory)).filter(name => name.endsWith('.xcresult'));
60
+ if (bundles.length !== 1) return null;
61
+ const invocation = { arguments: ['-project', project, '-scheme', 'WebDriverAgentRunner', '-destination', `id=${device.udid}`, 'test-without-building'],
62
+ resultBundlePath: path.join(resultsDirectory, bundles[0]), startedAtMs: pending.preparedAtMs };
63
+ const proof = initializationProof(await readSummary(invocation.resultBundlePath), invocation, device.udid, times.completedAtMs);
64
+ return proof && { ...proof, originalSetup: { pendingId: pending.id, actionId: fact.actionId,
65
+ globalSeq: fact.globalSeq, resultPath: path.resolve(resultPath), responseSha256: digest(bytes) } };
66
+ }
67
+
68
+ module.exports = { initializationProof, recoverLegacySetup };
@@ -51,7 +51,7 @@ function runtimeLocation() {
51
51
 
52
52
  function runtimeIdentity(location = runtimeLocation()) {
53
53
  const names = ['AI_APP_BRIDGE_ADB_TIMEOUT_MS', 'AI_APP_BRIDGE_DEVICECTL', 'AI_APP_BRIDGE_FACT_CACHE',
54
- 'AI_APP_BRIDGE_IOS_TEAM_ID', 'AI_APP_BRIDGE_PYTHON', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'DEVELOPMENT_TEAM', 'DEVELOPER_DIR', 'XCODEBUILD'];
54
+ 'AI_APP_BRIDGE_IOS_TEAM_ID', 'AI_APP_BRIDGE_PYTHON', 'AI_APP_BRIDGE_EXECUTOR_HOME', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'DEVELOPMENT_TEAM', 'DEVELOPER_DIR', 'XCODEBUILD'];
55
55
  const config = { facts: location.facts, profile: location.profile, ownership: canonicalPath(ownershipDirectory()),
56
56
  adb: executablePath(process.env.ADB || 'adb') ?? { unavailable: process.env.ADB || 'adb' },
57
57
  environment: Object.fromEntries(names.map(name => [name, process.env[name] ?? null])) };
@@ -19,6 +19,14 @@ async function deviceOwnership(args, { lease = getProcessDeviceMutationLease(),
19
19
  const recovered = await lease.reconcile(args.serial, async pending => {
20
20
  if (args.operation === 'cancel-install' && (pending.kind !== 'android-install' || pending.actionId !== args.actionId))
21
21
  return { settled: false, error: 'install_action_mismatch', actionId: pending.actionId };
22
+ if (pending.kind === 'android-test-executor') {
23
+ try { return await require('../executors/android-host').recoverAndroidExecutor(pending); }
24
+ catch (error) { return { settled: false, error: error.code || 'executor_completion_query_failed', message: error.message }; }
25
+ }
26
+ if (pending.kind === 'flutter-test-executor') {
27
+ try { return await require('../executors/flutter-host').recoverFlutterExecutor(pending); }
28
+ catch (error) { return { settled: false, error: error.code || 'executor_completion_query_failed', message: error.message }; }
29
+ }
22
30
  if (pending.kind === 'uia-node') {
23
31
  if (!uiaProtocol.validIdentity(pending) || pending.target.serial !== args.serial) return { settled: false, error: 'invalid_uia_execution_identity' };
24
32
  try {
@@ -100,6 +108,11 @@ async function deviceOwnership(args, { lease = getProcessDeviceMutationLease(),
100
108
  if (cleanup.errors.length) (recovered.cleanupErrors ||= []).push(...cleanup.errors);
101
109
  }
102
110
  }
111
+ if (args.operation === 'cancel-install' && recovered.ok && recovered.recovered === false) {
112
+ return { ...recovered, ok: false, error: 'install_action_not_pending', actionId: args.actionId,
113
+ message: 'No pending installation matches this action. Nothing was cancelled; read the original installation result to determine its outcome.',
114
+ dispatched: false, ambiguous: false };
115
+ }
103
116
  return recovered;
104
117
  }
105
118
 
@@ -2,9 +2,8 @@
2
2
 
3
3
  const { checkExecution } = require('./execution-scope');
4
4
 
5
- // WindowInspector retains other Activities during transitions, and focus can
6
- // lag the current Activity. Select its window group, including dialogs/popups
7
- // sharing its focus owner. Older trees keep their window-order semantics.
5
+ // The SDK owns Android window topology and publishes its foreground decision.
6
+ // Focus ownership is an input permission, not an Activity/window relationship.
8
7
 
9
8
  function visible(node) {
10
9
  return node && (node.effectiveVisible === true || node.visible === true)
@@ -21,12 +20,13 @@ function foregroundNativeWindow(rawTree) {
21
20
  if (!rawTree || typeof rawTree !== 'object') return null;
22
21
  const windows = Array.isArray(rawTree.windows) ? rawTree.windows : [];
23
22
  if (windows.length) {
24
- const activity = windows.findLast(item => item?.activityDecor === true && Object.hasOwn(item, 'focusOwnerWindowId'));
25
- const index = windows.findLastIndex(item => !explicitlyHidden(item?.root)
26
- && (!activity || item === activity || !Object.hasOwn(item || {}, 'focusOwnerWindowId')
27
- || item.focusOwnerWindowId !== null && item.focusOwnerWindowId === activity.focusOwnerWindowId));
28
- if (index < 0) return null;
23
+ const id = rawTree.foregroundWindowId;
24
+ if (typeof id !== 'string' || !id.trim()) return null;
25
+ const matches = windows.map((window, index) => window?.windowId === id ? index : -1).filter(index => index >= 0);
26
+ if (matches.length !== 1) return null;
27
+ const [index] = matches;
29
28
  const window = windows[index];
29
+ if (explicitlyHidden(window?.root)) return null;
30
30
  return { index, root: window?.root, type: window?.type, windowId: window?.windowId,
31
31
  bounds: window && Object.hasOwn(window, 'bounds') ? window.bounds : window?.root?.bounds };
32
32
  }
@@ -45,6 +45,9 @@ function explicitlyHidden(node) {
45
45
 
46
46
  function selectNativeNode(rawTree, spec, editable) {
47
47
  const reject = (error) => ({ ok: false, error, dispatched: false });
48
+ if (rawTree?.windows?.length && (typeof rawTree.foregroundWindowId !== 'string' || !rawTree.foregroundWindowId.trim())) {
49
+ return reject('native_window_metadata_unavailable');
50
+ }
48
51
  const window = nativeWindow(rawTree);
49
52
  if (!window) return reject('visible_observed_window_required');
50
53
  const selector = spec.selector || (typeof spec.text === 'string' ? { text: spec.text } : null);
@@ -90,7 +90,7 @@ function actionRequest(binding, { timeoutMs, actionId = randomUUID(), clickPolic
90
90
  function validDescriptor(value, root) {
91
91
  return record(value) && validRoot(root) && value.schemaVersion === runtimeSchema
92
92
  && isUuid(value.bootId) && isUuid(value.runtimeEpoch) && isHash(value.dexSha256)
93
- && Number.isSafeInteger(value.pid) && value.pid > 0 && Number.isSafeInteger(value.apiLevel) && value.apiLevel >= 33
93
+ && Number.isSafeInteger(value.pid) && value.pid > 0 && Number.isSafeInteger(value.apiLevel) && value.apiLevel >= 25
94
94
  && value.socketName === `aab-uia-${value.runtimeEpoch}` && isHash(value.token)
95
95
  && value.sessionPath === `${root}/sessions/${value.runtimeEpoch}` && typeof value.running === 'boolean';
96
96
  }
@@ -37,7 +37,7 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
37
37
  manifest = JSON.parse(fs.readFileSync(path.join(bundleDirectory, 'manifest.json'), 'utf8'));
38
38
  bytes = fs.readFileSync(path.join(bundleDirectory, 'ai-app-bridge-uia.jar'));
39
39
  } catch (error) { throw failure('uia_runtime_bundle_unavailable', 'The installed CLI has no complete UIA runtime bundle.', { cause: error.code }); }
40
- if (manifest?.schemaVersion !== 'aab.uia.bundle.v1' || manifest.mainClass !== mainClass || manifest.minApi !== 33
40
+ if (manifest?.schemaVersion !== 'aab.uia.bundle.v1' || manifest.mainClass !== mainClass || manifest.minApi !== 25
41
41
  || manifest.artifact !== 'ai-app-bridge-uia.jar' || bytes.length > 2 * 1024 * 1024 || protocol.digest(bytes) !== manifest.sha256) {
42
42
  throw failure('uia_runtime_bundle_invalid', 'The UIA runtime artifact does not match its bundle manifest.');
43
43
  }
@@ -133,7 +133,7 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
133
133
 
134
134
  async function installAsset(asset) {
135
135
  const level = await shell('getprop ro.build.version.sdk');
136
- if (!/^[0-9]+$/.test(level) || Number(level) < 33) throw failure('uia_android_api_33_required', 'UIA node execution requires Android API 33 or newer.', { apiLevel: level });
136
+ if (!/^[0-9]+$/.test(level) || Number(level) < 25) throw failure('uia_android_api_25_required', 'UIA node execution requires Android API 25 or newer.', { apiLevel: level });
137
137
  const destination = `${root}/runtime-${asset.manifest.sha256}.jar`;
138
138
  await shell(`umask 077\nmkdir -p ${quote(root)} && chmod 700 ${quote(root)}`);
139
139
  const existingHash = await shell(`if [ -f ${quote(destination)} ]; then ${sha256FileScript(destination)}; else printf '%s' null; fi`);
@@ -205,6 +205,7 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
205
205
  }
206
206
 
207
207
  async function ensureLocked(rotate) {
208
+ await require('../executors/automation-owner').assertAvailable(serial);
208
209
  const asset = bundle();
209
210
  const previous = await readJson(`${root}/runtime.json`);
210
211
  let peer = previous === null ? null : descriptor(previous);
@@ -293,6 +294,40 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
293
294
  }
294
295
 
295
296
  return {
297
+ async withInstrumentation(descriptorFile, start) {
298
+ return withConnectionLock(async () => {
299
+ const automation = require('../executors/automation-owner');
300
+ await automation.assertAvailable(serial);
301
+ const value = await readJson(`${root}/runtime.json`);
302
+ if (value !== null) {
303
+ const peer = descriptor(value);
304
+ if (peer.running) {
305
+ const connection = await connect(peer);
306
+ const status = await statusOf(connection);
307
+ if (status.pending !== 0 || status.acknowledged !== status.count || status.activeActionId !== null)
308
+ throw failure('uia_runtime_pending_actions', 'Settle and acknowledge the original UIA actions before opening instrumentation.');
309
+ await stopRuntime(connection);
310
+ }
311
+ }
312
+ const claim = automation.claim(serial, descriptorFile);
313
+ try {
314
+ const result = await start();
315
+ if (result?.ok === false && result.dispatched === false && result.ambiguous === false) automation.release(serial, claim.sessionId);
316
+ return result;
317
+ } catch (error) {
318
+ if (error.dispatched === false && error.ambiguous === false) automation.release(serial, claim.sessionId);
319
+ throw error;
320
+ }
321
+ });
322
+ },
323
+ async releaseInstrumentation(sessionId) {
324
+ return withConnectionLock(async () => {
325
+ const automation = require('../executors/automation-owner');
326
+ const owner = automation.owner(serial);
327
+ if (owner && owner.sessionId !== sessionId) throw failure('executor_automation_owner_changed', 'Another test session owns UiAutomation.');
328
+ await automation.assertAvailable(serial);
329
+ });
330
+ },
296
331
  ensure, post,
297
332
  async observe() {
298
333
  const connection = await withConnectionLock(() => ensureLocked(true));
@@ -342,6 +377,7 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
342
377
  return withConnectionLock(async () => {
343
378
  const value = await readJson(`${root}/runtime.json`);
344
379
  if (operation === 'start') {
380
+ await require('../executors/automation-owner').assertAvailable(serial);
345
381
  const asset = bundle(), destination = await installAsset(asset);
346
382
  const ownerRaw = await shell(`CLASSPATH=${quote(destination)} app_process /system/bin ${mainClass} ${quote(root)} ${asset.manifest.sha256} owner-status`);
347
383
  let owner;
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ const commands = new Set(['ui-observation', 'ios-ui-observation', 'web-ui-observation']);
4
+
5
+ function schema(command, optionTypes) {
6
+ const target = command === 'web-ui-observation' ? ['sessionId', 'runtimeEpoch', 'targetId', 'timeoutMs']
7
+ : command === 'ios-ui-observation' ? ['deviceId', 'bundleId', 'runtimeUrl', 'iosHost', 'iosPort', 'timeoutMs', 'devicectl']
8
+ : ['serial', 'packageName', 'port', 'adb', 'timeoutMs'];
9
+ const properties = Object.fromEntries(target.map(name => [name, optionTypes[name]]));
10
+ Object.assign(properties, { operation: { enum: ['start', 'status', 'stop'] },
11
+ durationMs: { type: 'integer', minimum: 100, maximum: 5000 }, leaseId: { type: 'string', minLength: 1, maxLength: 256 } });
12
+ if (command !== 'web-ui-observation') properties.provider = { enum: ['native', 'flutter'], default: 'native' };
13
+ return { type: 'object', additionalProperties: false, properties,
14
+ required: ['operation', ...(command === 'web-ui-observation' ? ['sessionId', 'runtimeEpoch'] : command === 'ios-ui-observation' ? ['deviceId', 'bundleId'] : ['packageName'])],
15
+ oneOf: [
16
+ { properties: { operation: { const: 'start' }, leaseId: false }, required: ['durationMs'] },
17
+ { properties: { operation: { const: 'stop' }, durationMs: false }, required: ['leaseId'] },
18
+ { properties: { operation: { const: 'status' }, durationMs: false, leaseId: false } },
19
+ ] };
20
+ }
21
+
22
+ function request(args) {
23
+ return { operation: args.operation,
24
+ ...(args.operation === 'start' ? { durationMs: args.durationMs } : {}),
25
+ ...(args.operation === 'stop' ? { leaseId: args.leaseId } : {}) };
26
+ }
27
+ function path(args) { return args.provider === 'flutter' ? '/v1/flutter/observation' : '/v1/ui/observation'; }
28
+
29
+ module.exports = { commands, schema, request, path };
@@ -43,6 +43,9 @@ class WebBridgeProvider {
43
43
  return { ok: true, session: this.sessionSummary(session), ownership: this.owner().status(sessionKey(args.sessionId)) };
44
44
  }
45
45
  case 'web-execution': return await this.executionControl(args);
46
+ case 'web-ui-observation': return await this.request(this.connected(args), 'read', {
47
+ name: 'uiObservation', args: require('./ui-observation').request(args),
48
+ }, args.timeoutMs ?? 5000);
46
49
  case 'web-dom': return args.history === true ? await this.captureResponse(args, 'dom') : await this.dom(args);
47
50
  case 'web-logs': return await this.captureResponse(args, 'logs');
48
51
  case 'web-network': return await this.captureResponse(args, 'network');
@@ -256,7 +259,9 @@ class WebBridgeProvider {
256
259
  this.pendingCommands.set(requestId, { socket, binding, type, payload, fail, resolve: result => end(null, result) });
257
260
  scope?.signal.addEventListener('abort', abort, { once: true });
258
261
  if (scope?.signal.aborted) { abort(); return; }
259
- sent = true; markExecutionDispatched();
262
+ sent = true;
263
+ // Acquiring evidence must not mark the enclosing UI action as dispatched.
264
+ if (!(type === 'read' && payload.name === 'uiObservation')) markExecutionDispatched();
260
265
  try { socket.send(wire, error => { if (error && this.pendingCommands.has(requestId)) fail('web_command_transport_lost'); }); }
261
266
  catch { fail('web_command_transport_lost'); }
262
267
  });
@@ -930,7 +930,7 @@ a mutation already running at timeout returns an ambiguous outcome. These checks
930
930
  do not make app touch handlers or business outcomes transactional.
931
931
  Explicit coordinate commands retain their primitive role.
932
932
 
933
- UIA observation and semantic clicks require Android API 33 or newer and the
933
+ UIA observation and semantic clicks require Android API 25 or newer and the
934
934
  bundled, hash-verified `runtime/uia` module. `uia-tree` opens or reuses one
935
935
  UiAutomation connection on the explicit device. XML snapshots carry boot,
936
936
  runtime and snapshot IDs; each node has an opaque reference. Ordinary UIA text
@@ -938,6 +938,12 @@ commands, Intent (including installer and permission choices), and JavaScript /
938
938
  Python Script calls send that reference and their original action ID to the
939
939
  same runtime. They do not convert text matches into physical coordinates.
940
940
 
941
+ API 25–32 use the process accessibility cache; API 33+ uses its connection
942
+ cache. Before API 30, Android exposes only the default-display window inventory.
943
+ All versions retain durable file and directory synchronization and original
944
+ Binder callbacks. Device validation covers API 25, 30 and 36; this is not a
945
+ claim that every vendor framework implementation has been tested.
946
+
941
947
  The runtime checks the focused default-display window, unique selector match,
942
948
  node attributes and clickable ancestor before one node action. Its guarantee
943
949
  is `same_connection_node_and_reobserved_attributes`, not a transaction across
@@ -966,6 +972,10 @@ ownership with actions. Stop requires committed terminal actions. Explicit start
966
972
  checks the phone's OS-managed process lock. A dead process can be reopened only
967
973
  after the new process acquires that lock and audits all original action records;
968
974
  any nonterminal or corrupt record blocks reopening, including after a reboot.
975
+ The 0.3.5 audit compares a nodeRef selector with the receipt's `binding.ref`;
976
+ the target attributes do not contain a `nodeRef` field. A successful audit may
977
+ retire acknowledged sessions and publish a new epoch. It does not resurrect
978
+ the old process or authorize replay of old actions.
969
979
  An HTTP timeout or a stale `running` descriptor does not authorize replacement.
970
980
  Authentication tokens
971
981
  stay in private phone descriptors and are absent from public status and Host
@@ -1104,7 +1114,18 @@ metadata, not the raw transport bytes. Settlement proves the recorded execution
1104
1114
  it does not turn an ambiguous action or unverified business assertion into a pass.
1105
1115
 
1106
1116
  Native Intent `longPress`, `swipe` and `scroll` use `/v1/action/gesture-target`.
1107
- The top visible SDK window owns pointer selection. A touchable, non-focusable
1117
+ The SDK selects the top visible window belonging to the current Activity on its
1118
+ display. Application windows use `WindowManager.LayoutParams.token` for Activity
1119
+ ownership; subwindows resolve that token through their parent window. Focus is
1120
+ not evidence of Activity ownership. A missing token, unresolved parent or unknown
1121
+ window type blocks selection with `native_window_metadata_unavailable`.
1122
+ Native tree snapshots publish this decision as `foregroundWindowId`, referring to
1123
+ exactly one entry in `windows`. Host summaries and semantic selection consume
1124
+ that identity, and SDK semantic, coordinate, gesture and H5 execution use the same
1125
+ window policy. Native window snapshots without this field require an updated SDK;
1126
+ Host does not reconstruct the decision from focus or array order.
1127
+
1128
+ A touchable, non-focusable
1108
1129
  popup can use its focused owner with the same application window token; the SDK
1109
1130
  does not select a background window when the popup blocks an action. Native/H5
1110
1131
  window selection shares this rule. Native window observation carries
@@ -1600,9 +1621,59 @@ explicit state assertions, or request Agent help through `ctx.askAgent`.
1600
1621
 
1601
1622
  ### Android 安装超时后的显式取消
1602
1623
 
1624
+ `freeze-app` 使用 SIGSTOP 暂停整个 App 进程,包括 SDK HTTP 服务;冻结期间的
1625
+ `tree`、`status` 超时是预期行为。它不是只暂停业务 UI。先完成需要的读取,再冻结;
1626
+ 后续 SDK 验证前必须 `thaw-app`,并重新确认可读。
1627
+
1603
1628
  `device-ownership status` 返回原安装的 `actionId`。若原 commit 回执因 OEM 确认页丢失,
1604
1629
  可用 `device-ownership --operation cancel-install --serial SERIAL --action-id ORIGINAL_ACTION_ID`。
1605
1630
  该命令只对保留的原 PM session 执行 abandon,先持久保存取消任务身份,再派发;
1606
1631
  重连后 `reconcile` 可读取同一任务的原回执。明确成功才解除该安装的设备占用。
1607
1632
  `phase: session-abandoned` 的 `requestSucceeded: null` 表示原安装结果未被推断,
1608
1633
  已经安装的 APK 不会回滚。取消按钮、等待超时或 Host 进程退出本身仍不是完成证据。
1634
+ 没有待处理安装时返回 `install_action_not_pending`、`recovered:false` 和
1635
+ `dispatched:false`。这同时适用于未知 actionId 和已经结算的安装;是否安装成功应读取
1636
+ 原操作结果。未抓住安装中途的取消不能当作 PM abandon 验证。
1637
+
1638
+ ### iOS 设备连接与原始失败对账
1639
+
1640
+ `devicectl list devices` 可以返回休眠隧道快照。`ios-devices` 对唯一或显式选择的未就绪
1641
+ 设备执行只读 `device info details`,核对 identifier/UDID 后使用当前隧道和 DDI 状态;
1642
+ `connectionProbe` 保留结果。多设备未选择时不会唤醒任意一台。`ios-doctor` 的
1643
+ `deviceConnected` 指开发者隧道可用,USB 配对与传输方式另见 selectedDevice。
1644
+
1645
+ `ios-execution reconcile` 可以识别原 launch 的 CoreDevice 4016 回执:要求精确调用、
1646
+ 失败 outcome、完整请求状态以及空的可用状态,且没有启动 result。它是已结算的启动前
1647
+ 拒绝;一般超时、丢连接和不匹配的 JSON 仍不能解除所有权。
1648
+
1649
+ ### WDA startup outcome recovery
1650
+
1651
+ `ios-setup --start-wda true` records the exact XCTest invocation and result bundle before starting the device test. A completed XCTest failure that explicitly says UI test initialization timed out while enabling automation is a settled failed startup (`ios_wda_automation_confirmation_required`). The Runner launch was dispatched; this does not undo its installation. Confirm Enable UI Automation on the device, then explicitly run setup again.
1652
+
1653
+ `ios-execution --operation reconcile --device-id DEVICE` reads the original result bundle after an interrupted Host. For a legacy generic `ios-setup` marker, supply `--setup-result-path ORIGINAL_RESPONSE.json`: recovery requires the original CLI JSON response (or its unwrapped value), its matching retained Host action in the current FactStore, the same device and startup interval, and the original XCTest bundle identified by that response. Missing, mismatched, or generic test failures remain unresolved. This option is accepted only for SDK/command reconciliation, never action cancellation or WDA session recovery. Lock files and original evidence are retained.
1654
+
1655
+ ### Bounded SDK UI observation
1656
+
1657
+ `ui-observation`, `ios-ui-observation` and `web-ui-observation` expose
1658
+ `start`, `status` and `stop`. Start requires `durationMs` in 100–5000; stop
1659
+ requires the returned `leaseId`. Android/iOS accept `provider: native|flutter`.
1660
+ The capability is available to JS and Python Script under `capture.read`.
1661
+ There is no unbounded lease, automatic renewal or activation at SDK startup.
1662
+ Closing/reopening a window establishes a new baseline; an idle interval is not
1663
+ evidence of an unchanged UI. Lifecycle and explicit business captures remain
1664
+ independent of heavy UI observation.
1665
+
1666
+ Native and Flutter controls use `/v1/ui/observation` and
1667
+ `/v1/flutter/observation`. The response schema is `aab.ui-observation/v1`.
1668
+ `GET /v1/flutter/snapshot` explicitly pulls fresh Dart UI state. Flutter tree,
1669
+ node and selection commands use it. `GET /v1/status` does not pull a Flutter
1670
+ tree or publish cached layout as current UI. Query a provider's observation
1671
+ command for its live lease state. These endpoints require rebuilt SDKs;
1672
+ installing a new CLI cannot patch an installed application's old SDK.
1673
+
1674
+ Ordinary CLI/MCP `feedback=full` opens a window before the action and releases
1675
+ it in finally. If observation is unavailable, the action is rejected before
1676
+ dispatch; acquiring evidence does not mark the enclosing UI action dispatched.
1677
+ Launch retains its independent system feedback path. Script/Intent bypass this
1678
+ feedback wrapper and explicitly request windows only when their evidence needs
1679
+ them. SDK/page expiry handles a Host that disappears without sending stop.
@@ -29,7 +29,7 @@ choosing any action. The Host does not retry the read or substitute a provider.
29
29
 
30
30
  The summary contains `provider` and `foreground` (`packageName`, `activity`, `component`, probe source and timestamps). Observation, decision, dispatch marker and receipt records preserve that route. The original `target` continues to identify the business operation and its app capture streams. System UIA observations do not imply that system-app network/state/event capture is available.
31
31
 
32
- Actions inherit the provider of their committed observation. An explicit conflicting provider is rejected. Before dispatch, the adapter checks the foreground component again; a changed component returns `reobserve_required` without dispatching a tap. Exact UIA taps use the API 33+ phone node runtime. Its `uia-node` execution receipt binds the Intent action ID, original request hash, runtime epoch, node/window attributes and original callback. The selected node is revalidated on the same automation connection before dispatch. A callback still requires a fresh observation and business checks; it does not create system-app SDK events.
32
+ Actions inherit the provider of their committed observation. An explicit conflicting provider is rejected. Before dispatch, the adapter checks the foreground component again; a changed component returns `reobserve_required` without dispatching a tap. Exact UIA taps use the API 25+ phone node runtime. Its `uia-node` execution receipt binds the Intent action ID, original request hash, runtime epoch, node/window attributes and original callback. The selected node is revalidated on the same automation connection before dispatch. A callback still requires a fresh observation and business checks; it does not create system-app SDK events.
33
33
 
34
34
  Device-scoped physical taps remain managed Android shell input. Cancellation
35
35
  and recovery follow the selected executor's admission and completion contract.
@@ -0,0 +1,182 @@
1
+ # Optional UI executors (0.3.6)
2
+
3
+ Bridge keeps its existing SDK paths and exposes optional executors through `capabilities`, `run`, and JavaScript/Python Script. Select an executor explicitly. No command silently changes a touch into a setter, switches framework after failure, or repeats an uncertain action.
4
+
5
+ ## What is installed
6
+
7
+ | Capability | Dependency location | Integration requirement |
8
+ | --- | --- | --- |
9
+ | Android UI Automator / Espresso | Application **androidTest** dependencies and its generated test APK | Small precompiled test entry; matching installed application and test packages |
10
+ | Android WebView H5 | Optional `ai-app-bridge-test-espresso-web` in androidTest | JavaScript already enabled by the application; explicit WebView and frame selection |
11
+ | Android Compose | Optional `ai-app-bridge-test-compose` in androidTest | Application-matched Compose test runtime and a JUnit rule surrounding the session |
12
+ | Flutter WidgetTester | `ai_app_bridge_test` in **dev_dependencies**, using Flutter SDK test packages | Debug APK built from `integration_test/bridge_test.dart` |
13
+ | Web Playwright | CLI-managed directory outside the page SDK | Explicitly prepare the pinned Playwright package and matching browser |
14
+ | iOS | Existing WDA / XCUITest integration | Existing signing, device and WDA prerequisites; no new iOS executor in this version |
15
+
16
+ These dependencies are isolated from ordinary production source sets. They are visible in build metadata and diagnostics. They do not disappear from the installation or compatibility requirements. Android does **not** need a separate business App, repository, or Gradle project. The standard test APK is an Android test artifact.
17
+
18
+ ## Android: one entry in the existing application
19
+
20
+ Add the selected artifacts to the application's `androidTestImplementation` configuration. All Bridge artifacts use the same version:
21
+
22
+ ```kotlin
23
+ android {
24
+ defaultConfig {
25
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
26
+ }
27
+ }
28
+ dependencies {
29
+ androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-instrumentation:0.3.6")
30
+ // Optional H5 adapter:
31
+ androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-espresso-web:0.3.6")
32
+ }
33
+ ```
34
+
35
+ Place this tiny entry in the existing `app/src/androidTest/java/...` directory:
36
+
37
+ ```java
38
+ package example.app;
39
+ import io.github.mobileaidev.aiappbridge.executor.instrumentation.AndroidExecutorTest;
40
+ public final class BridgeSessionTest extends AndroidExecutorTest {}
41
+ ```
42
+
43
+ This is a precompiled, long-running JUnit test. Host passes the session identity, token, Activity and lease as Instrumentation arguments. The test thread executes incoming commands serially. Python/JS scripts do not become Kotlin/Java source and do not require rebuilding for each workflow. You may extend the test's `adapters()` and add ordinary JUnit rules or idling resources. Arbitrary business field reflection and arbitrary existing `@Test` method invocation are not exposed as remote commands.
44
+
45
+ Opening a session **restarts and instruments the target application**. Install the matching main and test APKs built from this application first:
46
+
47
+ ```sh
48
+ ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest
49
+ adb -s DEVICE install -r -t app/build/outputs/apk/debug/app-debug.apk
50
+ adb -s DEVICE install -r -t app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
51
+ ai-app-bridge android-executor --operation status --serial DEVICE
52
+ ai-app-bridge android-executor --operation open --serial DEVICE \
53
+ --package-name example.app \
54
+ --instrumentation example.app.test/androidx.test.runner.AndroidJUnitRunner \
55
+ --test-class example.app.BridgeSessionTest --activity example.app.MainActivity
56
+ ```
57
+
58
+ Use the actual installed component names returned by `status`. Supply the returned `sessionId` and `runtimeEpoch` to subsequent operations. `observe` requires an `engine` listed in `open.capabilities.adapters`. `act` requires the current `snapshotId` and a `nodeId` from that observation. Changing adapters invalidates the previous snapshot.
59
+
60
+ The instrumentation artifact includes `uiautomator` and `espresso`. Add H5 in `adapters()` with `adapters.put("espresso-web", new EspressoWebExecutor())`, after obtaining `super.adapters()`. H5 observation accepts `webView: {by:"description",value:"..."}` or `resourceId`, and `framePath: [{name:"..."}]` or `{index:0}` entries. An unspecified WebView must match exactly one WebView. JavaScript is not automatically enabled. `webClick`, `webKeys`, `webClear` and `webScrollIntoView` use WebDriver JavaScript atoms, not Android IME or physical touch. Open Shadow DOM and cross-origin frame support must be checked for the concrete WebView; closed Shadow DOM is not advertised.
61
+
62
+ ## Dependency checks and Compose
63
+
64
+ Apply `io.github.mobileaidev.aiappbridge.test` from the same Gradle plugin artifact. It only validates resolved dependencies; it adds no UI bytecode instrumentation and does not rewrite versions. With JitPack, map this plugin ID in `pluginManagement.resolutionStrategy.eachPlugin` to `com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-gradle-plugin:<requested version>`, as for the existing `io.github.mobileaidev.aiappbridge.android` plugin. Include JitPack, Google Maven and Maven Central in the appropriate repositories.
65
+
66
+ The initial Android adapter profile is runner **1.7.0**, Espresso / Espresso-Web **3.7.0**, UI Automator **2.4.0**, Java **11**, minSdk **23**, compileSdk **35**. The source build uses AGP **8.9.1** and JDK **17**. This is a supported profile, not a promise that every old Gradle/AGP combination can consume the artifacts.
67
+
68
+ Compose is optional and its runtime is supplied by the consumer. The initial Compose adapter profile is **1.8.3**. Apply the application's **same Compose BOM** to its main/debug and androidTest dependencies, then add `ui-test-junit4` without an independent version. The plugin rejects a different main/test Compose runtime or an unvalidated adapter profile. It does not upgrade an application's Kotlin, Compose, AGP or compileSdk to make the check pass. An application outside this profile can keep the existing SDK/UI Automator route or provide a separately validated adapter build.
69
+
70
+ ```java
71
+ @Rule public final ComposeTestRule compose =
72
+ AndroidComposeTestRule_androidKt.createEmptyComposeRule();
73
+ @Override protected Map<String, ExecutorAdapter> adapters() {
74
+ Map<String, ExecutorAdapter> adapters = super.adapters();
75
+ adapters.put("compose", new ComposeExecutor(compose));
76
+ return adapters;
77
+ }
78
+ ```
79
+
80
+ The rule must start **before** Activity/composition creation. The adapter uses automatic test clock advancement and idle frame ticks; animation timing is not wall-clock fidelity. `click` uses Compose touch input; `composeInput`, `composeReplaceText`, `composeClearText`, scroll semantics and `semanticLongClick` are explicit semantics operations. WebView/platform Views and system dialogs require their corresponding adapters. No private Compose field reflection is used by Bridge.
81
+
82
+ ## Flutter
83
+
84
+ Add `ai_app_bridge_test: 0.3.6` to the application's `dev_dependencies`. The helper takes `flutter_test` and `integration_test` from the **same Flutter SDK** as the application. It is a Dart test helper, not an additional Android plugin with its own AGP/Kotlin versions.
85
+
86
+ The helper declares Flutter **>=3.41.0** and Dart **>=3.11.0 <4.0.0**. This release was built and exercised with Flutter **3.41.9 on Android API 25** and **3.44.8 on Android API 36**. These are the verified combinations; newer SDK versions still need validation with the application's plugin graph.
87
+
88
+ ```dart
89
+ // integration_test/bridge_test.dart
90
+ import 'package:ai_app_bridge_test/ai_app_bridge_test.dart';
91
+ import 'package:your_app/main.dart' as app;
92
+ void main() => aiAppBridgeTest(app.main);
93
+ ```
94
+
95
+ ```sh
96
+ flutter build apk --debug --target integration_test/bridge_test.dart \
97
+ --dart-define=INTEGRATION_TEST_SHOULD_REPORT_RESULTS_TO_NATIVE=false
98
+ adb -s DEVICE install -r -t build/app/outputs/flutter-apk/app-debug.apk
99
+ ai-app-bridge flutter-executor --operation open --serial DEVICE \
100
+ --package-name example.app --activity example.app.MainActivity
101
+ ```
102
+
103
+ This version supports the standard **Android Flutter embedder**, with its normal application private cache directory. Launch configuration and original receipts use `no_backup/ai-app-bridge-integration`, outside evictable caches. Opening restarts the test-entrypoint app. Closing drains the tester queue and terminates only the original process, identified by boot ID, PID and process start time. The helper uses a fully live frame policy. `tap`, `longPress`, drag/fling, `ensureVisible`, `pageBack`, `pump` and `enterText` are exposed. `enterText` changes Flutter editing state and reads it back; it does not prove native IME input. Flutter platform views, WebView DOM and system dialogs require another applicable provider. Flutter iOS/desktop/web test-host launch is not implemented by this helper.
104
+
105
+ ## Web
106
+
107
+ ```sh
108
+ ai-app-bridge web-executor --operation status --browser chromium
109
+ ai-app-bridge web-executor --operation prepare --browser chromium --timeout-ms 300000
110
+ ai-app-bridge web-executor --operation open --url http://localhost:3000 --browser chromium
111
+ ```
112
+
113
+ The CLI manages exact Playwright **1.63.0**, an included npm lock file and matching browser downloads. `prepare` is explicit; normal SDK usage does not download browsers. Cache keys include the dependency lock digest, OS and CPU architecture. Node **26.3.x** is the verified Host baseline (`>=26.3.0 <27` contract). Browser preparation is serialized and reports failures. `status` separates the static version from actual executable availability.
114
+
115
+ `observe` returns pages and documents. Pass the observed `targetId`, `frameId` and `documentId` to actions/waits. A navigation during automatic waiting cannot move an old command into the replacement document. Selectors must be unique; role names and text matching are exact. `css` accepts standard CSS syntax, not Playwright selector chains. Actions include click, doubleClick, hover, fill, type, press, check, select, drag, scrollIntoView and upload. Physical `wheel` is not exposed: Playwright's page mouse API cannot bind the event atomically to the observed document. It is never replaced with a synthetic DOM event. Dialogs default to dismiss; an action may explicitly request accept/dismiss. Browser dialogs, popup pages, open Shadow DOM and iframe observations are supported; native OS dialogs and closed Shadow DOM are outside this executor.
116
+
117
+ Control `text` contains the element's `innerText`, preserving an empty string. It is `null` for elements without `innerText`; hidden `textContent` is not substituted. Each action ID occupies one slot across all pages in the session. Reusing it on another page is rejected with `executor_receipt_identity_mismatch`; changing other action arguments is rejected with `idempotency_conflict`.
118
+
119
+ ## Agent, Python and JS contract
120
+
121
+ Discover only the operation needed:
122
+
123
+ ```json
124
+ {"command":"android-executor","operation":"act"}
125
+ ```
126
+
127
+ This is a `capabilities` selection, not an action. Execute through the existing `run` tool, or `ctx.call` in Script with **`app.test`** permission. Script targets remain `platform:"android"` for Android/Flutter Android, and `platform:"web"` for browser sessions. Device/package targeting is inherited from the Script target; session identities are explicit inputs.
128
+
129
+ ```javascript
130
+ // Within module.exports.main = async ctx => { ... }
131
+ const reply = await ctx.call('android-executor', {
132
+ ...ctx.inputs.identity, operation: 'observe', engine: 'espresso'
133
+ });
134
+ if (!reply.ok || !reply.result.ok) throw new Error(JSON.stringify(reply));
135
+ const observation = reply.result.observation;
136
+ const buttons = observation.nodes.filter(n => n.text === 'Submit');
137
+ if (buttons.length !== 1) throw new Error('Expected exactly one Submit button');
138
+ const result = await ctx.call('android-executor', {
139
+ ...ctx.inputs.identity, operation: 'act', snapshotId: observation.snapshotId,
140
+ actionId: 'submit-once', action: {type: 'click', nodeId: buttons[0].nodeId}
141
+ });
142
+ if (!result.ok || !result.result.ok) throw new Error(JSON.stringify(result));
143
+ ```
144
+
145
+ ```python
146
+ reply = ctx.call('flutter-executor', {**ctx.inputs['identity'], 'operation': 'observe'})
147
+ assert reply['ok'] and reply['result']['ok'], str(reply)
148
+ observation = reply['result']['observation']
149
+ nodes = [n for n in observation['nodes'] if n.get('key') == 'submit']
150
+ assert len(nodes) == 1
151
+ result = ctx.call('flutter-executor', {
152
+ **ctx.inputs['identity'], 'operation': 'act',
153
+ 'snapshotId': observation['snapshotId'], 'actionId': 'submit-once',
154
+ 'action': {'type': 'tap', 'nodeId': nodes[0]['nodeId']}
155
+ })
156
+ assert result['ok'] and result['result']['ok'], str(result)
157
+ ```
158
+
159
+ An action receipt means the framework call ended, not that a payment/order/business transaction succeeded. Observe again and assert the independent business result. Do not reuse stale snapshots or change an action's mechanism after a failure without a new decision.
160
+
161
+ ## Cancellation, recovery and lifecycle
162
+
163
+ - One test thread / browser worker serializes UI effects. Native device effects use the shared device ownership contract. UI Automator and the legacy UIA runtime cannot own Android UiAutomation simultaneously; the Host drains and hands over the connection explicitly.
164
+ - `timeoutMs` requests cancellation; it does not prove rollback. Already executing framework work must return its original receipt or the original process must be confirmed ended. An unsettled WebView atom ends the test session. No replacement action starts against an unresolved device effect.
165
+ - The same `actionId` with identical arguments returns its original receipt. A changed request is rejected. An unresolved receipt is never redispatched. Deduplication is session-scoped; it is not universal exactly-once business execution across process loss or new sessions.
166
+ - `receipt` remains readable after normal close. Host restart can recover Android/Flutter descriptors and read retained device records; a lost browser worker's original receipts remain readable, but its live browser session cannot be resumed.
167
+ - Use explicit `close` for normal teardown. Android/Flutter tests also have an idle lease (default 10 minutes, selectable 10 seconds–1 hour). Host loss preserves original state for recovery; it does not erase unresolved ownership. Run `device-ownership --operation reconcile --serial DEVICE` when required by the error. A closed Flutter test may require reconciliation/close to end its original app process.
168
+ - Each session permits at most 4096 action receipts, with bounded request/response and observation sizes. Receipts are retained for audit; storage is not automatically deleted across sessions. Operators must archive/remove closed session artifacts according to their retention policy. Never remove an unresolved session's records to bypass a device lock.
169
+
170
+ ## Performance evidence
171
+
172
+ On the same API-36 device, sample APK, Activity, counter button and Instrumentation lifetime, 20 effective samples per path (two warmup rounds excluded) alternated Espresso touch and the existing SDK touch. Both included Host persistence and the same Espresso observations before/after every action. Every counter increment was verified.
173
+
174
+ | Metric (ms) | Espresso | Existing SDK touch |
175
+ | --- | ---: | ---: |
176
+ | Median action | 362 | 314 |
177
+ | Action p95 | 397 | 345 |
178
+ | Median action + before/after observation | 413 | 359 |
179
+
180
+ This does not establish a general speedup. Startup, dependency preparation, screenshots, model time and CLI process startup are excluded and must be reported separately. Semantics/setter input is a different operation from physical keyboard/IME input; its shorter duration cannot establish an equivalent-interaction speedup.
181
+
182
+ The version's local verification and publication status are recorded in the repository delivery report. Local artifact checks are separate from public registry publication and real customer workflow acceptance.