@loadmill/droid-cua 2.19.0 → 2.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/build/index.js +109 -4
- package/build/src/browser/playwright/welcome-page.js +20 -0
- package/build/src/cli/aws-device-farm-app-upload.js +76 -0
- package/build/src/cli/cloud-status-line.js +98 -0
- package/build/src/cli/headless-runner.js +8 -7
- package/build/src/cli/headless-target.js +57 -22
- package/build/src/commands/loadmill.js +8 -1
- package/build/src/core/report/run-log-model.js +4 -0
- package/build/src/device/cloud/aws-device-farm/adapter.js +388 -0
- package/build/src/device/cloud/aws-device-farm/upload-cache.js +42 -0
- package/build/src/device/cloud/cloud-appium-client.js +2 -1
- package/build/src/device/cloud/connection.js +71 -14
- package/build/src/device/cloud/progress.js +24 -0
- package/build/src/device/cloud/registry.js +2 -1
- package/build/src/device/connection.js +3 -2
- package/build/src/device/loadmill.js +11 -0
- package/build/src/integrations/loadmill/executor.js +14 -6
- package/build/src/integrations/loadmill/interpreter.js +4 -4
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -160,6 +160,24 @@ LAMBDATEST_USERNAME=... LAMBDATEST_ACCESS_KEY=... droid-cua \
|
|
|
160
160
|
--app ./app/build/outputs/apk/debug/app-debug.apk \
|
|
161
161
|
--instructions tests/login.dcua \
|
|
162
162
|
--config ci/droid-cua.json
|
|
163
|
+
|
|
164
|
+
# List AWS Device Farm remote-access devices
|
|
165
|
+
AWS_DEVICE_FARM_ACCESS_KEY_ID=... \
|
|
166
|
+
AWS_DEVICE_FARM_SECRET_ACCESS_KEY=... \
|
|
167
|
+
AWS_DEVICE_FARM_PROJECT_ARN=... \
|
|
168
|
+
droid-cua --device-source aws-device-farm --list-devices
|
|
169
|
+
|
|
170
|
+
# Headless AWS Device Farm real-device run
|
|
171
|
+
AWS_DEVICE_FARM_ACCESS_KEY_ID=... \
|
|
172
|
+
AWS_DEVICE_FARM_SECRET_ACCESS_KEY=... \
|
|
173
|
+
AWS_DEVICE_FARM_PROJECT_ARN=... \
|
|
174
|
+
droid-cua \
|
|
175
|
+
--device-source aws-device-farm \
|
|
176
|
+
--platform android \
|
|
177
|
+
--device-name "Google Pixel 10 Pro" \
|
|
178
|
+
--os-version "16" \
|
|
179
|
+
--app ./app/build/outputs/apk/debug/app-debug.apk \
|
|
180
|
+
--instructions tests/login.dcua
|
|
163
181
|
```
|
|
164
182
|
|
|
165
183
|
Example headless config:
|
|
@@ -194,6 +212,7 @@ Supported CLI options include:
|
|
|
194
212
|
- `--device-name`
|
|
195
213
|
- `--os-version`
|
|
196
214
|
- `--app`
|
|
215
|
+
- `--list-devices`
|
|
197
216
|
- `--browser`
|
|
198
217
|
- `--instructions`
|
|
199
218
|
- `--config`
|
|
@@ -246,6 +265,20 @@ LambdaTest CLI runs:
|
|
|
246
265
|
- The CLI uploads the app to LambdaTest for each run and closes the cloud session when the run finishes or fails.
|
|
247
266
|
- Each CLI LambdaTest run gets a unique LambdaTest build name so repeated local runs are easy to distinguish in the LambdaTest UI.
|
|
248
267
|
|
|
268
|
+
AWS Device Farm CLI runs:
|
|
269
|
+
- Use `--device-source aws-device-farm` with `--platform`, `--device-name`, `--os-version`, `--app`, and `--instructions`.
|
|
270
|
+
- Set `AWS_DEVICE_FARM_ACCESS_KEY_ID`, `AWS_DEVICE_FARM_SECRET_ACCESS_KEY`, and `AWS_DEVICE_FARM_PROJECT_ARN` in the environment (an IAM user with Device Farm access and the ARN of a Device Farm project in `us-west-2`).
|
|
271
|
+
- `--device-source aws-device-farm --list-devices` prints the remote-access device catalog (name, platform, OS) so you can pick valid `--device-name`/`--os-version` values, then exits.
|
|
272
|
+
- Android runs require an `.apk`; iOS runs require an `.ipa`. `.app` bundles are not supported.
|
|
273
|
+
- Re-running with the same app binary skips the upload: uploads are cached by checksum and reused for up to 30 days (Device Farm's upload retention).
|
|
274
|
+
- Device provisioning takes ~1 minute; the CLI shows live progress while it waits.
|
|
275
|
+
- The session is stopped when the run finishes, fails, or is interrupted with Ctrl-C — billing stops as soon as the stop is requested, so the CLI does not wait for the session to fully wind down.
|
|
276
|
+
|
|
277
|
+
AWS Device Farm in the desktop app:
|
|
278
|
+
- Open **Integrations → AWS Device Farm** and enter an AWS Access Key ID + Secret Access Key. The keys are validated on connect, a `droid-cua` Device Farm project is created automatically (the IAM user needs the minimal Device Farm policy, including `ListProjects`/`CreateProject`), and the remote-access device catalog loads. Credentials are stored in plaintext `desktop-config.json`, the same as BrowserStack/LambdaTest.
|
|
279
|
+
- On the Devices page, pick AWS Device Farm as the source, choose a device + an uploaded `.apk`/`.ipa`, and start a Design/Run session. Provisioning takes ~1 minute and live progress flows into the device log.
|
|
280
|
+
- **Pause caveat:** a Device Farm session paused for more than 5 minutes may be ended by AWS and need reconnecting.
|
|
281
|
+
|
|
249
282
|
Minimal GitHub Actions shape:
|
|
250
283
|
```yaml
|
|
251
284
|
jobs:
|
|
@@ -273,6 +306,25 @@ jobs:
|
|
|
273
306
|
LAMBDATEST_ACCESS_KEY: ${{ secrets.LAMBDATEST_ACCESS_KEY }}
|
|
274
307
|
```
|
|
275
308
|
|
|
309
|
+
The same shape works for AWS Device Farm — swap the run step and env:
|
|
310
|
+
```yaml
|
|
311
|
+
- run: |
|
|
312
|
+
droid-cua \
|
|
313
|
+
--device-source aws-device-farm \
|
|
314
|
+
--platform android \
|
|
315
|
+
--device-name "Google Pixel 10 Pro" \
|
|
316
|
+
--os-version "16" \
|
|
317
|
+
--app ./app/build/outputs/apk/debug/app-debug.apk \
|
|
318
|
+
--instructions tests/login.dcua \
|
|
319
|
+
--config ci/droid-cua.json \
|
|
320
|
+
--debug
|
|
321
|
+
env:
|
|
322
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
323
|
+
AWS_DEVICE_FARM_ACCESS_KEY_ID: ${{ secrets.AWS_DEVICE_FARM_ACCESS_KEY_ID }}
|
|
324
|
+
AWS_DEVICE_FARM_SECRET_ACCESS_KEY: ${{ secrets.AWS_DEVICE_FARM_SECRET_ACCESS_KEY }}
|
|
325
|
+
AWS_DEVICE_FARM_PROJECT_ARN: ${{ secrets.AWS_DEVICE_FARM_PROJECT_ARN }}
|
|
326
|
+
```
|
|
327
|
+
|
|
276
328
|
Example without a config file:
|
|
277
329
|
```sh
|
|
278
330
|
droid-cua \
|
package/build/index.js
CHANGED
|
@@ -24,8 +24,11 @@ import { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget } from
|
|
|
24
24
|
import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
|
|
25
25
|
import { planScenarioRuns } from "./src/cli/scenario-planner.js";
|
|
26
26
|
import { resolveLambdaTestCredentials, uploadLambdaTestCliApp } from "./src/cli/lambdatest-app-upload.js";
|
|
27
|
+
import { resolveAwsDeviceFarmCredentials, uploadAwsDeviceFarmCliApp } from "./src/cli/aws-device-farm-app-upload.js";
|
|
28
|
+
import { createStatusLine } from "./src/cli/cloud-status-line.js";
|
|
27
29
|
import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
|
|
28
30
|
import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
|
|
31
|
+
import { setCloudProgressListener } from "./src/device/cloud/progress.js";
|
|
29
32
|
dotenv.config();
|
|
30
33
|
const args = minimist(process.argv.slice(2));
|
|
31
34
|
const targetConfig = resolveHeadlessTarget(args);
|
|
@@ -131,12 +134,27 @@ async function resolveLambdaTestDashboardUrl(credentials, sessionId, { attempts
|
|
|
131
134
|
return null;
|
|
132
135
|
}
|
|
133
136
|
async function main() {
|
|
137
|
+
if (targetConfig.listDevices) {
|
|
138
|
+
const credentials = resolveAwsDeviceFarmCredentials(process.env);
|
|
139
|
+
const adapter = getCloudProviderAdapter("aws-device-farm");
|
|
140
|
+
const devices = await adapter.getAvailableDevices(credentials);
|
|
141
|
+
for (const device of devices) {
|
|
142
|
+
console.log(`${device.name} — ${device.platform} ${device.osVersion}`);
|
|
143
|
+
}
|
|
144
|
+
console.log(`\n${devices.length} remote-access devices available.`);
|
|
145
|
+
console.log(`Run with: --device-name "<name>" --os-version "<osVersion>"`);
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
134
148
|
const isHeadlessInstructionsRun = Boolean(instructionsFile);
|
|
135
149
|
const isBrowserTarget = targetConfig.target === "web";
|
|
136
150
|
const isLambdaTestTarget = targetConfig.deviceSource === "lambdatest";
|
|
151
|
+
const isAwsDeviceFarmTarget = targetConfig.deviceSource === "aws-device-farm";
|
|
152
|
+
const isCloudTarget = isLambdaTestTarget || isAwsDeviceFarmTarget;
|
|
137
153
|
let lambdaTestSessionConnected = false;
|
|
138
154
|
let lambdaTestSessionUrl = null;
|
|
139
155
|
let lambdaTestCredentials = null;
|
|
156
|
+
let awsSessionConnected = false;
|
|
157
|
+
let awsDeviceFarmStatusLine = null;
|
|
140
158
|
const headlessDebug = createHeadlessDebugArtifacts({
|
|
141
159
|
cwd: process.cwd(),
|
|
142
160
|
enabled: isHeadlessInstructionsRun && debugMode
|
|
@@ -149,8 +167,8 @@ async function main() {
|
|
|
149
167
|
if (isBrowserTarget && !isHeadlessInstructionsRun) {
|
|
150
168
|
throw new Error("--target web is only supported with --instructions headless execution.");
|
|
151
169
|
}
|
|
152
|
-
if (
|
|
153
|
-
throw new Error(
|
|
170
|
+
if (isCloudTarget && !isHeadlessInstructionsRun) {
|
|
171
|
+
throw new Error(`--device-source ${targetConfig.deviceSource} is only supported with --instructions headless execution.`);
|
|
154
172
|
}
|
|
155
173
|
if (!isHeadlessInstructionsRun && args.scenario != null) {
|
|
156
174
|
throw new Error("--scenario can only be used with --instructions headless execution.");
|
|
@@ -206,7 +224,7 @@ async function main() {
|
|
|
206
224
|
}
|
|
207
225
|
}
|
|
208
226
|
// If no device specified, show interactive selection menu
|
|
209
|
-
if (!isBrowserTarget && !
|
|
227
|
+
if (!isBrowserTarget && !isCloudTarget && !avdName && !platform) {
|
|
210
228
|
const selection = await selectDevice();
|
|
211
229
|
platform = selection.platform;
|
|
212
230
|
avdName = selection.deviceName;
|
|
@@ -268,6 +286,85 @@ async function main() {
|
|
|
268
286
|
console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
|
|
269
287
|
console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
270
288
|
}
|
|
289
|
+
else if (isAwsDeviceFarmTarget) {
|
|
290
|
+
const awsCredentials = resolveAwsDeviceFarmCredentials(process.env);
|
|
291
|
+
awsDeviceFarmStatusLine = createStatusLine();
|
|
292
|
+
const statusLine = awsDeviceFarmStatusLine;
|
|
293
|
+
setCloudProgressListener((event) => statusLine.handleEvent(event));
|
|
294
|
+
console.log(`Uploading app to AWS Device Farm: ${path.resolve(targetConfig.appPath)}`);
|
|
295
|
+
statusLine.start("Uploading app to AWS Device Farm");
|
|
296
|
+
const upload = await uploadAwsDeviceFarmCliApp({
|
|
297
|
+
appPath: targetConfig.appPath,
|
|
298
|
+
platform: targetConfig.platform,
|
|
299
|
+
credentials: awsCredentials,
|
|
300
|
+
});
|
|
301
|
+
statusLine.stop();
|
|
302
|
+
console.log(`Uploaded app to AWS Device Farm: ${upload.appRef}`);
|
|
303
|
+
// A leaked metered session bills idle time until the provider's 5-minute
|
|
304
|
+
// timeout and holds one of the account's two concurrency slots. Register the
|
|
305
|
+
// Ctrl-C handler BEFORE provisioning so an interrupt during the ~1min
|
|
306
|
+
// PREPARING poll aborts the connect and lets AWS cleanup finish before exit.
|
|
307
|
+
const provisioningAbortController = new AbortController();
|
|
308
|
+
let awsSigintHandled = false;
|
|
309
|
+
let awsInterruptRequested = false;
|
|
310
|
+
process.once("SIGINT", async () => {
|
|
311
|
+
if (awsSigintHandled)
|
|
312
|
+
return;
|
|
313
|
+
awsSigintHandled = true;
|
|
314
|
+
awsInterruptRequested = true;
|
|
315
|
+
console.log("\nStopping AWS Device Farm session...");
|
|
316
|
+
provisioningAbortController.abort(new Error("Stopped by user"));
|
|
317
|
+
if (!awsSessionConnected) {
|
|
318
|
+
process.exitCode = 130;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
await disconnectDevice({ waitForCompletion: false });
|
|
322
|
+
process.exit(130);
|
|
323
|
+
});
|
|
324
|
+
try {
|
|
325
|
+
statusLine.start("Provisioning AWS Device Farm device");
|
|
326
|
+
targetId = await connectToDevice({
|
|
327
|
+
source: "aws-device-farm",
|
|
328
|
+
providerId: "aws-device-farm",
|
|
329
|
+
platform: targetConfig.platform,
|
|
330
|
+
deviceName: targetConfig.deviceName,
|
|
331
|
+
osVersion: targetConfig.osVersion,
|
|
332
|
+
appRef: upload.appRef,
|
|
333
|
+
credentials: awsCredentials,
|
|
334
|
+
sessionName: `droid-cua-cli-${new Date().toISOString()}`,
|
|
335
|
+
signal: provisioningAbortController.signal,
|
|
336
|
+
}, null, { log: (message) => statusLine.update(message) });
|
|
337
|
+
awsSessionConnected = true;
|
|
338
|
+
if (awsInterruptRequested) {
|
|
339
|
+
try {
|
|
340
|
+
await disconnectDevice({ waitForCompletion: false });
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
statusLine.stop();
|
|
344
|
+
awsSessionConnected = false;
|
|
345
|
+
process.exitCode = 130;
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
deviceInfo = await getDeviceInfo(targetId);
|
|
350
|
+
statusLine.stop();
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
statusLine.stop();
|
|
354
|
+
if (awsSessionConnected) {
|
|
355
|
+
await disconnectDevice({ waitForCompletion: false });
|
|
356
|
+
awsSessionConnected = false;
|
|
357
|
+
}
|
|
358
|
+
if (awsInterruptRequested) {
|
|
359
|
+
process.exitCode = 130;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
console.log(`Connected to AWS Device Farm device "${targetConfig.deviceName}" (${targetId})`);
|
|
365
|
+
console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
|
|
366
|
+
console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
367
|
+
}
|
|
271
368
|
else {
|
|
272
369
|
targetId = await connectToDevice(avdName, platform);
|
|
273
370
|
deviceInfo = await getDeviceInfo(targetId);
|
|
@@ -334,7 +431,7 @@ async function main() {
|
|
|
334
431
|
recordScreenshots,
|
|
335
432
|
screenshotDir,
|
|
336
433
|
isBrowserTarget,
|
|
337
|
-
|
|
434
|
+
isCloudTarget,
|
|
338
435
|
report: reportConfig
|
|
339
436
|
});
|
|
340
437
|
exitCode = result.exitCode;
|
|
@@ -358,6 +455,14 @@ async function main() {
|
|
|
358
455
|
console.log(`LambdaTest session id: ${targetId}`);
|
|
359
456
|
}
|
|
360
457
|
}
|
|
458
|
+
if (awsSessionConnected) {
|
|
459
|
+
console.log("Ending AWS Device Farm session...");
|
|
460
|
+
awsDeviceFarmStatusLine.start("Ending AWS Device Farm session");
|
|
461
|
+
await disconnectDevice({ waitForCompletion: false });
|
|
462
|
+
awsDeviceFarmStatusLine.stop();
|
|
463
|
+
awsSessionConnected = false;
|
|
464
|
+
console.log("AWS Device Farm session ended.");
|
|
465
|
+
}
|
|
361
466
|
}
|
|
362
467
|
if (exitCode !== null) {
|
|
363
468
|
process.exit(exitCode);
|
|
@@ -79,6 +79,25 @@ function buildWelcomePageHtml() {
|
|
|
79
79
|
|
|
80
80
|
.subhead { margin-top: 22px; font-size: 18px; line-height: 1.65; }
|
|
81
81
|
|
|
82
|
+
.return-action {
|
|
83
|
+
margin-top: 30px;
|
|
84
|
+
display: inline-flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
justify-content: center;
|
|
87
|
+
min-height: 46px;
|
|
88
|
+
padding: 0 22px;
|
|
89
|
+
border-radius: 999px;
|
|
90
|
+
background: #0f172a;
|
|
91
|
+
color: #ffffff;
|
|
92
|
+
font-size: 15px;
|
|
93
|
+
font-weight: 700;
|
|
94
|
+
text-decoration: none;
|
|
95
|
+
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.18);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.return-action:hover { background: #1e293b; }
|
|
99
|
+
.return-action:focus-visible { outline: 3px solid rgba(79, 70, 229, 0.34); outline-offset: 3px; }
|
|
100
|
+
|
|
82
101
|
.divider {
|
|
83
102
|
width: 48px;
|
|
84
103
|
height: 2px;
|
|
@@ -121,6 +140,7 @@ function buildWelcomePageHtml() {
|
|
|
121
140
|
</div>
|
|
122
141
|
<h1>Test Browser Ready</h1>
|
|
123
142
|
<p class="subhead">This is the browser the Loadmill Droid CUA agent will drive when you start a test.</p>
|
|
143
|
+
<a class="return-action" href="droid-cua://focus">Return to the Droid app</a>
|
|
124
144
|
<div class="divider" role="presentation"></div>
|
|
125
145
|
<p class="pointer">Switch back to Loadmill Droid CUA to design or run a test — you should keep this window in the background.</p>
|
|
126
146
|
<p class="tip">
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { stat } from "fs/promises";
|
|
3
|
+
import { awsDeviceFarmAdapter } from "../device/cloud/aws-device-farm/adapter.js";
|
|
4
|
+
import { resolveUploadedApp } from "../device/cloud/aws-device-farm/upload-cache.js";
|
|
5
|
+
function trimNullableString(value) {
|
|
6
|
+
if (typeof value !== "string") {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
return trimmed || null;
|
|
11
|
+
}
|
|
12
|
+
export function resolveAwsDeviceFarmCredentials(env = process.env) {
|
|
13
|
+
const username = trimNullableString(env.AWS_DEVICE_FARM_ACCESS_KEY_ID);
|
|
14
|
+
const accessKey = trimNullableString(env.AWS_DEVICE_FARM_SECRET_ACCESS_KEY);
|
|
15
|
+
const projectArn = trimNullableString(env.AWS_DEVICE_FARM_PROJECT_ARN);
|
|
16
|
+
if (!username || !accessKey || !projectArn) {
|
|
17
|
+
throw new Error("AWS_DEVICE_FARM_ACCESS_KEY_ID, AWS_DEVICE_FARM_SECRET_ACCESS_KEY and AWS_DEVICE_FARM_PROJECT_ARN are required for --device-source aws-device-farm.");
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
username,
|
|
21
|
+
accessKey,
|
|
22
|
+
projectArn,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function validateAwsDeviceFarmAppPath(appPath, platform) {
|
|
26
|
+
const normalizedPlatform = trimNullableString(platform)?.toLowerCase();
|
|
27
|
+
if (normalizedPlatform !== "android" && normalizedPlatform !== "ios") {
|
|
28
|
+
throw new Error("--platform must be one of: android, ios.");
|
|
29
|
+
}
|
|
30
|
+
const normalizedAppPath = trimNullableString(appPath);
|
|
31
|
+
if (!normalizedAppPath) {
|
|
32
|
+
throw new Error("--app is required with --device-source aws-device-farm.");
|
|
33
|
+
}
|
|
34
|
+
const resolvedPath = path.resolve(normalizedAppPath);
|
|
35
|
+
const extension = path.extname(resolvedPath).toLowerCase();
|
|
36
|
+
if (extension === ".app") {
|
|
37
|
+
throw new Error("AWS Device Farm real-device sessions require an .apk for Android or an .ipa for iOS. .app bundles are not supported.");
|
|
38
|
+
}
|
|
39
|
+
if (normalizedPlatform === "android" && extension !== ".apk") {
|
|
40
|
+
throw new Error("AWS Device Farm Android sessions require an .apk build.");
|
|
41
|
+
}
|
|
42
|
+
if (normalizedPlatform === "ios" && extension !== ".ipa") {
|
|
43
|
+
throw new Error("AWS Device Farm iOS sessions require an .ipa build.");
|
|
44
|
+
}
|
|
45
|
+
return resolvedPath;
|
|
46
|
+
}
|
|
47
|
+
export async function uploadAwsDeviceFarmCliApp({ appPath, platform, credentials, adapter = awsDeviceFarmAdapter, } = {}) {
|
|
48
|
+
const resolvedPath = validateAwsDeviceFarmAppPath(appPath, platform);
|
|
49
|
+
let fileStats;
|
|
50
|
+
try {
|
|
51
|
+
fileStats = await stat(resolvedPath);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const message = error instanceof Error ? error.message : "App file is unavailable.";
|
|
55
|
+
throw new Error(`App file is unavailable: ${message}`);
|
|
56
|
+
}
|
|
57
|
+
if (!fileStats.isFile()) {
|
|
58
|
+
throw new Error("Selected app path is not a file.");
|
|
59
|
+
}
|
|
60
|
+
// sha256+projectArn upload cache — re-runs of the same binary reuse the
|
|
61
|
+
// existing upload ARN instead of re-uploading (Device Farm has no
|
|
62
|
+
// server-side dedup; uploads age out after ~30 days).
|
|
63
|
+
const { app: uploadedRef } = await resolveUploadedApp({
|
|
64
|
+
adapter,
|
|
65
|
+
credentials,
|
|
66
|
+
appPath: resolvedPath,
|
|
67
|
+
});
|
|
68
|
+
if (!uploadedRef?.remotePath || typeof uploadedRef.remotePath !== "string") {
|
|
69
|
+
throw new Error("AWS Device Farm did not return a valid app reference.");
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
appRef: uploadedRef.remotePath,
|
|
73
|
+
appPath: resolvedPath,
|
|
74
|
+
uploadedRef,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2
|
+
/**
|
|
3
|
+
* Heroku-style volatile status line for long-running cloud-provider steps.
|
|
4
|
+
* On a TTY each progress update overwrites the previous one (spinner + step
|
|
5
|
+
* name + elapsed + latest detail) and the whole line is cleared when the step
|
|
6
|
+
* finishes. On a non-TTY (CI, piped output) it degrades to plain log lines,
|
|
7
|
+
* printed only when the detail changes. Feed it cloud progress events via
|
|
8
|
+
* handleEvent: "phase" events time named sub-steps and "note" events keep
|
|
9
|
+
* persistent info; both are printed (prefixed with `indent`) when the step
|
|
10
|
+
* finishes.
|
|
11
|
+
*/
|
|
12
|
+
export function createStatusLine({ stream = process.stderr, indent = "" } = {}) {
|
|
13
|
+
const tty = stream.isTTY;
|
|
14
|
+
let active = false;
|
|
15
|
+
let timer = null;
|
|
16
|
+
let frame = 0;
|
|
17
|
+
let stepName = "";
|
|
18
|
+
let detail = "";
|
|
19
|
+
let startedAt = 0;
|
|
20
|
+
let lastPrintedDetail = "";
|
|
21
|
+
let entries = [];
|
|
22
|
+
let currentPhase = null;
|
|
23
|
+
const render = () => {
|
|
24
|
+
frame = (frame + 1) % SPINNER_FRAMES.length;
|
|
25
|
+
const seconds = Math.floor((Date.now() - startedAt) / 1000);
|
|
26
|
+
const line = `${SPINNER_FRAMES[frame]} ${stepName} (${seconds}s)${detail ? ` — ${detail}` : ""}`;
|
|
27
|
+
stream.write(`\r\x1b[2K${line}`);
|
|
28
|
+
};
|
|
29
|
+
const closePhase = () => {
|
|
30
|
+
if (currentPhase) {
|
|
31
|
+
currentPhase.ms = Date.now() - currentPhase.startedAt;
|
|
32
|
+
currentPhase = null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
start(name) {
|
|
37
|
+
active = true;
|
|
38
|
+
stepName = name;
|
|
39
|
+
detail = "";
|
|
40
|
+
lastPrintedDetail = "";
|
|
41
|
+
entries = [];
|
|
42
|
+
currentPhase = null;
|
|
43
|
+
startedAt = Date.now();
|
|
44
|
+
if (tty) {
|
|
45
|
+
// First frame is deferred so any line the caller just printed lands
|
|
46
|
+
// on its own row before this step starts drawing.
|
|
47
|
+
timer = setInterval(render, 120);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
update(message) {
|
|
51
|
+
if (!active) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
detail = message;
|
|
55
|
+
if (tty) {
|
|
56
|
+
render();
|
|
57
|
+
}
|
|
58
|
+
else if (message !== lastPrintedDetail) {
|
|
59
|
+
lastPrintedDetail = message;
|
|
60
|
+
console.log(`… ${stepName}: ${message}`);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
handleEvent(event) {
|
|
64
|
+
if (!active) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (event.kind === "phase") {
|
|
68
|
+
closePhase();
|
|
69
|
+
currentPhase = { kind: "phase", name: event.message, startedAt: Date.now(), ms: 0 };
|
|
70
|
+
entries.push(currentPhase);
|
|
71
|
+
}
|
|
72
|
+
else if (event.kind === "note") {
|
|
73
|
+
entries.push({ kind: "note", text: event.message });
|
|
74
|
+
}
|
|
75
|
+
this.update(event.message);
|
|
76
|
+
},
|
|
77
|
+
stop() {
|
|
78
|
+
active = false;
|
|
79
|
+
if (timer) {
|
|
80
|
+
clearInterval(timer);
|
|
81
|
+
timer = null;
|
|
82
|
+
}
|
|
83
|
+
if (tty) {
|
|
84
|
+
stream.write("\r\x1b[2K");
|
|
85
|
+
}
|
|
86
|
+
closePhase();
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.kind === "phase") {
|
|
89
|
+
stream.write(`${indent}\x1b[32m✔\x1b[0m ${entry.name} \x1b[90m(${entry.ms}ms)\x1b[0m\n`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
stream.write(`${indent}\x1b[34mℹ\x1b[0m ${entry.text}\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
entries = [];
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -56,7 +56,7 @@ function emitCliAppContextDebug(runId, appContext, appContextText) {
|
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
-
function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget,
|
|
59
|
+
function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, secrets = null }) {
|
|
60
60
|
const executionEngineOptions = {
|
|
61
61
|
toolCapabilities: {
|
|
62
62
|
assert: true,
|
|
@@ -95,14 +95,14 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
|
|
|
95
95
|
engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
|
|
96
96
|
engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
|
|
97
97
|
}
|
|
98
|
-
else if (
|
|
98
|
+
else if (isCloudTarget) {
|
|
99
99
|
engineOptions.connectToDevice = async () => {
|
|
100
|
-
throw new Error("
|
|
100
|
+
throw new Error("Cloud session disconnected; automatic CLI reconnect is not supported yet.");
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
return engineOptions;
|
|
104
104
|
}
|
|
105
|
-
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget,
|
|
105
|
+
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, secrets = null, report = null, isBatchRun = false }) {
|
|
106
106
|
let executionMode = null;
|
|
107
107
|
const reportEnabled = Boolean(report?.enabled);
|
|
108
108
|
const collectedEvents = [];
|
|
@@ -146,7 +146,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
146
146
|
recordScreenshots,
|
|
147
147
|
screenshotDir,
|
|
148
148
|
isBrowserTarget,
|
|
149
|
-
|
|
149
|
+
isCloudTarget,
|
|
150
150
|
secrets
|
|
151
151
|
});
|
|
152
152
|
const engine = new ExecutionEngine(session, engineOptions);
|
|
@@ -156,6 +156,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
156
156
|
addOutput,
|
|
157
157
|
openAiClient: runtimeContext.openAiClient,
|
|
158
158
|
loadmillClient: runtimeContext.loadmillClient,
|
|
159
|
+
runtimeConfig: runtimeContext.config,
|
|
159
160
|
runtimeCredentials: runtimeContext.credentials,
|
|
160
161
|
// Inline screenshots as data URLs so the HTML report is self-contained.
|
|
161
162
|
...(reportEnabled ? { includeScreenshotDataUrl: true } : {}),
|
|
@@ -223,7 +224,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
223
224
|
throw error;
|
|
224
225
|
}
|
|
225
226
|
}
|
|
226
|
-
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget,
|
|
227
|
+
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, report = null, runHeadlessTestImpl = runHeadlessTest }) {
|
|
227
228
|
const isBatchRun = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
|
|
228
229
|
if (instructionsTarget?.type === "scenario") {
|
|
229
230
|
console.log(`\nRunning ${runs.length} scenario case${runs.length === 1 ? "" : "s"}\n`);
|
|
@@ -259,7 +260,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
259
260
|
recordScreenshots,
|
|
260
261
|
screenshotDir: testScreenshotDir,
|
|
261
262
|
isBrowserTarget,
|
|
262
|
-
|
|
263
|
+
isCloudTarget,
|
|
263
264
|
report,
|
|
264
265
|
isBatchRun
|
|
265
266
|
});
|