@loadmill/droid-cua 2.29.0 → 2.31.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.
Files changed (40) hide show
  1. package/README.md +5 -5
  2. package/build/index.js +142 -10
  3. package/build/src/browser/cloud-browser-catalog.js +27 -0
  4. package/build/src/browser/factory.js +33 -10
  5. package/build/src/browser/gesture-dispatch.js +281 -0
  6. package/build/src/browser/interface.js +3 -0
  7. package/build/src/browser/normalize.js +3 -1
  8. package/build/src/browser/playwright/actions.js +5 -271
  9. package/build/src/browser/webdriver/actions.js +10 -0
  10. package/build/src/browser/webdriver/client.js +83 -0
  11. package/build/src/browser/webdriver/connection.js +300 -0
  12. package/build/src/browser/webdriver/page-facade.js +123 -0
  13. package/build/src/browser/webdriver/session-state.js +35 -0
  14. package/build/src/cli/headless-execution-config.js +1 -1
  15. package/build/src/cli/headless-runner.js +37 -14
  16. package/build/src/cli/headless-target.js +87 -16
  17. package/build/src/cli/headless-test-source-label.js +4 -0
  18. package/build/src/cli/loadmill-cloud-device-connect-summary.js +6 -0
  19. package/build/src/cli/loadmill-cloud-session.js +26 -0
  20. package/build/src/core/context-optimization.js +1 -1
  21. package/build/src/core/droid-version.js +25 -0
  22. package/build/src/core/execution-engine.js +9 -0
  23. package/build/src/core/runtime-config.js +2 -2
  24. package/build/src/desktop-shared/connection-predicates.js +52 -0
  25. package/build/src/device/cloud/cloud-appium-client.js +22 -2
  26. package/build/src/device/cloud/loadmill-cloud/adapter.js +28 -12
  27. package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +13 -7
  28. package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +25 -5
  29. package/build/src/device/cloud/loadmill-cloud/errors.js +25 -0
  30. package/build/src/device/cloud/loadmill-cloud/run-pool.js +41 -0
  31. package/build/src/device/cloud/loadmill-cloud/run-status.js +4 -0
  32. package/build/src/integrations/loadmill/run-reporter.js +43 -0
  33. package/package.json +1 -1
  34. package/skill/droid-cua/SKILL.md +10 -3
  35. package/skill/droid-cua/agents/openai.yaml +2 -2
  36. package/skill/droid-cua/references/authoring-tests.md +36 -6
  37. package/skill/droid-cua/references/iterative-improvement.md +143 -0
  38. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +19 -0
  39. package/skill/droid-cua/references/project-and-auth.md +2 -0
  40. package/skill/droid-cua/references/running-and-healing.md +32 -14
