@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.
@@ -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 === "lambdatest") {
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 assertNoLambdaTestFlags(args) {
76
- const lambdaTestFlags = [
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 lambdaTestFlags) {
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 lambdatest.`);
90
+ throw new Error(`${label} can only be used with a cloud --device-source.`);
84
91
  }
85
92
  }
86
93
  }
87
- function normalizeLambdaTestAppPath(value) {
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("LambdaTest real-device sessions require an .apk for Android or an .ipa for iOS. .app bundles are not supported.");
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
- throw new Error("--app must point to an .apk or .ipa file for LambdaTest runs.");
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
- assertNoLambdaTestFlags(args);
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
- assertNoLambdaTestFlags(args);
167
+ assertNoCloudFlags(args);
135
168
  }
136
- if (deviceSource === "lambdatest") {
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 = normalizeLambdaTestAppPath(args.app);
172
+ const appPath = normalizeCloudAppPath(args.app, platform, deviceSource);
140
173
  if (avdName) {
141
- throw new Error("--avd cannot be used with --device-source lambdatest.");
174
+ throw new Error(`--avd cannot be used with --device-source ${deviceSource}.`);
142
175
  }
143
176
  if (!platform) {
144
- throw new Error("--platform is required with --device-source lambdatest.");
177
+ throw new Error(`--platform is required with --device-source ${deviceSource}.`);
145
178
  }
146
179
  if (!deviceName) {
147
- throw new Error("--device-name is required with --device-source lambdatest.");
180
+ throw new Error(`--device-name is required with --device-source ${deviceSource}.`);
148
181
  }
149
182
  if (!osVersion) {
150
- throw new Error("--os-version is required with --device-source lambdatest.");
183
+ throw new Error(`--os-version is required with --device-source ${deviceSource}.`);
151
184
  }
152
185
  if (!appPath) {
153
- throw new Error("--app is required with --device-source lambdatest.");
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: this.provider.getAuthHeader(this.creds),
15
+ ...(authHeader ? { Authorization: authHeader } : {}),
15
16
  ...(body ? { "Content-Type": "application/json" } : {})
16
17
  },
17
18
  body: body ? JSON.stringify(body) : undefined