@mobileaidev/ai-app-bridge 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ discovery. Every request checks the mapping before dispatch. Mutating requests
7
7
  are never replayed after a missing route or uncertain result. For manual cleanup,
8
8
  pass the exact serial and returned Host port to `remove-forward`.
9
9
 
10
- This source version is `0.3.7`; registry publication is a separate release step.
10
+ This source version is `0.3.8`; registry publication is a separate release step.
11
11
  The default installation includes the Script/Intent and capture contracts below.
12
12
  Local package verification does not change npm dist-tags.
13
13
  The supported Node range is `>=26.3.0 <27`; this release was checked on 26.3.0.
@@ -60,7 +60,7 @@ domains, commands, and options, then call `run` with the selected command.
60
60
 
61
61
  ```bash
62
62
  # Install the current stable release; see docs/RELEASE.md for packaging.
63
- npm install -g @mobileaidev/ai-app-bridge@0.3.7
63
+ npm install -g @mobileaidev/ai-app-bridge@0.3.8
64
64
 
65
65
  ai-app-bridge status --package-name io.github.mobileaidev.aiappbridge.sample
66
66
  ai-app-bridge tree --package-name io.github.mobileaidev.aiappbridge.sample
@@ -8,6 +8,7 @@ const { executionCommandSchema, nativeGestureSchema, nativeSelector, flutterSele
8
8
  const { flutterActionSchema, webCommandSchema } = require('./shared-kernel/provider-command-contracts');
9
9
 
10
10
  const commandDefinitions = [
11
+ { command: 'executor-prepare', domain: 'advanced', summary: 'Prepare optional test executors from the existing project: generate Android androidTest entry/dependencies and matching APKs, add Flutter dev dependency and build the generated entrypoint, prepare managed iOS WDA, or install pinned Playwright/browser. Host preparation does not install or launch a mobile application. No application ID is changed. Requires app.test in Script.', targetKind: 'host', options: ['platform'] },
11
12
  { command: 'ui-observation', domain: 'core', summary: 'Start a bounded UI observation window (100–5000 ms), inspect it, or stop its lease. Off by default; provider selects native or Flutter. New windows establish a fresh baseline.', targetApp: true, options: ['operation', 'provider', 'durationMs', 'leaseId', 'packageName', 'serial'] },
12
13
  { command: 'ios-ui-observation', domain: 'ios', summary: 'Control bounded iOS native or Flutter UI observation. Off by default; expires locally even if the Host exits.', targetKind: 'ios-app', options: ['operation', 'provider', 'durationMs', 'leaseId', 'deviceId', 'bundleId'] },
13
14
  { command: 'web-ui-observation', domain: 'web', summary: 'Control bounded Web DOM change observation. Off by default; expires locally even if the Host exits.', targetKind: 'web-target', options: ['operation', 'durationMs', 'leaseId', 'sessionId', 'runtimeEpoch', 'targetId'] },
@@ -151,6 +152,7 @@ const isolatedCommandDefinitions = [
151
152
  ];
152
153
 
153
154
  const mutationCommands = new Set([
155
+ 'executor-prepare',
154
156
  'install-apk', 'clear-app-data', 'freeze-app', 'thaw-app', 'launch-app',
155
157
  'launch-activity', 'permission-grant',
156
158
  'permission-revoke', 'permission-dialog', 'appops-set',
@@ -254,12 +256,13 @@ function isMutationCommand(command, args = {}) {
254
256
  }
255
257
 
256
258
  function isAndroidMutation(command, args = {}) {
257
- return isMutationCommand(command, args) && !['runtime', 'device-ownership'].includes(command)
259
+ return isMutationCommand(command, args) && !['runtime', 'device-ownership', 'executor-prepare'].includes(command)
258
260
  && !command.startsWith('ios-') && !command.startsWith('web-');
259
261
  }
260
262
 
261
263
  function executionTimeoutMs(command, args = {}) {
262
264
  if (args.timeoutMs !== undefined) return args.timeoutMs;
265
+ if (command === 'executor-prepare') return 600000;
263
266
  if (['android-executor', 'flutter-executor'].includes(command)) return args.operation === 'open' ? 60000 : 30000;
264
267
  if (command === 'web-executor') return args.operation === 'prepare' ? 300000 : 30000;
265
268
  if (iosSdkCommands.has(command) || command === 'ios-execution' || require('./ios-wda-port').commands.has(command)) return 30000;
@@ -273,7 +276,7 @@ function executionTimeoutMs(command, args = {}) {
273
276
  // These are execution capabilities, independent of MCP/CLI transport. Script
274
277
  // permissions gate Bridge calls; trusted local code is not a process sandbox.
275
278
  const scriptPermissions = Object.freeze({
276
- 'app.test': ['web-executor', 'android-executor', 'flutter-executor'],
279
+ 'app.test': ['web-executor', 'android-executor', 'flutter-executor', 'executor-prepare'],
277
280
  'app.read': ['status', 'tree', 'uia-tree', 'screenshot', 'flutter-tree', 'flutter-nodes', 'h5-dom', 'flutter-h5-dom', 'keyboard-state', 'permission-state',
278
281
  'ios-status', 'ios-tree', 'ios-uia-tree', 'ios-screenshot', 'ios-flutter-tree', 'ios-flutter-nodes', 'ios-h5-dom', 'ios-wda-status', 'web-status', 'web-dom'],
279
282
  'capture.read': ['ui-observation', 'ios-ui-observation', 'web-ui-observation', 'logs', 'network', 'state', 'events', 'logcat', 'webview-console', 'webview-network', 'ios-logs', 'ios-network', 'ios-state', 'ios-events', 'web-logs', 'web-network', 'web-state', 'web-events'],
@@ -291,7 +294,7 @@ function commandContract(command) {
291
294
  const definition = commandByName.get(command) || isolatedByName.get(command);
292
295
  if (!definition) throw new CommandError('unknown_command', `Unknown command: ${command}`, { field: 'command' });
293
296
  const isolated = isolatedByName.has(command);
294
- const platform = command === 'runtime' ? 'host' : isolated ? (command === 'evidence' ? 'host' : 'multi')
297
+ const platform = ['runtime', 'executor-prepare'].includes(command) ? 'host' : isolated ? (command === 'evidence' ? 'host' : 'multi')
295
298
  : definition.domain === 'ios' ? 'ios' : definition.domain === 'web' ? 'web' : 'android';
296
299
  const role = command === 'runtime' || command === 'intent' || command === 'script' || workflowCommands.has(command) ? 'execution'
297
300
  : command === 'evidence' ? 'evidence' : expertCommands.has(command) ? 'expert' : 'capability';
@@ -317,6 +320,7 @@ function commandContract(command) {
317
320
  function commandSchema(command) {
318
321
  const definition = commandByName.get(command) || isolatedByName.get(command);
319
322
  if (!definition) throw new CommandError('unknown_command', `Unknown command: ${command}`, { field: 'command' });
323
+ if (command === 'executor-prepare') return require('./executors/preparation').preparationSchema();
320
324
  if (require('./ui-observation').commands.has(command)) return require('./ui-observation').schema(command, optionTypes);
321
325
  if (command === 'web-executor') return require('./executors/command-schema').webExecutorSchema();
322
326
  if (command === 'android-executor') return require('./executors/command-schema').androidExecutorSchema();
@@ -585,6 +585,7 @@ function getSharedObservationCollector(factRecorder) {
585
585
  }
586
586
 
587
587
  async function runRawCommand(command, args = {}) {
588
+ if (command === 'executor-prepare') return require('./executors/preparation').prepareExecutor(args);
588
589
  if (command === 'android-executor') {
589
590
  androidExecutor ||= new (require('./executors/android-host').AndroidExecutorHost)();
590
591
  return androidExecutor.run(args);
@@ -56,7 +56,7 @@ function androidExecutorSchema() {
56
56
  const bound = (properties = {}, required = []) => ({ properties: { ...target, ...session, ...properties }, required: ['serial', 'packageName', 'sessionId', 'runtimeEpoch', ...required] });
57
57
  const action = { anyOf: [
58
58
  object({ type: { enum: ['click', 'longClick', 'scrollTo', 'swipeUp', 'swipeDown', 'swipeLeft', 'swipeRight', 'webClick', 'webClear', 'webScrollIntoView', 'semanticLongClick', 'composeClearText'] }, nodeId: identifier }, ['type', 'nodeId']),
59
- object({ type: { enum: ['setText', 'typeText', 'replaceText', 'webKeys', 'composeInput', 'composeReplaceText'] }, nodeId: identifier, text: { type: 'string', maxLength: 65536 } }, ['type', 'nodeId', 'text']),
59
+ object({ type: { enum: ['setText', 'typeText', 'replaceText', 'replaceTextViaInputConnection', 'webKeys', 'composeInput', 'composeReplaceText'] }, nodeId: identifier, text: { type: 'string', maxLength: 65536 } }, ['type', 'nodeId', 'text']),
60
60
  object({ type: { const: 'composeScrollToIndex' }, nodeId: identifier, index: integer(0, 1000000) }, ['type', 'nodeId', 'index']),
61
61
  object({ type: { const: 'scroll' }, nodeId: identifier, direction: { enum: ['up', 'down', 'left', 'right'] }, percent: { type: 'number', exclusiveMinimum: 0, maximum: 10 } }, ['type', 'nodeId', 'direction', 'percent']),
62
62
  object({ type: { enum: ['back', 'home', 'closeKeyboard'] } }, ['type']),
@@ -0,0 +1,248 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { pathToFileURL, fileURLToPath } = require('node:url');
6
+ const { createHash, randomUUID } = require('node:crypto');
7
+ const { CommandError } = require('../command-errors');
8
+ const { object, text, integer } = require('../shared-kernel/argument-schema');
9
+ const { resolveRequestPath } = require('../shared-kernel/request-context');
10
+ const { execFileBounded } = require('../shared-kernel/execution-io');
11
+ const { executorHome, atomicJson, readJson, preparePackage } = require('./managed-runtime');
12
+ const bridgeVersion = require('../../package.json').version;
13
+ const testClass = 'io.github.mobileaidev.aiappbridge.generated.BridgeSessionTest';
14
+ const identifier = { ...text, maxLength: 4096 };
15
+ const common = { timeoutMs: integer(1, 1800000), requestId: identifier, feedback: { enum: ['auto', 'off', 'full'] } };
16
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
17
+
18
+ function preparationSchema() {
19
+ const definitions = {
20
+ android: { properties: { projectDir: identifier, module: { ...text, pattern: '^(:[A-Za-z0-9_-]+)+$' },
21
+ variant: { ...text, pattern: '^[A-Za-z][A-Za-z0-9]*$' },
22
+ adapters: { type: 'array', uniqueItems: true, maxItems: 2, items: { enum: ['espresso-web', 'compose'] } },
23
+ repositoryUrl: identifier, gradlePath: identifier }, required: ['projectDir', 'module', 'variant'] },
24
+ flutter: { properties: { projectDir: identifier, entrypoint: identifier, flutterPath: identifier,
25
+ flavor: { ...text, pattern: '^[A-Za-z][A-Za-z0-9_]*$' }, testPackagePath: identifier,
26
+ mainArguments: { type: 'array', items: { type: 'string', maxLength: 4096 }, maxItems: 100 },
27
+ dartDefines: { type: 'array', items: identifier, maxItems: 100 } }, required: ['projectDir'] },
28
+ ios: { properties: { xcodebuild: identifier }, required: [] },
29
+ web: { properties: { browser: { enum: ['chromium', 'firefox', 'webkit'] } }, required: [] },
30
+ };
31
+ const branches = Object.entries(definitions).map(([platform, definition]) =>
32
+ object({ platform: { const: platform }, ...common, ...definition.properties }, ['platform', ...definition.required]));
33
+ return { type: 'object', additionalProperties: false, required: ['platform'],
34
+ properties: { ...Object.assign({}, ...branches.map(branch => branch.properties)), platform: { enum: Object.keys(definitions) } }, anyOf: branches };
35
+ }
36
+
37
+ function requireFile(file) {
38
+ if (!fs.statSync(file, { throwIfNoEntry: false })?.isFile())
39
+ throw new CommandError('executor_prepare_file_missing', `Required project file is missing: ${file}`);
40
+ return file;
41
+ }
42
+
43
+ function repository(value = 'https://jitpack.io') {
44
+ const url = new URL(value);
45
+ if (!['https:', 'file:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
46
+ throw new CommandError('invalid_argument', 'repositoryUrl must be an HTTPS or local file Maven repository without credentials, query or fragment.');
47
+ return url.href;
48
+ }
49
+
50
+ async function withProject(projectDir, action, { home = executorHome() } = {}) {
51
+ const project = fs.realpathSync(resolveRequestPath(projectDir));
52
+ const locks = path.join(home, 'preparation-locks');
53
+ fs.mkdirSync(locks, { recursive: true, mode: 0o700 });
54
+ const { DatabaseSync } = require('node:sqlite');
55
+ const lock = new DatabaseSync(path.join(locks, `${digest(project)}.sqlite`));
56
+ let locked = false;
57
+ try {
58
+ try { lock.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE'); locked = true; }
59
+ catch (error) { if (error.errcode === 5) throw new CommandError('executor_preparing', 'This project is already being prepared.'); throw error; }
60
+ const directory = path.join(project, 'build', 'ai-app-bridge', 'prepare', randomUUID());
61
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
62
+ const startedAtMs = Date.now();
63
+ try {
64
+ const result = { ok: true, bridgeVersion, directory, ...await action(project, directory), elapsedMs: Date.now() - startedAtMs };
65
+ atomicJson(path.join(directory, 'result.json'), result);
66
+ return result;
67
+ } catch (error) {
68
+ const result = { ok: false, bridgeVersion, directory, error: error.code || 'executor_prepare_failed',
69
+ message: error.message, elapsedMs: Date.now() - startedAtMs };
70
+ atomicJson(path.join(directory, 'result.json'), result);
71
+ throw new CommandError(result.error, result.message, { details: { directory, resultFile: path.join(directory, 'result.json') } });
72
+ }
73
+ } finally { if (locked) lock.exec('ROLLBACK'); lock.close(); }
74
+ }
75
+
76
+ async function loggedRun(run, file, args, directory, name, options) {
77
+ const logFile = path.join(directory, `${name}.log`);
78
+ try {
79
+ const result = await run(file, args, { ...options, maxBuffer: 16 * 1024 * 1024 });
80
+ fs.writeFileSync(logFile, result.stdout + result.stderr, { mode: 0o600 });
81
+ return result;
82
+ } catch (error) {
83
+ fs.writeFileSync(logFile, String(error.stdout || '') + String(error.stderr || '') + '\n' + error.message, { mode: 0o600 });
84
+ throw new CommandError('executor_prepare_build_failed', `${name} failed. Read ${logFile}`, { details: { logFile, exitCode: error.code } });
85
+ }
86
+ }
87
+
88
+ function androidEntry(adapters) {
89
+ const compose = adapters.includes('compose');
90
+ return `package io.github.mobileaidev.aiappbridge.generated;
91
+ public final class BridgeSessionTest extends io.github.mobileaidev.aiappbridge.executor.instrumentation.AndroidExecutorTest {
92
+ ${compose ? ' @org.junit.Rule public final androidx.compose.ui.test.junit4.ComposeTestRule compose = androidx.compose.ui.test.junit4.AndroidComposeTestRule_androidKt.createEmptyComposeRule();' : ''}
93
+ @Override protected java.util.Map<String, io.github.mobileaidev.aiappbridge.executor.ExecutorAdapter> adapters() {
94
+ java.util.Map<String, io.github.mobileaidev.aiappbridge.executor.ExecutorAdapter> adapters = super.adapters();
95
+ ${adapters.includes('espresso-web') ? ' adapters.put("espresso-web", new io.github.mobileaidev.aiappbridge.executor.web.EspressoWebExecutor());' : ''}
96
+ ${compose ? ' adapters.put("compose", new io.github.mobileaidev.aiappbridge.executor.compose.ComposeExecutor(compose));' : ''}
97
+ return adapters;
98
+ }
99
+ }
100
+ `;
101
+ }
102
+
103
+ async function prepareAndroid(args, { run = execFileBounded, home } = {}) {
104
+ if (process.platform === 'win32') throw new CommandError('executor_host_unsupported', 'Android preparation currently supports macOS/Linux Gradle wrappers.');
105
+ const repositoryUrl = repository(args.repositoryUrl);
106
+ return withProject(args.projectDir, async (project, directory) => {
107
+ const wrapper = requireFile(args.gradlePath ? resolveRequestPath(args.gradlePath) : path.join(project, 'gradlew'));
108
+ const adapters = args.adapters ?? [];
109
+ const javaFile = path.join(directory, 'java', ...testClass.split('.')) + '.java';
110
+ fs.mkdirSync(path.dirname(javaFile), { recursive: true });
111
+ fs.writeFileSync(javaFile, androidEntry(adapters));
112
+ const coordinate = module => `com.github.mobileAiDev.ai-app-bridge:${module}:${bridgeVersion}`;
113
+ const config = { directory, module: args.module, variant: args.variant, repositoryUrl,
114
+ pluginCoordinate: coordinate('ai-app-bridge-gradle-plugin'),
115
+ dependencies: [coordinate('ai-app-bridge-test-instrumentation'), ...adapters.map(adapter => coordinate(`ai-app-bridge-test-${adapter}`)),
116
+ ...(adapters.includes('compose') ? ['androidx.compose.ui:ui-test-junit4:1.8.3'] : [])] };
117
+ const configFile = path.join(directory, 'config.json');
118
+ atomicJson(configFile, config);
119
+ const template = path.resolve(__dirname, '../../runtime/executors/android/prepare.init.gradle');
120
+ await loggedRun(run, 'sh', [wrapper, '--init-script', template, `-Daab.prepare.config=${configFile}`,
121
+ `${args.module}:aiAppBridgePrepareExecutor`, '--console=plain', '--no-configuration-cache', '--max-workers=2'],
122
+ directory, 'gradle-build', { cwd: project, timeoutMs: args.timeoutMs ?? 600000 });
123
+ const build = readJson(path.join(directory, 'android-build.json'));
124
+ if (build?.schemaVersion !== 'aab.android-prepared-build/v1' || build.variant !== args.variant || build.module !== args.module
125
+ || !build.packageName || !build.testPackageName || !build.runner || !build.applicationApks?.length || !build.testApks?.length)
126
+ throw new CommandError('executor_prepare_artifact_missing', 'The requested application/test variant did not produce complete build metadata.');
127
+ const artifacts = files => files.map(file => ({ path: requireFile(file), sha256: digest(fs.readFileSync(file)) }));
128
+ return { platform: 'android', engine: 'android-instrumentation', ...build, testClass,
129
+ instrumentation: `${build.testPackageName}/${build.runner}`, repositoryUrl,
130
+ adapters: ['uiautomator', 'espresso', ...adapters], applicationApks: artifacts(build.applicationApks), testApks: artifacts(build.testApks),
131
+ lifecycle: 'built-not-installed', projectFilesEdited: [],
132
+ next: 'Install the matching application and test APKs, then android-executor open with the returned component and class plus the actual launcher Activity.' };
133
+ }, { home });
134
+ }
135
+
136
+ async function prepareFlutter(args, { run = execFileBounded, home } = {}) {
137
+ return withProject(args.projectDir, async (project, directory) => {
138
+ const flutter = args.flutterPath ?? 'flutter';
139
+ const entrypoint = requireFile(path.resolve(project, args.entrypoint ?? 'lib/main.dart'));
140
+ const pubspec = requireFile(path.join(project, 'pubspec.yaml'));
141
+ const original = fs.readFileSync(pubspec);
142
+ const defines = args.dartDefines ?? [];
143
+ if (defines.some(value => value.split('=')[0] === 'INTEGRATION_TEST_SHOULD_REPORT_RESULTS_TO_NATIVE'))
144
+ throw new CommandError('invalid_argument', 'The Bridge test entrypoint owns INTEGRATION_TEST_SHOULD_REPORT_RESULTS_TO_NATIVE.');
145
+ const probe = await loggedRun(run, flutter, ['--version', '--machine'], directory, 'flutter-version', { cwd: project, timeoutMs: 30000 });
146
+ const sdk = JSON.parse(probe.stdout);
147
+ const version = /^(\d+)\.(\d+)\.(\d+)$/.exec(sdk.frameworkVersion);
148
+ if (!version || Number(version[1]) !== 3 || Number(version[2]) < 41)
149
+ throw new CommandError('executor_flutter_sdk_unsupported', `Flutter ${sdk.frameworkVersion} is outside the supported stable Flutter 3.41+ profile.`);
150
+ // Establish the baseline from the existing lock, even on a clean computer.
151
+ // A normal pub get could silently change versions before our comparison.
152
+ await loggedRun(run, flutter, ['pub', 'get', '--enforce-lockfile'], directory, 'flutter-pub-get', { cwd: project, timeoutMs: args.timeoutMs ?? 600000 });
153
+ const beforeGraph = JSON.parse((await loggedRun(run, flutter, ['pub', 'deps', '--json'], directory, 'flutter-deps-before', { cwd: project, timeoutMs: 30000 })).stdout);
154
+ if (productionVersions(beforeGraph).has('ai_app_bridge_test'))
155
+ throw new CommandError('executor_flutter_dependency_scope', 'ai_app_bridge_test must be a dev_dependency; move the existing production dependency before preparing.');
156
+ let packageRoot = project;
157
+ while (!fs.existsSync(path.join(packageRoot, '.dart_tool/package_config.json'))) {
158
+ const parent = path.dirname(packageRoot);
159
+ if (parent === packageRoot) throw new CommandError('executor_flutter_dependency_missing', 'Pub did not create a package configuration.');
160
+ packageRoot = parent;
161
+ }
162
+ const lockFile = requireFile(path.join(packageRoot, 'pubspec.lock'));
163
+ const originalLock = fs.readFileSync(lockFile);
164
+ const packageConfig = path.join(packageRoot, '.dart_tool/package_config.json');
165
+ const beforeHelper = JSON.parse(fs.readFileSync(packageConfig)).packages.find(item => item.name === 'ai_app_bridge_test');
166
+ const beforeDependency = beforeGraph.packages.find(item => item.name === 'ai_app_bridge_test');
167
+ const matchingSource = beforeHelper && (args.testPackagePath
168
+ ? fs.realpathSync(fileURLToPath(new URL(beforeHelper.rootUri, pathToFileURL(packageConfig)))) === fs.realpathSync(resolveRequestPath(args.testPackagePath))
169
+ : beforeDependency?.source === 'hosted');
170
+ const dependency = args.testPackagePath
171
+ ? `dev:ai_app_bridge_test:${JSON.stringify({ path: fs.realpathSync(resolveRequestPath(args.testPackagePath)) })}`
172
+ : `dev:ai_app_bridge_test:${bridgeVersion}`;
173
+ const reusedDependency = matchingSource && beforeDependency.version === bridgeVersion;
174
+ try {
175
+ if (!reusedDependency) await loggedRun(run, flutter, ['pub', 'add', dependency], directory, 'flutter-dependencies', { cwd: project, timeoutMs: args.timeoutMs ?? 600000 });
176
+ const afterGraph = JSON.parse((await loggedRun(run, flutter, ['pub', 'deps', '--json'], directory, 'flutter-deps-after', { cwd: project, timeoutMs: 30000 })).stdout);
177
+ const afterVersions = new Map(afterGraph.packages.map(item => [item.name, item.version]));
178
+ const changes = [...productionVersions(beforeGraph)].filter(([name, version]) => afterVersions.get(name) !== version)
179
+ .map(([name, before]) => ({ name, before, after: afterVersions.get(name) ?? null }));
180
+ if (changes.length) {
181
+ atomicJson(path.join(directory, 'dependency-conflicts.json'), changes);
182
+ throw new CommandError('executor_flutter_dependency_conflict', 'The test helper would change existing application dependencies; its pubspec/lock changes were reverted. See dependency-conflicts.json.');
183
+ }
184
+ const packages = JSON.parse(fs.readFileSync(path.join(packageRoot, '.dart_tool/package_config.json')));
185
+ const helper = packages.packages.find(item => item.name === 'ai_app_bridge_test');
186
+ if (!helper || afterVersions.get('ai_app_bridge_test') !== bridgeVersion)
187
+ throw new CommandError('executor_flutter_dependency_mismatch', `Pub must resolve ai_app_bridge_test ${bridgeVersion}; received ${afterVersions.get('ai_app_bridge_test')}.`);
188
+ } catch (error) {
189
+ if (!original.equals(fs.readFileSync(pubspec)) || !originalLock.equals(fs.readFileSync(lockFile))) {
190
+ fs.writeFileSync(pubspec, original);
191
+ fs.writeFileSync(lockFile, originalLock);
192
+ try {
193
+ await loggedRun(run, flutter, ['pub', 'get', '--offline', '--enforce-lockfile'], directory, 'flutter-restore-dependencies', { cwd: project, timeoutMs: 60000 });
194
+ } catch (restoreError) {
195
+ throw new CommandError('executor_flutter_dependency_restore_failed', `Pubspec and lock were restored, but Pub metadata restoration failed after ${error.code || error.message}. ${restoreError.message}`);
196
+ }
197
+ }
198
+ throw error;
199
+ }
200
+ const generated = path.join(directory, 'bridge_test.dart');
201
+ const launch = args.mainArguments === undefined ? 'app.main' : `() => app.main(<String>${JSON.stringify(args.mainArguments).replaceAll('$', '\\$')})`;
202
+ const importUri = pathToFileURL(entrypoint).href.replaceAll("'", '%27').replaceAll('$', '%24');
203
+ fs.writeFileSync(generated, `import 'package:ai_app_bridge_test/ai_app_bridge_test.dart';\nimport '${importUri}' as app;\nvoid main() => aiAppBridgeTest(${launch});\n`);
204
+ await loggedRun(run, flutter, ['build', 'apk', '--debug', '--no-pub', '--target', generated,
205
+ '--dart-define=INTEGRATION_TEST_SHOULD_REPORT_RESULTS_TO_NATIVE=false',
206
+ ...(args.flavor ? ['--flavor', args.flavor] : []), ...defines.map(value => `--dart-define=${value}`)],
207
+ directory, 'flutter-build', { cwd: project, timeoutMs: args.timeoutMs ?? 600000 });
208
+ const apk = requireFile(path.join(project, 'build/app/outputs/flutter-apk', `app-${args.flavor ? `${args.flavor}-` : ''}debug.apk`));
209
+ return { platform: 'flutter', devicePlatform: 'android', engine: 'flutter-integration-test', flutter: sdk,
210
+ projectDir: project, entrypoint, generatedEntrypoint: generated,
211
+ reusedDependency: Boolean(reusedDependency),
212
+ applicationApks: [{ path: apk, sha256: digest(fs.readFileSync(apk)) }], lifecycle: 'built-not-installed',
213
+ dependencySource: args.testPackagePath ? { path: fs.realpathSync(resolveRequestPath(args.testPackagePath)) } : { hosted: 'pub.dev', version: bridgeVersion },
214
+ projectFilesEdited: original.equals(fs.readFileSync(pubspec)) ? [] : ['pubspec.yaml'],
215
+ applicationDependencyVersionsChanged: [],
216
+ dependencyMetadata: ['pubspec.lock', '.dart_tool/package_config.json', '.flutter-plugins-dependencies'],
217
+ next: 'Install this debug APK into the original application package, then flutter-executor open. Ordinary lib/main.dart builds do not start WidgetTester.' };
218
+ }, { home });
219
+ }
220
+
221
+ function productionVersions(graph) {
222
+ const packages = new Map(graph.packages.map(item => [item.name, item]));
223
+ const versions = new Map();
224
+ function visit(name) {
225
+ if (versions.has(name)) return;
226
+ const item = packages.get(name);
227
+ if (!item) throw new CommandError('executor_flutter_dependency_graph_invalid', `Pub dependency is absent: ${name}`);
228
+ versions.set(name, item.version);
229
+ for (const child of item.directDependencies) visit(child);
230
+ }
231
+ for (const item of graph.packages.filter(item => item.kind === 'root')) for (const name of item.directDependencies) visit(name);
232
+ return versions;
233
+ }
234
+
235
+ async function prepareExecutor(args, dependencies = {}) {
236
+ if (args.platform === 'android') return prepareAndroid(args, dependencies);
237
+ if (args.platform === 'flutter') return prepareFlutter(args, dependencies);
238
+ if (args.platform === 'web') return { platform: 'web', bridgeVersion, ...await preparePackage(args.browser ?? 'chromium', dependencies) };
239
+ if (args.platform === 'ios') {
240
+ const { prepareManagedWda, wdaBuildEnvironment } = require('../ios-wda-project');
241
+ const xcode = await (dependencies.run ?? execFileBounded)(args.xcodebuild ?? 'xcodebuild', ['-version'],
242
+ { timeoutMs: 30000, env: wdaBuildEnvironment() });
243
+ return { ...await prepareManagedWda(dependencies), xcode: xcode.stdout.trim() };
244
+ }
245
+ throw new CommandError('invalid_argument', 'Select android, flutter, ios or web.');
246
+ }
247
+
248
+ module.exports = { preparationSchema, prepareExecutor, prepareAndroid, prepareFlutter, androidEntry, withProject, loggedRun, testClass, bridgeVersion, productionVersions };
@@ -48,7 +48,9 @@ async function inspectApk(args, run = execute) {
48
48
  if (error instanceof CommandError) throw error;
49
49
  throw new CommandError('invalid_apk', 'APK manifest or signature verification failed.', { field: 'apkPath', details: { cause: error.code || null } });
50
50
  }
51
- const pkg = /^package: name='([^']+)' versionCode='([^']+)' versionName='([^']*)'/m.exec(badging.stdout);
51
+ // AGP-generated instrumentation APKs can omit both version attributes.
52
+ // Identity still comes from the manifest package, verified signer and bytes.
53
+ const pkg = /^package: name='([^']+)' versionCode='([0-9]*)' versionName='([^']*)'/m.exec(badging.stdout);
52
54
  const certificates = [...new Set([...signatures.stdout.matchAll(/^(?:Signer #\d+|V(?:[124]|3(?:\.[01])?) Signer:) certificate SHA-256 digest: ([a-fA-F0-9]{64})\r?$/gm)].map(match => match[1].toLowerCase()))];
53
55
  if (!pkg || !certificates.length) throw new CommandError('invalid_apk', 'The APK has no verified package identity.', { field: 'apkPath' });
54
56
  if (args.packageName !== undefined && args.packageName !== pkg[1]) throw new CommandError('apk_package_mismatch', 'packageName differs from the APK manifest.', { field: 'packageName', details: { requested: args.packageName, apk: pkg[1] } });
@@ -14,7 +14,7 @@ const { isMutationCommand, executionTimeoutMs } = require('./command-registry');
14
14
  const { normalizeExecutionTarget } = require('./shared-kernel/execution-target');
15
15
  const { descriptorBinding, bindingHeaders, assertRuntimeResponse, bindingFailure } = require('./ios-runtime-binding');
16
16
  const { openWdaPort, target: wdaTarget } = require('./ios-wda-port');
17
- const { prepareWdaProject, wdaBuildEnvironment } = require('./ios-wda-project');
17
+ const { prepareManagedWda, wdaBuildEnvironment } = require('./ios-wda-project');
18
18
  const { executeWDAAction, reconcileWDA, completionPort } = require('./ios-wda-execution');
19
19
  const { deviceCommandRejection, deviceCommandProof } = require('./ios-device-outcome');
20
20
  const { initializationProof } = require('./ios-wda-startup');
@@ -776,8 +776,8 @@ class IOSBridgeProvider {
776
776
  if (!teamId) return { ok: false, error: 'ios_team_id_required', message: 'Signing WDA requires an explicit teamId or configured DEVELOPMENT_TEAM.' };
777
777
  checkExecution();
778
778
  const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'aab-wda-runtime-'));
779
- const prepared = prepareWdaProject({ destination: path.join(directory, 'source') });
780
779
  const ctx = this.context(args);
780
+ const prepared = await prepareManagedWda();
781
781
  const xcodeArgs = ['-project', prepared.projectPath, '-scheme', 'WebDriverAgentRunner', '-sdk', 'iphoneos', '-destination', `id=${device.udid}`,
782
782
  '-derivedDataPath', path.join(directory, 'build'), `DEVELOPMENT_TEAM=${teamId}`, `PRODUCT_BUNDLE_IDENTIFIER=${wdaTestBundleId}`,
783
783
  'ENABLE_DEFAULT_HEADER_SEARCH_PATHS=NO', '-allowProvisioningUpdates'];
@@ -76,4 +76,51 @@ function prepareWdaProject({ destination, packageDirectory = path.dirname(requir
76
76
  return result;
77
77
  }
78
78
 
79
- module.exports = { supportedVersion, prepareWdaProject, wdaBuildEnvironment };
79
+ async function prepareManagedWda({ home = require('./executors/managed-runtime').executorHome() } = {}) {
80
+ const { atomicJson, readJson } = require('./executors/managed-runtime');
81
+ const bridgeVersion = require('../package.json').version;
82
+ const hash = createHash('sha256').update(fs.readFileSync(__filename)).update(bridgeVersion).update(supportedVersion);
83
+ const sourceRoot = path.join(__dirname, '..', 'runtime', 'ios-wda');
84
+ for (const name of fs.readdirSync(sourceRoot).sort()) hash.update(name).update(fs.readFileSync(path.join(sourceRoot, name)));
85
+ const nativeRoot = path.dirname(require.resolve('@mobileaidev/segmented-fact-store-native/package.json'));
86
+ for (const name of ['package.json', 'include/sfs.h', 'src/sfs.c']) hash.update(fs.readFileSync(path.join(nativeRoot, name)));
87
+ const dependencyDigest = hash.digest('hex');
88
+ const directory = path.join(home, 'packages', 'ios-wda', dependencyDigest);
89
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
90
+ const { DatabaseSync } = require('node:sqlite');
91
+ const lock = new DatabaseSync(path.join(directory, 'prepare.sqlite'));
92
+ let locked = false;
93
+ try {
94
+ try { lock.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE'); locked = true; }
95
+ catch (error) { if (error.errcode === 5) throw new CommandError('executor_preparing', 'This WDA project is already being prepared.'); throw error; }
96
+ const readyFile = path.join(directory, 'prepared.json');
97
+ const existing = readJson(readyFile);
98
+ const sha256 = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
99
+ if (existing) {
100
+ for (const item of existing.files) {
101
+ const file = path.join(directory, 'source', item.path);
102
+ if (!fs.existsSync(file) || sha256(file) !== item.sha256)
103
+ throw new CommandError('ios_wda_prepared_source_changed', 'The managed WDA source changed after preparation. Select a new executor home to prepare clean source.');
104
+ }
105
+ return { ...existing.result, reused: true };
106
+ }
107
+ const destination = path.join(directory, 'source');
108
+ const prepared = prepareWdaProject({ destination });
109
+ const files = [];
110
+ function visit(relative) {
111
+ for (const entry of fs.readdirSync(path.join(destination, relative), { withFileTypes: true })) {
112
+ const name = path.join(relative, entry.name);
113
+ if (entry.isDirectory()) visit(name);
114
+ else if (entry.isFile()) files.push({ path: name, sha256: sha256(path.join(destination, name)) });
115
+ }
116
+ }
117
+ visit('');
118
+ const result = { ok: true, platform: 'ios', engine: 'wda-xcuitest', bridgeVersion, dependencyDigest, directory,
119
+ ...prepared, reused: false, lifecycle: 'source-prepared',
120
+ next: 'Run ios-setup with startWda, deviceId, bundleId and teamId to sign, build and start this managed Runner. Device trust and Enable UI Automation still require the device owner.' };
121
+ atomicJson(readyFile, { result, files });
122
+ return result;
123
+ } finally { if (locked) lock.exec('ROLLBACK'); lock.close(); }
124
+ }
125
+
126
+ module.exports = { supportedVersion, prepareWdaProject, prepareManagedWda, wdaBuildEnvironment };
@@ -229,7 +229,7 @@ async function mutationResult(command, args, options, ctx) {
229
229
  const actionId = options.dispatchActionId || mutationActionId(ctx.executionId, ctx.callId);
230
230
  const bound = dispatchArgs(args, actionId);
231
231
  try {
232
- return await admitExecutionMutation(ctx.target, ctx.mutationLease, async () => {
232
+ const execute = async () => {
233
233
  try { return await actionOnce(command, bound, options, {
234
234
  actions: ctx.actions,
235
235
  target: ctx.target,
@@ -238,7 +238,8 @@ async function mutationResult(command, args, options, ctx) {
238
238
  actionId,
239
239
  onAction: ctx.onAction,
240
240
  }); } finally { ctx.onSettled(); }
241
- });
241
+ };
242
+ return command === 'executor-prepare' ? await execute() : await admitExecutionMutation(ctx.target, ctx.mutationLease, execute);
242
243
  } catch (error) {
243
244
  return unavailableEnvelope({ command, error: error.code || error.message, executionId: ctx.executionId, callId: ctx.callId });
244
245
  }
@@ -73,6 +73,10 @@ function commandPlatform(command) {
73
73
  // select another platform; a partial identity can override defaults only on the
74
74
  // same platform. Never mix an Android serial with an iOS/Web connection.
75
75
  function bindCommandTarget(command, args, defaultTarget = null, explicitTarget) {
76
+ if (command === 'executor-prepare') {
77
+ if (explicitTarget !== undefined) throw new CommandError('target_platform_mismatch', 'Executor preparation runs on the Host and accepts no device target.');
78
+ return { target: null, args: { ...args } };
79
+ }
76
80
  const platform = commandPlatform(command);
77
81
  const definition = definitions[platform];
78
82
  const selected = explicitTarget === undefined ? defaultTarget : normalizeExecutionTarget(explicitTarget);
@@ -7,8 +7,8 @@ const context = new AsyncLocalStorage();
7
7
  const requestDirectory = () => context.getStore()?.cwd ?? process.cwd();
8
8
  const resolveRequestPath = value => path.resolve(requestDirectory(), value);
9
9
  const inRequestDirectory = (cwd, action) => context.run({ cwd }, action);
10
- const localPaths = ['outFile', 'artifactDir', 'apkPath', 'appPath', 'recordingDir', 'outputDir', 'archiveDir', 'wdaProjectPath', 'agentModule'];
11
- const executables = ['adb', 'aaptPath', 'apksignerPath', 'devicectl', 'xcodebuild', 'pythonPath'];
10
+ const localPaths = ['outFile', 'artifactDir', 'apkPath', 'appPath', 'recordingDir', 'outputDir', 'archiveDir', 'wdaProjectPath', 'agentModule', 'projectDir', 'testPackagePath'];
11
+ const executables = ['adb', 'aaptPath', 'apksignerPath', 'devicectl', 'xcodebuild', 'pythonPath', 'flutterPath', 'gradlePath'];
12
12
 
13
13
  // Only contract-defined filesystem fields are resolved. Web URL paths, source
14
14
  // text and arbitrary Script inputs keep their original values.
@@ -165,6 +165,8 @@ function requestDigest(command, args) {
165
165
  }
166
166
 
167
167
  function targetFor(command, args) {
168
+ if (command === 'executor-prepare') return { kind: 'host', platform: 'host',
169
+ key: `executor-prepare:${JSON.stringify([args.platform, args.projectDir ?? null])}` };
168
170
  if (command === 'logcat' && String(args.deviceLogScope || '').toLowerCase() === 'device') {
169
171
  const serial = targetPart(args.serial);
170
172
  return {
@@ -1,4 +1,4 @@
1
- # Optional UI executors (0.3.7)
1
+ # Optional UI executors (0.3.8)
2
2
 
3
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
4
 
@@ -15,9 +15,35 @@ Bridge keeps its existing SDK paths and exposes optional executors through `capa
15
15
 