@@ -1,3 +1,4 @@
1
+ import { CLOUD_BROWSER_FAMILIES, CLOUD_BROWSER_VERSION_ALIASES, LOCAL_BROWSER_FAMILIES, } from "../browser/cloud-browser-catalog.js";
1
2
  function normalizeTarget(value) {
2
3
  if (value == null || value === "") {
3
4
  return "mobile";
@@ -24,18 +25,31 @@ function normalizeSessionMode(value) {
24
25
  }
25
26
  throw new Error("--session-mode must be one of: persistent, fresh.");
26
27
  }
27
- function normalizeBrowser(value) {
28
+ function normalizeBrowser(value, allowedFamilies) {
28
29
  if (value == null || value === "") {
29
30
  return "chrome";
30
31
  }
31
32
  if (typeof value !== "string") {
32
- throw new Error("--browser must be one of: chrome, edge.");
33
+ throw new Error(`--browser must be one of: ${allowedFamilies.join(", ")}.`);
33
34
  }
34
35
  const normalized = value.trim().toLowerCase();
35
- if (normalized === "chrome" || normalized === "edge") {
36
+ if (allowedFamilies.includes(normalized)) {
36
37
  return normalized;
37
38
  }
38
- throw new Error("--browser must be one of: chrome, edge.");
39
+ throw new Error(`--browser must be one of: ${allowedFamilies.join(", ")}.`);
40
+ }
41
+ function normalizeCloudBrowserVersion(value) {
42
+ if (value == null || value === "") {
43
+ return "latest";
44
+ }
45
+ if (typeof value !== "string") {
46
+ throw new Error(`--browser-version must be one of: ${CLOUD_BROWSER_VERSION_ALIASES.join(", ")}.`);
47
+ }
48
+ const normalized = value.trim().toLowerCase();
49
+ if (CLOUD_BROWSER_VERSION_ALIASES.includes(normalized)) {
50
+ return normalized;
51
+ }
52
+ throw new Error(`--browser-version must be one of: ${CLOUD_BROWSER_VERSION_ALIASES.join(", ")}.`);
39
53
  }
40
54
  const CLOUD_SOURCE_DISPLAY_NAMES = {
41
55
  lambdatest: "LambdaTest",
@@ -80,25 +94,48 @@ function readNonEmptyCliValue(value) {
80
94
  }
81
95
  return null;
82
96
  }
83
- const ARTIFACT_KINDS = ["video", "deviceLog", "appiumLog"];
84
- function normalizeArtifactKinds(value) {
97
+ export const MOBILE_ARTIFACT_KINDS = ["video", "deviceLog", "appiumLog"];
98
+ export const WEB_ARTIFACT_KINDS = ["video", "seleniumLog"];
99
+ const UNRESOLVED_POOL_ARTIFACT_KINDS = [...MOBILE_ARTIFACT_KINDS, "seleniumLog"];
100
+ const ARTIFACT_KINDS_BY_POOL = {
101
+ mobile: MOBILE_ARTIFACT_KINDS,
102
+ web: WEB_ARTIFACT_KINDS,
103
+ };
104
+ export function artifactKindsForPool(pool) {
105
+ const kinds = ARTIFACT_KINDS_BY_POOL[pool];
106
+ if (!kinds) {
107
+ throw new Error(`Unknown Loadmill Cloud pool: ${pool}. Expected "mobile" or "web".`);
108
+ }
109
+ return [...kinds];
110
+ }
111
+ export function scopeArtifactKindsToPool(kinds, pool) {
112
+ const allowed = artifactKindsForPool(pool);
113
+ return kinds.filter((kind) => allowed.includes(kind));
114
+ }
115
+ function allowedArtifactKindsForTarget(target, poolKnown) {
116
+ if (!poolKnown) {
117
+ return UNRESOLVED_POOL_ARTIFACT_KINDS;
118
+ }
119
+ return target === "web" ? WEB_ARTIFACT_KINDS : MOBILE_ARTIFACT_KINDS;
120
+ }
121
+ function normalizeArtifactKinds(value, allowedKinds) {
85
122
  if (value == null || value === "") {
86
123
  return null;
87
124
  }
88
125
  if (typeof value !== "string") {
89
- throw new Error(`--artifacts must be a comma-separated list of: ${ARTIFACT_KINDS.join(", ")}, or all.`);
126
+ throw new Error(`--artifacts must be a comma-separated list of: ${allowedKinds.join(", ")}, or all.`);
90
127
  }
91
128
  const raw = value.trim();
92
129
  if (!raw) {
93
130
  return null;
94
131
  }
95
132
  if (raw.toLowerCase() === "all") {
96
- return [...ARTIFACT_KINDS];
133
+ return [...allowedKinds];
97
134
  }
98
135
  const kinds = [...new Set(raw.split(",").map((kind) => kind.trim()).filter(Boolean))];
99
- const invalid = kinds.filter((kind) => !ARTIFACT_KINDS.includes(kind));
136
+ const invalid = kinds.filter((kind) => !allowedKinds.includes(kind));
100
137
  if (invalid.length > 0) {
101
- throw new Error(`--artifacts has invalid kind(s): ${invalid.join(", ")}. Must be one of: ${ARTIFACT_KINDS.join(", ")}, or all.`);
138
+ throw new Error(`--artifacts has invalid kind(s): ${invalid.join(", ")}. Must be one of: ${allowedKinds.join(", ")}, or all.`);
102
139
  }
103
140
  return kinds;
104
141
  }
@@ -144,9 +181,10 @@ function normalizeCloudAppPath(value, platform, deviceSource) {
144
181
  export function shouldWaitForLoadmillCloudCompletion(targetConfig) {
145
182
  return Array.isArray(targetConfig?.artifacts) && targetConfig.artifacts.length > 0;
146
183
  }
184
+ const MOBILE_ONLY_CLOUD_SOURCES = new Set(["lambdatest", "aws-device-farm"]);
147
185
  export function resolveHeadlessTarget(args = {}) {
148
186
  const target = normalizeTarget(args.target);
149
- const sessionMode = normalizeSessionMode(args["session-mode"]);
187
+ const targetGiven = hasNonEmptyString(args.target);
150
188
  const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
151
189
  const platform = normalizeMobilePlatform(args.platform, "--platform");
152
190
  // The mtc_ prefix on --cloud-session-id is loadmill-cloud's; when no
@@ -164,14 +202,44 @@ export function resolveHeadlessTarget(args = {}) {
164
202
  if (avdName || platform) {
165
203
  throw new Error("--avd and --platform cannot be used with --target web.");
166
204
  }
167
- if (deviceSource !== "local") {
168
- throw new Error("--device-source can only be used with --target mobile.");
205
+ if (MOBILE_ONLY_CLOUD_SOURCES.has(deviceSource)) {
206
+ throw new Error(`--device-source ${deviceSource} can only be used with --target mobile.`);
207
+ }
208
+ if (hasNonEmptyString(args["device-name"])) {
209
+ throw new Error("--device-name cannot be used with --target web.");
210
+ }
211
+ if (readNonEmptyCliValue(args["os-version"]) != null) {
212
+ throw new Error("--os-version cannot be used with --target web.");
213
+ }
214
+ if (hasNonEmptyString(args.app)) {
215
+ throw new Error("--app cannot be used with --target web.");
216
+ }
217
+ if (deviceSource === "loadmill-cloud") {
218
+ if (args["session-mode"] != null) {
219
+ throw new Error("--session-mode can only be used with --device-source local.");
220
+ }
221
+ return {
222
+ target,
223
+ sessionMode: null,
224
+ browserFamily: normalizeBrowser(args.browser, CLOUD_BROWSER_FAMILIES),
225
+ browserVersion: normalizeCloudBrowserVersion(args["browser-version"]),
226
+ deviceSource: "loadmill-cloud",
227
+ avdName: null,
228
+ platform: null,
229
+ deviceName: null,
230
+ osVersion: null,
231
+ appPath: null,
232
+ cloudSessionId: readNonEmptyCliValue(args["cloud-session-id"]),
233
+ artifacts: normalizeArtifactKinds(args.artifacts, WEB_ARTIFACT_KINDS),
234
+ listDevices: false,
235
+ };
169
236
  }
170
237
  assertNoCloudFlags(args);
171
238
  return {
172
239
  target,
173
- sessionMode,
174
- browserFamily: normalizeBrowser(args.browser),
240
+ sessionMode: normalizeSessionMode(args["session-mode"]),
241
+ browserFamily: normalizeBrowser(args.browser, LOCAL_BROWSER_FAMILIES),
242
+ browserVersion: null,
175
243
  deviceSource: "local",
176
244
  avdName: null,
177
245
  platform: null,
@@ -195,6 +263,7 @@ export function resolveHeadlessTarget(args = {}) {
195
263
  target,
196
264
  sessionMode: null,
197
265
  browserFamily: null,
266
+ browserVersion: null,
198
267
  deviceSource,
199
268
  avdName: null,
200
269
  platform: null,
@@ -215,7 +284,7 @@ export function resolveHeadlessTarget(args = {}) {
215
284
  const cloudSessionId = readNonEmptyCliValue(args["cloud-session-id"]);
216
285
  const isReconnect = deviceSource === "loadmill-cloud" && Boolean(cloudSessionId);
217
286
  const appPath = normalizeCloudAppPath(args.app, platform, deviceSource);
218
- const artifacts = normalizeArtifactKinds(args.artifacts);
287
+ const artifacts = normalizeArtifactKinds(args.artifacts, allowedArtifactKindsForTarget(target, targetGiven || !isReconnect));
219
288
  if (artifacts && deviceSource !== "loadmill-cloud") {
220
289
  throw new Error("--artifacts can only be used with --device-source loadmill-cloud.");
221
290
  }
@@ -241,6 +310,7 @@ export function resolveHeadlessTarget(args = {}) {
241
310
  target,
242
311
  sessionMode: null,
243
312
  browserFamily: null,
313
+ browserVersion: null,
244
314
  deviceSource,
245
315
  avdName: null,
246
316
  platform,
@@ -256,6 +326,7 @@ export function resolveHeadlessTarget(args = {}) {
256
326
  target,
257
327
  sessionMode: null,
258
328
  browserFamily: null,
329
+ browserVersion: null,
259
330
  deviceSource,
260
331
  avdName,
261
332
  platform,
@@ -0,0 +1,4 @@
1
+ const INLINE_SOURCE_PATTERN = /^\/dev\/(fd\/\d+|stdin)$/;
2
+ export function formatHeadlessTestSourceLabel(sourcePath) {
3
+ return INLINE_SOURCE_PATTERN.test(sourcePath) ? "(inline instructions)" : sourcePath;
4
+ }
@@ -0,0 +1,6 @@
1
+ export function buildLoadmillCloudDeviceConnectSummaryLines({ deviceInfo }) {
2
+ return [
3
+ `Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`,
4
+ `Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`,
5
+ ];
6
+ }
@@ -0,0 +1,26 @@
1
+ export async function withLoadmillCloudSession({ credentials, connect, teardown, run = null }) {
2
+ const targetId = await connect(credentials);
3
+ if (typeof run !== "function") {
4
+ return targetId;
5
+ }
6
+ try {
7
+ return await run(targetId);
8
+ }
9
+ finally {
10
+ await teardown();
11
+ }
12
+ }
13
+ export function createBrowserSessionTeardown({ disconnect, releaseSession }) {
14
+ return async () => {
15
+ try {
16
+ await disconnect();
17
+ }
18
+ catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ console.error(`[loadmill-cloud] browser disconnect failed: ${message}`);
21
+ }
22
+ finally {
23
+ await releaseSession();
24
+ }
25
+ };
26
+ }
@@ -1,5 +1,5 @@
1
1
  export const DEFAULT_CONTEXT_OPTIMIZATION_ENABLED = true;
2
- export const DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD = 30000;
2
+ export const DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD = 60000;
3
3
  export const MIN_CONTEXT_OPTIMIZATION_THRESHOLD = 10000;
4
4
  export const MAX_CONTEXT_OPTIMIZATION_THRESHOLD = 900000;
5
5
  export const CONTEXT_OPTIMIZATION_ENABLED_ENV = "DROID_CUA_CONTEXT_OPTIMIZATION_ENABLED";
@@ -0,0 +1,25 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export function getDroidVersion(moduleUrl = import.meta.url) {
5
+ try {
6
+ let directory = path.dirname(fileURLToPath(moduleUrl));
7
+ while (true) {
8
+ const packagePath = path.join(directory, "package.json");
9
+ if (existsSync(packagePath)) {
10
+ const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
11
+ if (packageJson.name === "@loadmill/droid-cua") {
12
+ return typeof packageJson.version === "string" ? packageJson.version : null;
13
+ }
14
+ }
15
+ const parent = path.dirname(directory);
16
+ if (parent === directory) {
17
+ return null;
18
+ }
19
+ directory = parent;
20
+ }
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
@@ -503,6 +503,15 @@ export class ExecutionEngine {
503
503
  }
504
504
  emitResponseOutputItems(items, addOutput, eventMeta, options = {}) {
505
505
  for (const item of items) {
506
+ if (item.type === "compaction") {
507
+ addOutput({
508
+ type: "info",
509
+ text: "Optimized context",
510
+ eventType: "system_message",
511
+ ...eventMeta({ payload: { event: "context.optimized" } }),
512
+ });
513
+ continue;
514
+ }
506
515
  if (item.type === "reasoning") {
507
516
  for (const entry of item.summary) {
508
517
  if (entry.type !== "summary_text")
@@ -123,7 +123,7 @@ export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToke
123
123
  loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
124
124
  };
125
125
  }
126
- export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, explainMode = false, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
126
+ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, explainMode = true, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
127
127
  const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
128
128
  ? cuaModel
129
129
  : resolveCuaModel(cuaModel);
@@ -133,7 +133,7 @@ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null,
133
133
  ? "loadmill"
134
134
  : normalizedProviderMode,
135
135
  cuaModel: normalizedCuaModel,
136
- explainMode: explainMode === true,
136
+ explainMode: explainMode !== false,
137
137
  stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
138
138
  contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
139
139
  contextOptimizationThreshold: normalizeContextOptimizationThreshold(contextOptimizationThreshold),
@@ -0,0 +1,52 @@
1
+ const CLOUD_CONNECTION_SOURCES = new Set(["browserstack", "lambdatest", "aws-device-farm", "loadmill-cloud"]);
2
+ /**
3
+ * True when a desktop connection needs the cloud-session lifecycle
4
+ * (provisioning, heartbeat, release) regardless of whether it targets a
5
+ * device or a browser.
6
+ *
7
+ * @param {{ source?: string | null }} connection
8
+ * @returns {boolean}
9
+ */
10
+ export function needsCloudSessionManagement(connection) {
11
+ return CLOUD_CONNECTION_SOURCES.has(connection?.source);
12
+ }
13
+ /**
14
+ * True when a desktop connection should route through the browser engine
15
+ * hooks (screenshot/action/platform) instead of the device ones.
16
+ *
17
+ * @param {{ platform?: string | null }} connection
18
+ * @returns {boolean}
19
+ */
20
+ export function usesBrowserEngineHooks(connection) {
21
+ return connection?.platform === "browser";
22
+ }
23
+ /**
24
+ * True when a desktop connection needs the Android tool-environment
25
+ * refresh, which only applies to Android device connections.
26
+ *
27
+ * @param {{ platform?: string | null }} connection
28
+ * @returns {boolean}
29
+ */
30
+ export function needsAndroidToolEnvRefresh(connection) {
31
+ return connection?.platform === "android";
32
+ }
33
+ /**
34
+ * The status/outcome pair a desktop provisioning failure must report so the
35
+ * remote Loadmill classification and the local JUnit history agree.
36
+ *
37
+ * @returns {{ status: "error", outcome: "error" }}
38
+ */
39
+ export function resolveProvisioningFailureReporting() {
40
+ return { status: "error", outcome: "error" };
41
+ }
42
+ /**
43
+ * Builds a `connectToBrowser` engine hook that refuses to relaunch a
44
+ * browser mid-run instead of silently starting a local one.
45
+ *
46
+ * @returns {() => Promise<never>}
47
+ */
48
+ export function createRefuseReconnectConnectToBrowser() {
49
+ return async () => {
50
+ throw new Error("Cloud session disconnected; automatic CLI reconnect is not supported yet.");
51
+ };
52
+ }
@@ -9,14 +9,34 @@ export class CloudAppiumClient {
9
9
  }
10
10
  async request(method, path, body = null) {
11
11
  const authHeader = this.provider.getAuthHeader(this.creds);
12
- const response = await fetch(`${this.provider.getHubUrl()}${path}`, {
12
+ const requestOptions = {
13
13
  method,
14
14
  headers: {
15
15
  ...(authHeader ? { Authorization: authHeader } : {}),
16
16
  ...(body ? { "Content-Type": "application/json" } : {})
17
17
  },
18
18
  body: body ? JSON.stringify(body) : undefined
19
- });
19
+ };
20
+ try {
21
+ return await this._sendRequest(path, requestOptions);
22
+ }
23
+ catch (error) {
24
+ if (error.message !== "Invalid pre-signed URL" || typeof this.provider.refreshWebdriverEndpoint !== "function") {
25
+ throw error;
26
+ }
27
+ try {
28
+ await this.provider.refreshWebdriverEndpoint();
29
+ }
30
+ catch (refreshError) {
31
+ throw new Error(`Failed to refresh the expired cloud WebDriver endpoint: ${refreshError.message}`, {
32
+ cause: error
33
+ });
34
+ }
35
+ return this._sendRequest(path, requestOptions);
36
+ }
37
+ }
38
+ async _sendRequest(path, requestOptions) {
39
+ const response = await fetch(`${this.provider.getHubUrl()}${path}`, requestOptions);
20
40
  const text = await response.text();
21
41
  const payload = text ? JSON.parse(text) : {};
22
42
  if (!response.ok) {
@@ -4,13 +4,9 @@ import { reportCloudNote, reportCloudPhase, reportCloudPhaseCloseText, reportClo
4
4
  import { LoadmillCloudControlPlaneClient } from "./control-plane-client.js";
5
5
  import { downloadArtifacts } from "./artifact-downloader.js";
6
6
  import { formatBytes } from "./format-bytes.js";
7
+ import { ARTIFACT_READY_STATUSES, TERMINAL_STATUSES } from "./run-status.js";
7
8
  import { timingLog } from "./timing.js";
8
9
  const RESUMABLE_STATUSES = new Set(["PROVISIONED", "CONNECTED", "RUNNING"]);
9
- const TERMINAL_STATUSES = new Set(["COMPLETED", "CANCELLED", "ERROR"]);
10
- // Artifacts are readable from DF while a run is still tearing down
11
- // (STOPPING), not just once it reaches a true terminal state. Mirrors the
12
- // server's ARTIFACT_READY_STATES.
13
- const ARTIFACT_READY_STATUSES = new Set(["STOPPING", ...TERMINAL_STATUSES]);
14
10
  const SLOT_HELD_STATUSES = new Set(["PREPARING", "PROVISIONED", "CONNECTED", "RUNNING"]);
15
11
  const ARTIFACT_POLL_BUDGET_MS = 3 * 60_000;
16
12
  const ARTIFACT_POLL_INTERVAL_MS = 5_000;
@@ -25,7 +21,11 @@ function deviceCacheKey(creds) {
25
21
  }
26
22
  let activeRun = null;
27
23
  function makeClient(creds) {
28
- return new LoadmillCloudControlPlaneClient({ bearerToken: creds.bearerToken, baseUrl: creds.baseUrl });
24
+ return new LoadmillCloudControlPlaneClient({
25
+ bearerToken: creds.bearerToken,
26
+ baseUrl: creds.baseUrl,
27
+ pool: "mobile",
28
+ });
29
29
  }
30
30
  function mapDevice(device) {
31
31
  return {
@@ -189,11 +189,9 @@ async function pollForArtifacts(client, sessionId, kinds, { budgetMs, intervalMs
189
189
  }
190
190
  catch (error) {
191
191
  const retryable = (error?.status === 409 && error?.code === "artifacts_not_ready") ||
192
- (error?.status === 502 && error?.code === "mobile_testing_artifacts_failed");
192
+ (error?.status >= 500 && error?.status < 600);
193
193
  if (retryable) {
194
- timingLog(error.status === 409
195
- ? `poll #${pollNumber}: 409 artifacts_not_ready`
196
- : `poll #${pollNumber}: 502 mobile_testing_artifacts_failed`);
194
+ timingLog(`poll #${pollNumber}: ${error.status} ${error.code ?? "unknown"}`);
197
195
  }
198
196
  else {
199
197
  throw error;
@@ -218,7 +216,11 @@ function formatDownloadDetail(fileName, bytesWritten, totalBytes) {
218
216
  }
219
217
  return `downloading ${fileName} — ${formatBytes(bytesWritten)}`;
220
218
  }
221
- async function fetchAndDownloadArtifacts({ client, sessionId, kinds }) {
219
+ export function formatArtifactRetryCommand({ client, sessionId, kinds }) {
220
+ const targetFlag = client?.pool === "web" ? "--target web " : "";
221
+ return `droid-cua ${targetFlag}--cloud-session-id ${sessionId} --artifacts ${kinds.join(",")}`;
222
+ }
223
+ export async function fetchAndDownloadArtifacts({ client, sessionId, kinds }) {
222
224
  reportCloudPhase("artifact list");
223
225
  const { artifacts, missingKinds, elapsedMs } = await pollForArtifacts(client, sessionId, kinds, {
224
226
  budgetMs: ARTIFACT_POLL_BUDGET_MS,
@@ -256,11 +258,14 @@ async function fetchAndDownloadArtifacts({ client, sessionId, kinds }) {
256
258
  reportCloudPrint(`Artifacts saved to ${destDir}`);
257
259
  }
258
260
  if (missingKinds.length > 0) {
259
- reportCloudNote(`WARNING: artifacts not ready for: ${missingKinds.join(", ")}. Retry later with: droid-cua --cloud-session-id ${sessionId} --artifacts ${kinds.join(",")}`);
261
+ reportCloudNote(`WARNING: artifacts not ready for: ${missingKinds.join(", ")}. Retry later with: ${formatArtifactRetryCommand({ client, sessionId, kinds })}`);
260
262
  }
261
263
  return { downloadedKinds: summary.map((item) => item.kind), missingKinds };
262
264
  }
263
265
  async function uploadAppIfNeeded(client, sessionId, { app, platform, signal }) {
266
+ if (!app) {
267
+ return null;
268
+ }
264
269
  const fileName = path.basename(app);
265
270
  const sha256 = await hashFile(app);
266
271
  reportCloudPhase("register app");
@@ -470,6 +475,17 @@ export const loadmillCloudAdapter = {
470
475
  }
471
476
  return activeRun.webdriverEndpoint;
472
477
  },
478
+ async refreshWebdriverEndpoint() {
479
+ if (!activeRun) {
480
+ throw new Error("Loadmill Cloud cannot refresh the webdriver endpoint without an active session.");
481
+ }
482
+ const result = await activeRun.client.refreshWebdriverEndpoint(activeRun.sessionId);
483
+ if (!result?.webdriverEndpoint || typeof result.webdriverEndpoint !== "string") {
484
+ throw new Error("Loadmill Cloud endpoint refresh returned no webdriver endpoint.");
485
+ }
486
+ activeRun.webdriverEndpoint = result.webdriverEndpoint;
487
+ return activeRun.webdriverEndpoint;
488
+ },
473
489
  getAuthHeader() {
474
490
  // Presigned hub URL carries its own auth; no header needed.
475
491
  return "";
@@ -4,9 +4,9 @@ import { mkdir } from "node:fs/promises";
4
4
  import { Readable, Transform } from "node:stream";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { isTimingEnabled, timingLog } from "./timing.js";
7
- // Mirrors the server-side caps: per-artifact 1 GiB (video/deviceLog/appiumLog),
7
+ // Mirrors the server-side caps: per-artifact 1 GiB (video/deviceLog/appiumLog/seleniumLog),
8
8
  // 4 GiB cumulative across a single download batch.
9
- export const ARTIFACT_KINDS = ["video", "deviceLog", "appiumLog"];
9
+ export const ARTIFACT_KINDS = ["video", "deviceLog", "appiumLog", "seleniumLog"];
10
10
  export const PER_ARTIFACT_CAP_BYTES = 1 * 1024 * 1024 * 1024;
11
11
  export const CUMULATIVE_CAP_BYTES = 4 * 1024 * 1024 * 1024;
12
12
  export const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
@@ -14,6 +14,7 @@ const KIND_DEFAULT_FILENAMES = {
14
14
  video: "video.mp4",
15
15
  deviceLog: "device.log",
16
16
  appiumLog: "appium.log",
17
+ seleniumLog: "selenium.log",
17
18
  };
18
19
  function normalizeKinds(kinds) {
19
20
  if (kinds == null || kinds === "all") {
@@ -24,8 +25,11 @@ function normalizeKinds(kinds) {
24
25
  }
25
26
  return [kinds];
26
27
  }
27
- function deriveFileName(kind, url) {
28
+ function deriveFileName(kind, url, indexForKind = 0) {
28
29
  const defaultName = KIND_DEFAULT_FILENAMES[kind] || `${kind}.bin`;
30
+ const suffix = indexForKind > 0 ? `-${indexForKind + 1}` : "";
31
+ const stemOf = (name) => (name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name);
32
+ const extOf = (name) => (name.includes(".") ? name.slice(name.lastIndexOf(".")) : "");
29
33
  try {
30
34
  const parsed = new URL(url);
31
35
  const base = path.basename(parsed.pathname);
@@ -33,14 +37,13 @@ function deriveFileName(kind, url) {
33
37
  // Allows extensions like ".logcat" (6 chars) — DF's device-log artifact
34
38
  // URLs use that extension and must not be forced to the ".log" default.
35
39
  if (/^\.[a-zA-Z0-9]{1,8}$/.test(ext)) {
36
- const stem = defaultName.includes(".") ? defaultName.slice(0, defaultName.lastIndexOf(".")) : defaultName;
37
- return `${stem}${ext}`;
40
+ return `${stemOf(defaultName)}${suffix}${ext}`;
38
41
  }
39
42
  }
40
43
  catch {
41
44
  // Not a parseable URL — fall back to the default name below.
42
45
  }
43
- return defaultName;
46
+ return `${stemOf(defaultName)}${suffix}${extOf(defaultName)}`;
44
47
  }
45
48
  // Truncates the stream in-place at `cap` bytes: chunks past the cap are
46
49
  // dropped (not pushed downstream), so the write stream stops growing while
@@ -118,7 +121,10 @@ perArtifactCapBytes = PER_ARTIFACT_CAP_BYTES, cumulativeCapBytes = CUMULATIVE_CA
118
121
  await mkdir(destDir, { recursive: true });
119
122
  const summary = [];
120
123
  let cumulativeBytes = 0;
124
+ const kindIndexCounters = new Map();
121
125
  for (const artifact of filtered) {
126
+ const indexForKind = kindIndexCounters.get(artifact.kind) ?? 0;
127
+ kindIndexCounters.set(artifact.kind, indexForKind + 1);
122
128
  if (cumulativeBytes >= cumulativeCapBytes) {
123
129
  onNote?.(`WARNING: ${artifact.kind} skipped — cumulative artifact cap (${cumulativeCapBytes} bytes) reached`);
124
130
  continue;
@@ -127,7 +133,7 @@ perArtifactCapBytes = PER_ARTIFACT_CAP_BYTES, cumulativeCapBytes = CUMULATIVE_CA
127
133
  if (typeof artifact.bytes === "number" && artifact.bytes > perArtifactCap) {
128
134
  onNote?.(`WARNING: ${artifact.kind} is ${artifact.bytes} bytes, exceeds the ${perArtifactCap} byte cap and will be truncated`);
129
135
  }
130
- const fileName = deriveFileName(artifact.kind, artifact.presignedGetUrl);
136
+ const fileName = deriveFileName(artifact.kind, artifact.presignedGetUrl, indexForKind);
131
137
  const filePath = path.join(destDir, fileName);
132
138
  const downloadStartedAt = Date.now();
133
139
  timingLog(`download start: kind=${artifact.kind} url=${artifact.presignedGetUrl}`);
@@ -1,7 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  const DEFAULT_BASE_URL = "https://app.loadmill.com";
4
- const MOBILE_TESTING_PATH = "/mobile-testing-cloud/v1";
4
+ const POOL_API_PATHS = {
5
+ mobile: "/mobile-testing-cloud/v1",
6
+ web: "/web-testing-cloud/v1",
7
+ };
8
+ const DEFAULT_POOL = "mobile";
5
9
  const LOADMILL_CLIENT_HEADER_VALUE = "cli";
6
10
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
7
11
  // The binary PUT carries the actual app file (tens to low-hundreds of MB) and needs a
@@ -13,7 +17,14 @@ const POLL_FALLBACK_INTERVAL_MS = 3_000;
13
17
  const SSE_RETRY_INTERVAL_MS = 5_000;
14
18
  // Server sends a `: ping` keepalive every 25s; treat total silence this long as a wedged connection.
15
19
  const SSE_IDLE_TIMEOUT_MS = 60_000;
16
- // This control plane mounts at /mobile-testing-cloud/v1, not under /api — do not append /api.
20
+ export function apiPathForPool(pool) {
21
+ const apiPath = POOL_API_PATHS[pool];
22
+ if (!apiPath) {
23
+ throw new Error(`Unknown Loadmill Cloud pool: ${pool}. Expected "mobile" or "web".`);
24
+ }
25
+ return apiPath;
26
+ }
27
+ // Each pool's control plane mounts at its own top-level path, not under /api — do not append /api.
17
28
  function normalizeApiBaseUrl(rawBaseUrl) {
18
29
  const candidate = (rawBaseUrl || DEFAULT_BASE_URL).trim();
19
30
  if (!candidate) {
@@ -67,20 +78,23 @@ async function buildResponseError(response) {
67
78
  }
68
79
  export class LoadmillCloudControlPlaneClient {
69
80
  /**
70
- * @param {{ bearerToken: string, baseUrl?: string, requestTimeoutMs?: number, uploadTimeoutMs?: number }} [options]
81
+ * @param {{ bearerToken: string, baseUrl?: string, pool?: "mobile" | "web", apiPath?: string, requestTimeoutMs?: number, uploadTimeoutMs?: number }} [options]
71
82
  */
72
- constructor({ bearerToken, baseUrl, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, uploadTimeoutMs = DEFAULT_UPLOAD_TIMEOUT_MS, } = {}) {
83
+ constructor({ bearerToken, baseUrl, pool = DEFAULT_POOL, apiPath, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, uploadTimeoutMs = DEFAULT_UPLOAD_TIMEOUT_MS, } = {}) {
73
84
  if (!bearerToken || typeof bearerToken !== "string" || !bearerToken.trim()) {
74
85
  throw new Error("LoadmillCloudControlPlaneClient requires a bearerToken.");
75
86
  }
87
+ const poolApiPath = apiPathForPool(pool);
76
88
  this.bearerToken = bearerToken.trim();
77
89
  this.baseUrl = normalizeApiBaseUrl(baseUrl);
90
+ this.pool = pool;
91
+ this.apiPath = apiPath ?? poolApiPath;
78
92
  this.requestTimeoutMs = requestTimeoutMs;
79
93
  this.uploadTimeoutMs = uploadTimeoutMs;
80
94
  this._heartbeatTimer = null;
81
95
  }
82
96
  _url(pathSuffix) {
83
- return `${this.baseUrl}${MOBILE_TESTING_PATH}${pathSuffix}`;
97
+ return `${this.baseUrl}${this.apiPath}${pathSuffix}`;
84
98
  }
85
99
  _authHeaders(extra = {}) {
86
100
  return {
@@ -136,6 +150,9 @@ export class LoadmillCloudControlPlaneClient {
136
150
  });
137
151
  }
138
152
  async registerApp(sessionId, { sha256, platform, fileName }) {
153
+ if (this.pool === "web") {
154
+ throw new Error("Loadmill Cloud web runs do not register an app — the web control plane has no /runs/:sessionId/app route.");
155
+ }
139
156
  return this._request(`/runs/${encodeURIComponent(sessionId)}/app`, {
140
157
  method: "POST",
141
158
  body: { sha256, platform, fileName },
@@ -176,6 +193,9 @@ export class LoadmillCloudControlPlaneClient {
176
193
  async heartbeat(sessionId, { signal } = {}) {
177
194
  return this._request(`/runs/${encodeURIComponent(sessionId)}/heartbeat`, { method: "POST", signal });
178
195
  }
196
+ async refreshWebdriverEndpoint(sessionId) {
197
+ return this._request(`/runs/${encodeURIComponent(sessionId)}/endpoint/refresh`, { method: "POST" });
198
+ }
179
199
  async getRunState(sessionId) {
180
200
  return this._request(`/runs/${encodeURIComponent(sessionId)}`);
181
201
  }
@@ -1,10 +1,25 @@
1
1
  export const LOADMILL_CLOUD_USAGE_LIMIT_ERROR_CODES = new Set([
2
2
  "mobile_testing_day_minute_quota_exceeded",
3
3
  "mobile_testing_week_minute_quota_exceeded",
4
+ "mobile_testing_month_minute_quota_exceeded",
4
5
  "mobile_testing_fixed_budget_minute_quota_exceeded",
5
6
  "mobile_testing_concurrency_exceeded",
6
7
  "mobile_testing_rate_limit_exceeded",
8
+ "loadmill_cloud_web_day_minute_quota_exceeded",
9
+ "loadmill_cloud_web_week_minute_quota_exceeded",
10
+ "loadmill_cloud_web_month_minute_quota_exceeded",
11
+ "loadmill_cloud_web_fixed_budget_minute_quota_exceeded",
12
+ "loadmill_cloud_web_concurrency_exceeded",
13
+ "loadmill_cloud_web_rate_limit_exceeded",
7
14
  ]);
15
+ export const LOADMILL_CLOUD_NOT_ENTITLED_ERROR_CODES = new Set([
16
+ "mobile_testing_cloud_not_allowed",
17
+ "loadmill_cloud_web_not_allowed",
18
+ ]);
19
+ const LOADMILL_CLOUD_NOT_ENTITLED_PRODUCT_NAMES = {
20
+ mobile_testing_cloud_not_allowed: "Loadmill Cloud mobile testing",
21
+ loadmill_cloud_web_not_allowed: "Loadmill Cloud web testing",
22
+ };
8
23
  export function isLoadmillCloudUsageLimitError(err) {
9
24
  if (!err || typeof err !== "object") {
10
25
  return false;
@@ -14,3 +29,13 @@ export function isLoadmillCloudUsageLimitError(err) {
14
29
  }
15
30
  return err.status === 429 && err.type === "rate_limit_error";
16
31
  }
32
+ export function isLoadmillCloudNotEntitledError(err) {
33
+ if (!err || typeof err !== "object") {
34
+ return false;
35
+ }
36
+ return typeof err.code === "string" && LOADMILL_CLOUD_NOT_ENTITLED_ERROR_CODES.has(err.code);
37
+ }
38
+ export function formatLoadmillCloudNotEntitledMessage(err) {
39
+ const product = LOADMILL_CLOUD_NOT_ENTITLED_PRODUCT_NAMES[err?.code] ?? "Loadmill Cloud";
40
+ return `Your team is not entitled to ${product}. Contact Loadmill support to enable it.`;
41
+ }