agent-scenario-loop 0.1.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/LICENSE +21 -0
- package/README.md +119 -0
- package/app/profile-session.ts +812 -0
- package/core/config-template.json +41 -0
- package/dist/core/agent-summary.d.ts +15 -0
- package/dist/core/agent-summary.js +177 -0
- package/dist/core/artifact-contract.d.ts +151 -0
- package/dist/core/artifact-contract.js +897 -0
- package/dist/core/artifact-layout.d.ts +56 -0
- package/dist/core/artifact-layout.js +61 -0
- package/dist/core/artifact-writer.d.ts +44 -0
- package/dist/core/artifact-writer.js +55 -0
- package/dist/core/comparison.d.ts +133 -0
- package/dist/core/comparison.js +294 -0
- package/dist/core/evidence-interpreter.d.ts +28 -0
- package/dist/core/evidence-interpreter.js +69 -0
- package/dist/core/execution-plan.d.ts +44 -0
- package/dist/core/execution-plan.js +95 -0
- package/dist/core/planner.d.ts +132 -0
- package/dist/core/planner.js +812 -0
- package/dist/core/ports.d.ts +198 -0
- package/dist/core/ports.js +146 -0
- package/dist/core/run-index.d.ts +62 -0
- package/dist/core/run-index.js +143 -0
- package/dist/core/schema-validator.d.ts +86 -0
- package/dist/core/schema-validator.js +407 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +27 -0
- package/dist/runner/agent-device-driver.d.ts +126 -0
- package/dist/runner/agent-device-driver.js +168 -0
- package/dist/runner/agent-device.d.ts +295 -0
- package/dist/runner/agent-device.js +1271 -0
- package/dist/runner/android-adb-driver.d.ts +175 -0
- package/dist/runner/android-adb-driver.js +399 -0
- package/dist/runner/android-adb.d.ts +254 -0
- package/dist/runner/android-adb.js +1618 -0
- package/dist/runner/argent-driver.d.ts +183 -0
- package/dist/runner/argent-driver.js +297 -0
- package/dist/runner/argent.d.ts +349 -0
- package/dist/runner/argent.js +1211 -0
- package/dist/runner/check-plan.d.ts +45 -0
- package/dist/runner/check-plan.js +210 -0
- package/dist/runner/cli.d.ts +20 -0
- package/dist/runner/cli.js +23 -0
- package/dist/runner/compare-latest.d.ts +99 -0
- package/dist/runner/compare-latest.js +233 -0
- package/dist/runner/compare.d.ts +58 -0
- package/dist/runner/compare.js +157 -0
- package/dist/runner/demo-loop.d.ts +45 -0
- package/dist/runner/demo-loop.js +170 -0
- package/dist/runner/example-android-live.d.ts +137 -0
- package/dist/runner/example-android-live.js +454 -0
- package/dist/runner/example-ios-live.d.ts +137 -0
- package/dist/runner/example-ios-live.js +471 -0
- package/dist/runner/host-doctor.d.ts +131 -0
- package/dist/runner/host-doctor.js +628 -0
- package/dist/runner/init-project.d.ts +88 -0
- package/dist/runner/init-project.js +263 -0
- package/dist/runner/ios-simctl-driver.d.ts +69 -0
- package/dist/runner/ios-simctl-driver.js +97 -0
- package/dist/runner/ios-simctl.d.ts +254 -0
- package/dist/runner/ios-simctl.js +1415 -0
- package/dist/runner/live-android.d.ts +137 -0
- package/dist/runner/live-android.js +539 -0
- package/dist/runner/live-comparison.d.ts +67 -0
- package/dist/runner/live-comparison.js +147 -0
- package/dist/runner/live-ios.d.ts +137 -0
- package/dist/runner/live-ios.js +460 -0
- package/dist/runner/live-proof-summary.d.ts +263 -0
- package/dist/runner/live-proof-summary.js +465 -0
- package/dist/runner/live-proof.d.ts +467 -0
- package/dist/runner/live-proof.js +920 -0
- package/dist/runner/local-env.d.ts +64 -0
- package/dist/runner/local-env.js +155 -0
- package/dist/runner/profile-android.d.ts +82 -0
- package/dist/runner/profile-android.js +671 -0
- package/dist/runner/profile-ios.d.ts +108 -0
- package/dist/runner/profile-ios.js +532 -0
- package/dist/runner/profile-mobile.d.ts +254 -0
- package/dist/runner/profile-mobile.js +1307 -0
- package/dist/runner/validate-project.d.ts +273 -0
- package/dist/runner/validate-project.js +1501 -0
- package/docs/adapters.md +145 -0
- package/docs/api.md +94 -0
- package/docs/authoring.md +196 -0
- package/docs/concepts.md +136 -0
- package/docs/consumer-rehearsal.md +115 -0
- package/docs/contracts.md +267 -0
- package/docs/live-proofs.md +270 -0
- package/docs/principles.md +46 -0
- package/examples/event-logs/app-startup-baseline.log +4 -0
- package/examples/event-logs/app-startup-current.log +4 -0
- package/examples/minimal-app/README.md +70 -0
- package/examples/mobile-app/README.md +302 -0
- package/examples/mobile-app/app.json +22 -0
- package/examples/mobile-app/asl/package-scripts.json +32 -0
- package/examples/mobile-app/asl.config.json +37 -0
- package/examples/mobile-app/event-logs/android-app-startup.log +4 -0
- package/examples/mobile-app/event-logs/android-open-close-cycle.log +12 -0
- package/examples/mobile-app/event-logs/android-scroll-settle.log +12 -0
- package/examples/mobile-app/event-logs/app-startup.log +4 -0
- package/examples/mobile-app/event-logs/open-close-cycle.log +12 -0
- package/examples/mobile-app/event-logs/scroll-settle.log +12 -0
- package/examples/mobile-app/index.ts +20 -0
- package/examples/mobile-app/metro.config.js +20 -0
- package/examples/mobile-app/package.json +62 -0
- package/examples/mobile-app/patches/expo-modules-jsi@56.0.10.patch +19 -0
- package/examples/mobile-app/plugins/with-ios-build-compat.js +271 -0
- package/examples/mobile-app/pnpm-lock.yaml +4440 -0
- package/examples/mobile-app/runner-manifests/evidence-provider.json +79 -0
- package/examples/mobile-app/runner-manifests/primary-runner.json +19 -0
- package/examples/mobile-app/scenarios/android/app-startup-video.json +73 -0
- package/examples/mobile-app/scenarios/android/app-startup.json +44 -0
- package/examples/mobile-app/scenarios/android/open-close-cycle.json +54 -0
- package/examples/mobile-app/scenarios/android/scroll-settle.json +49 -0
- package/examples/mobile-app/scenarios/ios/app-startup.json +44 -0
- package/examples/mobile-app/scenarios/ios/open-close-cycle.json +54 -0
- package/examples/mobile-app/scenarios/ios/scroll-settle.json +49 -0
- package/examples/mobile-app/scenarios/mobile/app-startup.json +91 -0
- package/examples/mobile-app/scenarios/mobile/open-close-cycle.json +160 -0
- package/examples/mobile-app/scenarios/mobile/scroll-settle.json +148 -0
- package/examples/mobile-app/scripts/asl-capture-accessibility-provider.mjs +112 -0
- package/examples/mobile-app/scripts/asl-capture-profiler-provider.mjs +127 -0
- package/examples/mobile-app/src/devtools/profile-session.ts +7 -0
- package/examples/mobile-app/src/example-screen.tsx +322 -0
- package/examples/mobile-app/tsconfig.json +16 -0
- package/examples/mobile-app/tsconfig.typecheck.json +13 -0
- package/examples/runners/README.md +44 -0
- package/examples/runners/adb-android.json +25 -0
- package/examples/runners/agent-device-android.json +27 -0
- package/examples/runners/agent-device-ios.json +27 -0
- package/examples/runners/argent-android.json +32 -0
- package/examples/runners/argent-ios.json +32 -0
- package/examples/runners/argent-react-profiler-provider.json +15 -0
- package/examples/runners/axe-accessibility-provider.json +24 -0
- package/examples/runners/manual-log-ingest.json +9 -0
- package/examples/runners/rozenite-profiler-provider.json +9 -0
- package/examples/runners/script-accessibility-provider.json +24 -0
- package/examples/runners/script-memory-provider.json +24 -0
- package/examples/runners/script-network-provider.json +24 -0
- package/examples/runners/script-profiler-provider.json +30 -0
- package/examples/runners/xcodebuildmcp-ios.json +29 -0
- package/examples/scenarios/ios/app-startup.json +28 -0
- package/examples/scenarios/ios/open-close-cycle.json +35 -0
- package/examples/scenarios/mobile/app-startup.json +72 -0
- package/examples/scenarios/mobile/media-open-close.json +141 -0
- package/examples/scenarios/mobile/open-close-cycle.json +135 -0
- package/examples/scenarios/mobile/scroll-settle.json +106 -0
- package/package.json +240 -0
- package/schemas/budget-verdict.schema.json +115 -0
- package/schemas/causal-run.schema.json +279 -0
- package/schemas/comparison.schema.json +196 -0
- package/schemas/health.schema.json +108 -0
- package/schemas/live-proof-set.schema.json +195 -0
- package/schemas/live-proof.schema.json +413 -0
- package/schemas/manifest.schema.json +204 -0
- package/schemas/metrics.schema.json +137 -0
- package/schemas/project-validation.schema.json +343 -0
- package/schemas/runner-capabilities.schema.json +217 -0
- package/schemas/scenario.schema.json +400 -0
- package/schemas/verdict.schema.json +88 -0
- package/templates/evidence-provider.json +83 -0
- package/templates/gitignore-snippet +9 -0
- package/templates/integration-readme.md +125 -0
- package/templates/mobile-scenario.json +133 -0
- package/templates/package-scripts.json +32 -0
- package/templates/primary-runner.json +19 -0
- package/templates/project.config.json +37 -0
- package/templates/scripts/asl-capture-accessibility-provider.mjs +112 -0
- package/templates/scripts/asl-capture-profiler-provider.mjs +127 -0
|
@@ -0,0 +1,1618 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER = void 0;
|
|
5
|
+
exports.buildAndroidHealth = buildAndroidHealth;
|
|
6
|
+
exports.buildAndroidVerdict = buildAndroidVerdict;
|
|
7
|
+
exports.buildReactNativeDebugHostPreferenceCommand = buildReactNativeDebugHostPreferenceCommand;
|
|
8
|
+
exports.escapeAndroidPreferenceXml = escapeAndroidPreferenceXml;
|
|
9
|
+
exports.execFileCommand = execFileCommand;
|
|
10
|
+
exports.main = main;
|
|
11
|
+
exports.parseAdbDevices = parseAdbDevices;
|
|
12
|
+
exports.parseArgs = parseArgs;
|
|
13
|
+
exports.parsePositiveInteger = parsePositiveInteger;
|
|
14
|
+
exports.parseReactNativeDebugHostPort = parseReactNativeDebugHostPort;
|
|
15
|
+
exports.resolveAndroidAdbDriverSteps = resolveAndroidAdbDriverSteps;
|
|
16
|
+
exports.applyAndroidSelectorResolution = applyAndroidSelectorResolution;
|
|
17
|
+
exports.buildAndroidSelectorHealthMetadata = buildAndroidSelectorHealthMetadata;
|
|
18
|
+
exports.needsAndroidSelectorResolution = needsAndroidSelectorResolution;
|
|
19
|
+
exports.runAndroidAdbDriverStep = runAndroidAdbDriverStep;
|
|
20
|
+
exports.runAndroidAdbPreflight = runAndroidAdbPreflight;
|
|
21
|
+
exports.selectDevice = selectDevice;
|
|
22
|
+
exports.usage = usage;
|
|
23
|
+
const { execFile } = require('node:child_process');
|
|
24
|
+
const crypto = require('node:crypto');
|
|
25
|
+
const fsp = require('node:fs/promises');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { buildAgentSummaryMarkdown } = require('../core/agent-summary');
|
|
28
|
+
const { createArtifactLayout } = require('../core/artifact-layout');
|
|
29
|
+
const { writeJsonArtifact, writeTextArtifact } = require('../core/artifact-writer');
|
|
30
|
+
const { SCHEMAS, assertValidJson } = require('../core/schema-validator');
|
|
31
|
+
const { hasHelpFlag, writeUsage } = require('./cli');
|
|
32
|
+
const { buildAndroidScrollCoordinatesFromBounds, createAndroidAdbDriver, formatAndroidAdbRawOutput, quoteAndroidShellArg, resolveAndroidSelectorFromUiTree, } = require('./android-adb-driver');
|
|
33
|
+
const ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER = '__ASL_ANDROID_DEVICE_EPOCH_MS__';
|
|
34
|
+
exports.ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER = ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER;
|
|
35
|
+
const ANDROID_READY_LOG_POLL_MS = 1000;
|
|
36
|
+
/**
|
|
37
|
+
* Prints CLI usage to stderr.
|
|
38
|
+
*
|
|
39
|
+
* @returns {void}
|
|
40
|
+
*/
|
|
41
|
+
function usage(output = process.stderr) {
|
|
42
|
+
writeUsage([
|
|
43
|
+
'Usage: asl-android-adb [--adb <path>] [--serial <device>] [--package <name>] [--run-id <id>] [--out <dir>]',
|
|
44
|
+
'',
|
|
45
|
+
'Checks adb/device readiness and writes health.json, verdict.json, agent-summary.md, and raw adb evidence.',
|
|
46
|
+
'Use --capture-logcat [--logcat-lines <count>] to attach a bounded adb logcat snapshot under raw/adb-logcat.txt.',
|
|
47
|
+
'Use --clear-logcat --launch [--launch-wait-ms <ms>] --wait-ms <ms> with --package <name> to capture a bounded app launch window.',
|
|
48
|
+
'Use --react-native-debug-host <host:port> with --package <name> to set the app debug server and adb reverse for React Native dev builds.',
|
|
49
|
+
'Use --android-dev-client-url <url> [--android-dev-client-wait-ms <ms>] [--android-dev-client-ready-pattern <pattern>] to open an Expo dev-client session before scenario deep links.',
|
|
50
|
+
], output);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Parses `--key value` arguments for the Android adb preflight CLI.
|
|
54
|
+
*
|
|
55
|
+
* @param {string[]} argv
|
|
56
|
+
* @returns {CliArgs}
|
|
57
|
+
*/
|
|
58
|
+
function parseArgs(argv) {
|
|
59
|
+
const args = {};
|
|
60
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
61
|
+
const token = argv[index];
|
|
62
|
+
if (token === '--') {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!token || !token.startsWith('--')) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const key = token.slice(2);
|
|
69
|
+
const value = argv[index + 1];
|
|
70
|
+
if (value && !value.startsWith('--')) {
|
|
71
|
+
args[key] = value;
|
|
72
|
+
index += 1;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
args[key] = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return args;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Creates a short random run id for Android preflight runs.
|
|
82
|
+
*
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function createRunId() {
|
|
86
|
+
return crypto.randomBytes(6).toString('hex');
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Parses a positive integer CLI value, falling back when absent or invalid.
|
|
90
|
+
*
|
|
91
|
+
* @param {string | boolean | undefined} value
|
|
92
|
+
* @param {number} fallback
|
|
93
|
+
* @returns {number}
|
|
94
|
+
*/
|
|
95
|
+
function parsePositiveInteger(value, fallback) {
|
|
96
|
+
if (typeof value !== 'string') {
|
|
97
|
+
return fallback;
|
|
98
|
+
}
|
|
99
|
+
const parsed = Number(value);
|
|
100
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Runs a command and captures stdout, stderr, and exit code without throwing.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} command
|
|
106
|
+
* @param {string[]} args
|
|
107
|
+
* @returns {Promise<CommandResult>}
|
|
108
|
+
*/
|
|
109
|
+
function execFileCommand(command, args) {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
execFile(command, args, (error, stdout, stderr) => {
|
|
112
|
+
resolve({
|
|
113
|
+
command,
|
|
114
|
+
args,
|
|
115
|
+
exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
|
|
116
|
+
stderr,
|
|
117
|
+
stdout,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Waits for the requested capture window.
|
|
124
|
+
*
|
|
125
|
+
* @param {number} ms
|
|
126
|
+
* @returns {Promise<void>}
|
|
127
|
+
*/
|
|
128
|
+
function delay(ms) {
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
setTimeout(resolve, ms);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Parses `adb devices -l` output into device rows.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} output
|
|
137
|
+
* @returns {AndroidDevice[]}
|
|
138
|
+
*/
|
|
139
|
+
function parseAdbDevices(output) {
|
|
140
|
+
return String(output)
|
|
141
|
+
.split(/\r?\n/u)
|
|
142
|
+
.slice(1)
|
|
143
|
+
.map((line) => line.trim())
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.map((line) => {
|
|
146
|
+
const [serial = '', state = '', ...rest] = line.split(/\s+/u);
|
|
147
|
+
return {
|
|
148
|
+
serial,
|
|
149
|
+
state,
|
|
150
|
+
description: rest.join(' '),
|
|
151
|
+
};
|
|
152
|
+
})
|
|
153
|
+
.filter((device) => device.serial.length > 0);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Selects an Android device by explicit serial or first online device.
|
|
157
|
+
*
|
|
158
|
+
* @param {AndroidDevice[]} devices
|
|
159
|
+
* @param {string | null | undefined} serial
|
|
160
|
+
* @returns {AndroidDevice | null}
|
|
161
|
+
*/
|
|
162
|
+
function selectDevice(devices, serial) {
|
|
163
|
+
if (serial) {
|
|
164
|
+
return devices.find((device) => device.serial === serial) ?? null;
|
|
165
|
+
}
|
|
166
|
+
return devices.find((device) => device.state === 'device') ?? null;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Creates scalar health-check metadata for an agent-readable next action.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} nextActionCode
|
|
172
|
+
* @param {string} nextAction
|
|
173
|
+
* @returns {NextActionHint}
|
|
174
|
+
*/
|
|
175
|
+
function nextActionHint(nextActionCode, nextAction) {
|
|
176
|
+
return {
|
|
177
|
+
nextAction,
|
|
178
|
+
nextActionCode,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Detects adb daemon/socket failures that are distinct from a genuinely missing device.
|
|
183
|
+
*
|
|
184
|
+
* @param {CommandResult} result
|
|
185
|
+
* @returns {boolean}
|
|
186
|
+
*/
|
|
187
|
+
function isAdbDaemonUnavailable(result) {
|
|
188
|
+
if (result.exitCode === 0) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
192
|
+
return (output.includes('cannot connect to daemon') ||
|
|
193
|
+
output.includes('failed to check server version') ||
|
|
194
|
+
output.includes('could not install *smartsocket* listener') ||
|
|
195
|
+
output.includes('adb server didn'));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Builds the health check details for device selection failures.
|
|
199
|
+
*
|
|
200
|
+
* @param {{devicesOutput: CommandResult, serial?: string | null}} options
|
|
201
|
+
* @returns {{code: string, message: string, metadata: NextActionHint}}
|
|
202
|
+
*/
|
|
203
|
+
function buildAndroidDeviceFailure({ devicesOutput, serial, }) {
|
|
204
|
+
if (isAdbDaemonUnavailable(devicesOutput)) {
|
|
205
|
+
return {
|
|
206
|
+
code: 'adb_daemon_unreachable',
|
|
207
|
+
message: 'adb devices could not reach or start the adb daemon.',
|
|
208
|
+
metadata: nextActionHint('rerun_with_adb_daemon_access', 'Start the adb daemon from a host shell or rerun the live proof with host adb daemon access, then confirm `adb devices -l` lists the target device as `device`.'),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
code: 'android_device_missing',
|
|
213
|
+
message: serial
|
|
214
|
+
? `No online Android device matched serial ${serial}.`
|
|
215
|
+
: 'No online Android device was found.',
|
|
216
|
+
metadata: nextActionHint('select_android_device', 'Start or unlock an Android emulator/device, confirm it appears as `device` in adb devices -l, or pass --serial for the intended device.'),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Reads the TCP port from a React Native debug server host string.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} debugHost
|
|
223
|
+
* @returns {number | null}
|
|
224
|
+
*/
|
|
225
|
+
function parseReactNativeDebugHostPort(debugHost) {
|
|
226
|
+
if (debugHost.includes('://')) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const match = /:(?<port>\d+)$/u.exec(debugHost);
|
|
230
|
+
const port = match?.groups?.port ? Number(match.groups.port) : NaN;
|
|
231
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Escapes text for the Android shared preference XML file.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} value
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
function escapeAndroidPreferenceXml(value) {
|
|
240
|
+
return value
|
|
241
|
+
.replace(/&/gu, '&')
|
|
242
|
+
.replace(/</gu, '<')
|
|
243
|
+
.replace(/>/gu, '>')
|
|
244
|
+
.replace(/"/gu, '"')
|
|
245
|
+
.replace(/'/gu, ''');
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Builds a device-side shell command that writes React Native debug host preferences.
|
|
249
|
+
*
|
|
250
|
+
* @param {{debugHost: string, packageName: string}} options
|
|
251
|
+
* @returns {string}
|
|
252
|
+
*/
|
|
253
|
+
function buildReactNativeDebugHostPreferenceCommand({ debugHost, packageName, }) {
|
|
254
|
+
const preferenceFile = `shared_prefs/${packageName}_preferences.xml`;
|
|
255
|
+
const lines = [
|
|
256
|
+
'<?xml version="1.0" encoding="utf-8" standalone="yes" ?>',
|
|
257
|
+
'<map>',
|
|
258
|
+
` <string name="debug_http_host">${escapeAndroidPreferenceXml(debugHost)}</string>`,
|
|
259
|
+
'</map>',
|
|
260
|
+
];
|
|
261
|
+
return [
|
|
262
|
+
`cd ${quoteAndroidShellArg(`/data/data/${packageName}`)}`,
|
|
263
|
+
'mkdir -p shared_prefs',
|
|
264
|
+
[
|
|
265
|
+
`printf ${quoteAndroidShellArg('%s\\n')}`,
|
|
266
|
+
...lines.map((line) => quoteAndroidShellArg(line)),
|
|
267
|
+
`> ${quoteAndroidShellArg(preferenceFile)}`,
|
|
268
|
+
].join(' '),
|
|
269
|
+
].join(' && ');
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Escapes a string for insertion as a SQLite string literal.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} value
|
|
275
|
+
* @returns {string}
|
|
276
|
+
*/
|
|
277
|
+
function escapeSqliteString(value) {
|
|
278
|
+
return value.replace(/'/gu, "''");
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Builds SQL that updates React Native AsyncStorage's Android RKStorage table.
|
|
282
|
+
*
|
|
283
|
+
* @param {{clearKeys?: string[], key: string, value: string}} options
|
|
284
|
+
* @returns {string}
|
|
285
|
+
*/
|
|
286
|
+
function buildAndroidAsyncStorageWriteSql({ clearKeys = [], key, value, }) {
|
|
287
|
+
return [
|
|
288
|
+
'PRAGMA busy_timeout=5000;',
|
|
289
|
+
...clearKeys.map((clearKey) => (`DELETE FROM catalystLocalStorage WHERE key='${escapeSqliteString(clearKey)}';`)),
|
|
290
|
+
`INSERT OR REPLACE INTO catalystLocalStorage (key,value) VALUES ('${escapeSqliteString(key)}','${escapeSqliteString(value)}');`,
|
|
291
|
+
].join('\n');
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Builds a package-scoped command for writing one Android AsyncStorage value.
|
|
295
|
+
*
|
|
296
|
+
* @param {{packageName: string, write: AndroidAsyncStorageWrite}} options
|
|
297
|
+
* @returns {string}
|
|
298
|
+
*/
|
|
299
|
+
function buildAndroidAsyncStorageWriteCommand({ packageName, write, }) {
|
|
300
|
+
const sql = buildAndroidAsyncStorageWriteSql({
|
|
301
|
+
...(write.clearKeys ? { clearKeys: write.clearKeys } : {}),
|
|
302
|
+
key: write.key,
|
|
303
|
+
value: write.value,
|
|
304
|
+
});
|
|
305
|
+
return [
|
|
306
|
+
'run-as',
|
|
307
|
+
quoteAndroidShellArg(packageName),
|
|
308
|
+
'sqlite3',
|
|
309
|
+
quoteAndroidShellArg('databases/RKStorage'),
|
|
310
|
+
quoteAndroidShellArg(sql),
|
|
311
|
+
].join(' ');
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Reads the selected device clock as epoch milliseconds for app-side timing.
|
|
315
|
+
*
|
|
316
|
+
* @param {{adbPath: string, deviceSerial: string, executor: CommandExecutor}} options
|
|
317
|
+
* @returns {Promise<CommandResult & {epochMs: number | null}>}
|
|
318
|
+
*/
|
|
319
|
+
async function readAndroidDeviceEpochMs({ adbPath, deviceSerial, executor, }) {
|
|
320
|
+
const result = await executor(adbPath, ['-s', deviceSerial, 'shell', 'date', '+%s']);
|
|
321
|
+
const seconds = Number(result.stdout.trim());
|
|
322
|
+
const epochMs = result.exitCode === 0 && Number.isFinite(seconds) && seconds > 0
|
|
323
|
+
? Math.round(seconds * 1000)
|
|
324
|
+
: null;
|
|
325
|
+
return {
|
|
326
|
+
...result,
|
|
327
|
+
epochMs,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Combines one adb command result into raw evidence text.
|
|
332
|
+
*
|
|
333
|
+
* @param {CommandResult} result
|
|
334
|
+
* @returns {string}
|
|
335
|
+
*/
|
|
336
|
+
function formatAndroidCommandRawOutput(result) {
|
|
337
|
+
return [
|
|
338
|
+
`$ adb ${result.args.join(' ')}`,
|
|
339
|
+
`exitCode=${result.exitCode}`,
|
|
340
|
+
result.stdout,
|
|
341
|
+
result.stderr,
|
|
342
|
+
].filter(Boolean).join('\n');
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Parses Android `pidof` output into stable process ids.
|
|
346
|
+
*
|
|
347
|
+
* @param {string} output
|
|
348
|
+
* @returns {string[]}
|
|
349
|
+
*/
|
|
350
|
+
function parseAndroidPidofOutput(output) {
|
|
351
|
+
return String(output)
|
|
352
|
+
.trim()
|
|
353
|
+
.split(/\s+/u)
|
|
354
|
+
.filter((pid) => /^\d+$/u.test(pid));
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Escapes text so it can be embedded in a regular expression.
|
|
358
|
+
*
|
|
359
|
+
* @param {string} value
|
|
360
|
+
* @returns {string}
|
|
361
|
+
*/
|
|
362
|
+
function escapeRegExp(value) {
|
|
363
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Finds crash-like Android logcat windows for one app process.
|
|
367
|
+
*
|
|
368
|
+
* Android crash logs often split the signal line from the `Process:` line, so
|
|
369
|
+
* this scans a small neighborhood around each crash marker before deciding it
|
|
370
|
+
* belongs to the launched app.
|
|
371
|
+
*
|
|
372
|
+
* @param {{logText: string, packageName: string, pids?: string[]}} options
|
|
373
|
+
* @returns {AndroidAppLifecycleScan}
|
|
374
|
+
*/
|
|
375
|
+
function scanAndroidAppLifecycleLog({ logText, packageName, pids = [], }) {
|
|
376
|
+
const lines = String(logText).split(/\r?\n/u);
|
|
377
|
+
const packagePattern = new RegExp(escapeRegExp(packageName), 'u');
|
|
378
|
+
const pidPatterns = pids.map((pid) => new RegExp(`\\b${escapeRegExp(pid)}\\b`, 'u'));
|
|
379
|
+
const crashPattern = /\b(FATAL EXCEPTION|Fatal signal|SIGSEGV|SIGABRT|ANR in|Force finishing activity|has died|Process .* died)\b/iu;
|
|
380
|
+
const evidence = new Set();
|
|
381
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
382
|
+
const line = lines[index] ?? '';
|
|
383
|
+
if (!crashPattern.test(line)) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const windowStart = Math.max(0, index - 3);
|
|
387
|
+
const windowEnd = Math.min(lines.length, index + 4);
|
|
388
|
+
const windowLines = lines.slice(windowStart, windowEnd);
|
|
389
|
+
const windowText = windowLines.join('\n');
|
|
390
|
+
const belongsToApp = packagePattern.test(windowText)
|
|
391
|
+
|| pidPatterns.some((pattern) => pattern.test(windowText));
|
|
392
|
+
if (belongsToApp) {
|
|
393
|
+
for (const evidenceLine of windowLines) {
|
|
394
|
+
if (evidenceLine.trim()) {
|
|
395
|
+
evidence.add(evidenceLine);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
crashed: evidence.size > 0,
|
|
402
|
+
evidence: Array.from(evidence),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Counts readiness markers in a bounded Android logcat snapshot.
|
|
407
|
+
*
|
|
408
|
+
* @param {{logText: string, pattern: string}} options
|
|
409
|
+
* @returns {number}
|
|
410
|
+
*/
|
|
411
|
+
function countAndroidReadyLogMatches({ logText, pattern, }) {
|
|
412
|
+
try {
|
|
413
|
+
return Array.from(String(logText).matchAll(new RegExp(pattern, 'gu'))).length;
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return String(logText).split(pattern).length - 1;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Waits until a startup deep link has produced an expected Android log marker.
|
|
421
|
+
*
|
|
422
|
+
* @param {{driver: import('./android-adb-driver').AndroidAdbDriver, logcatLines: number, pattern: string, quietMs: number, rawFileName: string, timeoutMs: number, wait: (ms: number) => Promise<void>}} options
|
|
423
|
+
* @returns {Promise<{ready: boolean, result: import('./android-adb-driver').AndroidAdbCommandResult}>}
|
|
424
|
+
*/
|
|
425
|
+
async function waitForAndroidReadyLog({ driver, logcatLines, pattern, quietMs, rawFileName, timeoutMs, wait, }) {
|
|
426
|
+
const deadline = Date.now() + timeoutMs;
|
|
427
|
+
let lastMatchCount = -1;
|
|
428
|
+
let lastChangeAt = Date.now();
|
|
429
|
+
let result = await driver.readLogs({ lines: logcatLines, rawFileName });
|
|
430
|
+
for (;;) {
|
|
431
|
+
const matchCount = countAndroidReadyLogMatches({
|
|
432
|
+
logText: `${result.stdout}\n${result.stderr}`,
|
|
433
|
+
pattern,
|
|
434
|
+
});
|
|
435
|
+
if (matchCount !== lastMatchCount) {
|
|
436
|
+
lastMatchCount = matchCount;
|
|
437
|
+
lastChangeAt = Date.now();
|
|
438
|
+
}
|
|
439
|
+
const ready = matchCount > 0 && Date.now() - lastChangeAt >= quietMs;
|
|
440
|
+
if (ready || Date.now() >= deadline) {
|
|
441
|
+
return { ready, result };
|
|
442
|
+
}
|
|
443
|
+
await wait(ANDROID_READY_LOG_POLL_MS);
|
|
444
|
+
result = await driver.readLogs({ lines: logcatLines, rawFileName });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Builds a runner health artifact from adb preflight checks.
|
|
449
|
+
*
|
|
450
|
+
* @param {{runId: string, checks: Record<string, unknown>[]}} options
|
|
451
|
+
* @returns {Record<string, unknown>}
|
|
452
|
+
*/
|
|
453
|
+
function buildAndroidHealth({ runId, checks }) {
|
|
454
|
+
const failed = checks.some((check) => check.status === 'failed');
|
|
455
|
+
return assertValidJson({
|
|
456
|
+
schemaVersion: '1.0.0',
|
|
457
|
+
scenarioId: 'android-adb-preflight',
|
|
458
|
+
flowId: 'android-adb-preflight',
|
|
459
|
+
runId,
|
|
460
|
+
healthStatus: failed ? 'failed' : 'passed',
|
|
461
|
+
checks,
|
|
462
|
+
}, SCHEMAS.health, 'Health artifact');
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Builds a verdict artifact for adb preflight readiness.
|
|
466
|
+
*
|
|
467
|
+
* @param {{runId: string, health: Record<string, unknown>}} options
|
|
468
|
+
* @returns {Record<string, unknown>}
|
|
469
|
+
*/
|
|
470
|
+
function buildAndroidVerdict({ runId, health }) {
|
|
471
|
+
const passed = health.healthStatus === 'passed';
|
|
472
|
+
return assertValidJson({
|
|
473
|
+
schemaVersion: '1.0.0',
|
|
474
|
+
scenarioId: 'android-adb-preflight',
|
|
475
|
+
flowId: 'android-adb-preflight',
|
|
476
|
+
runId,
|
|
477
|
+
healthStatus: health.healthStatus,
|
|
478
|
+
verdictStatus: passed ? 'not_evaluated' : 'inconclusive',
|
|
479
|
+
budgetChecks: [],
|
|
480
|
+
summary: passed
|
|
481
|
+
? 'Android adb preflight passed; no product budget has been evaluated.'
|
|
482
|
+
: 'Android adb preflight failed; runtime scenario execution is not ready.',
|
|
483
|
+
}, SCHEMAS.verdict, 'Verdict artifact');
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Builds the driver steps for this adb capture window.
|
|
487
|
+
*
|
|
488
|
+
* @param {{captureLogcat: boolean, driverSteps: AndroidAdbDriverStep[], logcatLines: number, waitMs: number}} options
|
|
489
|
+
* @returns {AndroidAdbDriverStep[]}
|
|
490
|
+
*/
|
|
491
|
+
function resolveAndroidAdbDriverSteps({ captureLogcat, driverSteps, logcatLines, waitMs, }) {
|
|
492
|
+
if (driverSteps.length > 0) {
|
|
493
|
+
let readLogsIndex = 0;
|
|
494
|
+
const resolved = driverSteps.map((step, index) => {
|
|
495
|
+
const actionIndex = index + 1;
|
|
496
|
+
if (step.driverAction === 'readLogs') {
|
|
497
|
+
readLogsIndex += 1;
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
...step,
|
|
501
|
+
...(step.driverAction === 'readLogs' ? { lines: step.lines ?? logcatLines } : {}),
|
|
502
|
+
rawFileName: step.rawFileName ?? defaultAndroidAdbRawFileName({
|
|
503
|
+
driverAction: step.driverAction,
|
|
504
|
+
index: actionIndex,
|
|
505
|
+
readLogsIndex,
|
|
506
|
+
}),
|
|
507
|
+
...(step.driverAction === 'record'
|
|
508
|
+
? {
|
|
509
|
+
captureFileName: step.captureFileName ?? defaultAndroidAdbCaptureFileName({
|
|
510
|
+
driverAction: step.driverAction,
|
|
511
|
+
index: actionIndex,
|
|
512
|
+
}),
|
|
513
|
+
}
|
|
514
|
+
: {}),
|
|
515
|
+
required: step.required !== false,
|
|
516
|
+
};
|
|
517
|
+
});
|
|
518
|
+
if (captureLogcat && readLogsIndex === 0) {
|
|
519
|
+
resolved.push({
|
|
520
|
+
driverAction: 'readLogs',
|
|
521
|
+
lines: logcatLines,
|
|
522
|
+
rawFileName: 'adb-logcat.txt',
|
|
523
|
+
required: true,
|
|
524
|
+
...(waitMs > 0 ? { waitMs } : {}),
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
return resolved;
|
|
528
|
+
}
|
|
529
|
+
return captureLogcat
|
|
530
|
+
? [{
|
|
531
|
+
driverAction: 'readLogs',
|
|
532
|
+
lines: logcatLines,
|
|
533
|
+
rawFileName: 'adb-logcat.txt',
|
|
534
|
+
required: true,
|
|
535
|
+
...(waitMs > 0 ? { waitMs } : {}),
|
|
536
|
+
}]
|
|
537
|
+
: [];
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Returns the default raw evidence filename for one adb driver action.
|
|
541
|
+
*
|
|
542
|
+
* @param {{driverAction: AndroidAdbDriverStep['driverAction'], index: number, readLogsIndex: number}} options
|
|
543
|
+
* @returns {string}
|
|
544
|
+
*/
|
|
545
|
+
function defaultAndroidAdbRawFileName({ driverAction, index, readLogsIndex, }) {
|
|
546
|
+
if (driverAction === 'readLogs') {
|
|
547
|
+
return readLogsIndex === 1 ? 'adb-logcat.txt' : `adb-logcat-${readLogsIndex}.txt`;
|
|
548
|
+
}
|
|
549
|
+
const suffix = index === 1 ? '' : `-${index}`;
|
|
550
|
+
if (driverAction === 'inspectTree') {
|
|
551
|
+
return `adb-ui-tree${suffix}.xml`;
|
|
552
|
+
}
|
|
553
|
+
if (driverAction === 'assertVisible') {
|
|
554
|
+
return `adb-assert-visible${suffix}.xml`;
|
|
555
|
+
}
|
|
556
|
+
if (driverAction === 'screenshot') {
|
|
557
|
+
return `adb-screenshot${suffix}.png`;
|
|
558
|
+
}
|
|
559
|
+
return `adb-${driverAction}${suffix}.txt`;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Returns the default capture filename for one adb driver action.
|
|
563
|
+
*
|
|
564
|
+
* @param {{driverAction: AndroidAdbDriverStep['driverAction'], index: number}} options
|
|
565
|
+
* @returns {string}
|
|
566
|
+
*/
|
|
567
|
+
function defaultAndroidAdbCaptureFileName({ driverAction, index, }) {
|
|
568
|
+
const suffix = index === 1 ? '' : `-${index}`;
|
|
569
|
+
if (driverAction === 'record') {
|
|
570
|
+
return `adb-record${suffix}.mp4`;
|
|
571
|
+
}
|
|
572
|
+
return `adb-${driverAction}${suffix}`;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Builds a stable health code suffix for an adb driver action.
|
|
576
|
+
*
|
|
577
|
+
* @param {AndroidAdbDriverStep['driverAction']} driverAction
|
|
578
|
+
* @returns {string}
|
|
579
|
+
*/
|
|
580
|
+
function androidDriverActionCode(driverAction) {
|
|
581
|
+
return driverAction.replace(/[A-Z]/gu, (letter) => `_${letter.toLowerCase()}`);
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Returns whether a driver step can derive missing coordinates from a selector.
|
|
585
|
+
*
|
|
586
|
+
* @param {AndroidAdbDriverStep} driverStep
|
|
587
|
+
* @returns {boolean}
|
|
588
|
+
*/
|
|
589
|
+
function needsAndroidSelectorResolution(driverStep) {
|
|
590
|
+
if (!driverStep.selector) {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
if (driverStep.driverAction === 'tap') {
|
|
594
|
+
return typeof driverStep.x !== 'number' || typeof driverStep.y !== 'number';
|
|
595
|
+
}
|
|
596
|
+
return driverStep.driverAction === 'scroll' && (typeof driverStep.startX !== 'number' ||
|
|
597
|
+
typeof driverStep.startY !== 'number' ||
|
|
598
|
+
typeof driverStep.endX !== 'number' ||
|
|
599
|
+
typeof driverStep.endY !== 'number');
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Applies a resolved selector to one tap or scroll driver step.
|
|
603
|
+
*
|
|
604
|
+
* @param {{driverStep: AndroidAdbDriverStep, resolution: import('./android-adb-driver').AndroidSelectorResolution}} options
|
|
605
|
+
* @returns {AndroidAdbDriverStep}
|
|
606
|
+
*/
|
|
607
|
+
function applyAndroidSelectorResolution({ driverStep, resolution, }) {
|
|
608
|
+
if (driverStep.driverAction === 'tap') {
|
|
609
|
+
return {
|
|
610
|
+
...driverStep,
|
|
611
|
+
x: driverStep.x ?? resolution.centerX,
|
|
612
|
+
y: driverStep.y ?? resolution.centerY,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
if (driverStep.driverAction === 'scroll') {
|
|
616
|
+
const coordinates = buildAndroidScrollCoordinatesFromBounds(resolution.bounds);
|
|
617
|
+
return {
|
|
618
|
+
...driverStep,
|
|
619
|
+
endX: driverStep.endX ?? coordinates.endX,
|
|
620
|
+
endY: driverStep.endY ?? coordinates.endY,
|
|
621
|
+
startX: driverStep.startX ?? coordinates.startX,
|
|
622
|
+
startY: driverStep.startY ?? coordinates.startY,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
return driverStep;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Converts a selector into scalar health-check metadata fields.
|
|
629
|
+
*
|
|
630
|
+
* @param {import('./android-adb-driver').AndroidSelector | undefined} selector
|
|
631
|
+
* @returns {Record<string, string>}
|
|
632
|
+
*/
|
|
633
|
+
function buildAndroidSelectorHealthMetadata(selector) {
|
|
634
|
+
if (!selector) {
|
|
635
|
+
return {};
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
selectorKind: selector.kind,
|
|
639
|
+
selectorValue: selector.value,
|
|
640
|
+
...(selector.match ? { selectorMatch: selector.match } : {}),
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Returns a compact single-line adb driver failure preview.
|
|
645
|
+
*
|
|
646
|
+
* @param {import('./android-adb-driver').AndroidAdbCommandResult} driverResult
|
|
647
|
+
* @returns {string | null}
|
|
648
|
+
*/
|
|
649
|
+
function previewAndroidDriverFailure(driverResult) {
|
|
650
|
+
const preview = [driverResult.stderr, driverResult.stdout]
|
|
651
|
+
.filter(Boolean)
|
|
652
|
+
.join('\n')
|
|
653
|
+
.replace(/\s+/gu, ' ')
|
|
654
|
+
.trim();
|
|
655
|
+
return preview ? preview.slice(0, 500) : null;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Builds next-action metadata for failed adb driver steps.
|
|
659
|
+
*
|
|
660
|
+
* @param {{driverResult: import('./android-adb-driver').AndroidAdbCommandResult, isReadLogs: boolean}} options
|
|
661
|
+
* @returns {Record<string, string>}
|
|
662
|
+
*/
|
|
663
|
+
function buildAndroidDriverFailureMetadata({ driverResult, isReadLogs, }) {
|
|
664
|
+
const failurePreview = previewAndroidDriverFailure(driverResult);
|
|
665
|
+
const diagnostic = `${driverResult.stderr}\n${driverResult.stdout}`;
|
|
666
|
+
const uiAutomationBusy = /uiautomationservice|uiautomator|already registered|\/sdcard\/agent-scenario-loop-ui\.xml|killed/iu
|
|
667
|
+
.test(diagnostic);
|
|
668
|
+
const metadata = uiAutomationBusy
|
|
669
|
+
? nextActionHint('reset_android_uiautomator', 'Android UIAutomator could not provide a UI tree, likely because another automation session owns the service. Close or reset competing UI automation sessions, then rerun the capture before treating this as an app or selector failure.')
|
|
670
|
+
: nextActionHint(isReadLogs ? 'inspect_android_logcat_capture' : 'inspect_android_driver_action', isReadLogs
|
|
671
|
+
? `Inspect raw/${driverResult.rawFileName}, confirm adb logcat access for the selected device, and rerun the capture.`
|
|
672
|
+
: `Inspect raw/${driverResult.rawFileName}, confirm the device is interactive and the action metadata is valid, then rerun the capture.`);
|
|
673
|
+
return {
|
|
674
|
+
...metadata,
|
|
675
|
+
...(failurePreview ? { failurePreview } : {}),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Runs one normalized adb driver step through the Android driver adapter.
|
|
680
|
+
*
|
|
681
|
+
* @param {{driver: import('./android-adb-driver').AndroidAdbDriver, driverStep: AndroidAdbDriverStep, logcatLines: number}} options
|
|
682
|
+
* @returns {Promise<import('./android-adb-driver').AndroidAdbCommandResult>}
|
|
683
|
+
*/
|
|
684
|
+
async function runAndroidAdbDriverStep({ capturesDir, driver, driverStep, logcatLines, }) {
|
|
685
|
+
if (driverStep.driverAction === 'readLogs') {
|
|
686
|
+
return driver.readLogs({
|
|
687
|
+
lines: driverStep.lines ?? logcatLines,
|
|
688
|
+
...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
if (driverStep.driverAction === 'inspectTree') {
|
|
692
|
+
return driver.inspectTree({
|
|
693
|
+
...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
if (driverStep.driverAction === 'assertVisible') {
|
|
697
|
+
if (!driverStep.selector) {
|
|
698
|
+
return {
|
|
699
|
+
action: 'assertVisible',
|
|
700
|
+
args: [],
|
|
701
|
+
command: 'adb',
|
|
702
|
+
exitCode: 1,
|
|
703
|
+
rawFileName: driverStep.rawFileName ?? 'adb-assert-visible.xml',
|
|
704
|
+
stderr: 'assertVisible driver action requires a selector.',
|
|
705
|
+
stdout: '',
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
return driver.assertVisible({
|
|
709
|
+
...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
|
|
710
|
+
selector: driverStep.selector,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
if (driverStep.driverAction === 'record') {
|
|
714
|
+
const captureFileName = driverStep.captureFileName ?? 'adb-record.mp4';
|
|
715
|
+
return driver.record({
|
|
716
|
+
...(typeof driverStep.durationSeconds === 'number' ? { durationSeconds: driverStep.durationSeconds } : {}),
|
|
717
|
+
outputPath: path.join(capturesDir, captureFileName),
|
|
718
|
+
...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
|
|
719
|
+
...(typeof driverStep.remotePath === 'string' ? { remotePath: driverStep.remotePath } : {}),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
if (driverStep.driverAction === 'screenshot') {
|
|
723
|
+
return driver.screenshot({
|
|
724
|
+
...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
if (driverStep.driverAction === 'scroll') {
|
|
728
|
+
if (typeof driverStep.startX !== 'number' ||
|
|
729
|
+
typeof driverStep.startY !== 'number' ||
|
|
730
|
+
typeof driverStep.endX !== 'number' ||
|
|
731
|
+
typeof driverStep.endY !== 'number') {
|
|
732
|
+
return {
|
|
733
|
+
action: 'scroll',
|
|
734
|
+
args: [],
|
|
735
|
+
command: 'adb',
|
|
736
|
+
exitCode: 1,
|
|
737
|
+
rawFileName: driverStep.rawFileName ?? 'adb-scroll.txt',
|
|
738
|
+
stderr: 'scroll driver action requires startX, startY, endX, and endY.',
|
|
739
|
+
stdout: '',
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
return driver.scroll({
|
|
743
|
+
...(typeof driverStep.durationMs === 'number' ? { durationMs: driverStep.durationMs } : {}),
|
|
744
|
+
endX: driverStep.endX,
|
|
745
|
+
endY: driverStep.endY,
|
|
746
|
+
...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
|
|
747
|
+
startX: driverStep.startX,
|
|
748
|
+
startY: driverStep.startY,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
if (typeof driverStep.x !== 'number' || typeof driverStep.y !== 'number') {
|
|
752
|
+
return {
|
|
753
|
+
action: 'tap',
|
|
754
|
+
args: [],
|
|
755
|
+
command: 'adb',
|
|
756
|
+
exitCode: 1,
|
|
757
|
+
rawFileName: driverStep.rawFileName ?? 'adb-tap.txt',
|
|
758
|
+
stderr: 'tap driver action requires x and y.',
|
|
759
|
+
stdout: '',
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
return driver.tap({
|
|
763
|
+
...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
|
|
764
|
+
x: driverStep.x,
|
|
765
|
+
y: driverStep.y,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Runs Android adb readiness checks and writes the preflight artifact set.
|
|
770
|
+
*
|
|
771
|
+
* @param {AndroidPreflightOptions} options
|
|
772
|
+
* @returns {Promise<AndroidPreflightResult>}
|
|
773
|
+
*/
|
|
774
|
+
async function runAndroidAdbPreflight({ adbPath = 'adb', captureLogcat = false, clearLogcat = false, deepLinks = [], delay: wait = delay, driverSteps = [], executor = execFileCommand, launch = false, launchWaitMs = 0, logcatLines = 1000, outputDir = path.resolve('artifacts/android-adb-preflight'), packageName = null, reactNativeDebugHost = null, runId = createRunId(), serial = null, startupDeepLinks = [], storageWrites = [], waitMs = 0, } = {}) {
|
|
775
|
+
const runDir = path.resolve(outputDir);
|
|
776
|
+
const layout = createArtifactLayout({ outputDir: runDir });
|
|
777
|
+
const rawDir = layout.raw;
|
|
778
|
+
await fsp.mkdir(rawDir, { recursive: true });
|
|
779
|
+
const raw = {};
|
|
780
|
+
const checks = [];
|
|
781
|
+
const version = await executor(adbPath, ['version']);
|
|
782
|
+
const adbAvailable = version.exitCode === 0;
|
|
783
|
+
raw['adb-version.txt'] = [version.stdout, version.stderr].filter(Boolean).join('\n');
|
|
784
|
+
checks.push({
|
|
785
|
+
name: 'adb_available',
|
|
786
|
+
status: adbAvailable ? 'passed' : 'failed',
|
|
787
|
+
source: 'runner',
|
|
788
|
+
code: adbAvailable ? 'adb_available' : 'adb_unavailable',
|
|
789
|
+
message: adbAvailable ? 'adb command is available.' : 'adb command could not be executed.',
|
|
790
|
+
...(!adbAvailable
|
|
791
|
+
? {
|
|
792
|
+
metadata: nextActionHint('fix_adb_command', 'Install Android platform-tools or pass --adb with a working adb binary, then rerun the capture.'),
|
|
793
|
+
}
|
|
794
|
+
: {}),
|
|
795
|
+
});
|
|
796
|
+
const devicesOutput = adbAvailable
|
|
797
|
+
? await executor(adbPath, ['devices', '-l'])
|
|
798
|
+
: {
|
|
799
|
+
command: adbPath,
|
|
800
|
+
args: ['devices', '-l'],
|
|
801
|
+
exitCode: 1,
|
|
802
|
+
stderr: 'adb unavailable',
|
|
803
|
+
stdout: '',
|
|
804
|
+
};
|
|
805
|
+
raw['adb-devices.txt'] = [devicesOutput.stdout, devicesOutput.stderr].filter(Boolean).join('\n');
|
|
806
|
+
const devices = parseAdbDevices(devicesOutput.stdout);
|
|
807
|
+
const device = selectDevice(devices, serial);
|
|
808
|
+
const deviceOnline = Boolean(device && device.state === 'device');
|
|
809
|
+
const deviceFailure = !deviceOnline
|
|
810
|
+
? buildAndroidDeviceFailure({ devicesOutput, serial })
|
|
811
|
+
: null;
|
|
812
|
+
checks.push({
|
|
813
|
+
name: 'android_device_connected',
|
|
814
|
+
status: deviceOnline ? 'passed' : 'failed',
|
|
815
|
+
source: 'runner',
|
|
816
|
+
code: deviceOnline ? 'android_device_connected' : deviceFailure?.code,
|
|
817
|
+
message: deviceOnline && device
|
|
818
|
+
? `Selected Android device ${device.serial}.`
|
|
819
|
+
: deviceFailure?.message,
|
|
820
|
+
...(!deviceOnline
|
|
821
|
+
? {
|
|
822
|
+
metadata: deviceFailure?.metadata,
|
|
823
|
+
}
|
|
824
|
+
: {}),
|
|
825
|
+
});
|
|
826
|
+
const metadata = {
|
|
827
|
+
adbPath,
|
|
828
|
+
captureLogcat,
|
|
829
|
+
clearLogcat,
|
|
830
|
+
deepLinks,
|
|
831
|
+
devices,
|
|
832
|
+
driverSteps,
|
|
833
|
+
launch,
|
|
834
|
+
logcatLines,
|
|
835
|
+
selectedDevice: device,
|
|
836
|
+
packageName,
|
|
837
|
+
reactNativeDebugHost,
|
|
838
|
+
startupDeepLinks,
|
|
839
|
+
storageWrites: storageWrites.map((write) => ({
|
|
840
|
+
clearKeys: write.clearKeys,
|
|
841
|
+
key: write.key,
|
|
842
|
+
label: write.label,
|
|
843
|
+
waitMs: write.waitMs,
|
|
844
|
+
})),
|
|
845
|
+
waitMs,
|
|
846
|
+
};
|
|
847
|
+
const resolvedDriverSteps = resolveAndroidAdbDriverSteps({
|
|
848
|
+
captureLogcat,
|
|
849
|
+
driverSteps,
|
|
850
|
+
logcatLines,
|
|
851
|
+
waitMs,
|
|
852
|
+
});
|
|
853
|
+
if (device && device.state === 'device') {
|
|
854
|
+
const shellPrefix = ['-s', device.serial, 'shell'];
|
|
855
|
+
const driver = createAndroidAdbDriver({
|
|
856
|
+
adbPath,
|
|
857
|
+
deviceSerial: device.serial,
|
|
858
|
+
executor,
|
|
859
|
+
});
|
|
860
|
+
const [model, release, sdk] = await Promise.all([
|
|
861
|
+
executor(adbPath, [...shellPrefix, 'getprop', 'ro.product.model']),
|
|
862
|
+
executor(adbPath, [...shellPrefix, 'getprop', 'ro.build.version.release']),
|
|
863
|
+
executor(adbPath, [...shellPrefix, 'getprop', 'ro.build.version.sdk']),
|
|
864
|
+
]);
|
|
865
|
+
metadata.deviceProperties = {
|
|
866
|
+
model: model.stdout.trim(),
|
|
867
|
+
release: release.stdout.trim(),
|
|
868
|
+
sdk: sdk.stdout.trim(),
|
|
869
|
+
};
|
|
870
|
+
raw['adb-device-properties.txt'] = [
|
|
871
|
+
`model=${model.stdout.trim()}`,
|
|
872
|
+
`release=${release.stdout.trim()}`,
|
|
873
|
+
`sdk=${sdk.stdout.trim()}`,
|
|
874
|
+
].join('\n');
|
|
875
|
+
let selectedPackageInstalled = false;
|
|
876
|
+
if (packageName) {
|
|
877
|
+
const packageCheck = await executor(adbPath, [...shellPrefix, 'pm', 'path', packageName]);
|
|
878
|
+
raw['adb-package.txt'] = [packageCheck.stdout, packageCheck.stderr].filter(Boolean).join('\n');
|
|
879
|
+
const packageInstalled = packageCheck.exitCode === 0 && packageCheck.stdout.includes('package:');
|
|
880
|
+
selectedPackageInstalled = packageInstalled;
|
|
881
|
+
checks.push({
|
|
882
|
+
name: 'android_package_installed',
|
|
883
|
+
status: packageInstalled ? 'passed' : 'failed',
|
|
884
|
+
source: 'runner',
|
|
885
|
+
code: packageInstalled
|
|
886
|
+
? 'android_package_installed'
|
|
887
|
+
: 'android_package_missing',
|
|
888
|
+
message: packageInstalled
|
|
889
|
+
? `Package ${packageName} is installed.`
|
|
890
|
+
: `Package ${packageName} is not installed on ${device.serial}.`,
|
|
891
|
+
...(!packageInstalled
|
|
892
|
+
? {
|
|
893
|
+
metadata: nextActionHint('install_android_package', 'Build and install the app on the selected device, or rerun with --package set to the installed application id.'),
|
|
894
|
+
}
|
|
895
|
+
: {}),
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
if (reactNativeDebugHost) {
|
|
899
|
+
const reactNativeDebugPort = parseReactNativeDebugHostPort(reactNativeDebugHost);
|
|
900
|
+
if (!packageName) {
|
|
901
|
+
checks.push({
|
|
902
|
+
name: 'android_react_native_debug_host_configured',
|
|
903
|
+
status: 'failed',
|
|
904
|
+
source: 'runner',
|
|
905
|
+
code: 'android_react_native_debug_host_missing_package',
|
|
906
|
+
message: 'React Native debug host setup was requested, but --package was not provided.',
|
|
907
|
+
metadata: nextActionHint('provide_android_package', 'Rerun with --package set to the installed Android application id when --react-native-debug-host is enabled.'),
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
else if (!selectedPackageInstalled) {
|
|
911
|
+
checks.push({
|
|
912
|
+
name: 'android_react_native_debug_host_configured',
|
|
913
|
+
status: 'failed',
|
|
914
|
+
source: 'runner',
|
|
915
|
+
code: 'android_react_native_debug_host_package_missing',
|
|
916
|
+
message: `React Native debug host setup requires installed package ${packageName}.`,
|
|
917
|
+
metadata: nextActionHint('install_android_package', 'Build and install the app on the selected device before configuring the React Native debug host.'),
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
else if (!reactNativeDebugPort) {
|
|
921
|
+
checks.push({
|
|
922
|
+
name: 'android_react_native_debug_host_configured',
|
|
923
|
+
status: 'failed',
|
|
924
|
+
source: 'runner',
|
|
925
|
+
code: 'android_react_native_debug_host_invalid',
|
|
926
|
+
message: `React Native debug host ${reactNativeDebugHost} must be a host:port value without a URL scheme.`,
|
|
927
|
+
metadata: nextActionHint('fix_react_native_debug_host', 'Pass a React Native debug host such as localhost:8097, not a full http:// URL.'),
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
else {
|
|
931
|
+
const reverseResult = await executor(adbPath, [
|
|
932
|
+
'-s',
|
|
933
|
+
device.serial,
|
|
934
|
+
'reverse',
|
|
935
|
+
`tcp:${reactNativeDebugPort}`,
|
|
936
|
+
`tcp:${reactNativeDebugPort}`,
|
|
937
|
+
]);
|
|
938
|
+
const preferenceCommand = buildReactNativeDebugHostPreferenceCommand({
|
|
939
|
+
debugHost: reactNativeDebugHost,
|
|
940
|
+
packageName,
|
|
941
|
+
});
|
|
942
|
+
const preferenceResult = await executor(adbPath, [
|
|
943
|
+
'-s',
|
|
944
|
+
device.serial,
|
|
945
|
+
'shell',
|
|
946
|
+
'run-as',
|
|
947
|
+
packageName,
|
|
948
|
+
'sh',
|
|
949
|
+
'-c',
|
|
950
|
+
quoteAndroidShellArg(preferenceCommand),
|
|
951
|
+
]);
|
|
952
|
+
const reversePassed = reverseResult.exitCode === 0;
|
|
953
|
+
const preferencePassed = preferenceResult.exitCode === 0;
|
|
954
|
+
raw['adb-react-native-reverse.txt'] = formatAndroidCommandRawOutput(reverseResult);
|
|
955
|
+
raw['adb-react-native-debug-host.txt'] = formatAndroidCommandRawOutput(preferenceResult);
|
|
956
|
+
checks.push({
|
|
957
|
+
name: 'android_react_native_reverse_configured',
|
|
958
|
+
status: reversePassed ? 'passed' : 'failed',
|
|
959
|
+
source: 'runner',
|
|
960
|
+
code: reversePassed
|
|
961
|
+
? 'android_react_native_reverse_configured'
|
|
962
|
+
: 'android_react_native_reverse_failed',
|
|
963
|
+
message: reversePassed
|
|
964
|
+
? `Configured adb reverse for React Native debug port ${reactNativeDebugPort}.`
|
|
965
|
+
: `Failed to configure adb reverse for React Native debug port ${reactNativeDebugPort}.`,
|
|
966
|
+
...(!reversePassed
|
|
967
|
+
? {
|
|
968
|
+
metadata: nextActionHint('inspect_android_react_native_reverse', 'Inspect raw/adb-react-native-reverse.txt, confirm the selected device supports adb reverse, then rerun the capture.'),
|
|
969
|
+
}
|
|
970
|
+
: {}),
|
|
971
|
+
});
|
|
972
|
+
checks.push({
|
|
973
|
+
name: 'android_react_native_debug_host_configured',
|
|
974
|
+
status: preferencePassed ? 'passed' : 'failed',
|
|
975
|
+
source: 'runner',
|
|
976
|
+
code: preferencePassed
|
|
977
|
+
? 'android_react_native_debug_host_configured'
|
|
978
|
+
: 'android_react_native_debug_host_failed',
|
|
979
|
+
message: preferencePassed
|
|
980
|
+
? `Configured React Native debug host ${reactNativeDebugHost} for ${packageName}.`
|
|
981
|
+
: `Failed to configure React Native debug host ${reactNativeDebugHost} for ${packageName}.`,
|
|
982
|
+
...(!preferencePassed
|
|
983
|
+
? {
|
|
984
|
+
metadata: nextActionHint('inspect_android_react_native_debug_host', 'Inspect raw/adb-react-native-debug-host.txt, confirm the app is debuggable and run-as works for the package, then rerun the capture.'),
|
|
985
|
+
}
|
|
986
|
+
: {}),
|
|
987
|
+
});
|
|
988
|
+
metadata.reactNativeDebugHostSetup = {
|
|
989
|
+
debugHost: reactNativeDebugHost,
|
|
990
|
+
port: reactNativeDebugPort,
|
|
991
|
+
preferenceRawPath: 'raw/adb-react-native-debug-host.txt',
|
|
992
|
+
reverseRawPath: 'raw/adb-react-native-reverse.txt',
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (clearLogcat) {
|
|
997
|
+
const clear = await driver.clearLogs();
|
|
998
|
+
const logcatCleared = clear.exitCode === 0;
|
|
999
|
+
raw[clear.rawFileName] = formatAndroidAdbRawOutput(clear);
|
|
1000
|
+
checks.push({
|
|
1001
|
+
name: 'android_logcat_cleared',
|
|
1002
|
+
status: logcatCleared ? 'passed' : 'failed',
|
|
1003
|
+
source: 'runner',
|
|
1004
|
+
code: logcatCleared ? 'android_logcat_cleared' : 'android_logcat_clear_failed',
|
|
1005
|
+
message: logcatCleared ? 'Cleared adb logcat before capture.' : 'adb logcat clear failed.',
|
|
1006
|
+
...(!logcatCleared
|
|
1007
|
+
? {
|
|
1008
|
+
metadata: nextActionHint('inspect_adb_logcat_clear', `Inspect raw/${clear.rawFileName}, confirm the selected device allows logcat access, then rerun the capture.`),
|
|
1009
|
+
}
|
|
1010
|
+
: {}),
|
|
1011
|
+
});
|
|
1012
|
+
metadata.logcatClear = {
|
|
1013
|
+
args: clear.args,
|
|
1014
|
+
exitCode: clear.exitCode,
|
|
1015
|
+
rawPath: `raw/${clear.rawFileName}`,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
const appLifecycleMetadata = {};
|
|
1019
|
+
let lifecyclePackageName = null;
|
|
1020
|
+
let knownLifecyclePids = [];
|
|
1021
|
+
if (launch) {
|
|
1022
|
+
if (!packageName) {
|
|
1023
|
+
checks.push({
|
|
1024
|
+
name: 'android_package_launched',
|
|
1025
|
+
status: 'failed',
|
|
1026
|
+
source: 'runner',
|
|
1027
|
+
code: 'android_launch_missing_package',
|
|
1028
|
+
message: 'Package launch was requested, but --package was not provided.',
|
|
1029
|
+
metadata: nextActionHint('provide_android_package', 'Rerun with --package set to the installed Android application id when --launch is enabled.'),
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
const launchResult = await driver.launchPackage(packageName);
|
|
1034
|
+
const launchPassed = launchResult.exitCode === 0;
|
|
1035
|
+
raw[launchResult.rawFileName] = formatAndroidAdbRawOutput(launchResult);
|
|
1036
|
+
checks.push({
|
|
1037
|
+
name: 'android_package_launched',
|
|
1038
|
+
status: launchPassed ? 'passed' : 'failed',
|
|
1039
|
+
source: 'runner',
|
|
1040
|
+
code: launchPassed ? 'android_package_launched' : 'android_package_launch_failed',
|
|
1041
|
+
message: launchPassed
|
|
1042
|
+
? `Launched package ${packageName}.`
|
|
1043
|
+
: `Failed to launch package ${packageName}.`,
|
|
1044
|
+
...(!launchPassed
|
|
1045
|
+
? {
|
|
1046
|
+
metadata: nextActionHint('inspect_android_launch', `Inspect raw/${launchResult.rawFileName}, verify the package has a launcher activity, and confirm the app can open manually on the device.`),
|
|
1047
|
+
}
|
|
1048
|
+
: {}),
|
|
1049
|
+
});
|
|
1050
|
+
metadata.launchResult = {
|
|
1051
|
+
args: launchResult.args,
|
|
1052
|
+
exitCode: launchResult.exitCode,
|
|
1053
|
+
rawPath: `raw/${launchResult.rawFileName}`,
|
|
1054
|
+
};
|
|
1055
|
+
if (launchPassed && launchWaitMs > 0) {
|
|
1056
|
+
await wait(launchWaitMs);
|
|
1057
|
+
checks.push({
|
|
1058
|
+
name: 'android_launch_waited',
|
|
1059
|
+
status: 'passed',
|
|
1060
|
+
source: 'runner',
|
|
1061
|
+
code: 'android_launch_waited',
|
|
1062
|
+
message: `Waited ${launchWaitMs}ms after Android package launch.`,
|
|
1063
|
+
});
|
|
1064
|
+
metadata.launchWaitMs = launchWaitMs;
|
|
1065
|
+
}
|
|
1066
|
+
if (launchPassed) {
|
|
1067
|
+
lifecyclePackageName = packageName;
|
|
1068
|
+
const pidofAfterLaunch = await executor(adbPath, [
|
|
1069
|
+
'-s',
|
|
1070
|
+
device.serial,
|
|
1071
|
+
'shell',
|
|
1072
|
+
'pidof',
|
|
1073
|
+
packageName,
|
|
1074
|
+
]);
|
|
1075
|
+
const rawPath = 'raw/adb-app-pidof-after-launch.txt';
|
|
1076
|
+
raw['adb-app-pidof-after-launch.txt'] = formatAndroidCommandRawOutput(pidofAfterLaunch);
|
|
1077
|
+
const afterLaunchPids = parseAndroidPidofOutput(pidofAfterLaunch.stdout);
|
|
1078
|
+
knownLifecyclePids = afterLaunchPids;
|
|
1079
|
+
const runningAfterLaunch = pidofAfterLaunch.exitCode === 0 && afterLaunchPids.length > 0;
|
|
1080
|
+
checks.push({
|
|
1081
|
+
name: 'android_app_process_running_after_launch',
|
|
1082
|
+
status: runningAfterLaunch ? 'passed' : 'failed',
|
|
1083
|
+
source: 'runner',
|
|
1084
|
+
code: runningAfterLaunch
|
|
1085
|
+
? 'android_app_process_running_after_launch'
|
|
1086
|
+
: 'android_app_not_running_after_launch',
|
|
1087
|
+
message: runningAfterLaunch
|
|
1088
|
+
? `Package ${packageName} is running after launch with PID ${afterLaunchPids.join(', ')}.`
|
|
1089
|
+
: `Package ${packageName} is not running after launch.`,
|
|
1090
|
+
...(!runningAfterLaunch
|
|
1091
|
+
? {
|
|
1092
|
+
metadata: nextActionHint('inspect_android_app_launch', `Inspect ${rawPath} and the app's device logs to find why the launched process exited before evidence capture.`),
|
|
1093
|
+
}
|
|
1094
|
+
: {}),
|
|
1095
|
+
});
|
|
1096
|
+
Object.assign(appLifecycleMetadata, {
|
|
1097
|
+
afterLaunchPids,
|
|
1098
|
+
afterLaunchRawPath: rawPath,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
for (const [index, deepLink] of startupDeepLinks.entries()) {
|
|
1104
|
+
const rawFileName = `adb-startup-deep-link-${index + 1}.txt`;
|
|
1105
|
+
const deepLinkResult = await driver.openDeepLink({
|
|
1106
|
+
packageName,
|
|
1107
|
+
rawFileName,
|
|
1108
|
+
url: deepLink.url,
|
|
1109
|
+
});
|
|
1110
|
+
const deepLinkOpened = deepLinkResult.exitCode === 0;
|
|
1111
|
+
raw[deepLinkResult.rawFileName] = formatAndroidAdbRawOutput(deepLinkResult);
|
|
1112
|
+
checks.push({
|
|
1113
|
+
name: 'android_startup_deep_link_opened',
|
|
1114
|
+
status: deepLinkOpened ? 'passed' : 'failed',
|
|
1115
|
+
source: 'runner',
|
|
1116
|
+
code: deepLinkOpened ? 'android_startup_deep_link_opened' : 'android_startup_deep_link_failed',
|
|
1117
|
+
message: deepLinkOpened
|
|
1118
|
+
? `Opened Android startup deep link ${deepLink.label ?? index + 1}.`
|
|
1119
|
+
: `Failed to open Android startup deep link ${deepLink.label ?? index + 1}.`,
|
|
1120
|
+
...(!deepLinkOpened
|
|
1121
|
+
? {
|
|
1122
|
+
metadata: nextActionHint('inspect_android_startup_deep_link', `Inspect raw/${deepLinkResult.rawFileName}, verify the dev-client URL and package intent filter, then rerun the capture.`),
|
|
1123
|
+
}
|
|
1124
|
+
: {}),
|
|
1125
|
+
});
|
|
1126
|
+
if (deepLink.waitMs && deepLink.waitMs > 0) {
|
|
1127
|
+
await wait(deepLink.waitMs);
|
|
1128
|
+
checks.push({
|
|
1129
|
+
name: 'android_startup_deep_link_waited',
|
|
1130
|
+
status: 'passed',
|
|
1131
|
+
source: 'runner',
|
|
1132
|
+
code: 'android_startup_deep_link_waited',
|
|
1133
|
+
message: `Waited ${deepLink.waitMs}ms after Android startup deep link ${deepLink.label ?? index + 1}.`,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
if (deepLink.readyLogPattern) {
|
|
1137
|
+
const rawFileName = `adb-startup-deep-link-${index + 1}-ready-log.txt`;
|
|
1138
|
+
const readyLog = await waitForAndroidReadyLog({
|
|
1139
|
+
driver,
|
|
1140
|
+
logcatLines,
|
|
1141
|
+
pattern: deepLink.readyLogPattern,
|
|
1142
|
+
quietMs: deepLink.readyLogQuietMs ?? 0,
|
|
1143
|
+
rawFileName,
|
|
1144
|
+
timeoutMs: deepLink.readyLogTimeoutMs ?? 60000,
|
|
1145
|
+
wait,
|
|
1146
|
+
});
|
|
1147
|
+
raw[rawFileName] = formatAndroidAdbRawOutput(readyLog.result);
|
|
1148
|
+
checks.push({
|
|
1149
|
+
name: 'android_startup_deep_link_ready',
|
|
1150
|
+
status: readyLog.ready ? 'passed' : 'failed',
|
|
1151
|
+
source: 'runner',
|
|
1152
|
+
code: readyLog.ready
|
|
1153
|
+
? 'android_startup_deep_link_ready'
|
|
1154
|
+
: 'android_startup_deep_link_not_ready',
|
|
1155
|
+
message: readyLog.ready
|
|
1156
|
+
? `Android startup deep link ${deepLink.label ?? index + 1} emitted readiness log evidence.`
|
|
1157
|
+
: `Android startup deep link ${deepLink.label ?? index + 1} did not emit readiness log evidence before timeout.`,
|
|
1158
|
+
...(!readyLog.ready
|
|
1159
|
+
? {
|
|
1160
|
+
metadata: nextActionHint('inspect_android_startup_deep_link_ready_log', `Inspect raw/${rawFileName}, confirm the dev-client loaded the app bundle, and increase the ready timeout only if the app is still making progress.`),
|
|
1161
|
+
}
|
|
1162
|
+
: {}),
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
for (const [index, write] of storageWrites.entries()) {
|
|
1167
|
+
const rawFileName = `adb-async-storage-write-${index + 1}.txt`;
|
|
1168
|
+
if (!packageName) {
|
|
1169
|
+
checks.push({
|
|
1170
|
+
name: 'android_async_storage_written',
|
|
1171
|
+
status: 'failed',
|
|
1172
|
+
source: 'runner',
|
|
1173
|
+
code: 'android_async_storage_missing_package',
|
|
1174
|
+
message: 'Android AsyncStorage write was requested, but --package was not provided.',
|
|
1175
|
+
metadata: nextActionHint('provide_android_package', 'Rerun with --package set to the installed Android application id when Android AsyncStorage profile-session storage is enabled.'),
|
|
1176
|
+
});
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
let resolvedWrite = write;
|
|
1180
|
+
if (write.value.includes(ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER)) {
|
|
1181
|
+
const deviceEpoch = await readAndroidDeviceEpochMs({
|
|
1182
|
+
adbPath,
|
|
1183
|
+
deviceSerial: device.serial,
|
|
1184
|
+
executor,
|
|
1185
|
+
});
|
|
1186
|
+
raw['adb-device-epoch-ms.txt'] = formatAndroidCommandRawOutput(deviceEpoch);
|
|
1187
|
+
const deviceClockRead = typeof deviceEpoch.epochMs === 'number';
|
|
1188
|
+
checks.push({
|
|
1189
|
+
name: 'android_device_clock_read',
|
|
1190
|
+
status: deviceClockRead ? 'passed' : 'failed',
|
|
1191
|
+
source: 'runner',
|
|
1192
|
+
code: deviceClockRead ? 'android_device_clock_read' : 'android_device_clock_unavailable',
|
|
1193
|
+
message: deviceClockRead
|
|
1194
|
+
? `Read Android device clock as ${deviceEpoch.epochMs}ms since epoch.`
|
|
1195
|
+
: 'Failed to read Android device clock for AsyncStorage timing evidence.',
|
|
1196
|
+
...(!deviceClockRead
|
|
1197
|
+
? {
|
|
1198
|
+
metadata: nextActionHint('inspect_android_device_clock', 'Inspect raw/adb-device-epoch-ms.txt and confirm the selected Android device supports `adb shell date +%s` before using storage-backed timing evidence.'),
|
|
1199
|
+
}
|
|
1200
|
+
: {}),
|
|
1201
|
+
});
|
|
1202
|
+
if (!deviceClockRead || deviceEpoch.epochMs === null) {
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
resolvedWrite = {
|
|
1206
|
+
...write,
|
|
1207
|
+
value: write.value.replaceAll(ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER, String(deviceEpoch.epochMs)),
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
const writeResult = await executor(adbPath, [
|
|
1211
|
+
'-s',
|
|
1212
|
+
device.serial,
|
|
1213
|
+
'shell',
|
|
1214
|
+
buildAndroidAsyncStorageWriteCommand({ packageName, write: resolvedWrite }),
|
|
1215
|
+
]);
|
|
1216
|
+
const writePassed = writeResult.exitCode === 0;
|
|
1217
|
+
raw[rawFileName] = formatAndroidCommandRawOutput(writeResult);
|
|
1218
|
+
checks.push({
|
|
1219
|
+
name: 'android_async_storage_written',
|
|
1220
|
+
status: writePassed ? 'passed' : 'failed',
|
|
1221
|
+
source: 'runner',
|
|
1222
|
+
code: writePassed ? 'android_async_storage_written' : 'android_async_storage_write_failed',
|
|
1223
|
+
message: writePassed
|
|
1224
|
+
? `Wrote Android AsyncStorage value ${write.label ?? index + 1}.`
|
|
1225
|
+
: `Failed to write Android AsyncStorage value ${write.label ?? index + 1}.`,
|
|
1226
|
+
...(!writePassed
|
|
1227
|
+
? {
|
|
1228
|
+
metadata: nextActionHint('inspect_android_async_storage_write', `Inspect raw/${rawFileName}, confirm the app is debuggable and sqlite3 can access RKStorage through run-as, then rerun the capture.`),
|
|
1229
|
+
}
|
|
1230
|
+
: {}),
|
|
1231
|
+
});
|
|
1232
|
+
if (write.waitMs && write.waitMs > 0) {
|
|
1233
|
+
await wait(write.waitMs);
|
|
1234
|
+
checks.push({
|
|
1235
|
+
name: 'android_async_storage_waited',
|
|
1236
|
+
status: 'passed',
|
|
1237
|
+
source: 'runner',
|
|
1238
|
+
code: 'android_async_storage_waited',
|
|
1239
|
+
message: `Waited ${write.waitMs}ms after Android AsyncStorage write ${write.label ?? index + 1}.`,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
for (const [index, deepLink] of deepLinks.entries()) {
|
|
1244
|
+
const rawFileName = `adb-deep-link-${index + 1}.txt`;
|
|
1245
|
+
const deepLinkResult = await driver.openDeepLink({
|
|
1246
|
+
packageName,
|
|
1247
|
+
rawFileName,
|
|
1248
|
+
url: deepLink.url,
|
|
1249
|
+
});
|
|
1250
|
+
const deepLinkOpened = deepLinkResult.exitCode === 0;
|
|
1251
|
+
raw[deepLinkResult.rawFileName] = formatAndroidAdbRawOutput(deepLinkResult);
|
|
1252
|
+
checks.push({
|
|
1253
|
+
name: 'android_deep_link_opened',
|
|
1254
|
+
status: deepLinkOpened ? 'passed' : 'failed',
|
|
1255
|
+
source: 'runner',
|
|
1256
|
+
code: deepLinkOpened ? 'android_deep_link_opened' : 'android_deep_link_failed',
|
|
1257
|
+
message: deepLinkOpened
|
|
1258
|
+
? `Opened Android deep link ${deepLink.label ?? index + 1}.`
|
|
1259
|
+
: `Failed to open Android deep link ${deepLink.label ?? index + 1}.`,
|
|
1260
|
+
...(!deepLinkOpened
|
|
1261
|
+
? {
|
|
1262
|
+
metadata: nextActionHint('inspect_android_deep_link', `Inspect raw/${deepLinkResult.rawFileName}, verify the app scheme/intent filter, and rerun with --package if the intent must target one app.`),
|
|
1263
|
+
}
|
|
1264
|
+
: {}),
|
|
1265
|
+
});
|
|
1266
|
+
if (deepLink.waitMs && deepLink.waitMs > 0) {
|
|
1267
|
+
await wait(deepLink.waitMs);
|
|
1268
|
+
checks.push({
|
|
1269
|
+
name: 'android_deep_link_waited',
|
|
1270
|
+
status: 'passed',
|
|
1271
|
+
source: 'runner',
|
|
1272
|
+
code: 'android_deep_link_waited',
|
|
1273
|
+
message: `Waited ${deepLink.waitMs}ms after Android deep link ${deepLink.label ?? index + 1}.`,
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
if (deepLinkOpened && packageName && !lifecyclePackageName) {
|
|
1277
|
+
lifecyclePackageName = packageName;
|
|
1278
|
+
const pidofAfterDeepLink = await executor(adbPath, [
|
|
1279
|
+
'-s',
|
|
1280
|
+
device.serial,
|
|
1281
|
+
'shell',
|
|
1282
|
+
'pidof',
|
|
1283
|
+
packageName,
|
|
1284
|
+
]);
|
|
1285
|
+
const rawPath = 'raw/adb-app-pidof-after-deep-link.txt';
|
|
1286
|
+
raw['adb-app-pidof-after-deep-link.txt'] = formatAndroidCommandRawOutput(pidofAfterDeepLink);
|
|
1287
|
+
const afterDeepLinkPids = parseAndroidPidofOutput(pidofAfterDeepLink.stdout);
|
|
1288
|
+
knownLifecyclePids = afterDeepLinkPids;
|
|
1289
|
+
const runningAfterDeepLink = pidofAfterDeepLink.exitCode === 0 && afterDeepLinkPids.length > 0;
|
|
1290
|
+
checks.push({
|
|
1291
|
+
name: 'android_app_process_running_after_deep_link',
|
|
1292
|
+
status: runningAfterDeepLink ? 'passed' : 'failed',
|
|
1293
|
+
source: 'runner',
|
|
1294
|
+
code: runningAfterDeepLink
|
|
1295
|
+
? 'android_app_process_running_after_deep_link'
|
|
1296
|
+
: 'android_app_not_running_after_deep_link',
|
|
1297
|
+
message: runningAfterDeepLink
|
|
1298
|
+
? `Package ${packageName} is running after deep link with PID ${afterDeepLinkPids.join(', ')}.`
|
|
1299
|
+
: `Package ${packageName} is not running after opening the deep link.`,
|
|
1300
|
+
...(!runningAfterDeepLink
|
|
1301
|
+
? {
|
|
1302
|
+
metadata: nextActionHint('inspect_android_deep_link_launch', `Inspect ${rawPath} and the app's device logs to find why the package-targeted deep link did not leave the app process running.`),
|
|
1303
|
+
}
|
|
1304
|
+
: {}),
|
|
1305
|
+
});
|
|
1306
|
+
Object.assign(appLifecycleMetadata, {
|
|
1307
|
+
afterDeepLinkPids,
|
|
1308
|
+
afterDeepLinkRawPath: rawPath,
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
const driverActionMetadata = [];
|
|
1313
|
+
const logcatMetadata = [];
|
|
1314
|
+
const selectorResolutionMetadata = [];
|
|
1315
|
+
for (const [index, driverStep] of resolvedDriverSteps.entries()) {
|
|
1316
|
+
if (driverStep.waitMs && driverStep.waitMs > 0) {
|
|
1317
|
+
await wait(driverStep.waitMs);
|
|
1318
|
+
checks.push({
|
|
1319
|
+
name: 'android_capture_window_waited',
|
|
1320
|
+
status: 'passed',
|
|
1321
|
+
source: 'runner',
|
|
1322
|
+
code: 'android_capture_window_waited',
|
|
1323
|
+
message: `Waited ${driverStep.waitMs}ms before running adb driver action ${driverStep.driverAction}.`,
|
|
1324
|
+
metadata: {
|
|
1325
|
+
driverAction: driverStep.driverAction,
|
|
1326
|
+
...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
|
|
1327
|
+
},
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
let executableDriverStep = driverStep;
|
|
1331
|
+
if (needsAndroidSelectorResolution(driverStep)) {
|
|
1332
|
+
const selectorRawFileName = `adb-selector-tree-${index + 1}.xml`;
|
|
1333
|
+
const treeResult = await driver.inspectTree({ rawFileName: selectorRawFileName });
|
|
1334
|
+
raw[treeResult.rawFileName] = formatAndroidAdbRawOutput(treeResult);
|
|
1335
|
+
const resolution = treeResult.exitCode === 0 && driverStep.selector
|
|
1336
|
+
? resolveAndroidSelectorFromUiTree({
|
|
1337
|
+
selector: driverStep.selector,
|
|
1338
|
+
uiTreeXml: treeResult.stdout,
|
|
1339
|
+
})
|
|
1340
|
+
: null;
|
|
1341
|
+
const resolved = Boolean(resolution);
|
|
1342
|
+
if (resolution) {
|
|
1343
|
+
executableDriverStep = applyAndroidSelectorResolution({
|
|
1344
|
+
driverStep,
|
|
1345
|
+
resolution,
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
checks.push({
|
|
1349
|
+
name: 'android_selector_resolved',
|
|
1350
|
+
status: resolved ? 'passed' : driverStep.required === false ? 'warning' : 'failed',
|
|
1351
|
+
source: 'runner',
|
|
1352
|
+
code: resolved ? 'android_selector_resolved' : 'android_selector_resolution_failed',
|
|
1353
|
+
message: resolved
|
|
1354
|
+
? `Resolved Android selector for adb driver action ${driverStep.driverAction}.`
|
|
1355
|
+
: `Failed to resolve Android selector for adb driver action ${driverStep.driverAction}.`,
|
|
1356
|
+
metadata: {
|
|
1357
|
+
driverAction: driverStep.driverAction,
|
|
1358
|
+
...buildAndroidSelectorHealthMetadata(driverStep.selector),
|
|
1359
|
+
...(!resolved
|
|
1360
|
+
? nextActionHint('fix_android_selector', `Inspect raw/${treeResult.rawFileName}, update the scenario selector, or provide explicit adb coordinates for this driver action.`)
|
|
1361
|
+
: {}),
|
|
1362
|
+
...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
|
|
1363
|
+
},
|
|
1364
|
+
});
|
|
1365
|
+
selectorResolutionMetadata.push({
|
|
1366
|
+
...(resolution ? { bounds: resolution.bounds } : {}),
|
|
1367
|
+
driverAction: driverStep.driverAction,
|
|
1368
|
+
rawPath: `raw/${treeResult.rawFileName}`,
|
|
1369
|
+
...(driverStep.selector ? { selector: driverStep.selector } : {}),
|
|
1370
|
+
status: resolved ? 'passed' : 'failed',
|
|
1371
|
+
...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
const driverResult = await runAndroidAdbDriverStep({
|
|
1375
|
+
capturesDir: layout.captures,
|
|
1376
|
+
driver,
|
|
1377
|
+
driverStep: executableDriverStep,
|
|
1378
|
+
logcatLines,
|
|
1379
|
+
});
|
|
1380
|
+
raw[driverResult.rawFileName] = formatAndroidAdbRawOutput(driverResult);
|
|
1381
|
+
const failed = driverResult.exitCode !== 0;
|
|
1382
|
+
const codeSuffix = androidDriverActionCode(driverStep.driverAction);
|
|
1383
|
+
const isReadLogs = driverStep.driverAction === 'readLogs';
|
|
1384
|
+
checks.push({
|
|
1385
|
+
name: isReadLogs ? 'android_logcat_captured' : `android_${codeSuffix}`,
|
|
1386
|
+
status: failed && driverStep.required === false ? 'warning' : failed ? 'failed' : 'passed',
|
|
1387
|
+
source: 'runner',
|
|
1388
|
+
code: isReadLogs
|
|
1389
|
+
? driverResult.exitCode === 0 ? 'android_logcat_captured' : 'android_logcat_failed'
|
|
1390
|
+
: driverResult.exitCode === 0 ? `android_${codeSuffix}_completed` : `android_${codeSuffix}_failed`,
|
|
1391
|
+
message: isReadLogs
|
|
1392
|
+
? driverResult.exitCode === 0
|
|
1393
|
+
? `Captured the last ${driverStep.lines ?? logcatLines} adb logcat lines.`
|
|
1394
|
+
: 'adb logcat capture failed.'
|
|
1395
|
+
: driverResult.exitCode === 0
|
|
1396
|
+
? `Completed adb driver action ${driverStep.driverAction}.`
|
|
1397
|
+
: `adb driver action ${driverStep.driverAction} failed.`,
|
|
1398
|
+
metadata: {
|
|
1399
|
+
driverAction: executableDriverStep.driverAction,
|
|
1400
|
+
...buildAndroidSelectorHealthMetadata(executableDriverStep.selector),
|
|
1401
|
+
...(failed ? buildAndroidDriverFailureMetadata({ driverResult, isReadLogs }) : {}),
|
|
1402
|
+
...(executableDriverStep.stepId ? { stepId: executableDriverStep.stepId } : {}),
|
|
1403
|
+
},
|
|
1404
|
+
});
|
|
1405
|
+
const actionMetadata = {
|
|
1406
|
+
args: driverResult.args,
|
|
1407
|
+
driverAction: executableDriverStep.driverAction,
|
|
1408
|
+
exitCode: driverResult.exitCode,
|
|
1409
|
+
...(driverResult.capturePath
|
|
1410
|
+
? { capturePath: `captures/${path.basename(driverResult.capturePath)}` }
|
|
1411
|
+
: {}),
|
|
1412
|
+
rawPath: `raw/${driverResult.rawFileName}`,
|
|
1413
|
+
...(executableDriverStep.selector ? { selector: executableDriverStep.selector } : {}),
|
|
1414
|
+
...(executableDriverStep.stepId ? { stepId: executableDriverStep.stepId } : {}),
|
|
1415
|
+
};
|
|
1416
|
+
driverActionMetadata.push(actionMetadata);
|
|
1417
|
+
if (executableDriverStep.driverAction === 'readLogs') {
|
|
1418
|
+
logcatMetadata.push(actionMetadata);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
if (selectorResolutionMetadata.length > 0) {
|
|
1422
|
+
metadata.selectorResolutions = selectorResolutionMetadata;
|
|
1423
|
+
}
|
|
1424
|
+
if (driverActionMetadata.length > 0) {
|
|
1425
|
+
metadata.driverActions = driverActionMetadata;
|
|
1426
|
+
}
|
|
1427
|
+
if (logcatMetadata.length === 1) {
|
|
1428
|
+
metadata.logcat = logcatMetadata[0];
|
|
1429
|
+
}
|
|
1430
|
+
else if (logcatMetadata.length > 1) {
|
|
1431
|
+
metadata.logcat = logcatMetadata;
|
|
1432
|
+
}
|
|
1433
|
+
if (lifecyclePackageName) {
|
|
1434
|
+
const pidofAfterCapture = await executor(adbPath, [
|
|
1435
|
+
'-s',
|
|
1436
|
+
device.serial,
|
|
1437
|
+
'shell',
|
|
1438
|
+
'pidof',
|
|
1439
|
+
lifecyclePackageName,
|
|
1440
|
+
]);
|
|
1441
|
+
const pidofAfterCaptureRawPath = 'raw/adb-app-pidof-after-capture.txt';
|
|
1442
|
+
raw['adb-app-pidof-after-capture.txt'] = formatAndroidCommandRawOutput(pidofAfterCapture);
|
|
1443
|
+
const afterCapturePids = parseAndroidPidofOutput(pidofAfterCapture.stdout);
|
|
1444
|
+
const lifecycleLogLines = Math.max(logcatLines, 200);
|
|
1445
|
+
const lifecycleLog = await driver.readLogs({
|
|
1446
|
+
lines: lifecycleLogLines,
|
|
1447
|
+
rawFileName: 'adb-app-lifecycle-log.txt',
|
|
1448
|
+
});
|
|
1449
|
+
raw[lifecycleLog.rawFileName] = formatAndroidAdbRawOutput(lifecycleLog);
|
|
1450
|
+
const allKnownPids = Array.from(new Set([...knownLifecyclePids, ...afterCapturePids]));
|
|
1451
|
+
const scan = lifecycleLog.exitCode === 0
|
|
1452
|
+
? scanAndroidAppLifecycleLog({
|
|
1453
|
+
logText: `${lifecycleLog.stdout}\n${lifecycleLog.stderr}`,
|
|
1454
|
+
packageName: lifecyclePackageName,
|
|
1455
|
+
pids: allKnownPids,
|
|
1456
|
+
})
|
|
1457
|
+
: { crashed: false, evidence: [] };
|
|
1458
|
+
const runningAfterCapture = pidofAfterCapture.exitCode === 0 && afterCapturePids.length > 0;
|
|
1459
|
+
const lifecycleStatus = !runningAfterCapture || scan.crashed
|
|
1460
|
+
? 'failed'
|
|
1461
|
+
: lifecycleLog.exitCode === 0
|
|
1462
|
+
? 'passed'
|
|
1463
|
+
: 'warning';
|
|
1464
|
+
checks.push({
|
|
1465
|
+
name: 'android_app_lifecycle_stable',
|
|
1466
|
+
status: lifecycleStatus,
|
|
1467
|
+
source: 'runner',
|
|
1468
|
+
code: !runningAfterCapture
|
|
1469
|
+
? 'android_app_exited_during_capture'
|
|
1470
|
+
: scan.crashed
|
|
1471
|
+
? 'android_app_crashed_during_capture'
|
|
1472
|
+
: lifecycleLog.exitCode === 0
|
|
1473
|
+
? 'android_app_lifecycle_stable'
|
|
1474
|
+
: 'android_app_lifecycle_log_unavailable',
|
|
1475
|
+
message: !runningAfterCapture
|
|
1476
|
+
? `Package ${lifecyclePackageName} was not running after evidence capture.`
|
|
1477
|
+
: scan.crashed
|
|
1478
|
+
? `Package ${lifecyclePackageName} emitted crash evidence during capture.`
|
|
1479
|
+
: lifecycleLog.exitCode === 0
|
|
1480
|
+
? `Package ${lifecyclePackageName} remained running with no crash evidence in the bounded log window.`
|
|
1481
|
+
: `Could not read Android lifecycle logs for package ${lifecyclePackageName}.`,
|
|
1482
|
+
...(!runningAfterCapture || scan.crashed
|
|
1483
|
+
? {
|
|
1484
|
+
metadata: nextActionHint('inspect_android_app_crash', `Inspect raw/${lifecycleLog.rawFileName} and ${pidofAfterCaptureRawPath}; scenario timing evidence is not trustworthy until the app stays alive.`),
|
|
1485
|
+
}
|
|
1486
|
+
: lifecycleLog.exitCode !== 0
|
|
1487
|
+
? {
|
|
1488
|
+
metadata: nextActionHint('inspect_android_lifecycle_log', `Inspect raw/${lifecycleLog.rawFileName}; lifecycle log capture failed but the app process was still running.`),
|
|
1489
|
+
}
|
|
1490
|
+
: {}),
|
|
1491
|
+
});
|
|
1492
|
+
metadata.appLifecycle = {
|
|
1493
|
+
...appLifecycleMetadata,
|
|
1494
|
+
afterCapturePids,
|
|
1495
|
+
afterCaptureRawPath: pidofAfterCaptureRawPath,
|
|
1496
|
+
crashEvidence: scan.evidence,
|
|
1497
|
+
lifecycleLogLines,
|
|
1498
|
+
lifecycleLogRawPath: `raw/${lifecycleLog.rawFileName}`,
|
|
1499
|
+
packageName: lifecyclePackageName,
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
else {
|
|
1504
|
+
if (clearLogcat || launch || startupDeepLinks.length > 0 || storageWrites.length > 0) {
|
|
1505
|
+
checks.push({
|
|
1506
|
+
name: 'android_capture_window_started',
|
|
1507
|
+
status: 'failed',
|
|
1508
|
+
source: 'runner',
|
|
1509
|
+
code: 'android_capture_window_no_device',
|
|
1510
|
+
message: 'Android capture window setup was requested, but no online Android device was selected.',
|
|
1511
|
+
metadata: nextActionHint('select_android_device', 'Start or unlock an Android emulator/device, confirm it appears as `device` in adb devices -l, then rerun the capture.'),
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
if (resolvedDriverSteps.some((step) => step.driverAction === 'readLogs')) {
|
|
1515
|
+
checks.push({
|
|
1516
|
+
name: 'android_logcat_captured',
|
|
1517
|
+
status: 'failed',
|
|
1518
|
+
source: 'runner',
|
|
1519
|
+
code: 'android_logcat_no_device',
|
|
1520
|
+
message: 'adb logcat capture was requested, but no online Android device was selected.',
|
|
1521
|
+
metadata: nextActionHint('select_android_device', 'Start or unlock an Android emulator/device before requesting logcat capture.'),
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
if (resolvedDriverSteps.some((step) => step.driverAction !== 'readLogs')) {
|
|
1525
|
+
checks.push({
|
|
1526
|
+
name: 'android_driver_actions_completed',
|
|
1527
|
+
status: 'failed',
|
|
1528
|
+
source: 'runner',
|
|
1529
|
+
code: 'android_driver_actions_no_device',
|
|
1530
|
+
message: 'adb driver actions were requested, but no online Android device was selected.',
|
|
1531
|
+
metadata: nextActionHint('select_android_device', 'Start or unlock an Android emulator/device before running adb driver actions.'),
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
const health = buildAndroidHealth({ runId, checks });
|
|
1536
|
+
const verdict = buildAndroidVerdict({ runId, health });
|
|
1537
|
+
const agentSummary = buildAgentSummaryMarkdown({ health, verdict });
|
|
1538
|
+
await Promise.all(Object.entries(raw).map(([fileName, content]) => fsp.writeFile(path.join(rawDir, fileName), `${content.trimEnd()}\n`, 'utf8')));
|
|
1539
|
+
await fsp.writeFile(path.join(rawDir, 'android-metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
|
|
1540
|
+
await writeJsonArtifact({
|
|
1541
|
+
filePath: layout.health,
|
|
1542
|
+
value: health,
|
|
1543
|
+
schema: SCHEMAS.health,
|
|
1544
|
+
label: 'Health artifact',
|
|
1545
|
+
});
|
|
1546
|
+
await writeJsonArtifact({
|
|
1547
|
+
filePath: layout.verdict,
|
|
1548
|
+
value: verdict,
|
|
1549
|
+
schema: SCHEMAS.verdict,
|
|
1550
|
+
label: 'Verdict artifact',
|
|
1551
|
+
});
|
|
1552
|
+
await writeTextArtifact({
|
|
1553
|
+
filePath: layout.agentSummary,
|
|
1554
|
+
content: agentSummary,
|
|
1555
|
+
});
|
|
1556
|
+
return {
|
|
1557
|
+
agentSummary,
|
|
1558
|
+
device,
|
|
1559
|
+
health,
|
|
1560
|
+
metadata,
|
|
1561
|
+
raw,
|
|
1562
|
+
runDir,
|
|
1563
|
+
verdict,
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Runs the android-adb preflight CLI.
|
|
1568
|
+
*
|
|
1569
|
+
* @returns {Promise<void>}
|
|
1570
|
+
*/
|
|
1571
|
+
async function main() {
|
|
1572
|
+
const argv = process.argv.slice(2);
|
|
1573
|
+
if (hasHelpFlag(argv)) {
|
|
1574
|
+
usage(process.stdout);
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
const args = parseArgs(argv);
|
|
1578
|
+
const result = await runAndroidAdbPreflight({
|
|
1579
|
+
...(typeof args.adb === 'string' ? { adbPath: args.adb } : {}),
|
|
1580
|
+
captureLogcat: args['capture-logcat'] === true || args['capture-logcat'] === 'true',
|
|
1581
|
+
clearLogcat: args['clear-logcat'] === true || args['clear-logcat'] === 'true',
|
|
1582
|
+
launch: args.launch === true || args.launch === 'true',
|
|
1583
|
+
launchWaitMs: parsePositiveInteger(args['launch-wait-ms'], 0),
|
|
1584
|
+
logcatLines: parsePositiveInteger(args['logcat-lines'], 1000),
|
|
1585
|
+
...(typeof args.out === 'string' ? { outputDir: args.out } : {}),
|
|
1586
|
+
...(typeof args.package === 'string' ? { packageName: args.package } : {}),
|
|
1587
|
+
...(typeof args['react-native-debug-host'] === 'string'
|
|
1588
|
+
? { reactNativeDebugHost: args['react-native-debug-host'] }
|
|
1589
|
+
: {}),
|
|
1590
|
+
...(typeof args['run-id'] === 'string' ? { runId: args['run-id'] } : {}),
|
|
1591
|
+
...(typeof args.serial === 'string' ? { serial: args.serial } : {}),
|
|
1592
|
+
...(typeof args['android-dev-client-url'] === 'string'
|
|
1593
|
+
? {
|
|
1594
|
+
startupDeepLinks: [{
|
|
1595
|
+
label: 'android-dev-client-url',
|
|
1596
|
+
...(typeof args['android-dev-client-ready-pattern'] === 'string'
|
|
1597
|
+
? { readyLogPattern: args['android-dev-client-ready-pattern'] }
|
|
1598
|
+
: {}),
|
|
1599
|
+
readyLogQuietMs: parsePositiveInteger(args['android-dev-client-ready-quiet-ms'], 0),
|
|
1600
|
+
readyLogTimeoutMs: parsePositiveInteger(args['android-dev-client-ready-timeout-ms'], 60000),
|
|
1601
|
+
url: args['android-dev-client-url'],
|
|
1602
|
+
waitMs: parsePositiveInteger(args['android-dev-client-wait-ms'], 1000),
|
|
1603
|
+
}],
|
|
1604
|
+
}
|
|
1605
|
+
: {}),
|
|
1606
|
+
waitMs: parsePositiveInteger(args['wait-ms'], 0),
|
|
1607
|
+
});
|
|
1608
|
+
process.stdout.write(`${result.runDir}\n`);
|
|
1609
|
+
if (result.health.healthStatus !== 'passed') {
|
|
1610
|
+
process.exitCode = 1;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
if (require.main === module) {
|
|
1614
|
+
main().catch((error) => {
|
|
1615
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1616
|
+
process.exitCode = 1;
|
|
1617
|
+
});
|
|
1618
|
+
}
|