16
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
17
 
18
- ## Android: one entry in the existing application
18
+ ## Automatic preparation in the existing project
19
19
 
20
- Add the selected artifacts to the application's `androidTestImplementation` configuration. All Bridge artifacts use the same version:
20
+ Use `executor-prepare` from CLI, MCP `run`, or JavaScript/Python `ctx.call` with `app.test`. This is a Host operation: it does not require a device target, install an App, start UI observation, or open an executor session. Preparation uses the selected project's existing toolchain and keeps its application ID. The result separates preparation from installation and session readiness.
21
+
22
+ ```sh
23
+ ai-app-bridge executor-prepare --platform android --project-dir /project \
24
+ --module :app --variant debug --adapters '["espresso-web"]'
25
+ ai-app-bridge executor-prepare --platform flutter --project-dir /flutter-app \
26
+ --flutter-path /flutter-sdk/bin/flutter
27
+ ai-app-bridge executor-prepare --platform ios
28
+ ai-app-bridge executor-prepare --platform web --browser chromium
29
+ ```
30
+
31
+ - Android: a temporary Gradle init script adds the test dependencies and a generated session class only for this invocation. The dependency-check plugin is applied automatically. The result contains the actual application ID, instrumentation component, test class, APK paths and SHA-256 values. Existing matching test dependencies/runners are reused; conflicting Bridge test dependencies are rejected. Business Gradle, manifest and source files are not edited. Select the actual module and debuggable variant; flavors and APK splits retain their original identities. The current build integration uses the AGP 7.4–8.x variant API on macOS/Linux; AGP 9 and Windows preparation are not part of this profile. A local `file:` Maven `repositoryUrl` can explicitly select development artifacts; ordinary preparation resolves the same release version from JitPack.
32
+ - Flutter: the command runs the project's selected Flutter executable, adds the exact `ai_app_bridge_test` version to `dev_dependencies` with Pub, and generates a test entry under `build/ai-app-bridge`. It supports Pub workspaces, an explicit `entrypoint`, `flavor`, `dartDefines`, and `mainArguments` for applications whose main accepts a string list. Pubspec/lock changes are visible configuration; the result lists them. Main Dart code is not edited. The helper uses the same Flutter SDK as the App. Baseline resolution uses `pub get --enforce-lockfile`: commit a valid application/workspace lockfile first. This prevents dependency changes before comparison, including on a new computer. If adding the helper changes an existing production dependency version or Pub fails midway, preparation restores the pubspec/lock and reports the conflict or original error. A successful dependency solve is followed by a real debug build. Repeated preparation reuses the exact resolved helper. `testPackagePath` explicitly selects a local helper during development and must carry the matching Bridge version. The current WidgetTester host remains Android-only; iOS Flutter controls use the existing iOS Flutter SDK/WDA paths.
33
+ - iOS: checks Xcode and prepares a pinned WDA project in the managed executor directory. Source hashes are verified before reuse. `ios-setup --start-wda` reuses that project to sign, build and launch the Runner for the explicitly selected phone. Xcode, signing credentials, device trust and Enable UI Automation are still required. No XCTest source is added to the business application.
34
+ - Web: uses the existing pinned Playwright/browser preparation, outside the page SDK. It can be reused without modifying the web project or adding browser dependencies to its production bundle.
35
+
36
+ Generated files and logs are placed under the project's `build/ai-app-bridge/prepare/<id>`; each preparation writes `result.json`, including failures. Mobile preparation reports `built-not-installed` (iOS source preparation reports `source-prepared`). Install the returned matching artifacts with the public install commands, then open the selected executor. JS/Python workflow changes do not require rebuilding an already prepared App; changes to application code or test dependencies do.
37
+
38
+ Installing the npm package alone does not install Android SDK/JDK, Flutter, Xcode, Python, or project-specific tools such as Rust. Preparation reports the missing prerequisite or original compiler log; it never upgrades a business toolchain to hide a conflict.
39
+
40
+ Android uses the project's Gradle wrapper. For a monorepo that shares a wrapper outside `projectDir`, pass its exact path as `--gradle-path /path/to/gradlew`. Existing matching test dependencies and a custom test runner are retained. Installing a generated androidTest APK does not require it to declare an application version; manifest package, signature and installed APK bytes are still verified.
41
+
42
+ Android WebView H5 requires the optional `espresso-web` adapter and the application's existing JavaScript configuration. iOS WKWebView uses the existing bound H5 SDK path. Playwright manages browser pages; it is not silently used as the controller for a native App's embedded WebView.
43
+
44
+ ## Android: optional manual customization
45
+
46
+ Automatic preparation generates the entry and dependencies below. Add them manually only when the project needs custom test rules or adapters. All Bridge artifacts use the same version:
21
47
 
