@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
|
@@ -37,18 +37,25 @@ function normalizeBrowser(value) {
|
|
|
37
37
|
}
|
|
38
38
|
throw new Error("--browser must be one of: chrome, edge.");
|
|
39
39
|
}
|
|
40
|
+
const CLOUD_SOURCE_DISPLAY_NAMES = {
|
|
41
|
+
lambdatest: "LambdaTest",
|
|
42
|
+
"aws-device-farm": "AWS Device Farm",
|
|
43
|
+
};
|
|
44
|
+
function isCloudDeviceSource(deviceSource) {
|
|
45
|
+
return Object.hasOwn(CLOUD_SOURCE_DISPLAY_NAMES, deviceSource);
|
|
46
|
+
}
|
|
40
47
|
function normalizeDeviceSource(value) {
|
|
41
48
|
if (value == null || value === "") {
|
|
42
49
|
return "local";
|
|
43
50
|
}
|
|
44
51
|
if (typeof value !== "string") {
|
|
45
|
-
throw new Error("--device-source must be one of: local, lambdatest.");
|
|
52
|
+
throw new Error("--device-source must be one of: local, lambdatest, aws-device-farm.");
|
|
46
53
|
}
|
|
47
54
|
const normalized = value.trim().toLowerCase();
|
|
48
|
-
if (normalized === "local" || normalized
|
|
55
|
+
if (normalized === "local" || isCloudDeviceSource(normalized)) {
|
|
49
56
|
return normalized;
|
|
50
57
|
}
|
|
51
|
-
throw new Error("--device-source must be one of: local, lambdatest.");
|
|
58
|
+
throw new Error("--device-source must be one of: local, lambdatest, aws-device-farm.");
|
|
52
59
|
}
|
|
53
60
|
function normalizeMobilePlatform(value, label) {
|
|
54
61
|
if (!hasNonEmptyString(value)) {
|
|
@@ -72,31 +79,38 @@ function readNonEmptyCliValue(value) {
|
|
|
72
79
|
}
|
|
73
80
|
return null;
|
|
74
81
|
}
|
|
75
|
-
function
|
|
76
|
-
const
|
|
82
|
+
function assertNoCloudFlags(args) {
|
|
83
|
+
const cloudFlags = [
|
|
77
84
|
["device-name", "--device-name"],
|
|
78
85
|
["os-version", "--os-version"],
|
|
79
86
|
["app", "--app"],
|
|
80
87
|
];
|
|
81
|
-
for (const [key, label] of
|
|
88
|
+
for (const [key, label] of cloudFlags) {
|
|
82
89
|
if (args[key] != null) {
|
|
83
|
-
throw new Error(`${label} can only be used with --device-source
|
|
90
|
+
throw new Error(`${label} can only be used with a cloud --device-source.`);
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
|
-
function
|
|
94
|
+
function normalizeCloudAppPath(value, platform, deviceSource) {
|
|
88
95
|
if (!hasNonEmptyString(value)) {
|
|
89
96
|
return null;
|
|
90
97
|
}
|
|
98
|
+
const displayName = CLOUD_SOURCE_DISPLAY_NAMES[deviceSource];
|
|
91
99
|
const appPath = value.trim();
|
|
92
100
|
const lower = appPath.toLowerCase();
|
|
93
|
-
if (lower.endsWith(".apk") || lower.endsWith(".ipa")) {
|
|
94
|
-
return appPath;
|
|
95
|
-
}
|
|
96
101
|
if (lower.endsWith(".app")) {
|
|
97
|
-
throw new Error(
|
|
102
|
+
throw new Error(`${displayName} real-device sessions require an .apk for Android or an .ipa for iOS. .app bundles are not supported.`);
|
|
103
|
+
}
|
|
104
|
+
if (!lower.endsWith(".apk") && !lower.endsWith(".ipa")) {
|
|
105
|
+
throw new Error(`--app must point to an .apk or .ipa file for ${displayName} runs.`);
|
|
106
|
+
}
|
|
107
|
+
if (platform === "android" && !lower.endsWith(".apk")) {
|
|
108
|
+
throw new Error(`${displayName} Android sessions require an .apk build.`);
|
|
109
|
+
}
|
|
110
|
+
if (platform === "ios" && !lower.endsWith(".ipa")) {
|
|
111
|
+
throw new Error(`${displayName} iOS sessions require an .ipa build.`);
|
|
98
112
|
}
|
|
99
|
-
|
|
113
|
+
return appPath;
|
|
100
114
|
}
|
|
101
115
|
export function resolveHeadlessTarget(args = {}) {
|
|
102
116
|
const target = normalizeTarget(args.target);
|
|
@@ -104,6 +118,9 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
104
118
|
const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
|
|
105
119
|
const platform = normalizeMobilePlatform(args.platform, "--platform");
|
|
106
120
|
const deviceSource = normalizeDeviceSource(args["device-source"]);
|
|
121
|
+
if (args["list-devices"] != null && deviceSource !== "aws-device-farm") {
|
|
122
|
+
throw new Error("--list-devices can only be used with --device-source aws-device-farm.");
|
|
123
|
+
}
|
|
107
124
|
if (target === "web") {
|
|
108
125
|
if (avdName || platform) {
|
|
109
126
|
throw new Error("--avd and --platform cannot be used with --target web.");
|
|
@@ -111,7 +128,7 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
111
128
|
if (deviceSource !== "local") {
|
|
112
129
|
throw new Error("--device-source can only be used with --target mobile.");
|
|
113
130
|
}
|
|
114
|
-
|
|
131
|
+
assertNoCloudFlags(args);
|
|
115
132
|
return {
|
|
116
133
|
target,
|
|
117
134
|
sessionMode,
|
|
@@ -122,6 +139,7 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
122
139
|
deviceName: null,
|
|
123
140
|
osVersion: null,
|
|
124
141
|
appPath: null,
|
|
142
|
+
listDevices: false,
|
|
125
143
|
};
|
|
126
144
|
}
|
|
127
145
|
if (args["session-mode"] != null) {
|
|
@@ -130,27 +148,42 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
130
148
|
if (args.browser != null) {
|
|
131
149
|
throw new Error("--browser can only be used with --target web.");
|
|
132
150
|
}
|
|
151
|
+
// --list-devices only needs credentials — no device/app flags required.
|
|
152
|
+
if (args["list-devices"] != null) {
|
|
153
|
+
return {
|
|
154
|
+
target,
|
|
155
|
+
sessionMode: null,
|
|
156
|
+
browserFamily: null,
|
|
157
|
+
deviceSource,
|
|
158
|
+
avdName: null,
|
|
159
|
+
platform: null,
|
|
160
|
+
deviceName: null,
|
|
161
|
+
osVersion: null,
|
|
162
|
+
appPath: null,
|
|
163
|
+
listDevices: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
133
166
|
if (deviceSource === "local") {
|
|
134
|
-
|
|
167
|
+
assertNoCloudFlags(args);
|
|
135
168
|
}
|
|
136
|
-
if (deviceSource
|
|
169
|
+
if (isCloudDeviceSource(deviceSource)) {
|
|
137
170
|
const deviceName = hasNonEmptyString(args["device-name"]) ? args["device-name"].trim() : null;
|
|
138
171
|
const osVersion = readNonEmptyCliValue(args["os-version"]);
|
|
139
|
-
const appPath =
|
|
172
|
+
const appPath = normalizeCloudAppPath(args.app, platform, deviceSource);
|
|
140
173
|
if (avdName) {
|
|
141
|
-
throw new Error(
|
|
174
|
+
throw new Error(`--avd cannot be used with --device-source ${deviceSource}.`);
|
|
142
175
|
}
|
|
143
176
|
if (!platform) {
|
|
144
|
-
throw new Error(
|
|
177
|
+
throw new Error(`--platform is required with --device-source ${deviceSource}.`);
|
|
145
178
|
}
|
|
146
179
|
if (!deviceName) {
|
|
147
|
-
throw new Error(
|
|
180
|
+
throw new Error(`--device-name is required with --device-source ${deviceSource}.`);
|
|
148
181
|
}
|
|
149
182
|
if (!osVersion) {
|
|
150
|
-
throw new Error(
|
|
183
|
+
throw new Error(`--os-version is required with --device-source ${deviceSource}.`);
|
|
151
184
|
}
|
|
152
185
|
if (!appPath) {
|
|
153
|
-
throw new Error(
|
|
186
|
+
throw new Error(`--app is required with --device-source ${deviceSource}.`);
|
|
154
187
|
}
|
|
155
188
|
return {
|
|
156
189
|
target,
|
|
@@ -162,6 +195,7 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
162
195
|
deviceName,
|
|
163
196
|
osVersion,
|
|
164
197
|
appPath,
|
|
198
|
+
listDevices: false,
|
|
165
199
|
};
|
|
166
200
|
}
|
|
167
201
|
return {
|
|
@@ -174,5 +208,6 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
174
208
|
deviceName: null,
|
|
175
209
|
osVersion: null,
|
|
176
210
|
appPath: null,
|
|
211
|
+
listDevices: false,
|
|
177
212
|
};
|
|
178
213
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Loadmill command handler
|
|
3
3
|
*/
|
|
4
4
|
import { createLoadmillApiClient, executeLoadmillCommand, getApiToken } from "../integrations/loadmill/index.js";
|
|
5
|
-
import { createResolvedLlmClient, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "../core/runtime-config.js";
|
|
5
|
+
import { createResolvedLlmClient, resolveEffectiveHelperModel, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "../core/runtime-config.js";
|
|
6
6
|
/**
|
|
7
7
|
* Handle /loadmill command
|
|
8
8
|
* @param {string} args - Command arguments (natural language command)
|
|
@@ -60,10 +60,17 @@ export async function handleLoadmill(args, session, context) {
|
|
|
60
60
|
credentials: runtimeCredentials,
|
|
61
61
|
clientName: "cli",
|
|
62
62
|
});
|
|
63
|
+
const helperModel = runtimeConfig.llmProviderMode === "openai"
|
|
64
|
+
? resolveEffectiveHelperModel({
|
|
65
|
+
llmProviderMode: runtimeConfig.llmProviderMode,
|
|
66
|
+
cuaModel: runtimeConfig.cuaModel,
|
|
67
|
+
})
|
|
68
|
+
: null;
|
|
63
69
|
// Execute the command
|
|
64
70
|
const result = await executeLoadmillCommand(command, {
|
|
65
71
|
loadmillClient,
|
|
66
72
|
openAiClient,
|
|
73
|
+
helperModel,
|
|
67
74
|
onProgress: ({ message }) => {
|
|
68
75
|
addOutput({ type: 'info', text: `[Loadmill] ${message}` });
|
|
69
76
|
}
|
|
@@ -34,6 +34,10 @@ export function mediaKindOf(line) {
|
|
|
34
34
|
export function isHiddenExecutionLogEvent(line) {
|
|
35
35
|
if (line.eventType === "run_paused")
|
|
36
36
|
return true;
|
|
37
|
+
// Connecting markers drive renderer state (isConnecting) but carry no
|
|
38
|
+
// user-facing content beyond the cloud progress lines already shown.
|
|
39
|
+
if (line.eventType === "run_connecting" || line.eventType === "run_connected")
|
|
40
|
+
return true;
|
|
37
41
|
return mediaKindOf(line) === "hidden-debug";
|
|
38
42
|
}
|
|
39
43
|
export function isGalleryMediaEvent(line) {
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { CreateProjectCommand, CreateRemoteAccessSessionCommand, CreateUploadCommand, DeleteUploadCommand, DeviceFarmClient, GetAccountSettingsCommand, GetRemoteAccessSessionCommand, GetUploadCommand, ListDevicesCommand, ListProjectsCommand, StopRemoteAccessSessionCommand } from "@aws-sdk/client-device-farm";
|
|
4
|
+
import { reportCloudNote, reportCloudPhase, reportCloudProgress } from "../progress.js";
|
|
5
|
+
const REGION = "us-west-2";
|
|
6
|
+
// Documented default remote-access concurrency quota.
|
|
7
|
+
const REMOTE_ACCESS_PARALLEL_LIMIT = 2;
|
|
8
|
+
// Statuses that mean an upload or remote-access session will NEVER reach the
|
|
9
|
+
// state we're waiting for. Hitting one stops polling immediately instead of
|
|
10
|
+
// spinning until the timeout.
|
|
11
|
+
const TERMINAL_FAILURE_STATUSES = new Set(["FAILED", "ERRORED", "STOPPING", "STOPPED"]);
|
|
12
|
+
// Poll budgets. Provisioning is documented at ~1 min; uploads process in
|
|
13
|
+
// seconds; these caps are generous backstops so a stuck AWS resource surfaces
|
|
14
|
+
// an error instead of hanging the CLI/desktop forever.
|
|
15
|
+
const PROVISION_TIMEOUT_MS = 5 * 60 * 1000; // wait for session RUNNING
|
|
16
|
+
const UPLOAD_TIMEOUT_MS = 3 * 60 * 1000; // wait for upload processing
|
|
17
|
+
const RELEASE_TIMEOUT_MS = 5 * 60 * 1000; // wait for session COMPLETED
|
|
18
|
+
// Single session, mirrors the activeSession design in connection.js.
|
|
19
|
+
let activeRemoteSession = null; // { sessionArn, hubUrl }
|
|
20
|
+
function makeClient(creds) {
|
|
21
|
+
return new DeviceFarmClient({
|
|
22
|
+
region: REGION,
|
|
23
|
+
credentials: {
|
|
24
|
+
accessKeyId: creds.username,
|
|
25
|
+
secretAccessKey: creds.accessKey
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function sleep(ms) {
|
|
30
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
32
|
+
// sleep() that rejects the instant `signal` aborts, so a Stop during the
|
|
33
|
+
// provisioning poll wakes the wait immediately instead of waiting out the full
|
|
34
|
+
// interval. With no signal it behaves exactly like sleep().
|
|
35
|
+
function abortableSleep(ms, signal) {
|
|
36
|
+
if (signal?.aborted) {
|
|
37
|
+
return Promise.reject(signal.reason ?? new Error("Provisioning cancelled."));
|
|
38
|
+
}
|
|
39
|
+
if (!signal) {
|
|
40
|
+
return sleep(ms);
|
|
41
|
+
}
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const onAbort = () => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
reject(signal.reason ?? new Error("Provisioning cancelled."));
|
|
46
|
+
};
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
signal.removeEventListener("abort", onAbort);
|
|
49
|
+
resolve();
|
|
50
|
+
}, ms);
|
|
51
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Polls `fetchResult()` every `intervalMs` until `isDone(status)` is true,
|
|
56
|
+
* returning the full result. Throws once `maxWaitMs` elapses, and — unless
|
|
57
|
+
* `treatTerminalAsDone` is set — as soon as a terminal-failure status appears.
|
|
58
|
+
* `statusOf` extracts the status string from each result; `onStatus` reports it
|
|
59
|
+
* for progress lines. An optional `signal` aborts the poll: it's checked at the
|
|
60
|
+
* top of each iteration and the inter-poll wait wakes immediately on abort.
|
|
61
|
+
*/
|
|
62
|
+
async function pollUntil(fetchResult, { statusOf, isDone, intervalMs, maxWaitMs, label, onStatus, treatTerminalAsDone = false, signal }) {
|
|
63
|
+
const deadline = Date.now() + maxWaitMs;
|
|
64
|
+
for (;;) {
|
|
65
|
+
if (signal?.aborted) {
|
|
66
|
+
throw signal.reason ?? new Error(`${label} cancelled.`);
|
|
67
|
+
}
|
|
68
|
+
const result = await fetchResult();
|
|
69
|
+
const status = statusOf(result);
|
|
70
|
+
onStatus?.(status);
|
|
71
|
+
if (isDone(status)) {
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
if (TERMINAL_FAILURE_STATUSES.has(status)) {
|
|
75
|
+
if (treatTerminalAsDone) {
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`${label} entered terminal state "${status}".`);
|
|
79
|
+
}
|
|
80
|
+
if (Date.now() >= deadline) {
|
|
81
|
+
throw new Error(`${label} timed out after ${Math.round(maxWaitMs / 1000)}s (last status "${status}").`);
|
|
82
|
+
}
|
|
83
|
+
await abortableSleep(intervalMs, signal);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Statuses that mean a remote-access session is already terminating or gone — no
|
|
87
|
+
// further Stop is needed.
|
|
88
|
+
const SESSION_TERMINATING_STATUSES = new Set(["STOPPING", "STOPPED", "COMPLETED"]);
|
|
89
|
+
// AWS ignores StopRemoteAccessSession on a session that is still provisioning
|
|
90
|
+
// (PENDING/PREPARING): a single Stop there is a no-op, the session finishes
|
|
91
|
+
// provisioning, and it leaks as RUNNING — billed up to the session's max
|
|
92
|
+
// duration. So after a cancel mid-provisioning, reconcile until the session is
|
|
93
|
+
// confirmed terminating: a session only becomes stoppable once it reaches
|
|
94
|
+
// RUNNING, so re-issue Stop each tick until GetRemoteAccessSession reports a
|
|
95
|
+
// terminating status (or a bounded wait elapses). Never throws — it's a
|
|
96
|
+
// best-effort background cleanup so the cancel itself stays instant.
|
|
97
|
+
async function ensureRemoteAccessSessionStopped(client, arn, { timeoutMs = PROVISION_TIMEOUT_MS, intervalMs = 3000 } = {}) {
|
|
98
|
+
const deadline = Date.now() + timeoutMs;
|
|
99
|
+
for (;;) {
|
|
100
|
+
let status;
|
|
101
|
+
try {
|
|
102
|
+
const { remoteAccessSession } = await client.send(new GetRemoteAccessSessionCommand({ arn }));
|
|
103
|
+
status = remoteAccessSession?.status;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Can't read state — fire one best-effort Stop and give up reconciling.
|
|
107
|
+
await client.send(new StopRemoteAccessSessionCommand({ arn })).catch(() => { });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!status || SESSION_TERMINATING_STATUSES.has(status)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
await client.send(new StopRemoteAccessSessionCommand({ arn })).catch(() => { });
|
|
114
|
+
if (Date.now() >= deadline) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await sleep(intervalMs);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// ListDevices is paginated (~15 devices/page). Callers that match a specific
|
|
121
|
+
// device client-side MUST page through every result, or devices on later pages
|
|
122
|
+
// are invisible.
|
|
123
|
+
async function listAllDevices(client, filters) {
|
|
124
|
+
const devices = [];
|
|
125
|
+
let nextToken;
|
|
126
|
+
do {
|
|
127
|
+
const response = await client.send(new ListDevicesCommand({ filters, nextToken }));
|
|
128
|
+
devices.push(...(response.devices ?? []));
|
|
129
|
+
nextToken = response.nextToken;
|
|
130
|
+
} while (nextToken);
|
|
131
|
+
return devices;
|
|
132
|
+
}
|
|
133
|
+
async function requestAccountInfo(creds) {
|
|
134
|
+
const { accountSettings } = await makeClient(creds).send(new GetAccountSettingsCommand({}));
|
|
135
|
+
const trialMinutes = accountSettings?.trialMinutes;
|
|
136
|
+
const remaining = typeof trialMinutes?.remaining === "number" ? trialMinutes.remaining : null;
|
|
137
|
+
const total = typeof trialMinutes?.total === "number" ? trialMinutes.total : null;
|
|
138
|
+
// Some accounts report no trial allotment (fields absent). Surface plain
|
|
139
|
+
// metered billing instead of throwing on `.remaining.toFixed(...)` — a throw
|
|
140
|
+
// here makes validateCredentials report valid credentials as invalid.
|
|
141
|
+
const summary = remaining !== null && total !== null
|
|
142
|
+
? `Metered billing — ${remaining.toFixed(0)} of ${total} free trial minutes remaining.`
|
|
143
|
+
: "Metered billing.";
|
|
144
|
+
return {
|
|
145
|
+
plan: "Metered",
|
|
146
|
+
parallelLimit: REMOTE_ACCESS_PARALLEL_LIMIT,
|
|
147
|
+
...(remaining !== null ? { trialMinutesRemaining: remaining } : {}),
|
|
148
|
+
summary
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** @type {import("../adapter").CloudProviderAdapter} */
|
|
152
|
+
export const awsDeviceFarmAdapter = {
|
|
153
|
+
id: "aws-device-farm",
|
|
154
|
+
displayName: "AWS Device Farm",
|
|
155
|
+
async validateCredentials(creds) {
|
|
156
|
+
try {
|
|
157
|
+
const account = await requestAccountInfo(creds);
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
message: account.summary,
|
|
161
|
+
account
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
message: error instanceof Error ? error.message : "Failed to validate AWS Device Farm credentials."
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
async getAccountInfo(creds) {
|
|
172
|
+
return requestAccountInfo(creds);
|
|
173
|
+
},
|
|
174
|
+
async ensureProject(creds) {
|
|
175
|
+
const client = makeClient(creds);
|
|
176
|
+
let nextToken;
|
|
177
|
+
do {
|
|
178
|
+
const { projects = [], nextToken: token } = await client.send(new ListProjectsCommand({ nextToken }));
|
|
179
|
+
const existing = projects.find((project) => project.name === "droid-cua");
|
|
180
|
+
if (existing) {
|
|
181
|
+
return existing.arn;
|
|
182
|
+
}
|
|
183
|
+
nextToken = token;
|
|
184
|
+
} while (nextToken);
|
|
185
|
+
const { project } = await client.send(new CreateProjectCommand({ name: "droid-cua" }));
|
|
186
|
+
return project.arn;
|
|
187
|
+
},
|
|
188
|
+
async getAvailableDevices(creds) {
|
|
189
|
+
const client = makeClient(creds);
|
|
190
|
+
const rawDevices = await listAllDevices(client, [
|
|
191
|
+
{ attribute: "REMOTE_ACCESS_ENABLED", operator: "EQUALS", values: ["TRUE"] }
|
|
192
|
+
]);
|
|
193
|
+
return rawDevices
|
|
194
|
+
.map((device) => ({
|
|
195
|
+
id: device.arn,
|
|
196
|
+
name: device.name,
|
|
197
|
+
deviceName: device.name,
|
|
198
|
+
platform: device.platform.toLowerCase(),
|
|
199
|
+
osVersion: device.os,
|
|
200
|
+
arn: device.arn
|
|
201
|
+
}))
|
|
202
|
+
.sort((left, right) => {
|
|
203
|
+
if (left.platform !== right.platform) {
|
|
204
|
+
return left.platform.localeCompare(right.platform);
|
|
205
|
+
}
|
|
206
|
+
if (left.name !== right.name) {
|
|
207
|
+
return left.name.localeCompare(right.name);
|
|
208
|
+
}
|
|
209
|
+
return right.osVersion.localeCompare(left.osVersion, undefined, { numeric: true, sensitivity: "base" });
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
async uploadApp(creds, localPath) {
|
|
213
|
+
const client = makeClient(creds);
|
|
214
|
+
reportCloudPhase("create upload");
|
|
215
|
+
const { upload } = await client.send(new CreateUploadCommand({
|
|
216
|
+
projectArn: creds.projectArn,
|
|
217
|
+
name: path.basename(localPath),
|
|
218
|
+
type: localPath.toLowerCase().endsWith(".ipa") ? "IOS_APP" : "ANDROID_APP",
|
|
219
|
+
contentType: "application/octet-stream"
|
|
220
|
+
}));
|
|
221
|
+
reportCloudPhase(`upload ${path.basename(localPath)} to S3`);
|
|
222
|
+
const putResponse = await fetch(upload.url, {
|
|
223
|
+
method: "PUT",
|
|
224
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
225
|
+
body: await readFile(localPath)
|
|
226
|
+
});
|
|
227
|
+
// A failed PUT leaves the binary absent; AWS keeps the upload at INITIALIZED
|
|
228
|
+
// forever, so without this check the processing poll never terminates.
|
|
229
|
+
if (!putResponse.ok) {
|
|
230
|
+
throw new Error(`Device Farm S3 upload failed (HTTP ${putResponse.status}) for ${path.basename(localPath)}.`);
|
|
231
|
+
}
|
|
232
|
+
reportCloudPhase("wait for upload processing");
|
|
233
|
+
// SUCCEEDED resolves; FAILED is in TERMINAL_FAILURE_STATUSES so pollUntil
|
|
234
|
+
// throws on it (e.g. wrong upload type for the binary).
|
|
235
|
+
await pollUntil(() => client.send(new GetUploadCommand({ arn: upload.arn })), {
|
|
236
|
+
statusOf: (result) => result.upload.status,
|
|
237
|
+
isDone: (status) => status === "SUCCEEDED",
|
|
238
|
+
intervalMs: 3000,
|
|
239
|
+
maxWaitMs: UPLOAD_TIMEOUT_MS,
|
|
240
|
+
label: `Device Farm upload processing for ${path.basename(localPath)}`,
|
|
241
|
+
onStatus: (status) => reportCloudProgress(`upload status: ${status}`)
|
|
242
|
+
});
|
|
243
|
+
return { id: upload.arn, remotePath: upload.arn };
|
|
244
|
+
},
|
|
245
|
+
async getAppStatus(creds, ref) {
|
|
246
|
+
const { upload } = await makeClient(creds).send(new GetUploadCommand({ arn: ref.id }));
|
|
247
|
+
if (upload.status === "SUCCEEDED") {
|
|
248
|
+
return { status: "uploaded" };
|
|
249
|
+
}
|
|
250
|
+
if (upload.status === "INITIALIZED" || upload.status === "PROCESSING") {
|
|
251
|
+
return { status: "processing" };
|
|
252
|
+
}
|
|
253
|
+
return { status: "missing" };
|
|
254
|
+
},
|
|
255
|
+
async deleteApp(creds, ref) {
|
|
256
|
+
await makeClient(creds).send(new DeleteUploadCommand({ arn: ref.id }));
|
|
257
|
+
},
|
|
258
|
+
async prepareSession(creds, opts, { signal } = {}) {
|
|
259
|
+
if (signal?.aborted) {
|
|
260
|
+
throw signal.reason ?? new Error("Provisioning cancelled.");
|
|
261
|
+
}
|
|
262
|
+
const client = makeClient(creds);
|
|
263
|
+
reportCloudPhase("resolve device ARN");
|
|
264
|
+
reportCloudProgress(`locating "${opts.deviceName}" (${opts.osVersion}) in the device catalog...`);
|
|
265
|
+
// AWS's OS_VERSION filter does NOT match the device `os` field (EQUALS on a
|
|
266
|
+
// real version like "17.3.1" returns nothing), and names repeat across OS
|
|
267
|
+
// versions (e.g. two "Apple iPad (2022)" entries at 16.4 and 17.3.1). So page
|
|
268
|
+
// through the remote-access catalog and match on name + osVersion client-side
|
|
269
|
+
// — the exact pairing getAvailableDevices surfaced for selection.
|
|
270
|
+
const devices = await listAllDevices(client, [
|
|
271
|
+
{ attribute: "PLATFORM", operator: "EQUALS", values: [opts.platform.toUpperCase()] },
|
|
272
|
+
{ attribute: "REMOTE_ACCESS_ENABLED", operator: "EQUALS", values: ["TRUE"] }
|
|
273
|
+
]);
|
|
274
|
+
const device = devices.find((entry) => entry.name === opts.deviceName && entry.os === opts.osVersion);
|
|
275
|
+
if (!device) {
|
|
276
|
+
const sameName = devices.filter((entry) => entry.name === opts.deviceName);
|
|
277
|
+
if (sameName.length > 0) {
|
|
278
|
+
// The device name exists, just not at the requested OS version. Don't
|
|
279
|
+
// silently provision a different OS (a CLI --os-version typo would then
|
|
280
|
+
// run somewhere unexpected) — list the valid versions so the caller can
|
|
281
|
+
// re-run correctly.
|
|
282
|
+
const available = [...new Set(sameName.map((entry) => entry.os))]
|
|
283
|
+
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }))
|
|
284
|
+
.join(", ");
|
|
285
|
+
throw new Error(`No remote-access-enabled "${opts.deviceName}" runs ${opts.platform.toUpperCase()} ${opts.osVersion}. ` +
|
|
286
|
+
`Available OS versions for this device: ${available}.`);
|
|
287
|
+
}
|
|
288
|
+
throw new Error(`No remote-access-enabled ${opts.platform.toUpperCase()} device matches "${opts.deviceName}" (${opts.osVersion}). ` +
|
|
289
|
+
"It may have left the Device Farm fleet — refresh Available Devices and pick another.");
|
|
290
|
+
}
|
|
291
|
+
if (signal?.aborted) {
|
|
292
|
+
throw signal.reason ?? new Error("Provisioning cancelled.");
|
|
293
|
+
}
|
|
294
|
+
reportCloudPhase("create remote access session");
|
|
295
|
+
const { remoteAccessSession: created } = await client.send(new CreateRemoteAccessSessionCommand({
|
|
296
|
+
projectArn: creds.projectArn,
|
|
297
|
+
deviceArn: device.arn,
|
|
298
|
+
appArn: opts.app,
|
|
299
|
+
name: opts.sessionName,
|
|
300
|
+
configuration: { billingMethod: "METERED" }
|
|
301
|
+
}));
|
|
302
|
+
reportCloudPhase("wait for session RUNNING");
|
|
303
|
+
let session;
|
|
304
|
+
try {
|
|
305
|
+
({ remoteAccessSession: session } = await pollUntil(() => client.send(new GetRemoteAccessSessionCommand({ arn: created.arn })), {
|
|
306
|
+
statusOf: (result) => result.remoteAccessSession.status,
|
|
307
|
+
isDone: (status) => status === "RUNNING",
|
|
308
|
+
intervalMs: 5000,
|
|
309
|
+
maxWaitMs: PROVISION_TIMEOUT_MS,
|
|
310
|
+
label: `Device Farm provisioning for "${opts.deviceName}" (${opts.osVersion})`,
|
|
311
|
+
onStatus: (status) => reportCloudProgress(`remote access session status: ${status}`),
|
|
312
|
+
signal
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
// The session was created but never reached RUNNING (cancel/timeout/terminal
|
|
317
|
+
// failure). activeRemoteSession was never set and prepareSession is now
|
|
318
|
+
// throwing, so connection.js's connect-failure cleanup can't reach it.
|
|
319
|
+
// A single Stop is unreliable here: AWS ignores StopRemoteAccessSession on a
|
|
320
|
+
// still-provisioning (PENDING/PREPARING) session, which then finishes
|
|
321
|
+
// provisioning and leaks as RUNNING. Reconcile before returning so callers
|
|
322
|
+
// can safely exit after this promise rejects.
|
|
323
|
+
reportCloudPhase("stop remote access session");
|
|
324
|
+
await ensureRemoteAccessSessionStopped(client, created.arn);
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
activeRemoteSession = {
|
|
328
|
+
sessionArn: session.arn,
|
|
329
|
+
hubUrl: session.endpoints.remoteDriverEndpoint.replace(/\/+$/, "")
|
|
330
|
+
};
|
|
331
|
+
},
|
|
332
|
+
async releaseSession(creds, { waitForCompletion = true } = {}) {
|
|
333
|
+
if (!activeRemoteSession) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const client = makeClient(creds);
|
|
337
|
+
const sessionArn = activeRemoteSession.sessionArn;
|
|
338
|
+
reportCloudPhase("stop remote access session");
|
|
339
|
+
await client.send(new StopRemoteAccessSessionCommand({ arn: sessionArn }));
|
|
340
|
+
// Stop accepted → billing stopped and the session is released. Clear state
|
|
341
|
+
// now so a slow/failed COMPLETED wait below can't leave it stale.
|
|
342
|
+
activeRemoteSession = null;
|
|
343
|
+
// Billing stops the moment the stop request is accepted; waiting for
|
|
344
|
+
// COMPLETED (and the billed-minutes report) adds ~2+ minutes, so
|
|
345
|
+
// user-facing flows (the CLI) skip it.
|
|
346
|
+
if (!waitForCompletion) {
|
|
347
|
+
reportCloudProgress("session stop requested — billing stopped");
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// This flow runs rarely (development/debugging), so waiting for the
|
|
351
|
+
// session to fully COMPLETE to surface the billed device-minutes is
|
|
352
|
+
// worth the extra ~1-2 minutes of wall time. The stop is already in
|
|
353
|
+
// flight, so any terminal state (STOPPED/COMPLETED) counts as done.
|
|
354
|
+
reportCloudPhase("wait for session COMPLETED");
|
|
355
|
+
const { remoteAccessSession: session } = await pollUntil(() => client.send(new GetRemoteAccessSessionCommand({ arn: sessionArn })), {
|
|
356
|
+
statusOf: (result) => result.remoteAccessSession.status,
|
|
357
|
+
isDone: (status) => status === "COMPLETED",
|
|
358
|
+
intervalMs: 5000,
|
|
359
|
+
maxWaitMs: RELEASE_TIMEOUT_MS,
|
|
360
|
+
label: "Device Farm session teardown",
|
|
361
|
+
onStatus: (status) => reportCloudProgress(`remote access session status: ${status}`),
|
|
362
|
+
treatTerminalAsDone: true
|
|
363
|
+
});
|
|
364
|
+
reportCloudNote(`billed deviceMinutes: ${JSON.stringify(session.deviceMinutes)}`);
|
|
365
|
+
},
|
|
366
|
+
buildCapabilities(opts) {
|
|
367
|
+
// Device & app are bound at remote-access-session creation, not via capabilities;
|
|
368
|
+
// udid/platformVersion caps can break session creation against Device Farm.
|
|
369
|
+
return {
|
|
370
|
+
platformName: opts.platform,
|
|
371
|
+
"appium:automationName": opts.platform === "ios" ? "XCUITest" : "UiAutomator2",
|
|
372
|
+
"appium:newCommandTimeout": 240
|
|
373
|
+
};
|
|
374
|
+
},
|
|
375
|
+
getHubUrl() {
|
|
376
|
+
return activeRemoteSession.hubUrl;
|
|
377
|
+
},
|
|
378
|
+
getAuthHeader() {
|
|
379
|
+
// The hub endpoint is a presigned capability URL — no auth header needed.
|
|
380
|
+
return "";
|
|
381
|
+
},
|
|
382
|
+
async getSessionArtifacts() {
|
|
383
|
+
return {};
|
|
384
|
+
},
|
|
385
|
+
async setSessionStatus() {
|
|
386
|
+
// Device Farm has no session pass/fail status equivalent.
|
|
387
|
+
}
|
|
388
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { reportCloudProgress } from "../progress.js";
|
|
7
|
+
export const DEFAULT_UPLOAD_CACHE_PATH = path.join(os.tmpdir(), "droid-cua", "aws-device-farm-upload-cache.json");
|
|
8
|
+
// Stream the binary through the hash rather than buffering the whole .apk/.ipa
|
|
9
|
+
// (tens–hundreds of MB) in one allocation just to checksum it.
|
|
10
|
+
async function hashFile(filePath) {
|
|
11
|
+
const hash = createHash("sha256");
|
|
12
|
+
for await (const chunk of createReadStream(filePath)) {
|
|
13
|
+
hash.update(chunk);
|
|
14
|
+
}
|
|
15
|
+
return hash.digest("hex");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Device Farm has no server-side upload dedup — the upload ARN itself is the
|
|
19
|
+
* cache (reusable for ~30 days), keyed by sha256(binary)+projectArn. Cached
|
|
20
|
+
* ARNs are liveness-checked via getAppStatus before reuse; uploads are never
|
|
21
|
+
* deleted here, stale ones age out via the 30-day expiry.
|
|
22
|
+
*
|
|
23
|
+
* @returns {Promise<{ app: { id: string, remotePath: string }, status: { status: string } }>}
|
|
24
|
+
*/
|
|
25
|
+
export async function resolveUploadedApp({ adapter, credentials, appPath, cachePath = DEFAULT_UPLOAD_CACHE_PATH }) {
|
|
26
|
+
const sha256 = await hashFile(appPath);
|
|
27
|
+
const cacheKey = `${sha256}:${credentials.projectArn}`;
|
|
28
|
+
const cache = existsSync(cachePath) ? JSON.parse(await readFile(cachePath, "utf8")) : {};
|
|
29
|
+
if (cache[cacheKey]) {
|
|
30
|
+
const cachedApp = { id: cache[cacheKey], remotePath: cache[cacheKey] };
|
|
31
|
+
const cachedStatus = await adapter.getAppStatus(credentials, cachedApp);
|
|
32
|
+
if (cachedStatus.status === "uploaded") {
|
|
33
|
+
reportCloudProgress("upload cache hit — reusing existing upload ARN");
|
|
34
|
+
return { app: cachedApp, status: cachedStatus };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const app = await adapter.uploadApp(credentials, appPath);
|
|
38
|
+
cache[cacheKey] = app.remotePath;
|
|
39
|
+
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
40
|
+
await writeFile(cachePath, JSON.stringify(cache, null, 2));
|
|
41
|
+
return { app, status: await adapter.getAppStatus(credentials, app) };
|
|
42
|
+
}
|
|
@@ -8,10 +8,11 @@ export class CloudAppiumClient {
|
|
|
8
8
|
this.creds = creds;
|
|
9
9
|
}
|
|
10
10
|
async request(method, path, body = null) {
|
|
11
|
+
const authHeader = this.provider.getAuthHeader(this.creds);
|
|
11
12
|
const response = await fetch(`${this.provider.getHubUrl()}${path}`, {
|
|
12
13
|
method,
|
|
13
14
|
headers: {
|
|
14
|
-
Authorization:
|
|
15
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
15
16
|
...(body ? { "Content-Type": "application/json" } : {})
|
|
16
17
|
},
|
|
17
18
|
body: body ? JSON.stringify(body) : undefined
|