@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.
- package/README.md +5 -5
- package/build/index.js +142 -10
- package/build/src/browser/cloud-browser-catalog.js +27 -0
- package/build/src/browser/factory.js +33 -10
- package/build/src/browser/gesture-dispatch.js +281 -0
- package/build/src/browser/interface.js +3 -0
- package/build/src/browser/normalize.js +3 -1
- package/build/src/browser/playwright/actions.js +5 -271
- package/build/src/browser/webdriver/actions.js +10 -0
- package/build/src/browser/webdriver/client.js +83 -0
- package/build/src/browser/webdriver/connection.js +300 -0
- package/build/src/browser/webdriver/page-facade.js +123 -0
- package/build/src/browser/webdriver/session-state.js +35 -0
- package/build/src/cli/headless-execution-config.js +1 -1
- package/build/src/cli/headless-runner.js +37 -14
- package/build/src/cli/headless-target.js +87 -16
- package/build/src/cli/headless-test-source-label.js +4 -0
- package/build/src/cli/loadmill-cloud-device-connect-summary.js +6 -0
- package/build/src/cli/loadmill-cloud-session.js +26 -0
- package/build/src/core/context-optimization.js +1 -1
- package/build/src/core/droid-version.js +25 -0
- package/build/src/core/execution-engine.js +9 -0
- package/build/src/core/runtime-config.js +2 -2
- package/build/src/desktop-shared/connection-predicates.js +52 -0
- package/build/src/device/cloud/cloud-appium-client.js +22 -2
- package/build/src/device/cloud/loadmill-cloud/adapter.js +28 -12
- package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +13 -7
- package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +25 -5
- package/build/src/device/cloud/loadmill-cloud/errors.js +25 -0
- package/build/src/device/cloud/loadmill-cloud/run-pool.js +41 -0
- package/build/src/device/cloud/loadmill-cloud/run-status.js +4 -0
- package/build/src/integrations/loadmill/run-reporter.js +43 -0
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +10 -3
- package/skill/droid-cua/agents/openai.yaml +2 -2
- package/skill/droid-cua/references/authoring-tests.md +36 -6
- package/skill/droid-cua/references/iterative-improvement.md +143 -0
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +19 -0
- package/skill/droid-cua/references/project-and-auth.md +2 -0
- 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(
|
|
33
|
+
throw new Error(`--browser must be one of: ${allowedFamilies.join(", ")}.`);
|
|
33
34
|
}
|
|
34
35
|
const normalized = value.trim().toLowerCase();
|
|
35
|
-
if (normalized
|
|
36
|
+
if (allowedFamilies.includes(normalized)) {
|
|
36
37
|
return normalized;
|
|
37
38
|
}
|
|
38
|
-
throw new Error(
|
|
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
|
|
84
|
-
|
|
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: ${
|
|
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 [...
|
|
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) => !
|
|
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: ${
|
|
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
|
|
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
|
|
168
|
-
throw new Error(
|
|
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,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 =
|
|
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 =
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
192
|
+
(error?.status >= 500 && error?.status < 600);
|
|
193
193
|
if (retryable) {
|
|
194
|
-
timingLog(error.status
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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}${
|
|
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
|
+
}
|