22
48
  ```kotlin
23
49
  android {
@@ -26,9 +52,9 @@ android {
26
52
  }
27
53
  }
28
54
  dependencies {
29
- androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-instrumentation:0.3.7")
55
+ androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-instrumentation:0.3.8")
30
56
  // Optional H5 adapter:
31
- androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-espresso-web:0.3.7")
57
+ androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-espresso-web:0.3.8")
32
58
  }
33
59
  ```
34
60
 
@@ -79,11 +105,13 @@ Compose is optional and its runtime is supplied by the consumer. The initial Com
79
105
 
80
106
  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
107
 
108
+ Espresso text actions have different semantics. `replaceText` is the framework's setter path; a custom editor can deliberately suppress its business listeners, so an immediate text readback is insufficient. `replaceTextViaInputConnection` explicitly selects the editor's full text and commits through its `InputConnection`, reporting `espresso-input-connection`. It supports Unicode when the editor accepts that connection and does not fall back to a setter. `typeText` injects key input and has the framework's keyboard character limits. Reopen the screen and check persisted application state for all three paths; none alone proves a physical keyboard/IME workflow. Espresso observations include checked state for `Checkable` widgets.
109
+
82
110
  ## Flutter
83
111
 
84
- Add `ai_app_bridge_test: 0.3.7` 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.
112
+ Add `ai_app_bridge_test: 0.3.8` 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
113
 
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.
114
+ The helper declares Flutter **>=3.41.0** and Dart **>=3.11.0 <4.0.0**. The 0.3.8 automatic preparation was exercised with LocalSend on Flutter **3.41.9 / Android API 36**, preserving all 214 production dependency versions. Earlier executor validation covered Flutter 3.41.9 / API 25 and 3.44.8 / API 36. These are specific verified combinations; other SDK versions still need validation with the application's plugin graph.
87
115
 
