@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.
- package/README.md +7 -3
- package/bin/android-permissions.js +39 -5
- package/bin/command-discovery.js +8 -2
- package/bin/command-registry.js +26 -9
- package/bin/device-provider.js +11 -2
- package/bin/execution-host.js +18 -0
- package/bin/executors/android-host.js +201 -0
- package/bin/executors/android-port.js +114 -0
- package/bin/executors/automation-owner.js +42 -0
- package/bin/executors/command-schema.js +102 -0
- package/bin/executors/flutter-host.js +123 -0
- package/bin/executors/managed-runtime.js +85 -0
- package/bin/executors/playwright-host.js +146 -0
- package/bin/executors/playwright-worker.js +307 -0
- package/bin/executors/receipt-journal.js +56 -0
- package/bin/feedback-probe.js +27 -1
- package/bin/ios-device-outcome.js +17 -0
- package/bin/ios-execution.js +13 -1
- package/bin/ios-provider.js +74 -24
- package/bin/ios-runtime-binding.js +1 -1
- package/bin/ios-wda-startup.js +68 -0
- package/bin/runtime-directory.js +1 -1
- package/bin/shared-kernel/device-ownership-recovery.js +13 -0
- package/bin/shared-kernel/native-target.js +11 -8
- package/bin/shared-kernel/uia-protocol.js +1 -1
- package/bin/shared-kernel/uia-runtime-port.js +38 -2
- package/bin/ui-observation.js +29 -0
- package/bin/web-provider.js +6 -1
- package/docs/COMMAND_CONTRACT.md +73 -2
- package/docs/INTENT_FOREGROUND.md +1 -1
- package/docs/OPTIONAL_EXECUTORS.md +182 -0
- package/docs/RELEASE.md +36 -14
- package/docs/SCRIPT_AUTHORING.md +7 -0
- package/package.json +6 -1
- package/runtime/executors/playwright/package-lock.json +45 -0
- package/runtime/executors/playwright/package.json +8 -0
- package/runtime/uia/ai-app-bridge-uia.jar +0 -0
- 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 };
|
package/bin/runtime-directory.js
CHANGED
|
@@ -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
|
-
//
|
|
6
|
-
//
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 >=
|
|
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 !==
|
|
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) <
|
|
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 };
|
package/bin/web-provider.js
CHANGED
|
@@ -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;
|
|
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
|
});
|
package/docs/COMMAND_CONTRACT.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|