@mobileaidev/ai-app-bridge 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -39
- package/bin/ai-app-bridge.js +56 -17
- package/bin/command-discovery.js +19 -4
- package/bin/command-registry.js +21 -6
- package/bin/command-request.js +68 -4
- package/bin/execution-host.js +32 -5
- package/bin/execution-runtime.js +17 -10
- package/bin/executors/command-schema.js +1 -1
- package/bin/executors/preparation.js +262 -0
- package/bin/extraction/json-value.js +26 -0
- package/bin/extraction/prepare.js +57 -0
- package/bin/extraction/regex.js +30 -0
- package/bin/extraction/runner.js +77 -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/mcp-server.js +15 -20
- package/bin/public-reply.js +184 -0
- package/bin/response-store.js +60 -0
- package/bin/runtime-client.js +32 -15
- package/bin/runtime-directory.js +37 -8
- package/bin/script/node-runtime-adapter.js +139 -123
- package/bin/script/python-runtime-adapter.js +1 -1
- package/bin/script/script-diagnostics.js +21 -0
- package/bin/script/script-durable-restore.js +1 -0
- package/bin/script/script-host-port.js +3 -2
- package/bin/script/script-sdk.js +39 -4
- package/bin/script/script-sdk.py +79 -7
- package/bin/script/script-session-channel.js +27 -10
- package/bin/script/script-supervisor.js +8 -0
- package/bin/shared-kernel/argument-schema.js +44 -12
- package/bin/shared-kernel/evidence-archive.js +2 -2
- package/bin/shared-kernel/evidence-schema.js +16 -1
- package/bin/shared-kernel/evidence-store.js +3 -3
- package/bin/shared-kernel/execution-contracts.js +13 -5
- 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/COMMAND_CONTRACT.md +91 -19
- package/docs/EVIDENCE_ARCHIVE.md +14 -1
- package/docs/INSTALLATION.md +73 -0
- package/docs/INTENT_FOREGROUND.md +4 -1
- package/docs/OPTIONAL_EXECUTORS.md +41 -13
- package/docs/RELEASE.md +60 -113
- package/docs/RESPONSE_EXTRACTION.md +122 -0
- package/docs/SCRIPT_AUTHORING.md +125 -6
- package/node_modules/@mobileaidev/segmented-fact-store-native/PREBUILDS.md +29 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding-path.js +29 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +1 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +1 -3
- package/node_modules/@mobileaidev/segmented-fact-store-native/install.js +5 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +11 -5
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-arm64/segmented_fact_store.node +0 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-x64/segmented_fact_store.node +0 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-arm64-glibc/segmented_fact_store.node +0 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-x64-glibc/segmented_fact_store.node +0 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/manifest.json +27 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/build-release-prebuilds.js +33 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/stage-prebuild.js +17 -0
- package/package.json +12 -5
- package/runtime/executors/android/prepare.init.gradle +92 -0
- package/runtime/executors/playwright/package-lock.json +2 -2
- package/runtime/executors/playwright/package.json +1 -1
- package/skills/ai-app-bridge-use/SKILL.md +19 -4
|
@@ -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,262 @@
|
|
|
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, ...(error.details ? { details: error.details } : {}), elapsedMs: Date.now() - startedAtMs };
|
|
70
|
+
atomicJson(path.join(directory, 'result.json'), result);
|
|
71
|
+
throw new CommandError(result.error, result.message, { details: { ...error.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
|
+
requirements: { minCompileSdk: adapters.includes('compose') ? 35 : 34, minAgp: '8.1.1',
|
|
115
|
+
dependencies: ['androidx.test.uiautomator:uiautomator:2.4.0', ...(adapters.includes('compose') ? ['androidx.compose.ui:ui-test-junit4-android:1.8.3'] : [])] },
|
|
116
|
+
pluginCoordinate: coordinate('ai-app-bridge-gradle-plugin'),
|
|
117
|
+
dependencies: [coordinate('ai-app-bridge-test-instrumentation'), ...adapters.map(adapter => coordinate(`ai-app-bridge-test-${adapter}`)),
|
|
118
|
+
...(adapters.includes('compose') ? ['androidx.compose.ui:ui-test-junit4:1.8.3'] : [])] };
|
|
119
|
+
const configFile = path.join(directory, 'config.json');
|
|
120
|
+
atomicJson(configFile, config);
|
|
121
|
+
const template = path.resolve(__dirname, '../../runtime/executors/android/prepare.init.gradle');
|
|
122
|
+
const preflightFile = path.join(directory, 'android-preflight.json');
|
|
123
|
+
try { await loggedRun(run, 'sh', [wrapper, '--init-script', template, `-Daab.prepare.config=${configFile}`,
|
|
124
|
+
`${args.module}:aiAppBridgePrepareExecutor`, '--console=plain', '--no-configuration-cache', '--max-workers=2'],
|
|
125
|
+
directory, 'gradle-build', { cwd: project, timeoutMs: args.timeoutMs ?? 600000 }); }
|
|
126
|
+
catch (error) {
|
|
127
|
+
const preflight = readJson(preflightFile);
|
|
128
|
+
if (preflight && preflight.status !== 'compatible') throw new CommandError(
|
|
129
|
+
preflight.status === 'incompatible' ? 'executor_prepare_incompatible' : 'executor_prepare_configuration_unresolved',
|
|
130
|
+
`Android executor requirements were not met before compilation: ${preflight.issues.join('; ')}. No project configuration was upgraded.`,
|
|
131
|
+
{ details: { ...error.details, preflight, preflightFile } });
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
const preflight = readJson(preflightFile);
|
|
135
|
+
if (preflight?.status !== 'compatible' || preflight.module !== args.module || preflight.variant !== args.variant)
|
|
136
|
+
throw new CommandError('executor_prepare_configuration_unresolved', 'The build did not report the selected module and variant preflight.', { details: { preflightFile } });
|
|
137
|
+
const build = readJson(path.join(directory, 'android-build.json'));
|
|
138
|
+
if (build?.schemaVersion !== 'aab.android-prepared-build/v1' || build.variant !== args.variant || build.module !== args.module
|
|
139
|
+
|| !build.packageName || !build.testPackageName || !build.runner || !build.applicationApks?.length || !build.testApks?.length)
|
|
140
|
+
throw new CommandError('executor_prepare_artifact_missing', 'The requested application/test variant did not produce complete build metadata.');
|
|
141
|
+
const artifacts = files => files.map(file => ({ path: requireFile(file), sha256: digest(fs.readFileSync(file)) }));
|
|
142
|
+
return { platform: 'android', engine: 'android-instrumentation', ...build, testClass, preflight,
|
|
143
|
+
instrumentation: `${build.testPackageName}/${build.runner}`, repositoryUrl,
|
|
144
|
+
adapters: ['uiautomator', 'espresso', ...adapters], applicationApks: artifacts(build.applicationApks), testApks: artifacts(build.testApks),
|
|
145
|
+
lifecycle: 'built-not-installed', projectFilesEdited: [],
|
|
146
|
+
next: 'Install the matching application and test APKs, then android-executor open with the returned component and class plus the actual launcher Activity.' };
|
|
147
|
+
}, { home });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function prepareFlutter(args, { run = execFileBounded, home } = {}) {
|
|
151
|
+
return withProject(args.projectDir, async (project, directory) => {
|
|
152
|
+
const flutter = args.flutterPath ?? 'flutter';
|
|
153
|
+
const entrypoint = requireFile(path.resolve(project, args.entrypoint ?? 'lib/main.dart'));
|
|
154
|
+
const pubspec = requireFile(path.join(project, 'pubspec.yaml'));
|
|
155
|
+
const original = fs.readFileSync(pubspec);
|
|
156
|
+
const defines = args.dartDefines ?? [];
|
|
157
|
+
if (defines.some(value => value.split('=')[0] === 'INTEGRATION_TEST_SHOULD_REPORT_RESULTS_TO_NATIVE'))
|
|
158
|
+
throw new CommandError('invalid_argument', 'The Bridge test entrypoint owns INTEGRATION_TEST_SHOULD_REPORT_RESULTS_TO_NATIVE.');
|
|
159
|
+
const probe = await loggedRun(run, flutter, ['--version', '--machine'], directory, 'flutter-version', { cwd: project, timeoutMs: 30000 });
|
|
160
|
+
const sdk = JSON.parse(probe.stdout);
|
|
161
|
+
const version = /^(\d+)\.(\d+)\.(\d+)$/.exec(sdk.frameworkVersion);
|
|
162
|
+
if (!version || Number(version[1]) !== 3 || Number(version[2]) < 41)
|
|
163
|
+
throw new CommandError('executor_flutter_sdk_unsupported', `Flutter ${sdk.frameworkVersion} is outside the supported stable Flutter 3.41+ profile.`);
|
|
164
|
+
// Establish the baseline from the existing lock, even on a clean computer.
|
|
165
|
+
// A normal pub get could silently change versions before our comparison.
|
|
166
|
+
await loggedRun(run, flutter, ['pub', 'get', '--enforce-lockfile'], directory, 'flutter-pub-get', { cwd: project, timeoutMs: args.timeoutMs ?? 600000 });
|
|
167
|
+
const beforeGraph = JSON.parse((await loggedRun(run, flutter, ['pub', 'deps', '--json'], directory, 'flutter-deps-before', { cwd: project, timeoutMs: 30000 })).stdout);
|
|
168
|
+
if (productionVersions(beforeGraph).has('ai_app_bridge_test'))
|
|
169
|
+
throw new CommandError('executor_flutter_dependency_scope', 'ai_app_bridge_test must be a dev_dependency; move the existing production dependency before preparing.');
|
|
170
|
+
let packageRoot = project;
|
|
171
|
+
while (!fs.existsSync(path.join(packageRoot, '.dart_tool/package_config.json'))) {
|
|
172
|
+
const parent = path.dirname(packageRoot);
|
|
173
|
+
if (parent === packageRoot) throw new CommandError('executor_flutter_dependency_missing', 'Pub did not create a package configuration.');
|
|
174
|
+
packageRoot = parent;
|
|
175
|
+
}
|
|
176
|
+
const lockFile = requireFile(path.join(packageRoot, 'pubspec.lock'));
|
|
177
|
+
const originalLock = fs.readFileSync(lockFile);
|
|
178
|
+
const packageConfig = path.join(packageRoot, '.dart_tool/package_config.json');
|
|
179
|
+
const beforeHelper = JSON.parse(fs.readFileSync(packageConfig)).packages.find(item => item.name === 'ai_app_bridge_test');
|
|
180
|
+
const beforeDependency = beforeGraph.packages.find(item => item.name === 'ai_app_bridge_test');
|
|
181
|
+
const matchingSource = beforeHelper && (args.testPackagePath
|
|
182
|
+
? fs.realpathSync(fileURLToPath(new URL(beforeHelper.rootUri, pathToFileURL(packageConfig)))) === fs.realpathSync(resolveRequestPath(args.testPackagePath))
|
|
183
|
+
: beforeDependency?.source === 'hosted');
|
|
184
|
+
const dependency = args.testPackagePath
|
|
185
|
+
? `dev:ai_app_bridge_test:${JSON.stringify({ path: fs.realpathSync(resolveRequestPath(args.testPackagePath)) })}`
|
|
186
|
+
: `dev:ai_app_bridge_test:${bridgeVersion}`;
|
|
187
|
+
const reusedDependency = matchingSource && beforeDependency.version === bridgeVersion;
|
|
188
|
+
try {
|
|
189
|
+
if (!reusedDependency) await loggedRun(run, flutter, ['pub', 'add', dependency], directory, 'flutter-dependencies', { cwd: project, timeoutMs: args.timeoutMs ?? 600000 });
|
|
190
|
+
const afterGraph = JSON.parse((await loggedRun(run, flutter, ['pub', 'deps', '--json'], directory, 'flutter-deps-after', { cwd: project, timeoutMs: 30000 })).stdout);
|
|
191
|
+
const afterVersions = new Map(afterGraph.packages.map(item => [item.name, item.version]));
|
|
192
|
+
const changes = [...productionVersions(beforeGraph)].filter(([name, version]) => afterVersions.get(name) !== version)
|
|
193
|
+
.map(([name, before]) => ({ name, before, after: afterVersions.get(name) ?? null }));
|
|
194
|
+
if (changes.length) {
|
|
195
|
+
atomicJson(path.join(directory, 'dependency-conflicts.json'), changes);
|
|
196
|
+
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.');
|
|
197
|
+
}
|
|
198
|
+
const packages = JSON.parse(fs.readFileSync(path.join(packageRoot, '.dart_tool/package_config.json')));
|
|
199
|
+
const helper = packages.packages.find(item => item.name === 'ai_app_bridge_test');
|
|
200
|
+
if (!helper || afterVersions.get('ai_app_bridge_test') !== bridgeVersion)
|
|
201
|
+
throw new CommandError('executor_flutter_dependency_mismatch', `Pub must resolve ai_app_bridge_test ${bridgeVersion}; received ${afterVersions.get('ai_app_bridge_test')}.`);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (!original.equals(fs.readFileSync(pubspec)) || !originalLock.equals(fs.readFileSync(lockFile))) {
|
|
204
|
+
fs.writeFileSync(pubspec, original);
|
|
205
|
+
fs.writeFileSync(lockFile, originalLock);
|
|
206
|
+
try {
|
|
207
|
+
await loggedRun(run, flutter, ['pub', 'get', '--offline', '--enforce-lockfile'], directory, 'flutter-restore-dependencies', { cwd: project, timeoutMs: 60000 });
|
|
208
|
+
} catch (restoreError) {
|
|
209
|
+
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}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
const generated = path.join(directory, 'bridge_test.dart');
|
|
215
|
+
const launch = args.mainArguments === undefined ? 'app.main' : `() => app.main(<String>${JSON.stringify(args.mainArguments).replaceAll('$', '\\$')})`;
|
|
216
|
+
const importUri = pathToFileURL(entrypoint).href.replaceAll("'", '%27').replaceAll('$', '%24');
|
|
217
|
+
fs.writeFileSync(generated, `import 'package:ai_app_bridge_test/ai_app_bridge_test.dart';\nimport '${importUri}' as app;\nvoid main() => aiAppBridgeTest(${launch});\n`);
|
|
218
|
+
await loggedRun(run, flutter, ['build', 'apk', '--debug', '--no-pub', '--target', generated,
|
|
219
|
+
'--dart-define=INTEGRATION_TEST_SHOULD_REPORT_RESULTS_TO_NATIVE=false',
|
|
220
|
+
...(args.flavor ? ['--flavor', args.flavor] : []), ...defines.map(value => `--dart-define=${value}`)],
|
|
221
|
+
directory, 'flutter-build', { cwd: project, timeoutMs: args.timeoutMs ?? 600000 });
|
|
222
|
+
const apk = requireFile(path.join(project, 'build/app/outputs/flutter-apk', `app-${args.flavor ? `${args.flavor}-` : ''}debug.apk`));
|
|
223
|
+
return { platform: 'flutter', devicePlatform: 'android', engine: 'flutter-integration-test', flutter: sdk,
|
|
224
|
+
projectDir: project, entrypoint, generatedEntrypoint: generated,
|
|
225
|
+
reusedDependency: Boolean(reusedDependency),
|
|
226
|
+
applicationApks: [{ path: apk, sha256: digest(fs.readFileSync(apk)) }], lifecycle: 'built-not-installed',
|
|
227
|
+
dependencySource: args.testPackagePath ? { path: fs.realpathSync(resolveRequestPath(args.testPackagePath)) } : { hosted: 'pub.dev', version: bridgeVersion },
|
|
228
|
+
projectFilesEdited: original.equals(fs.readFileSync(pubspec)) ? [] : ['pubspec.yaml'],
|
|
229
|
+
applicationDependencyVersionsChanged: [],
|
|
230
|
+
dependencyMetadata: ['pubspec.lock', '.dart_tool/package_config.json', '.flutter-plugins-dependencies'],
|
|
231
|
+
next: 'Install this debug APK into the original application package, then flutter-executor open. Ordinary lib/main.dart builds do not start WidgetTester.' };
|
|
232
|
+
}, { home });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function productionVersions(graph) {
|
|
236
|
+
const packages = new Map(graph.packages.map(item => [item.name, item]));
|
|
237
|
+
const versions = new Map();
|
|
238
|
+
function visit(name) {
|
|
239
|
+
if (versions.has(name)) return;
|
|
240
|
+
const item = packages.get(name);
|
|
241
|
+
if (!item) throw new CommandError('executor_flutter_dependency_graph_invalid', `Pub dependency is absent: ${name}`);
|
|
242
|
+
versions.set(name, item.version);
|
|
243
|
+
for (const child of item.directDependencies) visit(child);
|
|
244
|
+
}
|
|
245
|
+
for (const item of graph.packages.filter(item => item.kind === 'root')) for (const name of item.directDependencies) visit(name);
|
|
246
|
+
return versions;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function prepareExecutor(args, dependencies = {}) {
|
|
250
|
+
if (args.platform === 'android') return prepareAndroid(args, dependencies);
|
|
251
|
+
if (args.platform === 'flutter') return prepareFlutter(args, dependencies);
|
|
252
|
+
if (args.platform === 'web') return { platform: 'web', bridgeVersion, ...await preparePackage(args.browser ?? 'chromium', dependencies) };
|
|
253
|
+
if (args.platform === 'ios') {
|
|
254
|
+
const { prepareManagedWda, wdaBuildEnvironment } = require('../ios-wda-project');
|
|
255
|
+
const xcode = await (dependencies.run ?? execFileBounded)(args.xcodebuild ?? 'xcodebuild', ['-version'],
|
|
256
|
+
{ timeoutMs: 30000, env: wdaBuildEnvironment() });
|
|
257
|
+
return { ...await prepareManagedWda(dependencies), xcode: xcode.stdout.trim() };
|
|
258
|
+
}
|
|
259
|
+
throw new CommandError('invalid_argument', 'Select android, flutter, ios or web.');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = { preparationSchema, prepareExecutor, prepareAndroid, prepareFlutter, androidEntry, withProject, loggedRun, testClass, bridgeVersion, productionVersions };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function validateJsonValue(value, location = '', parents = new Set()) {
|
|
4
|
+
function invalid(reason) { throw Object.assign(new TypeError(`${location || '/'}: ${reason}`),
|
|
5
|
+
{ code: 'extraction_type_error', valuePath: location }); }
|
|
6
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
|
7
|
+
if (typeof value === 'number') {
|
|
8
|
+
if (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) invalid('expected a finite number in the safe integer range');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (typeof value !== 'object') invalid(`unsupported JSON type: ${typeof value}`);
|
|
12
|
+
if (parents.has(value)) invalid('cyclic value');
|
|
13
|
+
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) invalid('expected a plain JSON object');
|
|
14
|
+
if (Object.getOwnPropertySymbols(value).length) invalid('symbol keys are unsupported');
|
|
15
|
+
parents.add(value);
|
|
16
|
+
const keyPath = key => `${location}/${String(key).replace(/~/g, '~0').replace(/\//g, '~1')}`;
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
if (Object.keys(value).length !== value.length) invalid('sparse arrays or extra array properties are unsupported');
|
|
19
|
+
for (let i = 0; i < value.length; i++) validateJsonValue(value[i], keyPath(i), parents);
|
|
20
|
+
} else {
|
|
21
|
+
for (const key of Object.keys(value)) validateJsonValue(value[key], keyPath(key), parents);
|
|
22
|
+
}
|
|
23
|
+
parents.delete(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { validateJsonValue };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
const Module = require('node:module');
|
|
7
|
+
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const { CommandError } = require('../command-errors');
|
|
9
|
+
const { requestDirectory } = require('../shared-kernel/request-context');
|
|
10
|
+
const { inspectPython } = require('../script/python-runtime-adapter');
|
|
11
|
+
const { executablePath } = require('../shared-kernel/executable-path');
|
|
12
|
+
|
|
13
|
+
function prepareExtraction(extract) {
|
|
14
|
+
if (extract === null) return null;
|
|
15
|
+
const timeoutMs = extract.timeoutMs ?? 2000;
|
|
16
|
+
const reject = (message, field, code = 'invalid_argument') => { throw new CommandError(code, message, { field }); };
|
|
17
|
+
if (extract.mode === 'regex') {
|
|
18
|
+
const flags = extract.flags ?? '';
|
|
19
|
+
if (new Set(flags).size !== flags.length) reject('Regex flags must be unique.', 'extract.flags');
|
|
20
|
+
if (extract.inputPath !== '' && (!extract.inputPath.startsWith('/') || /~(?![01])/u.test(extract.inputPath))) {
|
|
21
|
+
reject('inputPath must be a JSON Pointer (for example /text; escape ~ as ~0 and / as ~1).', 'extract.inputPath');
|
|
22
|
+
}
|
|
23
|
+
try { new RegExp(extract.pattern, flags); }
|
|
24
|
+
catch (error) { reject(error.message, 'extract.pattern'); }
|
|
25
|
+
return { ...extract, flags, timeoutMs };
|
|
26
|
+
}
|
|
27
|
+
const cwd = requestDirectory();
|
|
28
|
+
const filename = extract.sourcePath ? path.resolve(cwd, extract.sourcePath) : `extract.${extract.language === 'python' ? 'py' : 'js'}`;
|
|
29
|
+
let source = extract.source;
|
|
30
|
+
if (extract.sourcePath) {
|
|
31
|
+
try {
|
|
32
|
+
if (fs.statSync(filename).size > 65536) reject('Extraction source exceeds 64 KiB UTF-8.', 'extract.sourcePath');
|
|
33
|
+
source = fs.readFileSync(filename, 'utf8');
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error instanceof CommandError) throw error;
|
|
36
|
+
reject(`Cannot read extraction source: ${error.message}`, 'extract.sourcePath', 'extraction_source_unavailable');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!source.length || Buffer.byteLength(source) > 65536) reject('Extraction source must contain 1–65536 UTF-8 bytes.', extract.sourcePath ? 'extract.sourcePath' : 'extract.source');
|
|
40
|
+
let executable = process.execPath;
|
|
41
|
+
if (extract.language === 'javascript') {
|
|
42
|
+
try { new vm.Script(Module.wrap(source), { filename }); }
|
|
43
|
+
catch (error) { reject(`${filename}: ${error.message}`, 'extract.source', 'extraction_syntax_error'); }
|
|
44
|
+
} else {
|
|
45
|
+
const python = inspectPython();
|
|
46
|
+
if (!python.available) reject('Python 3.9+ is required only for Python extraction. Set AI_APP_BRIDGE_PYTHON to an available interpreter.', 'extract.language', 'extraction_runtime_unavailable');
|
|
47
|
+
executable = executablePath(python.executable);
|
|
48
|
+
if (!executable) reject('The selected Python interpreter is unavailable.', 'extract.language', 'extraction_runtime_unavailable');
|
|
49
|
+
const checked = spawnSync(executable, ['-c', 'import sys; compile(sys.stdin.read(), sys.argv[1], "exec")', filename],
|
|
50
|
+
{ input: source, encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', maxBuffer: 16384, cwd });
|
|
51
|
+
if (checked.error) reject(checked.error.message, 'extract.language', 'extraction_runtime_unavailable');
|
|
52
|
+
if (checked.status !== 0) reject(checked.stderr.trim(), 'extract.source', 'extraction_syntax_error');
|
|
53
|
+
}
|
|
54
|
+
return { mode: 'script', language: extract.language, source, filename, cwd, executable, timeoutMs };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { prepareExtraction };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function extractRegex(response, { inputPath, pattern, flags = '' }) {
|
|
4
|
+
let input = response;
|
|
5
|
+
if (inputPath !== '') {
|
|
6
|
+
for (const part of inputPath.slice(1).split('/')) {
|
|
7
|
+
const key = part.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
8
|
+
if (input === null || typeof input !== 'object' || !Object.hasOwn(input, key)) {
|
|
9
|
+
throw Object.assign(new Error(`JSON Pointer does not exist: ${inputPath}`), { code: 'extraction_path_not_found' });
|
|
10
|
+
}
|
|
11
|
+
input = input[key];
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
if (typeof input !== 'string') throw Object.assign(new Error('Regex inputPath must select a string.'), { code: 'extraction_input_type' });
|
|
15
|
+
const expression = new RegExp(pattern, `${flags}g`);
|
|
16
|
+
const matches = [];
|
|
17
|
+
let match;
|
|
18
|
+
while ((match = expression.exec(input)) !== null) {
|
|
19
|
+
if (matches.length === 1000) throw Object.assign(new Error('Regex exceeded 1000 matches; no partial result was returned.'), { code: 'extraction_match_limit' });
|
|
20
|
+
matches.push({ match: match[0], groups: Array.from(match).slice(1).map(value => value === undefined ? null : value),
|
|
21
|
+
namedGroups: Object.fromEntries(Object.entries(match.groups || {}).map(([key, value]) => [key, value === undefined ? null : value])) });
|
|
22
|
+
if (match[0] === '') {
|
|
23
|
+
const code = input.codePointAt(expression.lastIndex);
|
|
24
|
+
expression.lastIndex += flags.includes('u') && code > 0xffff ? 2 : 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return matches;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { extractRegex };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawn } = require('node:child_process');
|
|
7
|
+
const { createScriptSessionChannel } = require('../script/script-session-channel');
|
|
8
|
+
|
|
9
|
+
const MAX_INPUT_FRAME_BYTES = 8 * 1024 * 1024 + 64 * 1024;
|
|
10
|
+
const MAX_OUTPUT_FRAME_BYTES = 256 * 1024 + 64 * 1024;
|
|
11
|
+
let active = 0;
|
|
12
|
+
|
|
13
|
+
async function runExtraction(prepared, inputs) {
|
|
14
|
+
if (active >= 2) return { ok: false, error: 'extraction_busy', message: 'Two extraction workers are active. Retry extraction using the response ref; do not repeat the action.' };
|
|
15
|
+
active++;
|
|
16
|
+
let directory, child, channel, timer, exited;
|
|
17
|
+
let result;
|
|
18
|
+
let timings = {};
|
|
19
|
+
let started = performance.now();
|
|
20
|
+
try {
|
|
21
|
+
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aab-extract-'));
|
|
22
|
+
const python = prepared.language === 'python';
|
|
23
|
+
const artifact = path.join(directory, python ? 'main.py' : 'main.js');
|
|
24
|
+
fs.writeFileSync(artifact, prepared.source ?? '');
|
|
25
|
+
const sdk = path.resolve(__dirname, '../script', python ? 'script-sdk.py' : 'script-sdk.js');
|
|
26
|
+
started = performance.now();
|
|
27
|
+
child = spawn(prepared.executable ?? process.execPath, [sdk, artifact], {
|
|
28
|
+
shell: false, cwd: prepared.cwd, stdio: ['pipe', 'pipe', 'pipe'],
|
|
29
|
+
});
|
|
30
|
+
channel = createScriptSessionChannel(child, { maxInputFrameBytes: MAX_INPUT_FRAME_BYTES, maxOutputFrameBytes: MAX_OUTPUT_FRAME_BYTES });
|
|
31
|
+
exited = new Promise(resolve => {
|
|
32
|
+
child.once('exit', resolve);
|
|
33
|
+
child.once('error', () => { if (child.pid == null) resolve(); });
|
|
34
|
+
});
|
|
35
|
+
const timeout = new Promise(resolve => { timer = setTimeout(() => resolve({ type: 'fail', error: 'extraction_timeout' }), prepared.timeoutMs); });
|
|
36
|
+
const next = () => Promise.race([channel.nextMessage(), timeout]);
|
|
37
|
+
let message = await next();
|
|
38
|
+
if (message.type === 'ready') {
|
|
39
|
+
timings.coldStartMs = performance.now() - started;
|
|
40
|
+
channel.send({ type: 'start', inputs, extraction: prepared.mode === 'regex'
|
|
41
|
+
? { mode: 'regex', inputPath: prepared.inputPath, pattern: prepared.pattern, flags: prepared.flags }
|
|
42
|
+
: { mode: 'script' }, sourceName: prepared.filename, entrypoint: 'main' });
|
|
43
|
+
message = await next();
|
|
44
|
+
}
|
|
45
|
+
timings.responseMs = performance.now() - started;
|
|
46
|
+
if (message.timings) Object.assign(timings, message.timings);
|
|
47
|
+
if (message.type === 'return' && Object.hasOwn(message, 'result')) {
|
|
48
|
+
require('./json-value').validateJsonValue(message.result);
|
|
49
|
+
if (Buffer.byteLength(JSON.stringify(message.result)) > 256 * 1024) result = { ok: false, error: 'extraction_output_too_large' };
|
|
50
|
+
else result = { ok: true, result: message.result };
|
|
51
|
+
} else {
|
|
52
|
+
const code = ['channel_closed', 'stopped'].includes(message.error) ? 'extraction_worker_exited'
|
|
53
|
+
: message.error === 'frame_too_large' ? 'extraction_frame_too_large'
|
|
54
|
+
: message.error === 'malformed_frame' ? 'extraction_malformed_frame' : message.error;
|
|
55
|
+
result = { ok: false, error: typeof code === 'string' && /^extraction_[a-z0-9_]{1,110}$/.test(code) ? code : 'extraction_failed',
|
|
56
|
+
...(message.message ? { message: message.message } : {}), ...(message.diagnostic ? { diagnostic: message.diagnostic } : {}) };
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
result = { ok: false, error: error.code === 'extraction_type_error' ? error.code : 'extraction_worker_failed', message: error.message };
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
channel?.stop();
|
|
63
|
+
if (exited) await exited;
|
|
64
|
+
// Descendants may inherit pipes, but are outside the worker lifecycle.
|
|
65
|
+
// Close our pipe handles after the worker exits instead of waiting for
|
|
66
|
+
// every descendant to close its copies of stdout/stderr.
|
|
67
|
+
child?.stdin?.destroy(); child?.stdout?.destroy(); child?.stderr?.destroy();
|
|
68
|
+
try { if (directory) fs.rmSync(directory, { recursive: true, force: true }); }
|
|
69
|
+
catch (error) { result = { ok: false, error: 'extraction_cleanup_failed', message: error.message }; }
|
|
70
|
+
finally { active--; }
|
|
71
|
+
}
|
|
72
|
+
const diagnostics = channel?.diagnostics();
|
|
73
|
+
return { ...result, durationMs: Math.round((performance.now() - started) * 100) / 100, timings,
|
|
74
|
+
...(!result.ok && diagnostics && Object.keys(diagnostics).length ? { diagnostics } : {}) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { runExtraction, MAX_INPUT_FRAME_BYTES, MAX_OUTPUT_FRAME_BYTES };
|
|
@@ -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 };
|