88
116
  ```dart
89
117
  // integration_test/bridge_test.dart
package/docs/RELEASE.md CHANGED
@@ -1,8 +1,17 @@
1
- # 0.3.7 发行与接入交接
1
+ # 0.3.8 发行与接入交接
2
2
 
3
3
  本文件记录正式版的依赖关系和出仓库交付入口。封版要求是同一提交的源码、发行包与公开接入合同一致;单个样本的测试进度不改变包版本或发布状态。推送 Git、创建远端标签及发布 npm/pub 包由维护者执行。
4
4
 
5
- ## 0.3.7 修复
5
+ ## 0.3.8 自动准备
6
+
7
+ - 新增 `executor-prepare`,为原工程准备可选测试执行器,公开命令数为 122。
8
+ - Android 自动生成 androidTest 入口、注入测试依赖并构建原包名的主包与测试包;Flutter 自动添加 dev_dependency、生成入口并检查业务依赖版本。
9
+ - iOS 管理并校验可复用的 WDA 工程;Web 复用精确版本 Playwright 和浏览器准备流程。
10
+ - 准备不启动持续 UI 观察,不自动升级业务工具链;实际设备/浏览器验证与发布证据另行记录。
11
+ - Espresso 增加显式 `replaceTextViaInputConnection` 输入选项,并返回可勾选控件的实际状态;原有 setter 和按键输入分别保留其语义。NotallyX 真机回归验证了自定义 `setText` 抑制业务监听的情况,不能只以屏幕文字变化认定保存成功。
12
+ - 允许安装标准 AGP 生成的无版本号 androidTest APK,包名、签名和安装后文件哈希校验继续执行。
13
+
14
+ ## 随本版包含的 0.3.7 修复
6
15
 
7
16
  - Android Host 读取全部焦点记录,跳过 `null`,不再依赖记录排列顺序。只有一个有效窗口时直接使用;多个窗口通过系统 `mTopFocusedDisplayId` 选择,仍不能唯一定位时返回 `foreground_ambiguous`。
8
17
  - 包名匹配、SDK 窗口与控件身份校验沿用既有路径。App SDK 只同步发行版本。
@@ -17,7 +26,7 @@
17
26
  - 公开 capabilities + run、Python/JS Script 的 app.test 权限、观测身份、原始回执、取消和设备占用接线。
18
27
  - Compose 主包/测试包版本检查;按测试配置隔离依赖,不自动升级业务 AGP/Kotlin/Compose。
19
28
  - Android 7 权限输出的零 flags 省略及空格分隔格式支持,来源为实际 API-25 环境和 AOSP Settings 输出实现。
20
- - 本地交付和外部发布分开记录;这份源码不表示 npm/JitPack/pub.dev 已发布 0.3.7
29
+ - 本地交付和外部发布分开记录;这份源码不表示 npm/JitPack/pub.dev 已发布 0.3.8
21
30
 
22
31
  接入合同、具体依赖和实测性能见 [OPTIONAL_EXECUTORS.md](OPTIONAL_EXECUTORS.md)。
23
32
 
@@ -41,18 +50,18 @@
41
50
 
42
51
  ## 新增可选分发物
43
52
 
44
- Android `ai-app-bridge-test-core`、`ai-app-bridge-test-uia`、`ai-app-bridge-test-espresso`、`ai-app-bridge-test-instrumentation`、`ai-app-bridge-test-espresso-web`、`ai-app-bridge-test-compose` 均使用 0.3.7,通过 androidTestImplementation 消费。Gradle 插件增加 `io.github.mobileaidev.aiappbridge.test` 依赖校验入口。新 Flutter 包 `ai_app_bridge_test` 使用 0.3.7,只作为 dev_dependency;它的发布不依赖 Android SDK 的 JitPack 坐标。
53
+ Android `ai-app-bridge-test-core`、`ai-app-bridge-test-uia`、`ai-app-bridge-test-espresso`、`ai-app-bridge-test-instrumentation`、`ai-app-bridge-test-espresso-web`、`ai-app-bridge-test-compose` 均使用 0.3.8,通过 androidTestImplementation 消费。Gradle 插件增加 `io.github.mobileaidev.aiappbridge.test` 依赖校验入口。新 Flutter 包 `ai_app_bridge_test` 使用 0.3.8,只作为 dev_dependency;它的发布不依赖 Android SDK 的 JitPack 坐标。
45
54
 
46
55
  ## 版本与消费方式
47
56
 
48
57
  | 交付物 | 发行版本 | 独立消费入口 | 发布依赖 |
49
58
  | --- | --- | --- | --- |
50
- | Android SDK | `0.3.7` | JitPack `com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-android:0.3.7` | 同名 Git tag,JitPack 对该提交成功构建 |
51
- | Android Gradle 插件 | `0.3.7` | JitPack `ai-app-bridge-gradle-plugin` 模块及插件 ID `io.github.mobileaidev.aiappbridge.android` | 与 SDK 相同的 Git tag;不再使用旧默认 `0.2.8` |
52
- | 原生 iOS SDK | Git tag `0.3.7` | Git URL 的仓库根 `Package.swift`,产品 `AiAppBridgeIOS` | 根清单包含 Swift runtime、C adapter 和 segmented C store,无外部 C 包路径 |
53
- | Flutter 插件 | `0.3.7` | pub `ai_app_bridge_flutter` | Android 固定依赖上述 SDK;iOS Swift/C 源码随插件分发 |
54
- | Desktop CLI/MCP | `0.3.7` | npm `@mobileaidev/ai-app-bridge` | 包含 UIA bundle、WDA 模板和 native store 源码;WDA 上游固定 `14.1.1` |
55
- | Web SDK | `0.3.7` | npm `@mobileaidev/ai-app-bridge-web` | 独立浏览器源码包,无 npm 对 CLI 的安装依赖 |
59
+ | Android SDK | `0.3.8` | JitPack `com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-android:0.3.8` | 同名 Git tag,JitPack 对该提交成功构建 |
60
+ | Android Gradle 插件 | `0.3.8` | JitPack `ai-app-bridge-gradle-plugin` 模块及插件 ID `io.github.mobileaidev.aiappbridge.android` | 与 SDK 相同的 Git tag;不再使用旧默认 `0.2.8` |
61
+ | 原生 iOS SDK | Git tag `0.3.8` | Git URL 的仓库根 `Package.swift`,产品 `AiAppBridgeIOS` | 根清单包含 Swift runtime、C adapter 和 segmented C store,无外部 C 包路径 |
62
+ | Flutter 插件 | `0.3.8` | pub `ai_app_bridge_flutter` | Android 固定依赖上述 SDK;iOS Swift/C 源码随插件分发 |
63
+ | Desktop CLI/MCP | `0.3.8` | npm `@mobileaidev/ai-app-bridge` | 包含 UIA bundle、WDA 模板和 native store 源码;WDA 上游固定 `14.1.1` |
64
+ | Web SDK | `0.3.8` | npm `@mobileaidev/ai-app-bridge-web` | 独立浏览器源码包,无 npm 对 CLI 的安装依赖 |
56
65
  | Native store | `0.1.0` | 随 CLI 的 bundled dependency 安装 | 不要求另行发布到 npm;`file:../../native/segmented-fact-store` 是工作区构建入口,最终 tarball 必须包含该依赖源码 |
57
66
 
58
67
  Flutter 的 podspec 是随 pub 插件消费的本地 podspec,不是独立 CocoaPods trunk 发布包;原生 iOS 使用根 Swift package。Flutter SwiftPM 的 `../FlutterFramework` 由 Flutter 的集成生成,不能当作本仓库的外部私有依赖,也不应将本机 Flutter framework 打包进插件。
@@ -61,11 +70,11 @@ Host 支持范围声明为 Node `>=26.3.0 <27`,本轮实际验证基线是 **2
61
70
 
62
71
  ## 发布顺序
63
72
 
64
- 1. 完成源码审阅并冻结一个提交,核对以下命令的产物确实来自它;包含当前 untracked 的实际源码、测试和文档,排除本机生成目录。所有对外发行版本使用同一个 `0.3.7`,若需要改版本,先同时更新上表涉及的 manifest 与固定依赖。
65
- 2. 维护者推送提交与 `0.3.7` 标签,让 JitPack 构建 Android SDK/插件。确认两条公开坐标可解析后,再发布依赖它们的 Flutter 包。本地 Gradle project/path/AAR 替换不能证明 JitPack 坐标可消费。
73
+ 1. 完成源码审阅并冻结一个提交,核对以下命令的产物确实来自它;包含当前 untracked 的实际源码、测试和文档,排除本机生成目录。所有对外发行版本使用同一个 `0.3.8`,若需要改版本,先同时更新上表涉及的 manifest 与固定依赖。
74
+ 2. 维护者推送提交与 `0.3.8` 标签,让 JitPack 构建 Android SDK/插件。确认两条公开坐标可解析后,再发布依赖它们的 Flutter 包。本地 Gradle project/path/AAR 替换不能证明 JitPack 坐标可消费。
66
75
  3. 原生 iOS 消费相同 Git tag 的根 package;完成根 package 的 iOS 构建,不仅构建 `ios/ai-app-bridge-ios/Package.swift`。Flutter iOS 则检查实际 pub 包内 Swift/C 源码与声明相符。
67
76
  4. CLI 与 Web SDK 可分别发布到 npm 的 `latest` dist-tag。CLI 的 native store 已打包随行,不等待一个不存在的单独 registry 依赖。Flutter 包发布以第 2 步完成为前提。
68
- 5. 同步 npm `next` 指向 `0.3.7`,让已有候选入口也使用本次正式版。将 GitHub `main` 与发行提交同步,并创建非预发布的 GitHub Release。
77
+ 5. 同步 npm `next` 指向 `0.3.8`,让已有候选入口也使用本次正式版。将 GitHub `main` 与发行提交同步,并创建非预发布的 GitHub Release。
69
78
  6. 从 registry/tag 安装刚发布的确切版本,读取 `capabilities` 和版本,核对来源及支持范围,确认默认安装入口指向本次发行版本。正式发布不自动等于全平台生产验收完成。
70
79
 
71
80
  正式发布命令需在对应目录由维护者执行,例如 npm 使用 `npm publish --tag latest`;pub 使用 `flutter pub publish`。这些命令属于发布动作,不能混入本地验证脚本。
@@ -106,7 +115,7 @@ npm 升级不会替换已连接的 MCP 进程。用 `ai-app-bridge --version`
106
115
  已有工作结束后显式停止旧 Runtime,并在 Cursor 等客户端重连 MCP,核对 initialize
107
116
  中的 `serverInfo.version`。工具描述仍有旧 `batch`/`smoke` 时刷新客户端缓存。
108
117
 
109
- 实际发布状态与验证边界见仓库 `docs/RELEASE_HANDOFF_0.3.7_2026-09-15.md`。
118
+ 实际发布状态与验证边界见仓库 `docs/RELEASE_HANDOFF_0.3.8_2026-09-15.md`。
110
119
  Android Gradle 插件的 `webSocketCaptureEnabled`、`logInstrumentationEnabled`、
111
120
  `webViewDebuggingEnabled` 没有对应插桩实现,现明确弃用并在显式设置时输出提示;
112
121
  旧配置仍可构建。当前有效开关是 `enabled`、`okHttpCaptureEnabled`,以及可选
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobileaidev/ai-app-bridge",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Desktop CLI and MCP server for AI App Bridge across Android, iOS, Flutter, WebView, and Web targets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,70 @@
1
+ // Applied only to this preparation build. No business Gradle/source file is edited.
2
+ initscript {
3
+ def config = new groovy.json.JsonSlurper().parse(new File(System.getProperty('aab.prepare.config')))
4
+ repositories {
5
+ maven { url = uri(config.repositoryUrl) }
6
+ google()
7
+ mavenCentral()
8
+ }
9
+ dependencies { classpath config.pluginCoordinate }
10
+ }
11
+
12
+ def config = new groovy.json.JsonSlurper().parse(new File(System.getProperty('aab.prepare.config')))
13
+ def repositoryMode
14
+ gradle.settingsEvaluated { settings ->
15
+ repositoryMode = settings.dependencyResolutionManagement.repositoriesMode.get().name()
16
+ settings.dependencyResolutionManagement.repositories {
17
+ maven { url = uri(config.repositoryUrl) }
18
+ google()
19
+ mavenCentral()
20
+ }
21
+ }
22
+ gradle.beforeProject { consumer ->
23
+ if (consumer.path != config.module) return
24
+ consumer.plugins.withId('com.android.application') {
25
+ if (repositoryMode == 'PREFER_PROJECT') {
26
+ consumer.repositories {
27
+ maven { url = uri(config.repositoryUrl) }
28
+ google()
29
+ mavenCentral()
30
+ }
31
+ }
32
+ consumer.pluginManager.apply(io.github.mobileaidev.aiappbridge.gradle.AiAppBridgeTestGradlePlugin)
33
+ consumer.extensions.getByName('androidComponents').finalizeDsl { android ->
34
+ if (!android.defaultConfig.testInstrumentationRunner) {
35
+ android.defaultConfig.testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner'
36
+ }
37
+ // Consumer dependencies are complete only after its build script.
38
+ config.dependencies.each { coordinate ->
39
+ def parts = coordinate.split(':')
40
+ def existing = consumer.configurations.getByName('androidTestImplementation').allDependencies.findAll { it.name == parts[1] }
41
+ if (existing.empty) consumer.dependencies.add('androidTestImplementation', coordinate)
42
+ else if (parts[1].startsWith('ai-app-bridge-test-') && (existing.size() != 1 || existing[0].version != parts[2])) {
43
+ throw new GradleException('[AiAppBridge] Existing test dependency must match ' + coordinate + '; no dependency was replaced.')
44
+ }
45
+ }
46
+ }
47
+ consumer.android.sourceSets.androidTest.java.srcDir(new File(config.directory, 'java'))
48
+ consumer.android.applicationVariants.all { variant ->
49
+ if (variant.name != config.variant) return
50
+ if (!variant.buildType.debuggable) throw new GradleException('[AiAppBridge] Executor preparation requires a debuggable variant.')
51
+ def testVariant = variant.testVariant
52
+ if (testVariant == null) throw new GradleException('[AiAppBridge] Enable androidTest for the selected variant.')
53
+ consumer.tasks.register('aiAppBridgePrepareExecutor') { task ->
54
+ task.dependsOn(variant.assembleProvider, testVariant.assembleProvider)
55
+ task.doLast {
56
+ def report = [
57
+ schemaVersion: 'aab.android-prepared-build/v1',
58
+ projectDir: consumer.rootDir.absolutePath, module: consumer.path, variant: variant.name,
59
+ gradleVersion: consumer.gradle.gradleVersion,
60
+ packageName: variant.applicationId, testPackageName: testVariant.applicationId,
61
+ runner: testVariant.mergedFlavor.testInstrumentationRunner,
62
+ applicationApks: variant.outputs.collect { it.outputFile.absolutePath },
63
+ testApks: testVariant.outputs.collect { it.outputFile.absolutePath }
64
+ ]
65
+ new File(config.directory, 'android-build.json').text = groovy.json.JsonOutput.toJson(report)
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "ai-app-bridge-playwright-runtime",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "ai-app-bridge-playwright-runtime",
9
- "version": "0.3.7",
9
+ "version": "0.3.8",
10
10
  "dependencies": {
11
11
  "playwright": "1.63.0"
12
12
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-app-bridge-playwright-runtime",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "private": true,
5
5
  "description": "Optional, isolated browser runtime managed by AI App Bridge.",
6
6
  "engines": { "node": ">=26.3.0 <27" },