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,1271 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.agentDeviceDriverActionCode = agentDeviceDriverActionCode;
|
|
5
|
+
exports.buildAgentDeviceHealth = buildAgentDeviceHealth;
|
|
6
|
+
exports.buildAgentDeviceVerdict = buildAgentDeviceVerdict;
|
|
7
|
+
exports.buildAgentDeviceSelectorHealthMetadata = buildAgentDeviceSelectorHealthMetadata;
|
|
8
|
+
exports.checkAgentDeviceAvailability = checkAgentDeviceAvailability;
|
|
9
|
+
exports.defaultAgentDeviceCaptureFileName = defaultAgentDeviceCaptureFileName;
|
|
10
|
+
exports.defaultAgentDeviceRawFileName = defaultAgentDeviceRawFileName;
|
|
11
|
+
exports.execFileCommand = execFileCommand;
|
|
12
|
+
exports.execFileCommandWithTimeout = execFileCommandWithTimeout;
|
|
13
|
+
exports.isAgentDeviceSelector = isAgentDeviceSelector;
|
|
14
|
+
exports.main = main;
|
|
15
|
+
exports.parseArgs = parseArgs;
|
|
16
|
+
exports.parseAgentDeviceSessionMode = parseAgentDeviceSessionMode;
|
|
17
|
+
exports.parseRequiredPlatforms = parseRequiredPlatforms;
|
|
18
|
+
exports.readAgentDeviceSessions = readAgentDeviceSessions;
|
|
19
|
+
exports.readAgentDeviceStepOptions = readAgentDeviceStepOptions;
|
|
20
|
+
exports.resolveAgentDeviceDriverSteps = resolveAgentDeviceDriverSteps;
|
|
21
|
+
exports.runAgentDeviceCapture = runAgentDeviceCapture;
|
|
22
|
+
exports.runAgentDeviceDriverStep = runAgentDeviceDriverStep;
|
|
23
|
+
exports.usage = usage;
|
|
24
|
+
exports.validateAgentDeviceDriverSteps = validateAgentDeviceDriverSteps;
|
|
25
|
+
exports.writeAgentDeviceAvailabilityArtifacts = writeAgentDeviceAvailabilityArtifacts;
|
|
26
|
+
const { execFile } = require('node:child_process');
|
|
27
|
+
const crypto = require('node:crypto');
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const fsp = require('node:fs/promises');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const { buildAgentSummaryMarkdown } = require('../core/agent-summary');
|
|
32
|
+
const { createArtifactLayout } = require('../core/artifact-layout');
|
|
33
|
+
const { writeJsonArtifact, writeTextArtifact } = require('../core/artifact-writer');
|
|
34
|
+
const { buildScenarioExecutionPlan } = require('../core/execution-plan');
|
|
35
|
+
const { SCHEMAS, assertValidJson } = require('../core/schema-validator');
|
|
36
|
+
const { hasHelpFlag, writeUsage } = require('./cli');
|
|
37
|
+
const { createAgentDeviceDriver, formatAgentDeviceRawOutput, } = require('./agent-device-driver');
|
|
38
|
+
const { loadAslLocalEnv, readStringArgOrEnv } = require('./local-env');
|
|
39
|
+
const DEFAULT_AGENT_DEVICE_REQUIRED_COMMANDS = [
|
|
40
|
+
'open',
|
|
41
|
+
'snapshot',
|
|
42
|
+
'screenshot',
|
|
43
|
+
'is',
|
|
44
|
+
'click',
|
|
45
|
+
'scroll',
|
|
46
|
+
'logs',
|
|
47
|
+
'devices',
|
|
48
|
+
'session list',
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* Prints CLI usage.
|
|
52
|
+
*
|
|
53
|
+
* @returns {void}
|
|
54
|
+
*/
|
|
55
|
+
function usage(output = process.stderr) {
|
|
56
|
+
writeUsage([
|
|
57
|
+
'Usage: asl-agent-device --platform <ios|android> --scenario <path> [--out <dir>] [--run-id <id>]',
|
|
58
|
+
'',
|
|
59
|
+
'Executes scenario-declared portable driver actions through the external agent-device CLI.',
|
|
60
|
+
'Writes health.json, verdict.json, agent-summary.md, raw command transcripts, and capture artifacts.',
|
|
61
|
+
'Use --check --out <dir> to verify the configured agent-device command surface and preserve availability artifacts.',
|
|
62
|
+
'Use --open --app <bundle-or-package> to open the app before running driver actions.',
|
|
63
|
+
'Use --udid <id> for iOS simulators or --serial <id> for Android devices.',
|
|
64
|
+
'Use --session <name> [--session-mode reuse|bind] to reuse an existing session or bind a named session to direct target flags.',
|
|
65
|
+
'Use --command-timeout-ms <ms> to bound each external agent-device invocation.',
|
|
66
|
+
'Use --require-platforms ios,android with --check when device discovery must prove booted OS targets.',
|
|
67
|
+
], output);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Parses `--key value` CLI arguments.
|
|
71
|
+
*
|
|
72
|
+
* @param {string[]} argv
|
|
73
|
+
* @returns {CliArgs}
|
|
74
|
+
*/
|
|
75
|
+
function parseArgs(argv) {
|
|
76
|
+
const args = {};
|
|
77
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
78
|
+
const token = argv[index];
|
|
79
|
+
if (token === '--') {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!token?.startsWith('--')) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const key = token.slice(2);
|
|
86
|
+
const value = argv[index + 1];
|
|
87
|
+
if (value && !value.startsWith('--')) {
|
|
88
|
+
args[key] = value;
|
|
89
|
+
index += 1;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
args[key] = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return args;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Reads and parses a JSON object from disk.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} filePath
|
|
101
|
+
* @returns {Record<string, unknown>}
|
|
102
|
+
*/
|
|
103
|
+
function readJson(filePath) {
|
|
104
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Checks whether a boolean-style flag is enabled.
|
|
108
|
+
*
|
|
109
|
+
* @param {string | boolean | undefined} value
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
function isEnabled(value) {
|
|
113
|
+
return value === true || value === 'true';
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Reads a positive integer from CLI or scenario metadata.
|
|
117
|
+
*
|
|
118
|
+
* @param {unknown} value
|
|
119
|
+
* @param {number} fallback
|
|
120
|
+
* @returns {number}
|
|
121
|
+
*/
|
|
122
|
+
function readPositiveInteger(value, fallback) {
|
|
123
|
+
const parsed = typeof value === 'string' ? Number(value) : value;
|
|
124
|
+
return typeof parsed === 'number' && Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Reads a finite number from adapter metadata.
|
|
128
|
+
*
|
|
129
|
+
* @param {unknown} value
|
|
130
|
+
* @returns {number | undefined}
|
|
131
|
+
*/
|
|
132
|
+
function readFiniteNumber(value) {
|
|
133
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Creates a short random run id.
|
|
137
|
+
*
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
function createRunId() {
|
|
141
|
+
return crypto.randomBytes(6).toString('hex');
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Runs a command and captures stdout, stderr, and exit code without throwing.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} command
|
|
147
|
+
* @param {string[]} args
|
|
148
|
+
* @returns {Promise<CommandResult>}
|
|
149
|
+
*/
|
|
150
|
+
function execFileCommand(command, args) {
|
|
151
|
+
return execFileCommandWithTimeout(command, args);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Runs a command with a bounded timeout and captures stdout, stderr, and exit code without throwing.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} command
|
|
157
|
+
* @param {string[]} args
|
|
158
|
+
* @param {number} [timeoutMs]
|
|
159
|
+
* @returns {Promise<CommandResult>}
|
|
160
|
+
*/
|
|
161
|
+
function execFileCommandWithTimeout(command, args, timeoutMs = 60_000) {
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
execFile(command, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
164
|
+
resolve({
|
|
165
|
+
command,
|
|
166
|
+
args,
|
|
167
|
+
exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
|
|
168
|
+
stderr: [
|
|
169
|
+
stderr,
|
|
170
|
+
error?.killed || error?.signal === 'SIGTERM' ? `agent-device command timed out after ${timeoutMs}ms.` : '',
|
|
171
|
+
].filter(Boolean).join('\n'),
|
|
172
|
+
stdout,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Returns a compact single-line preview for command diagnostics.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} value
|
|
181
|
+
* @returns {string | undefined}
|
|
182
|
+
*/
|
|
183
|
+
function previewCommandOutput(value) {
|
|
184
|
+
const preview = value.replace(/\s+/gu, ' ').trim();
|
|
185
|
+
return preview.length > 240 ? `${preview.slice(0, 237)}...` : preview || undefined;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Classifies an agent-device availability failure into the next operational step.
|
|
189
|
+
*
|
|
190
|
+
* @param {CommandResult} result
|
|
191
|
+
* @returns {Record<string, string>}
|
|
192
|
+
*/
|
|
193
|
+
function classifyAgentDeviceAvailabilityFailure(result) {
|
|
194
|
+
const diagnostic = `${result.stderr}\n${result.stdout}`;
|
|
195
|
+
if (/operation not permitted|permission denied|sandbox|eacces|eperm|daemon|\.agent-device|cannot bind|smartsocket/iu.test(diagnostic)) {
|
|
196
|
+
return {
|
|
197
|
+
failureClass: 'host_access',
|
|
198
|
+
nextAction: 'Rerun agent-device availability with host/device access before treating this as an app, scenario, or runner regression.',
|
|
199
|
+
nextActionCode: 'rerun_with_host_access',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (/timed out|timeout/iu.test(diagnostic)) {
|
|
203
|
+
return {
|
|
204
|
+
failureClass: 'timeout',
|
|
205
|
+
nextAction: 'Confirm agent-device can run without prompts, increase --command-timeout-ms if it is legitimately slow, then rerun the availability check.',
|
|
206
|
+
nextActionCode: 'increase_agent_device_timeout',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (/enoent|not found|command not found|no such file or directory/iu.test(diagnostic)) {
|
|
210
|
+
return {
|
|
211
|
+
failureClass: 'missing_binary',
|
|
212
|
+
nextAction: 'Install agent-device or pass the correct binary with --agent-device before starting live proof.',
|
|
213
|
+
nextActionCode: 'configure_agent_device_binary',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
failureClass: 'command_surface',
|
|
218
|
+
nextAction: 'Inspect the failed agent-device command output, fix the command surface, then rerun the availability check before starting live proof.',
|
|
219
|
+
nextActionCode: 'inspect_agent_device_availability',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Parses a comma-separated platform requirement list for availability checks.
|
|
224
|
+
*
|
|
225
|
+
* @param {unknown} value
|
|
226
|
+
* @returns {import('./agent-device-driver').AgentDevicePlatform[]}
|
|
227
|
+
*/
|
|
228
|
+
function parseRequiredPlatforms(value) {
|
|
229
|
+
if (typeof value !== 'string') {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
return value
|
|
233
|
+
.split(',')
|
|
234
|
+
.map((platform) => platform.trim())
|
|
235
|
+
.filter((platform) => ['android', 'apple', 'ios', 'linux', 'macos'].includes(platform));
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Parses how a named agent-device session should participate in target selection.
|
|
239
|
+
*
|
|
240
|
+
* @param {unknown} value
|
|
241
|
+
* @returns {AgentDeviceSessionMode}
|
|
242
|
+
*/
|
|
243
|
+
function parseAgentDeviceSessionMode(value) {
|
|
244
|
+
if (value === undefined || value === false) {
|
|
245
|
+
return 'reuse';
|
|
246
|
+
}
|
|
247
|
+
if (value === 'bind' || value === 'reuse') {
|
|
248
|
+
return value;
|
|
249
|
+
}
|
|
250
|
+
throw new Error('--session-mode must be either reuse or bind.');
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Builds one availability check result from an agent-device command execution.
|
|
254
|
+
*
|
|
255
|
+
* @param {{code: string, expectedPattern: RegExp, name: string, result: CommandResult}} options
|
|
256
|
+
* @returns {AgentDeviceAvailabilityCheck}
|
|
257
|
+
*/
|
|
258
|
+
function buildAgentDeviceAvailabilityCheck({ code, expectedPattern, name, result, }) {
|
|
259
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
260
|
+
const passed = result.exitCode === 0 && expectedPattern.test(output);
|
|
261
|
+
const check = {
|
|
262
|
+
args: result.args,
|
|
263
|
+
code,
|
|
264
|
+
command: result.command,
|
|
265
|
+
exitCode: result.exitCode,
|
|
266
|
+
message: passed ? `${name} is available.` : `${name} did not return the expected agent-device output.`,
|
|
267
|
+
name,
|
|
268
|
+
status: passed ? 'passed' : 'failed',
|
|
269
|
+
};
|
|
270
|
+
if (!passed) {
|
|
271
|
+
const stderrPreview = previewCommandOutput(result.stderr);
|
|
272
|
+
const stdoutPreview = previewCommandOutput(result.stdout);
|
|
273
|
+
check.metadata = classifyAgentDeviceAvailabilityFailure(result);
|
|
274
|
+
if (stderrPreview) {
|
|
275
|
+
check.stderrPreview = stderrPreview;
|
|
276
|
+
}
|
|
277
|
+
if (stdoutPreview) {
|
|
278
|
+
check.stdoutPreview = stdoutPreview;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return check;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Parses agent-device device discovery JSON.
|
|
285
|
+
*
|
|
286
|
+
* @param {CommandResult} result
|
|
287
|
+
* @returns {Array<Record<string, unknown>>}
|
|
288
|
+
*/
|
|
289
|
+
function readAgentDeviceDiscoveryDevices(result) {
|
|
290
|
+
if (result.exitCode !== 0 || result.stdout.trim().length === 0) {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const parsed = JSON.parse(result.stdout);
|
|
295
|
+
const data = parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)
|
|
296
|
+
? parsed.data
|
|
297
|
+
: null;
|
|
298
|
+
return Array.isArray(data?.devices)
|
|
299
|
+
? data.devices.filter((device) => Boolean(device) && typeof device === 'object' && !Array.isArray(device))
|
|
300
|
+
: [];
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Parses agent-device active session JSON.
|
|
308
|
+
*
|
|
309
|
+
* @param {CommandResult} result
|
|
310
|
+
* @returns {Array<Record<string, unknown>>}
|
|
311
|
+
*/
|
|
312
|
+
function readAgentDeviceSessions(result) {
|
|
313
|
+
if (result.exitCode !== 0 || result.stdout.trim().length === 0) {
|
|
314
|
+
return [];
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const parsed = JSON.parse(result.stdout);
|
|
318
|
+
const data = parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)
|
|
319
|
+
? parsed.data
|
|
320
|
+
: parsed;
|
|
321
|
+
const sessions = Array.isArray(data.sessions)
|
|
322
|
+
? data.sessions
|
|
323
|
+
: Array.isArray(parsed.sessions)
|
|
324
|
+
? parsed.sessions
|
|
325
|
+
: [];
|
|
326
|
+
return sessions.filter((session) => Boolean(session) && typeof session === 'object' && !Array.isArray(session));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Formats active agent-device sessions for compact health metadata.
|
|
334
|
+
*
|
|
335
|
+
* @param {Array<Record<string, unknown>>} sessions
|
|
336
|
+
* @returns {string}
|
|
337
|
+
*/
|
|
338
|
+
function summarizeAgentDeviceSessions(sessions) {
|
|
339
|
+
return sessions
|
|
340
|
+
.slice(0, 8)
|
|
341
|
+
.map((session, index) => {
|
|
342
|
+
const name = typeof session.name === 'string'
|
|
343
|
+
? session.name
|
|
344
|
+
: typeof session.id === 'string'
|
|
345
|
+
? session.id
|
|
346
|
+
: `session-${index + 1}`;
|
|
347
|
+
const platform = typeof session.platform === 'string' ? session.platform : null;
|
|
348
|
+
const target = typeof session.target === 'string' ? session.target : null;
|
|
349
|
+
const device = typeof session.device === 'string'
|
|
350
|
+
? session.device
|
|
351
|
+
: typeof session.deviceId === 'string'
|
|
352
|
+
? session.deviceId
|
|
353
|
+
: typeof session.udid === 'string'
|
|
354
|
+
? session.udid
|
|
355
|
+
: typeof session.serial === 'string'
|
|
356
|
+
? session.serial
|
|
357
|
+
: null;
|
|
358
|
+
return [name, platform, target, device].filter(Boolean).join(':');
|
|
359
|
+
})
|
|
360
|
+
.join(', ');
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Returns the stable session name that agent-device accepts.
|
|
364
|
+
*
|
|
365
|
+
* @param {Record<string, unknown>} session
|
|
366
|
+
* @returns {string | null}
|
|
367
|
+
*/
|
|
368
|
+
function readAgentDeviceSessionName(session) {
|
|
369
|
+
return typeof session.name === 'string' && session.name.length > 0
|
|
370
|
+
? session.name
|
|
371
|
+
: typeof session.id === 'string' && session.id.length > 0
|
|
372
|
+
? session.id
|
|
373
|
+
: null;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Returns known device identifiers attached to one agent-device session.
|
|
377
|
+
*
|
|
378
|
+
* @param {Record<string, unknown>} session
|
|
379
|
+
* @returns {string[]}
|
|
380
|
+
*/
|
|
381
|
+
function readAgentDeviceSessionTargets(session) {
|
|
382
|
+
return [
|
|
383
|
+
session.id,
|
|
384
|
+
session.deviceId,
|
|
385
|
+
session.device_id,
|
|
386
|
+
session.device_udid,
|
|
387
|
+
session.serial,
|
|
388
|
+
session.udid,
|
|
389
|
+
].filter((value) => typeof value === 'string' && value.length > 0);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Selects a single active session for the requested platform and device target.
|
|
393
|
+
*
|
|
394
|
+
* @param {{platform: import('./agent-device-driver').AgentDevicePlatform, requestedTarget: string | null, sessions: Array<Record<string, unknown>>, target: string}} options
|
|
395
|
+
* @returns {string | null}
|
|
396
|
+
*/
|
|
397
|
+
function selectAgentDeviceSession({ platform, requestedTarget, sessions, target, }) {
|
|
398
|
+
const platformCandidates = sessions.filter((session) => session.platform === platform &&
|
|
399
|
+
(typeof session.target !== 'string' || session.target === target));
|
|
400
|
+
const targetCandidates = requestedTarget
|
|
401
|
+
? platformCandidates.filter((session) => readAgentDeviceSessionTargets(session).includes(requestedTarget))
|
|
402
|
+
: platformCandidates;
|
|
403
|
+
if (targetCandidates.length !== 1) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
const [selectedSession] = targetCandidates;
|
|
407
|
+
return selectedSession ? readAgentDeviceSessionName(selectedSession) : null;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Builds an agent-readable availability addendum with device and session counts.
|
|
411
|
+
*
|
|
412
|
+
* @param {AgentDeviceAvailabilityResult} result
|
|
413
|
+
* @returns {string}
|
|
414
|
+
*/
|
|
415
|
+
function buildAgentDeviceAvailabilitySummary(result) {
|
|
416
|
+
const sessionSummary = summarizeAgentDeviceSessions(result.sessions);
|
|
417
|
+
return [
|
|
418
|
+
'',
|
|
419
|
+
'## agent-device availability',
|
|
420
|
+
'',
|
|
421
|
+
`- Devices: ${result.devices.length}`,
|
|
422
|
+
`- Active sessions: ${result.sessions.length}`,
|
|
423
|
+
...(sessionSummary ? [`- Session hints: ${sessionSummary}`] : []),
|
|
424
|
+
'',
|
|
425
|
+
].join('\n');
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Checks whether device discovery found a booted mobile target for one platform.
|
|
429
|
+
*
|
|
430
|
+
* @param {Array<Record<string, unknown>>} devices
|
|
431
|
+
* @param {import('./agent-device-driver').AgentDevicePlatform} platform
|
|
432
|
+
* @returns {boolean}
|
|
433
|
+
*/
|
|
434
|
+
function hasBootedMobilePlatform(devices, platform) {
|
|
435
|
+
return devices.some((device) => device.platform === platform &&
|
|
436
|
+
device.target === 'mobile' &&
|
|
437
|
+
device.booted === true);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Verifies that the configured agent-device command exposes ASL-required surfaces.
|
|
441
|
+
*
|
|
442
|
+
* @param {AgentDeviceAvailabilityOptions} options
|
|
443
|
+
* @returns {Promise<AgentDeviceAvailabilityResult>}
|
|
444
|
+
*/
|
|
445
|
+
async function checkAgentDeviceAvailability({ agentDevicePath = 'agent-device', commandTimeoutMs = 30_000, executor, requiredCommands = DEFAULT_AGENT_DEVICE_REQUIRED_COMMANDS, requiredPlatforms = [], } = {}) {
|
|
446
|
+
const run = executor ?? ((command, args) => execFileCommandWithTimeout(command, args, commandTimeoutMs));
|
|
447
|
+
const checks = [];
|
|
448
|
+
const help = await run(agentDevicePath, ['--help']);
|
|
449
|
+
checks.push(buildAgentDeviceAvailabilityCheck({
|
|
450
|
+
code: 'agent_device_help_available',
|
|
451
|
+
expectedPattern: /CLI to control iOS and Android devices/u,
|
|
452
|
+
name: 'agent_device_help',
|
|
453
|
+
result: help,
|
|
454
|
+
}));
|
|
455
|
+
for (const commandName of requiredCommands) {
|
|
456
|
+
const commandLabel = commandName.replace(/\s+/gu, '_');
|
|
457
|
+
const pattern = commandName === 'session list'
|
|
458
|
+
? /\bsession\s+list\b/u
|
|
459
|
+
: new RegExp(`\\b${commandName.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}\\b`, 'u');
|
|
460
|
+
checks.push(buildAgentDeviceAvailabilityCheck({
|
|
461
|
+
code: `agent_device_command_${commandLabel}_available`,
|
|
462
|
+
expectedPattern: pattern,
|
|
463
|
+
name: `agent_device_command_${commandLabel}`,
|
|
464
|
+
result: help,
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
467
|
+
const devicesResult = await run(agentDevicePath, ['devices', '--json']);
|
|
468
|
+
const devices = readAgentDeviceDiscoveryDevices(devicesResult);
|
|
469
|
+
const discoveryPassed = devicesResult.exitCode === 0 && devices.length > 0;
|
|
470
|
+
const devicesCheck = {
|
|
471
|
+
args: devicesResult.args,
|
|
472
|
+
code: 'agent_device_devices_available',
|
|
473
|
+
command: devicesResult.command,
|
|
474
|
+
exitCode: devicesResult.exitCode,
|
|
475
|
+
message: discoveryPassed
|
|
476
|
+
? `agent-device discovered ${devices.length} device(s).`
|
|
477
|
+
: 'agent-device did not return any discoverable devices.',
|
|
478
|
+
name: 'agent_device_devices',
|
|
479
|
+
status: discoveryPassed ? 'passed' : 'failed',
|
|
480
|
+
};
|
|
481
|
+
if (!discoveryPassed) {
|
|
482
|
+
const stderrPreview = previewCommandOutput(devicesResult.stderr);
|
|
483
|
+
const stdoutPreview = previewCommandOutput(devicesResult.stdout);
|
|
484
|
+
devicesCheck.metadata = classifyAgentDeviceAvailabilityFailure(devicesResult);
|
|
485
|
+
if (stderrPreview) {
|
|
486
|
+
devicesCheck.stderrPreview = stderrPreview;
|
|
487
|
+
}
|
|
488
|
+
if (stdoutPreview) {
|
|
489
|
+
devicesCheck.stdoutPreview = stdoutPreview;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
checks.push(devicesCheck);
|
|
493
|
+
const sessionsResult = await run(agentDevicePath, ['session', 'list', '--json']);
|
|
494
|
+
const sessions = readAgentDeviceSessions(sessionsResult);
|
|
495
|
+
const sessionsPassed = sessionsResult.exitCode === 0;
|
|
496
|
+
const sessionsCheck = {
|
|
497
|
+
args: sessionsResult.args,
|
|
498
|
+
code: 'agent_device_sessions_available',
|
|
499
|
+
command: sessionsResult.command,
|
|
500
|
+
exitCode: sessionsResult.exitCode,
|
|
501
|
+
message: sessionsPassed
|
|
502
|
+
? `agent-device reported ${sessions.length} active session(s).`
|
|
503
|
+
: 'agent-device could not list active sessions.',
|
|
504
|
+
metadata: {
|
|
505
|
+
sessionCount: sessions.length,
|
|
506
|
+
...(sessions.length > 0 ? { activeSessions: summarizeAgentDeviceSessions(sessions) } : {}),
|
|
507
|
+
},
|
|
508
|
+
name: 'agent_device_sessions',
|
|
509
|
+
status: sessionsPassed ? 'passed' : 'failed',
|
|
510
|
+
};
|
|
511
|
+
if (!sessionsPassed) {
|
|
512
|
+
const stderrPreview = previewCommandOutput(sessionsResult.stderr);
|
|
513
|
+
const stdoutPreview = previewCommandOutput(sessionsResult.stdout);
|
|
514
|
+
sessionsCheck.metadata = {
|
|
515
|
+
...sessionsCheck.metadata,
|
|
516
|
+
...classifyAgentDeviceAvailabilityFailure(sessionsResult),
|
|
517
|
+
};
|
|
518
|
+
if (stderrPreview) {
|
|
519
|
+
sessionsCheck.stderrPreview = stderrPreview;
|
|
520
|
+
}
|
|
521
|
+
if (stdoutPreview) {
|
|
522
|
+
sessionsCheck.stdoutPreview = stdoutPreview;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
checks.push(sessionsCheck);
|
|
526
|
+
for (const platform of requiredPlatforms) {
|
|
527
|
+
const passed = hasBootedMobilePlatform(devices, platform);
|
|
528
|
+
checks.push({
|
|
529
|
+
args: devicesResult.args,
|
|
530
|
+
code: `agent_device_booted_${platform}_available`,
|
|
531
|
+
command: devicesResult.command,
|
|
532
|
+
exitCode: devicesResult.exitCode,
|
|
533
|
+
message: passed
|
|
534
|
+
? `agent-device discovered a booted ${platform} mobile target.`
|
|
535
|
+
: `agent-device did not discover a booted ${platform} mobile target.`,
|
|
536
|
+
name: `agent_device_booted_${platform}`,
|
|
537
|
+
status: passed ? 'passed' : 'failed',
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
const status = checks.every((check) => check.status === 'passed') ? 'passed' : 'failed';
|
|
541
|
+
return {
|
|
542
|
+
agentDevicePath,
|
|
543
|
+
checks,
|
|
544
|
+
devices,
|
|
545
|
+
requiredCommands,
|
|
546
|
+
requiredPlatforms,
|
|
547
|
+
sessions,
|
|
548
|
+
status,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Waits for the requested capture window.
|
|
553
|
+
*
|
|
554
|
+
* @param {number} ms
|
|
555
|
+
* @returns {Promise<void>}
|
|
556
|
+
*/
|
|
557
|
+
function delay(ms) {
|
|
558
|
+
return new Promise((resolve) => {
|
|
559
|
+
setTimeout(resolve, ms);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Creates scalar health-check metadata for an agent-readable next action.
|
|
564
|
+
*
|
|
565
|
+
* @param {string} nextActionCode
|
|
566
|
+
* @param {string} nextAction
|
|
567
|
+
* @returns {NextActionHint}
|
|
568
|
+
*/
|
|
569
|
+
function nextActionHint(nextActionCode, nextAction) {
|
|
570
|
+
return {
|
|
571
|
+
nextAction,
|
|
572
|
+
nextActionCode,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Normalizes CLI diagnostic text into scalar health metadata.
|
|
577
|
+
*
|
|
578
|
+
* @param {string} value
|
|
579
|
+
* @returns {string}
|
|
580
|
+
*/
|
|
581
|
+
function normalizeAgentDeviceDiagnosticText(value) {
|
|
582
|
+
return value.replace(/\s+/gu, ' ').trim().slice(0, 500);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Reads structured agent-device JSON errors from stdout or stderr.
|
|
586
|
+
*
|
|
587
|
+
* @param {{stdout: string, stderr: string}} result
|
|
588
|
+
* @returns {AgentDeviceErrorMetadata}
|
|
589
|
+
*/
|
|
590
|
+
function readAgentDeviceErrorMetadata(result) {
|
|
591
|
+
for (const content of [result.stdout, result.stderr]) {
|
|
592
|
+
const trimmed = content.trim();
|
|
593
|
+
if (!trimmed.startsWith('{')) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
const parsed = JSON.parse(trimmed);
|
|
598
|
+
const error = parsed.error && typeof parsed.error === 'object' && !Array.isArray(parsed.error)
|
|
599
|
+
? parsed.error
|
|
600
|
+
: null;
|
|
601
|
+
if (!error) {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
...(typeof error.code === 'string'
|
|
606
|
+
? { agentDeviceErrorCode: normalizeAgentDeviceDiagnosticText(error.code) }
|
|
607
|
+
: {}),
|
|
608
|
+
...(typeof error.message === 'string'
|
|
609
|
+
? { agentDeviceErrorMessage: normalizeAgentDeviceDiagnosticText(error.message) }
|
|
610
|
+
: {}),
|
|
611
|
+
...(typeof error.hint === 'string'
|
|
612
|
+
? { agentDeviceErrorHint: normalizeAgentDeviceDiagnosticText(error.hint) }
|
|
613
|
+
: {}),
|
|
614
|
+
...(typeof error.diagnosticId === 'string'
|
|
615
|
+
? { agentDeviceDiagnosticId: normalizeAgentDeviceDiagnosticText(error.diagnosticId) }
|
|
616
|
+
: {}),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return {};
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Reads a quoted agent-device session name from a diagnostic message.
|
|
627
|
+
*
|
|
628
|
+
* @param {string | undefined} message
|
|
629
|
+
* @returns {string | null}
|
|
630
|
+
*/
|
|
631
|
+
function readDiagnosticSessionName(message) {
|
|
632
|
+
if (!message) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
return /session "([^"]+)"/u.exec(message)?.[1] ?? null;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Builds the most specific next-action hint available from an agent-device failure.
|
|
639
|
+
*
|
|
640
|
+
* @param {AgentDeviceFailureHintOptions} options
|
|
641
|
+
* @returns {NextActionHint}
|
|
642
|
+
*/
|
|
643
|
+
function buildAgentDeviceFailureHint({ defaultNextAction, defaultNextActionCode, errorMetadata, rawFileName, }) {
|
|
644
|
+
const errorCode = errorMetadata.agentDeviceErrorCode;
|
|
645
|
+
const errorMessage = errorMetadata.agentDeviceErrorMessage;
|
|
646
|
+
const sessionName = readDiagnosticSessionName(errorMessage);
|
|
647
|
+
if (errorCode === 'DEVICE_IN_USE') {
|
|
648
|
+
return nextActionHint('reuse_agent_device_session', sessionName
|
|
649
|
+
? `Device is already owned by agent-device session "${sessionName}". Reuse that session with --agent-device-session ${sessionName}, close it, or choose another device before rerunning.`
|
|
650
|
+
: 'Device is already owned by another agent-device session. Reuse the owning session with --agent-device-session, close it, or choose another device before rerunning.');
|
|
651
|
+
}
|
|
652
|
+
if (errorMessage && /bound to .* cannot be used with --platform=/u.test(errorMessage)) {
|
|
653
|
+
return nextActionHint('select_agent_device_session', 'The selected agent-device session is bound to another platform or device. Use a platform-specific --agent-device-session, close the bound session, or rerun without the conflicting session.');
|
|
654
|
+
}
|
|
655
|
+
if (errorMessage && /No active session\. Run open first/u.test(errorMessage)) {
|
|
656
|
+
return nextActionHint('open_agent_device_session', `agent-device has no active session for this action. Inspect raw/${rawFileName}, make the app open step pass, or pass an existing --agent-device-session before rerunning.`);
|
|
657
|
+
}
|
|
658
|
+
if (errorMessage && /session lock policy/u.test(errorMessage)) {
|
|
659
|
+
return nextActionHint('fix_agent_device_session_lock', 'agent-device rejected the command because session lock policy conflicts with target selectors. Reuse the locked session directly, remove conflicting target selectors, or close the session before rerunning.');
|
|
660
|
+
}
|
|
661
|
+
return nextActionHint(defaultNextActionCode, defaultNextAction);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Builds a health artifact from agent-device capture checks.
|
|
665
|
+
*
|
|
666
|
+
* @param {{runId: string, checks: Record<string, unknown>[]}} options
|
|
667
|
+
* @returns {Record<string, unknown>}
|
|
668
|
+
*/
|
|
669
|
+
function buildAgentDeviceHealth({ runId, checks }) {
|
|
670
|
+
const failed = checks.some((check) => check.status === 'failed');
|
|
671
|
+
return assertValidJson({
|
|
672
|
+
schemaVersion: '1.0.0',
|
|
673
|
+
scenarioId: 'agent-device-capture',
|
|
674
|
+
flowId: 'agent-device-capture',
|
|
675
|
+
runId,
|
|
676
|
+
healthStatus: failed ? 'failed' : 'passed',
|
|
677
|
+
checks,
|
|
678
|
+
}, SCHEMAS.health, 'Health artifact');
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Builds a verdict artifact for agent-device capture readiness.
|
|
682
|
+
*
|
|
683
|
+
* @param {{runId: string, health: Record<string, unknown>}} options
|
|
684
|
+
* @returns {Record<string, unknown>}
|
|
685
|
+
*/
|
|
686
|
+
function buildAgentDeviceVerdict({ runId, health }) {
|
|
687
|
+
const passed = health.healthStatus === 'passed';
|
|
688
|
+
return assertValidJson({
|
|
689
|
+
schemaVersion: '1.0.0',
|
|
690
|
+
scenarioId: 'agent-device-capture',
|
|
691
|
+
flowId: 'agent-device-capture',
|
|
692
|
+
runId,
|
|
693
|
+
healthStatus: health.healthStatus,
|
|
694
|
+
verdictStatus: passed ? 'not_evaluated' : 'inconclusive',
|
|
695
|
+
budgetChecks: [],
|
|
696
|
+
summary: passed
|
|
697
|
+
? 'agent-device capture passed; no product budget has been evaluated.'
|
|
698
|
+
: 'agent-device capture failed; runtime scenario execution is not ready.',
|
|
699
|
+
}, SCHEMAS.verdict, 'Verdict artifact');
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Converts one agent-device availability check into a schema-safe health check.
|
|
703
|
+
*
|
|
704
|
+
* @param {AgentDeviceAvailabilityCheck} check
|
|
705
|
+
* @returns {Record<string, unknown>}
|
|
706
|
+
*/
|
|
707
|
+
function agentDeviceAvailabilityHealthCheck(check) {
|
|
708
|
+
return {
|
|
709
|
+
name: check.name,
|
|
710
|
+
status: check.status,
|
|
711
|
+
source: 'runner',
|
|
712
|
+
code: check.code,
|
|
713
|
+
message: check.message,
|
|
714
|
+
metadata: {
|
|
715
|
+
command: check.command,
|
|
716
|
+
args: check.args.join(' '),
|
|
717
|
+
exitCode: check.exitCode,
|
|
718
|
+
...(check.stderrPreview ? { stderrPreview: check.stderrPreview } : {}),
|
|
719
|
+
...(check.stdoutPreview ? { stdoutPreview: check.stdoutPreview } : {}),
|
|
720
|
+
...(check.metadata ?? {}),
|
|
721
|
+
},
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Writes ASL artifacts for an agent-device command-surface availability check.
|
|
726
|
+
*
|
|
727
|
+
* @param {AgentDeviceAvailabilityArtifactOptions} options
|
|
728
|
+
* @returns {Promise<{agentSummary: string, health: Record<string, unknown>, runDir: string, verdict: Record<string, unknown>}>}
|
|
729
|
+
*/
|
|
730
|
+
async function writeAgentDeviceAvailabilityArtifacts({ outputDir, result, runId = createRunId(), }) {
|
|
731
|
+
const runDir = path.resolve(outputDir);
|
|
732
|
+
const layout = createArtifactLayout({ outputDir: runDir });
|
|
733
|
+
const checks = result.checks.map(agentDeviceAvailabilityHealthCheck);
|
|
734
|
+
const health = assertValidJson({
|
|
735
|
+
schemaVersion: '1.0.0',
|
|
736
|
+
scenarioId: 'agent-device-availability',
|
|
737
|
+
flowId: 'agent-device-availability',
|
|
738
|
+
runId,
|
|
739
|
+
healthStatus: result.status,
|
|
740
|
+
checks,
|
|
741
|
+
}, SCHEMAS.health, 'Health artifact');
|
|
742
|
+
const verdict = assertValidJson({
|
|
743
|
+
schemaVersion: '1.0.0',
|
|
744
|
+
scenarioId: 'agent-device-availability',
|
|
745
|
+
flowId: 'agent-device-availability',
|
|
746
|
+
runId,
|
|
747
|
+
healthStatus: health.healthStatus,
|
|
748
|
+
verdictStatus: result.status === 'passed' ? 'not_evaluated' : 'inconclusive',
|
|
749
|
+
budgetChecks: [],
|
|
750
|
+
summary: result.status === 'passed'
|
|
751
|
+
? 'agent-device command surface is available; no product budget has been evaluated.'
|
|
752
|
+
: 'agent-device command surface is unavailable; fix runner environment health before live proof.',
|
|
753
|
+
}, SCHEMAS.verdict, 'Verdict artifact');
|
|
754
|
+
const agentSummary = [
|
|
755
|
+
buildAgentSummaryMarkdown({ health, verdict }).trimEnd(),
|
|
756
|
+
buildAgentDeviceAvailabilitySummary(result),
|
|
757
|
+
].join('\n');
|
|
758
|
+
await fsp.mkdir(layout.raw, { recursive: true });
|
|
759
|
+
await writeJsonArtifact({
|
|
760
|
+
filePath: layout.health,
|
|
761
|
+
value: health,
|
|
762
|
+
schema: SCHEMAS.health,
|
|
763
|
+
label: 'Health artifact',
|
|
764
|
+
});
|
|
765
|
+
await writeJsonArtifact({
|
|
766
|
+
filePath: layout.verdict,
|
|
767
|
+
value: verdict,
|
|
768
|
+
schema: SCHEMAS.verdict,
|
|
769
|
+
label: 'Verdict artifact',
|
|
770
|
+
});
|
|
771
|
+
await writeTextArtifact({
|
|
772
|
+
filePath: layout.agentSummary,
|
|
773
|
+
content: agentSummary,
|
|
774
|
+
});
|
|
775
|
+
await fsp.writeFile(path.join(layout.raw, 'agent-device-availability.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
|
776
|
+
return {
|
|
777
|
+
agentSummary,
|
|
778
|
+
health,
|
|
779
|
+
runDir,
|
|
780
|
+
verdict,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Reads agent-device adapter metadata from a normalized scenario step.
|
|
785
|
+
*
|
|
786
|
+
* @param {ScenarioExecutionStep} step
|
|
787
|
+
* @returns {Record<string, unknown>}
|
|
788
|
+
*/
|
|
789
|
+
function readAgentDeviceStepOptions(step) {
|
|
790
|
+
const agentDeviceOptions = step.adapterOptions?.agentDevice;
|
|
791
|
+
return agentDeviceOptions && typeof agentDeviceOptions === 'object' && !Array.isArray(agentDeviceOptions)
|
|
792
|
+
? agentDeviceOptions
|
|
793
|
+
: {};
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Returns true when a normalized step has a portable selector.
|
|
797
|
+
*
|
|
798
|
+
* @param {unknown} value
|
|
799
|
+
* @returns {value is import('./agent-device-driver').AgentDeviceSelector}
|
|
800
|
+
*/
|
|
801
|
+
function isAgentDeviceSelector(value) {
|
|
802
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
const selector = value;
|
|
806
|
+
return typeof selector.kind === 'string' && typeof selector.value === 'string' && selector.value.length > 0;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Returns the default raw file name for one agent-device action.
|
|
810
|
+
*
|
|
811
|
+
* @param {{driverAction: AgentDeviceDriverStep['driverAction'], index: number}} options
|
|
812
|
+
* @returns {string}
|
|
813
|
+
*/
|
|
814
|
+
function defaultAgentDeviceRawFileName({ driverAction, index, }) {
|
|
815
|
+
return `agent-device-${driverAction}-${index}.txt`;
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Returns the default capture file name for one agent-device action.
|
|
819
|
+
*
|
|
820
|
+
* @param {{driverAction: AgentDeviceDriverStep['driverAction'], index: number}} options
|
|
821
|
+
* @returns {string}
|
|
822
|
+
*/
|
|
823
|
+
function defaultAgentDeviceCaptureFileName({ driverAction, index, }) {
|
|
824
|
+
return `agent-device-${driverAction}-${index}.png`;
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Expands normalized scenario steps into agent-device driver actions.
|
|
828
|
+
*
|
|
829
|
+
* @param {Record<string, unknown>} scenario
|
|
830
|
+
* @returns {AgentDeviceDriverStep[]}
|
|
831
|
+
*/
|
|
832
|
+
function resolveAgentDeviceDriverSteps(scenario) {
|
|
833
|
+
const executionPlan = buildScenarioExecutionPlan(scenario);
|
|
834
|
+
return executionPlan.steps
|
|
835
|
+
.filter((step) => ['assertVisible', 'inspectTree', 'readLogs', 'screenshot', 'scroll', 'tap'].includes(String(step.driverAction)))
|
|
836
|
+
.map((step, index) => {
|
|
837
|
+
const agentDeviceOptions = readAgentDeviceStepOptions(step);
|
|
838
|
+
const action = step.driverAction;
|
|
839
|
+
const actionIndex = index + 1;
|
|
840
|
+
return {
|
|
841
|
+
driverAction: action,
|
|
842
|
+
...(typeof agentDeviceOptions.amount === 'string' ? { amount: agentDeviceOptions.amount } : {}),
|
|
843
|
+
...(typeof agentDeviceOptions.captureFileName === 'string' && agentDeviceOptions.captureFileName.length > 0
|
|
844
|
+
? { captureFileName: agentDeviceOptions.captureFileName }
|
|
845
|
+
: action === 'screenshot'
|
|
846
|
+
? { captureFileName: defaultAgentDeviceCaptureFileName({ driverAction: action, index: actionIndex }) }
|
|
847
|
+
: {}),
|
|
848
|
+
...(typeof agentDeviceOptions.direction === 'string' ? { direction: agentDeviceOptions.direction } : {}),
|
|
849
|
+
...(typeof readFiniteNumber(agentDeviceOptions.durationMs) === 'number'
|
|
850
|
+
? { durationMs: readFiniteNumber(agentDeviceOptions.durationMs) }
|
|
851
|
+
: {}),
|
|
852
|
+
...(typeof readFiniteNumber(agentDeviceOptions.endX) === 'number' ? { endX: readFiniteNumber(agentDeviceOptions.endX) } : {}),
|
|
853
|
+
...(typeof readFiniteNumber(agentDeviceOptions.endY) === 'number' ? { endY: readFiniteNumber(agentDeviceOptions.endY) } : {}),
|
|
854
|
+
...(typeof readFiniteNumber(agentDeviceOptions.pixels) === 'number' ? { pixels: readFiniteNumber(agentDeviceOptions.pixels) } : {}),
|
|
855
|
+
rawFileName: typeof agentDeviceOptions.rawFileName === 'string' && agentDeviceOptions.rawFileName.length > 0
|
|
856
|
+
? agentDeviceOptions.rawFileName
|
|
857
|
+
: defaultAgentDeviceRawFileName({ driverAction: action, index: actionIndex }),
|
|
858
|
+
...(typeof agentDeviceOptions.ref === 'string' ? { ref: agentDeviceOptions.ref } : {}),
|
|
859
|
+
required: step.required !== false,
|
|
860
|
+
...(isAgentDeviceSelector(step.selector) ? { selector: step.selector } : {}),
|
|
861
|
+
stepId: step.id,
|
|
862
|
+
...(typeof readFiniteNumber(agentDeviceOptions.startX) === 'number' ? { startX: readFiniteNumber(agentDeviceOptions.startX) } : {}),
|
|
863
|
+
...(typeof readFiniteNumber(agentDeviceOptions.startY) === 'number' ? { startY: readFiniteNumber(agentDeviceOptions.startY) } : {}),
|
|
864
|
+
waitMs: readPositiveInteger(agentDeviceOptions.waitMs ?? step.timeoutMs, 0),
|
|
865
|
+
...(typeof readFiniteNumber(agentDeviceOptions.x) === 'number' ? { x: readFiniteNumber(agentDeviceOptions.x) } : {}),
|
|
866
|
+
...(typeof readFiniteNumber(agentDeviceOptions.y) === 'number' ? { y: readFiniteNumber(agentDeviceOptions.y) } : {}),
|
|
867
|
+
};
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Returns profile-time validation errors for agent-device driver steps.
|
|
872
|
+
*
|
|
873
|
+
* @param {AgentDeviceDriverStep[]} driverSteps
|
|
874
|
+
* @returns {string[]}
|
|
875
|
+
*/
|
|
876
|
+
function validateAgentDeviceDriverSteps(driverSteps) {
|
|
877
|
+
const errors = [];
|
|
878
|
+
for (const step of driverSteps) {
|
|
879
|
+
const stepLabel = step.stepId ? `step \`${step.stepId}\`` : 'unnamed step';
|
|
880
|
+
if (step.driverAction === 'tap' && !step.selector && !step.ref && (typeof step.x !== 'number' || typeof step.y !== 'number')) {
|
|
881
|
+
errors.push(`${stepLabel} uses driverAction \`tap\` but is missing a selector, adapterOptions.agentDevice.ref, or adapterOptions.agentDevice.x/y.`);
|
|
882
|
+
}
|
|
883
|
+
if (step.driverAction === 'assertVisible' && !step.selector) {
|
|
884
|
+
errors.push(`${stepLabel} uses driverAction \`assertVisible\` but is missing a portable selector.`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return errors;
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Builds scalar health metadata for one portable selector.
|
|
891
|
+
*
|
|
892
|
+
* @param {import('./agent-device-driver').AgentDeviceSelector | undefined} selector
|
|
893
|
+
* @returns {Record<string, string>}
|
|
894
|
+
*/
|
|
895
|
+
function buildAgentDeviceSelectorHealthMetadata(selector) {
|
|
896
|
+
if (!selector) {
|
|
897
|
+
return {};
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
selectorKind: selector.kind,
|
|
901
|
+
selectorValue: selector.value,
|
|
902
|
+
...(selector.match ? { selectorMatch: selector.match } : {}),
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Runs one agent-device driver action.
|
|
907
|
+
*
|
|
908
|
+
* @param {{capturesDir: string, driver: import('./agent-device-driver').AgentDeviceDriver, driverStep: AgentDeviceDriverStep}} options
|
|
909
|
+
* @returns {Promise<import('./agent-device-driver').AgentDeviceCommandResult>}
|
|
910
|
+
*/
|
|
911
|
+
async function runAgentDeviceDriverStep({ capturesDir, driver, driverStep, }) {
|
|
912
|
+
if (driverStep.driverAction === 'assertVisible' && driverStep.selector) {
|
|
913
|
+
return driver.assertVisible({
|
|
914
|
+
selector: driverStep.selector,
|
|
915
|
+
...(driverStep.rawFileName ? { rawFileName: driverStep.rawFileName } : {}),
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
if (driverStep.driverAction === 'inspectTree') {
|
|
919
|
+
return driver.inspectTree({
|
|
920
|
+
...(driverStep.rawFileName ? { rawFileName: driverStep.rawFileName } : {}),
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
if (driverStep.driverAction === 'readLogs') {
|
|
924
|
+
return driver.readLogs({
|
|
925
|
+
...(driverStep.rawFileName ? { rawFileName: driverStep.rawFileName } : {}),
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
if (driverStep.driverAction === 'screenshot') {
|
|
929
|
+
return driver.screenshot({
|
|
930
|
+
outputPath: path.join(capturesDir, driverStep.captureFileName ?? 'agent-device-screenshot.png'),
|
|
931
|
+
...(driverStep.rawFileName ? { rawFileName: driverStep.rawFileName } : {}),
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
if (driverStep.driverAction === 'scroll') {
|
|
935
|
+
return driver.scroll({
|
|
936
|
+
...(driverStep.amount ? { amount: driverStep.amount } : {}),
|
|
937
|
+
...(driverStep.direction ? { direction: driverStep.direction } : {}),
|
|
938
|
+
...(typeof driverStep.durationMs === 'number' ? { durationMs: driverStep.durationMs } : {}),
|
|
939
|
+
...(typeof driverStep.endX === 'number' ? { endX: driverStep.endX } : {}),
|
|
940
|
+
...(typeof driverStep.endY === 'number' ? { endY: driverStep.endY } : {}),
|
|
941
|
+
...(typeof driverStep.pixels === 'number' ? { pixels: driverStep.pixels } : {}),
|
|
942
|
+
...(driverStep.rawFileName ? { rawFileName: driverStep.rawFileName } : {}),
|
|
943
|
+
...(typeof driverStep.startX === 'number' ? { startX: driverStep.startX } : {}),
|
|
944
|
+
...(typeof driverStep.startY === 'number' ? { startY: driverStep.startY } : {}),
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
if (driverStep.driverAction === 'tap') {
|
|
948
|
+
return driver.tap({
|
|
949
|
+
...(driverStep.rawFileName ? { rawFileName: driverStep.rawFileName } : {}),
|
|
950
|
+
...(driverStep.ref ? { ref: driverStep.ref } : {}),
|
|
951
|
+
...(driverStep.selector ? { selector: driverStep.selector } : {}),
|
|
952
|
+
...(typeof driverStep.x === 'number' ? { x: driverStep.x } : {}),
|
|
953
|
+
...(typeof driverStep.y === 'number' ? { y: driverStep.y } : {}),
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
throw new Error(`Unsupported agent-device driver action: ${driverStep.driverAction}`);
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Builds a stable health code suffix for one agent-device driver action.
|
|
960
|
+
*
|
|
961
|
+
* @param {AgentDeviceDriverStep['driverAction']} driverAction
|
|
962
|
+
* @returns {string}
|
|
963
|
+
*/
|
|
964
|
+
function agentDeviceDriverActionCode(driverAction) {
|
|
965
|
+
return driverAction.replace(/[A-Z]/gu, (match) => `_${match.toLowerCase()}`);
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Runs scenario-declared portable actions through agent-device and writes artifacts.
|
|
969
|
+
*
|
|
970
|
+
* @param {AgentDeviceCaptureOptions} options
|
|
971
|
+
* @returns {Promise<AgentDeviceCaptureResult>}
|
|
972
|
+
*/
|
|
973
|
+
async function runAgentDeviceCapture({ agentDevicePath = 'agent-device', app = null, commandTimeoutMs = 60_000, delay: wait = delay, device = null, driverSteps, executor, open = false, outputDir = path.resolve('artifacts/agent-device-capture'), platform, runId = createRunId(), scenario = null, serial = null, session = null, sessionMode = 'reuse', target = 'mobile', udid = null, waitMs = 0, }) {
|
|
974
|
+
const run = executor ?? ((command, args) => execFileCommandWithTimeout(command, args, commandTimeoutMs));
|
|
975
|
+
const runDir = path.resolve(outputDir);
|
|
976
|
+
const layout = createArtifactLayout({ outputDir: runDir });
|
|
977
|
+
const rawDir = layout.raw;
|
|
978
|
+
await fsp.mkdir(rawDir, { recursive: true });
|
|
979
|
+
await fsp.mkdir(layout.captures, { recursive: true });
|
|
980
|
+
const raw = {};
|
|
981
|
+
const captures = {
|
|
982
|
+
screenshots: [],
|
|
983
|
+
};
|
|
984
|
+
const checks = [];
|
|
985
|
+
const driverActionMetadata = [];
|
|
986
|
+
const resolvedDriverSteps = driverSteps ?? (scenario ? resolveAgentDeviceDriverSteps(scenario) : []);
|
|
987
|
+
const driverStepErrors = validateAgentDeviceDriverSteps(resolvedDriverSteps);
|
|
988
|
+
if (driverStepErrors.length > 0) {
|
|
989
|
+
throw new Error(`Invalid agent-device driver step metadata: ${driverStepErrors.join(' ')}`);
|
|
990
|
+
}
|
|
991
|
+
const requestedTarget = udid ?? serial ?? device ?? null;
|
|
992
|
+
let sessionName = typeof session === 'string' && session.length > 0 ? session : null;
|
|
993
|
+
let sessionSelectionMode = sessionName ? 'explicit' : 'none';
|
|
994
|
+
if (!sessionName) {
|
|
995
|
+
const sessionList = await run(agentDevicePath, ['session', 'list', '--json']);
|
|
996
|
+
raw['agent-device-session-list.txt'] = formatAgentDeviceRawOutput(sessionList);
|
|
997
|
+
if (sessionList.exitCode === 0) {
|
|
998
|
+
const selectedSession = selectAgentDeviceSession({
|
|
999
|
+
platform,
|
|
1000
|
+
requestedTarget,
|
|
1001
|
+
sessions: readAgentDeviceSessions(sessionList),
|
|
1002
|
+
target,
|
|
1003
|
+
});
|
|
1004
|
+
if (selectedSession) {
|
|
1005
|
+
sessionName = selectedSession;
|
|
1006
|
+
sessionSelectionMode = 'auto';
|
|
1007
|
+
checks.push({
|
|
1008
|
+
name: 'agent_device_session_selected',
|
|
1009
|
+
status: 'passed',
|
|
1010
|
+
source: 'runner',
|
|
1011
|
+
code: 'agent_device_session_auto_selected',
|
|
1012
|
+
message: `Selected agent-device session ${selectedSession} for ${platform}.`,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const normalizedSessionMode = parseAgentDeviceSessionMode(sessionMode);
|
|
1018
|
+
const sessionOwnsTarget = Boolean(sessionName) && (normalizedSessionMode === 'reuse' || !requestedTarget);
|
|
1019
|
+
const driver = createAgentDeviceDriver({
|
|
1020
|
+
agentDevicePath,
|
|
1021
|
+
...(!sessionOwnsTarget && device ? { device } : {}),
|
|
1022
|
+
executor: run,
|
|
1023
|
+
platform,
|
|
1024
|
+
...(!sessionOwnsTarget && serial ? { serial } : {}),
|
|
1025
|
+
...(sessionName ? { session: sessionName } : {}),
|
|
1026
|
+
...(!sessionOwnsTarget ? { target } : {}),
|
|
1027
|
+
...(!sessionOwnsTarget && udid ? { udid } : {}),
|
|
1028
|
+
});
|
|
1029
|
+
const metadata = {
|
|
1030
|
+
app,
|
|
1031
|
+
device,
|
|
1032
|
+
driverActions: [],
|
|
1033
|
+
open,
|
|
1034
|
+
platform,
|
|
1035
|
+
...(requestedTarget ? { requestedTarget } : {}),
|
|
1036
|
+
selectedTarget: sessionOwnsTarget ? sessionName : requestedTarget,
|
|
1037
|
+
session: sessionName,
|
|
1038
|
+
sessionSelectionMode,
|
|
1039
|
+
sessionMode: normalizedSessionMode,
|
|
1040
|
+
target,
|
|
1041
|
+
targetSelectionMode: sessionOwnsTarget ? 'session' : sessionName ? 'session_bind' : 'direct',
|
|
1042
|
+
};
|
|
1043
|
+
if (open) {
|
|
1044
|
+
if (!app) {
|
|
1045
|
+
checks.push({
|
|
1046
|
+
name: 'agent_device_opened',
|
|
1047
|
+
status: 'failed',
|
|
1048
|
+
source: 'runner',
|
|
1049
|
+
code: 'agent_device_open_missing_app',
|
|
1050
|
+
message: 'agent-device app open was requested, but no app id or URL was provided.',
|
|
1051
|
+
metadata: nextActionHint('provide_agent_device_app', 'Pass --app with a bundle id, package name, app name, or URL before requesting --open.'),
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
const openResult = await driver.open({ appOrUrl: app });
|
|
1056
|
+
raw[openResult.rawFileName] = formatAgentDeviceRawOutput(openResult);
|
|
1057
|
+
const errorMetadata = openResult.exitCode !== 0 ? readAgentDeviceErrorMetadata(openResult) : {};
|
|
1058
|
+
checks.push({
|
|
1059
|
+
name: 'agent_device_opened',
|
|
1060
|
+
status: openResult.exitCode === 0 ? 'passed' : 'failed',
|
|
1061
|
+
source: 'runner',
|
|
1062
|
+
code: openResult.exitCode === 0 ? 'agent_device_opened' : 'agent_device_open_failed',
|
|
1063
|
+
message: openResult.exitCode === 0 ? `Opened ${app} with agent-device.` : `Failed to open ${app} with agent-device.`,
|
|
1064
|
+
...(openResult.exitCode !== 0
|
|
1065
|
+
? {
|
|
1066
|
+
metadata: {
|
|
1067
|
+
...buildAgentDeviceFailureHint({
|
|
1068
|
+
defaultNextAction: `Inspect raw/${openResult.rawFileName}, confirm the selected device is available, and rerun the capture.`,
|
|
1069
|
+
defaultNextActionCode: 'inspect_agent_device_open',
|
|
1070
|
+
errorMetadata,
|
|
1071
|
+
rawFileName: openResult.rawFileName,
|
|
1072
|
+
}),
|
|
1073
|
+
...errorMetadata,
|
|
1074
|
+
},
|
|
1075
|
+
}
|
|
1076
|
+
: {}),
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
if (waitMs > 0) {
|
|
1081
|
+
await wait(waitMs);
|
|
1082
|
+
checks.push({
|
|
1083
|
+
name: 'agent_device_capture_window_waited',
|
|
1084
|
+
status: 'passed',
|
|
1085
|
+
source: 'runner',
|
|
1086
|
+
code: 'agent_device_capture_window_waited',
|
|
1087
|
+
message: `Waited ${waitMs}ms before running agent-device driver actions.`,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
for (const driverStep of resolvedDriverSteps) {
|
|
1091
|
+
if (driverStep.waitMs && driverStep.waitMs > 0) {
|
|
1092
|
+
await wait(driverStep.waitMs);
|
|
1093
|
+
checks.push({
|
|
1094
|
+
name: 'agent_device_driver_action_waited',
|
|
1095
|
+
status: 'passed',
|
|
1096
|
+
source: 'runner',
|
|
1097
|
+
code: 'agent_device_driver_action_waited',
|
|
1098
|
+
message: `Waited ${driverStep.waitMs}ms before running agent-device driver action ${driverStep.driverAction}.`,
|
|
1099
|
+
metadata: {
|
|
1100
|
+
driverAction: driverStep.driverAction,
|
|
1101
|
+
...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
const driverResult = await runAgentDeviceDriverStep({
|
|
1106
|
+
capturesDir: layout.captures,
|
|
1107
|
+
driver,
|
|
1108
|
+
driverStep,
|
|
1109
|
+
});
|
|
1110
|
+
raw[driverResult.rawFileName] = formatAgentDeviceRawOutput(driverResult);
|
|
1111
|
+
const failed = driverResult.exitCode !== 0;
|
|
1112
|
+
const errorMetadata = failed ? readAgentDeviceErrorMetadata(driverResult) : {};
|
|
1113
|
+
const codeSuffix = agentDeviceDriverActionCode(driverStep.driverAction);
|
|
1114
|
+
checks.push({
|
|
1115
|
+
name: `agent_device_${codeSuffix}`,
|
|
1116
|
+
status: failed && driverStep.required === false ? 'warning' : failed ? 'failed' : 'passed',
|
|
1117
|
+
source: 'runner',
|
|
1118
|
+
code: driverResult.exitCode === 0 ? `agent_device_${codeSuffix}_completed` : `agent_device_${codeSuffix}_failed`,
|
|
1119
|
+
message: driverResult.exitCode === 0
|
|
1120
|
+
? `Completed agent-device driver action ${driverStep.driverAction}.`
|
|
1121
|
+
: `agent-device driver action ${driverStep.driverAction} failed.`,
|
|
1122
|
+
metadata: {
|
|
1123
|
+
driverAction: driverStep.driverAction,
|
|
1124
|
+
...(failed
|
|
1125
|
+
? buildAgentDeviceFailureHint({
|
|
1126
|
+
defaultNextAction: `Inspect raw/${driverResult.rawFileName}, confirm the device is interactive and the action metadata is valid, then rerun the capture.`,
|
|
1127
|
+
defaultNextActionCode: 'inspect_agent_device_driver_action',
|
|
1128
|
+
errorMetadata,
|
|
1129
|
+
rawFileName: driverResult.rawFileName,
|
|
1130
|
+
})
|
|
1131
|
+
: {}),
|
|
1132
|
+
...(failed ? errorMetadata : {}),
|
|
1133
|
+
...buildAgentDeviceSelectorHealthMetadata(driverStep.selector),
|
|
1134
|
+
...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
|
|
1135
|
+
},
|
|
1136
|
+
});
|
|
1137
|
+
const actionMetadata = {
|
|
1138
|
+
args: driverResult.args,
|
|
1139
|
+
driverAction: driverStep.driverAction,
|
|
1140
|
+
exitCode: driverResult.exitCode,
|
|
1141
|
+
...(driverResult.capturePath
|
|
1142
|
+
? { capturePath: `captures/${path.basename(driverResult.capturePath)}` }
|
|
1143
|
+
: {}),
|
|
1144
|
+
rawPath: `raw/${driverResult.rawFileName}`,
|
|
1145
|
+
...(driverStep.selector ? { selector: driverStep.selector } : {}),
|
|
1146
|
+
...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
|
|
1147
|
+
};
|
|
1148
|
+
driverActionMetadata.push(actionMetadata);
|
|
1149
|
+
if (driverStep.driverAction === 'screenshot' && driverResult.exitCode === 0 && driverResult.capturePath) {
|
|
1150
|
+
captures.screenshots.push(`captures/${path.basename(driverResult.capturePath)}`);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
metadata.driverActions = driverActionMetadata;
|
|
1154
|
+
metadata.captures = captures;
|
|
1155
|
+
const health = buildAgentDeviceHealth({ runId, checks });
|
|
1156
|
+
const verdict = buildAgentDeviceVerdict({ runId, health });
|
|
1157
|
+
const agentSummary = buildAgentSummaryMarkdown({ health, verdict });
|
|
1158
|
+
await Promise.all(Object.entries(raw).map(([fileName, content]) => fsp.writeFile(path.join(rawDir, fileName), `${content.trimEnd()}\n`, 'utf8')));
|
|
1159
|
+
await fsp.writeFile(path.join(rawDir, 'agent-device-metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
|
|
1160
|
+
await writeJsonArtifact({
|
|
1161
|
+
filePath: layout.health,
|
|
1162
|
+
value: health,
|
|
1163
|
+
schema: SCHEMAS.health,
|
|
1164
|
+
label: 'Health artifact',
|
|
1165
|
+
});
|
|
1166
|
+
await writeJsonArtifact({
|
|
1167
|
+
filePath: layout.verdict,
|
|
1168
|
+
value: verdict,
|
|
1169
|
+
schema: SCHEMAS.verdict,
|
|
1170
|
+
label: 'Verdict artifact',
|
|
1171
|
+
});
|
|
1172
|
+
await writeTextArtifact({
|
|
1173
|
+
filePath: layout.agentSummary,
|
|
1174
|
+
content: agentSummary,
|
|
1175
|
+
});
|
|
1176
|
+
return {
|
|
1177
|
+
agentSummary,
|
|
1178
|
+
captures,
|
|
1179
|
+
health,
|
|
1180
|
+
metadata,
|
|
1181
|
+
raw,
|
|
1182
|
+
runDir,
|
|
1183
|
+
verdict,
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Runs the agent-device capture CLI.
|
|
1188
|
+
*
|
|
1189
|
+
* @returns {Promise<void>}
|
|
1190
|
+
*/
|
|
1191
|
+
async function main() {
|
|
1192
|
+
const argv = process.argv.slice(2);
|
|
1193
|
+
if (hasHelpFlag(argv)) {
|
|
1194
|
+
usage(process.stdout);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
loadAslLocalEnv();
|
|
1198
|
+
const args = parseArgs(argv);
|
|
1199
|
+
const commandTimeoutMsValue = readStringArgOrEnv(args['command-timeout-ms'], ['ASL_AGENT_DEVICE_COMMAND_TIMEOUT_MS']);
|
|
1200
|
+
const commandTimeoutMs = readPositiveInteger(commandTimeoutMsValue, 60_000);
|
|
1201
|
+
const agentDevicePath = readStringArgOrEnv(args['agent-device'], ['ASL_AGENT_DEVICE_BIN']);
|
|
1202
|
+
if (args.check === true || args.check === 'true') {
|
|
1203
|
+
const requiredPlatforms = readStringArgOrEnv(args['require-platforms'], ['ASL_AGENT_DEVICE_REQUIRED_PLATFORMS']);
|
|
1204
|
+
const result = await checkAgentDeviceAvailability({
|
|
1205
|
+
...(agentDevicePath ? { agentDevicePath } : {}),
|
|
1206
|
+
commandTimeoutMs,
|
|
1207
|
+
requiredPlatforms: parseRequiredPlatforms(requiredPlatforms),
|
|
1208
|
+
});
|
|
1209
|
+
if (typeof args.out === 'string') {
|
|
1210
|
+
await writeAgentDeviceAvailabilityArtifacts({
|
|
1211
|
+
outputDir: args.out,
|
|
1212
|
+
result,
|
|
1213
|
+
...(typeof args['run-id'] === 'string' ? { runId: args['run-id'] } : {}),
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1217
|
+
if (result.status !== 'passed') {
|
|
1218
|
+
process.exitCode = 1;
|
|
1219
|
+
}
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (typeof args.platform !== 'string' || typeof args.scenario !== 'string') {
|
|
1223
|
+
usage();
|
|
1224
|
+
process.exitCode = 1;
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
if (!['android', 'apple', 'ios', 'linux', 'macos'].includes(args.platform)) {
|
|
1228
|
+
throw new Error('--platform must be one of android, ios, macos, linux, or apple.');
|
|
1229
|
+
}
|
|
1230
|
+
const platform = args.platform;
|
|
1231
|
+
const app = readStringArgOrEnv(args.app, platform === 'android'
|
|
1232
|
+
? ['ASL_ANDROID_APP_ID', 'ASL_EXAMPLE_ANDROID_APP_ID']
|
|
1233
|
+
: ['ASL_IOS_APP_ID', 'ASL_EXAMPLE_IOS_APP_ID']);
|
|
1234
|
+
const serial = readStringArgOrEnv(args.serial, ['ASL_ANDROID_SERIAL', 'ASL_EXAMPLE_ANDROID_SERIAL']);
|
|
1235
|
+
const session = readStringArgOrEnv(args.session, platform === 'android'
|
|
1236
|
+
? ['ASL_ANDROID_AGENT_DEVICE_SESSION', 'ASL_EXAMPLE_ANDROID_AGENT_DEVICE_SESSION']
|
|
1237
|
+
: ['ASL_IOS_AGENT_DEVICE_SESSION', 'ASL_EXAMPLE_IOS_AGENT_DEVICE_SESSION']);
|
|
1238
|
+
const sessionMode = readStringArgOrEnv(args['session-mode'], platform === 'android'
|
|
1239
|
+
? ['ASL_ANDROID_AGENT_DEVICE_SESSION_MODE', 'ASL_EXAMPLE_ANDROID_AGENT_DEVICE_SESSION_MODE']
|
|
1240
|
+
: ['ASL_IOS_AGENT_DEVICE_SESSION_MODE', 'ASL_EXAMPLE_IOS_AGENT_DEVICE_SESSION_MODE']);
|
|
1241
|
+
const udid = readStringArgOrEnv(args.udid, ['ASL_IOS_UDID', 'ASL_EXAMPLE_IOS_UDID']);
|
|
1242
|
+
const result = await runAgentDeviceCapture({
|
|
1243
|
+
...(agentDevicePath ? { agentDevicePath } : {}),
|
|
1244
|
+
...(app ? { app } : {}),
|
|
1245
|
+
commandTimeoutMs,
|
|
1246
|
+
...(typeof args.device === 'string' ? { device: args.device } : {}),
|
|
1247
|
+
...(typeof args.out === 'string' ? { outputDir: args.out } : {}),
|
|
1248
|
+
open: isEnabled(args.open),
|
|
1249
|
+
platform,
|
|
1250
|
+
...(typeof args['run-id'] === 'string' ? { runId: args['run-id'] } : {}),
|
|
1251
|
+
scenario: readJson(path.resolve(args.scenario)),
|
|
1252
|
+
...(serial ? { serial } : {}),
|
|
1253
|
+
...(session ? { session } : {}),
|
|
1254
|
+
...(sessionMode ? { sessionMode: parseAgentDeviceSessionMode(sessionMode) } : {}),
|
|
1255
|
+
...(typeof args.target === 'string' && ['desktop', 'mobile', 'tv'].includes(args.target)
|
|
1256
|
+
? { target: args.target }
|
|
1257
|
+
: {}),
|
|
1258
|
+
...(udid ? { udid } : {}),
|
|
1259
|
+
waitMs: readPositiveInteger(args['wait-ms'], 0),
|
|
1260
|
+
});
|
|
1261
|
+
process.stdout.write(`${result.runDir}\n`);
|
|
1262
|
+
if (result.health.healthStatus !== 'passed') {
|
|
1263
|
+
process.exitCode = 1;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
if (require.main === module) {
|
|
1267
|
+
main().catch((error) => {
|
|
1268
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1269
|
+
process.exitCode = 1;
|
|
1270
|
+
});
|
|
1271
|
+
}
|