@mobileaidev/ai-app-bridge 0.3.6 → 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 +2 -2
- package/bin/command-registry.js +7 -3
- package/bin/device-provider.js +42 -8
- package/bin/execution-host.js +1 -0
- package/bin/executors/command-schema.js +1 -1
- package/bin/executors/preparation.js +248 -0
- package/bin/intent/install-intent.js +3 -1
- package/bin/ios-provider.js +2 -2
- package/bin/ios-wda-project.js +48 -1
- package/bin/script/script-host-port.js +3 -2
- package/bin/shared-kernel/execution-target.js +4 -0
- package/bin/shared-kernel/request-context.js +2 -2
- package/bin/target-execution.js +2 -0
- package/docs/OPTIONAL_EXECUTORS.md +35 -7
- package/docs/RELEASE.md +28 -14
- package/package.json +1 -1
- package/runtime/executors/android/prepare.init.gradle +70 -0
- package/runtime/executors/playwright/package-lock.json +2 -2
- package/runtime/executors/playwright/package.json +1 -1
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.
|
|
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.
|
|
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
|
package/bin/command-registry.js
CHANGED
|
@@ -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 =
|
|
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();
|
package/bin/device-provider.js
CHANGED
|
@@ -1752,18 +1752,52 @@ function parseForegroundWindow(raw) {
|
|
|
1752
1752
|
'mResumedActivity',
|
|
1753
1753
|
'mFocusedApp',
|
|
1754
1754
|
];
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1755
|
+
const records = [];
|
|
1756
|
+
const focusedDisplayIds = new Set();
|
|
1757
|
+
let displayId = null;
|
|
1758
|
+
for (const line of lines) {
|
|
1759
|
+
if (/^WINDOW MANAGER\b/.test(line)) displayId = null;
|
|
1760
|
+
const display = line.match(/^\s*Display: mDisplayId=(\d+)\b/);
|
|
1761
|
+
if (display) displayId = Number(display[1]);
|
|
1762
|
+
const focusedDisplay = line.match(/^\s*mTopFocusedDisplayId=(-?\d+)\b/);
|
|
1763
|
+
if (focusedDisplay) focusedDisplayIds.add(Number(focusedDisplay[1]));
|
|
1764
|
+
const focus = line.match(/^\s*(mCurrentFocus|mTopResumedActivity|mResumedActivity|mFocusedApp)(?:\[(\d+)\])?\s*[:=]\s*(.*)$/);
|
|
1765
|
+
if (!focus) continue;
|
|
1766
|
+
const component = parseComponentFromWindowLine(focus[3]);
|
|
1759
1767
|
if (!component) continue;
|
|
1768
|
+
records.push({
|
|
1769
|
+
source: focus[1],
|
|
1770
|
+
displayId: focus[2] === undefined ? displayId : Number(focus[2]),
|
|
1771
|
+
value: focus[3].trim(),
|
|
1772
|
+
component,
|
|
1773
|
+
raw: line.trim(),
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
for (const marker of markers) {
|
|
1777
|
+
const unique = new Map();
|
|
1778
|
+
for (const record of records.filter((item) => item.source === marker)) {
|
|
1779
|
+
unique.set(`${record.displayId}:${record.value}`, record);
|
|
1780
|
+
}
|
|
1781
|
+
const candidates = [...unique.values()];
|
|
1782
|
+
if (!candidates.length) continue;
|
|
1783
|
+
const focusedDisplayId = focusedDisplayIds.size === 1 ? [...focusedDisplayIds][0] : null;
|
|
1784
|
+
const selected = candidates.length === 1 ? candidates
|
|
1785
|
+
: candidates.filter((item) => focusedDisplayId !== null && item.displayId === focusedDisplayId);
|
|
1786
|
+
if (selected.length !== 1) {
|
|
1787
|
+
return {
|
|
1788
|
+
ok: false,
|
|
1789
|
+
error: 'foreground_ambiguous',
|
|
1790
|
+
source: marker,
|
|
1791
|
+
focusedDisplayId,
|
|
1792
|
+
candidates: candidates.map((item) => ({ displayId: item.displayId, ...item.component, raw: item.raw })),
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
const record = selected[0];
|
|
1760
1796
|
return {
|
|
1761
1797
|
ok: true,
|
|
1762
1798
|
source: marker,
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
component: component.component,
|
|
1766
|
-
raw: line.trim(),
|
|
1799
|
+
...record.component,
|
|
1800
|
+
raw: record.raw,
|
|
1767
1801
|
};
|
|
1768
1802
|
}
|
|
1769
1803
|
return {
|
package/bin/execution-host.js
CHANGED
|
@@ -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
|
-
|
|
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] } });
|
package/bin/ios-provider.js
CHANGED
|
@@ -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 {
|
|
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'];
|
package/bin/ios-wda-project.js
CHANGED
|
@@ -76,4 +76,51 @@ function prepareWdaProject({ destination, packageDirectory = path.dirname(requir
|
|
|
76
76
|
return result;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
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
|
-
|
|
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.
|
package/bin/target-execution.js
CHANGED
|
@@ -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.
|
|
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
|
-
##
|
|
18
|
+
## Automatic preparation in the existing project
|
|
19
19
|
|
|
20
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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**.
|
|
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,22 @@
|
|
|
1
|
-
# 0.3.
|
|
1
|
+
# 0.3.8 发行与接入交接
|
|
2
2
|
|
|
3
3
|
本文件记录正式版的依赖关系和出仓库交付入口。封版要求是同一提交的源码、发行包与公开接入合同一致;单个样本的测试进度不改变包版本或发布状态。推送 Git、创建远端标签及发布 npm/pub 包由维护者执行。
|
|
4
4
|
|
|
5
|
-
## 0.3.
|
|
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 修复
|
|
15
|
+
|
|
16
|
+
- Android Host 读取全部焦点记录,跳过 `null`,不再依赖记录排列顺序。只有一个有效窗口时直接使用;多个窗口通过系统 `mTopFocusedDisplayId` 选择,仍不能唯一定位时返回 `foreground_ambiguous`。
|
|
17
|
+
- 包名匹配、SDK 窗口与控件身份校验沿用既有路径。App SDK 只同步发行版本。
|
|
18
|
+
|
|
19
|
+
## 随本版包含的 0.3.6 能力
|
|
6
20
|
|
|
7
21
|
- Android、iOS、Flutter、Web 与内嵌 H5 的持续 UI 观察默认关闭;仅按需开启 100–5000 ms 的观察窗口,期限到达、主动停止或相应生命周期退出时清理观察任务。新增三个平台观察控制命令,公开命令共 121 个。
|
|
8
22
|
- Flutter 当前树改为显式读取 `/v1/flutter/snapshot`,状态请求不再触发树遍历;CLI/MCP 与 App 内 SDK 需要配套升级。Android 指纹编码减少 JNI 调用与临时分配。实测证据及边界见仓库中的 UI 观察性能评估记录。
|
|
@@ -12,7 +26,7 @@
|
|
|
12
26
|
- 公开 capabilities + run、Python/JS Script 的 app.test 权限、观测身份、原始回执、取消和设备占用接线。
|
|
13
27
|
- Compose 主包/测试包版本检查;按测试配置隔离依赖,不自动升级业务 AGP/Kotlin/Compose。
|
|
14
28
|
- Android 7 权限输出的零 flags 省略及空格分隔格式支持,来源为实际 API-25 环境和 AOSP Settings 输出实现。
|
|
15
|
-
- 本地交付和外部发布分开记录;这份源码不表示 npm/JitPack/pub.dev 已发布 0.3.
|
|
29
|
+
- 本地交付和外部发布分开记录;这份源码不表示 npm/JitPack/pub.dev 已发布 0.3.8。
|
|
16
30
|
|
|
17
31
|
接入合同、具体依赖和实测性能见 [OPTIONAL_EXECUTORS.md](OPTIONAL_EXECUTORS.md)。
|
|
18
32
|
|
|
@@ -36,18 +50,18 @@
|
|
|
36
50
|
|
|
37
51
|
## 新增可选分发物
|
|
38
52
|
|
|
39
|
-
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.
|
|
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 坐标。
|
|
40
54
|
|
|
41
55
|
## 版本与消费方式
|
|
42
56
|
|
|
43
57
|
| 交付物 | 发行版本 | 独立消费入口 | 发布依赖 |
|
|
44
58
|
| --- | --- | --- | --- |
|
|
45
|
-
| Android SDK | `0.3.
|
|
46
|
-
| Android Gradle 插件 | `0.3.
|
|
47
|
-
| 原生 iOS SDK | Git tag `0.3.
|
|
48
|
-
| Flutter 插件 | `0.3.
|
|
49
|
-
| Desktop CLI/MCP | `0.3.
|
|
50
|
-
| Web SDK | `0.3.
|
|
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 的安装依赖 |
|
|
51
65
|
| Native store | `0.1.0` | 随 CLI 的 bundled dependency 安装 | 不要求另行发布到 npm;`file:../../native/segmented-fact-store` 是工作区构建入口,最终 tarball 必须包含该依赖源码 |
|
|
52
66
|
|
|
53
67
|
Flutter 的 podspec 是随 pub 插件消费的本地 podspec,不是独立 CocoaPods trunk 发布包;原生 iOS 使用根 Swift package。Flutter SwiftPM 的 `../FlutterFramework` 由 Flutter 的集成生成,不能当作本仓库的外部私有依赖,也不应将本机 Flutter framework 打包进插件。
|
|
@@ -56,11 +70,11 @@ Host 支持范围声明为 Node `>=26.3.0 <27`,本轮实际验证基线是 **2
|
|
|
56
70
|
|
|
57
71
|
## 发布顺序
|
|
58
72
|
|
|
59
|
-
1. 完成源码审阅并冻结一个提交,核对以下命令的产物确实来自它;包含当前 untracked 的实际源码、测试和文档,排除本机生成目录。所有对外发行版本使用同一个 `0.3.
|
|
60
|
-
2. 维护者推送提交与 `0.3.
|
|
73
|
+
1. 完成源码审阅并冻结一个提交,核对以下命令的产物确实来自它;包含当前 untracked 的实际源码、测试和文档,排除本机生成目录。所有对外发行版本使用同一个 `0.3.8`,若需要改版本,先同时更新上表涉及的 manifest 与固定依赖。
|
|
74
|
+
2. 维护者推送提交与 `0.3.8` 标签,让 JitPack 构建 Android SDK/插件。确认两条公开坐标可解析后,再发布依赖它们的 Flutter 包。本地 Gradle project/path/AAR 替换不能证明 JitPack 坐标可消费。
|
|
61
75
|
3. 原生 iOS 消费相同 Git tag 的根 package;完成根 package 的 iOS 构建,不仅构建 `ios/ai-app-bridge-ios/Package.swift`。Flutter iOS 则检查实际 pub 包内 Swift/C 源码与声明相符。
|
|
62
76
|
4. CLI 与 Web SDK 可分别发布到 npm 的 `latest` dist-tag。CLI 的 native store 已打包随行,不等待一个不存在的单独 registry 依赖。Flutter 包发布以第 2 步完成为前提。
|
|
63
|
-
5. 同步 npm `next` 指向 `0.3.
|
|
77
|
+
5. 同步 npm `next` 指向 `0.3.8`,让已有候选入口也使用本次正式版。将 GitHub `main` 与发行提交同步,并创建非预发布的 GitHub Release。
|
|
64
78
|
6. 从 registry/tag 安装刚发布的确切版本,读取 `capabilities` 和版本,核对来源及支持范围,确认默认安装入口指向本次发行版本。正式发布不自动等于全平台生产验收完成。
|
|
65
79
|
|
|
66
80
|
正式发布命令需在对应目录由维护者执行,例如 npm 使用 `npm publish --tag latest`;pub 使用 `flutter pub publish`。这些命令属于发布动作,不能混入本地验证脚本。
|
|
@@ -101,7 +115,7 @@ npm 升级不会替换已连接的 MCP 进程。用 `ai-app-bridge --version`
|
|
|
101
115
|
已有工作结束后显式停止旧 Runtime,并在 Cursor 等客户端重连 MCP,核对 initialize
|
|
102
116
|
中的 `serverInfo.version`。工具描述仍有旧 `batch`/`smoke` 时刷新客户端缓存。
|
|
103
117
|
|
|
104
|
-
实际发布状态与验证边界见仓库 `docs/
|
|
118
|
+
实际发布状态与验证边界见仓库 `docs/RELEASE_HANDOFF_0.3.8_2026-09-15.md`。
|
|
105
119
|
Android Gradle 插件的 `webSocketCaptureEnabled`、`logInstrumentationEnabled`、
|
|
106
120
|
`webViewDebuggingEnabled` 没有对应插桩实现,现明确弃用并在显式设置时输出提示;
|
|
107
121
|
旧配置仍可构建。当前有效开关是 `enabled`、`okHttpCaptureEnabled`,以及可选
|
package/package.json
CHANGED
|
@@ -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.
|
|
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.
|
|
9
|
+
"version": "0.3.8",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"playwright": "1.63.0"
|
|
12
12
|
},
|