@loadmill/droid-cua 2.29.0 → 2.30.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 +4 -4
- 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 +26 -13
- 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/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/loadmill-cloud/adapter.js +17 -12
- package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +13 -7
- package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +22 -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/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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const W3C_KEY_CODES = {
|
|
2
|
+
Backspace: "",
|
|
3
|
+
Tab: "",
|
|
4
|
+
Enter: "",
|
|
5
|
+
Shift: "",
|
|
6
|
+
Control: "",
|
|
7
|
+
Alt: "",
|
|
8
|
+
Escape: "",
|
|
9
|
+
Space: "",
|
|
10
|
+
PageUp: "",
|
|
11
|
+
PageDown: "",
|
|
12
|
+
End: "",
|
|
13
|
+
Home: "",
|
|
14
|
+
ArrowLeft: "",
|
|
15
|
+
ArrowUp: "",
|
|
16
|
+
ArrowRight: "",
|
|
17
|
+
ArrowDown: "",
|
|
18
|
+
Delete: "",
|
|
19
|
+
Meta: "",
|
|
20
|
+
};
|
|
21
|
+
const SCROLL_BY_SCRIPT = "window.scrollBy(arguments[0], arguments[1]);";
|
|
22
|
+
function keyCodeFor(name) {
|
|
23
|
+
return W3C_KEY_CODES[name] ?? name;
|
|
24
|
+
}
|
|
25
|
+
function pointerSource(steps) {
|
|
26
|
+
return [{ type: "pointer", id: "mouse", parameters: { pointerType: "mouse" }, actions: steps }];
|
|
27
|
+
}
|
|
28
|
+
function keySource(steps) {
|
|
29
|
+
return [{ type: "key", id: "keyboard", actions: steps }];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A Playwright-`page`-shaped facade over a `WebDriverClient`, exposing only
|
|
33
|
+
* the surface the shared gesture dispatch drives and encoding every gesture
|
|
34
|
+
* to its exact W3C JSON body.
|
|
35
|
+
*
|
|
36
|
+
* @param {{ client: import("./client.js").WebDriverClient, scrollMode?: "wheel"|"script" }} options
|
|
37
|
+
*/
|
|
38
|
+
export function createWebDriverPageFacade({ client, scrollMode = "wheel" }) {
|
|
39
|
+
return {
|
|
40
|
+
mouse: {
|
|
41
|
+
async click(x, y) {
|
|
42
|
+
await client.performActions(pointerSource([
|
|
43
|
+
{ type: "pointerMove", duration: 0, x, y },
|
|
44
|
+
{ type: "pointerDown", button: 0 },
|
|
45
|
+
{ type: "pause", duration: 100 },
|
|
46
|
+
{ type: "pointerUp", button: 0 },
|
|
47
|
+
]));
|
|
48
|
+
},
|
|
49
|
+
async dblclick(x, y) {
|
|
50
|
+
await client.performActions(pointerSource([
|
|
51
|
+
{ type: "pointerMove", duration: 0, x, y },
|
|
52
|
+
{ type: "pointerDown", button: 0 },
|
|
53
|
+
{ type: "pointerUp", button: 0 },
|
|
54
|
+
{ type: "pointerDown", button: 0 },
|
|
55
|
+
{ type: "pointerUp", button: 0 },
|
|
56
|
+
]));
|
|
57
|
+
},
|
|
58
|
+
async move(x, y) {
|
|
59
|
+
await client.performActions(pointerSource([{ type: "pointerMove", duration: 0, x, y }]));
|
|
60
|
+
},
|
|
61
|
+
async down() {
|
|
62
|
+
await client.performActions(pointerSource([{ type: "pointerDown", button: 0 }]));
|
|
63
|
+
},
|
|
64
|
+
async up() {
|
|
65
|
+
await client.performActions(pointerSource([{ type: "pointerUp", button: 0 }]));
|
|
66
|
+
},
|
|
67
|
+
async wheel(deltaX, deltaY) {
|
|
68
|
+
if (scrollMode === "script") {
|
|
69
|
+
await client.executeScript(SCROLL_BY_SCRIPT, [deltaX, deltaY]);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await client.performActions([
|
|
73
|
+
{
|
|
74
|
+
type: "wheel",
|
|
75
|
+
id: "wheel",
|
|
76
|
+
actions: [{ type: "scroll", x: 0, y: 0, deltaX, deltaY, duration: 0 }],
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
keyboard: {
|
|
82
|
+
async down(key) {
|
|
83
|
+
await client.performActions(keySource([{ type: "keyDown", value: key }]));
|
|
84
|
+
},
|
|
85
|
+
async up(key) {
|
|
86
|
+
await client.performActions(keySource([{ type: "keyUp", value: key }]));
|
|
87
|
+
},
|
|
88
|
+
async press(shortcut) {
|
|
89
|
+
const codes = String(shortcut).split("+").map(keyCodeFor);
|
|
90
|
+
const downs = codes.map((value) => ({ type: "keyDown", value }));
|
|
91
|
+
const ups = [...codes].reverse().map((value) => ({ type: "keyUp", value }));
|
|
92
|
+
await client.performActions(keySource([...downs, ...ups]));
|
|
93
|
+
},
|
|
94
|
+
async type(text) {
|
|
95
|
+
const steps = [];
|
|
96
|
+
for (const char of String(text)) {
|
|
97
|
+
steps.push({ type: "keyDown", value: char });
|
|
98
|
+
steps.push({ type: "keyUp", value: char });
|
|
99
|
+
}
|
|
100
|
+
await client.performActions(keySource(steps));
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
async goto(url) {
|
|
104
|
+
await client.navigateTo(url);
|
|
105
|
+
},
|
|
106
|
+
async goBack() {
|
|
107
|
+
await client.goBack();
|
|
108
|
+
},
|
|
109
|
+
async goForward() {
|
|
110
|
+
await client.goForward();
|
|
111
|
+
},
|
|
112
|
+
async url() {
|
|
113
|
+
return await client.executeScript("return window.location.href;", []);
|
|
114
|
+
},
|
|
115
|
+
async screenshot() {
|
|
116
|
+
const base64 = await client.getScreenshot();
|
|
117
|
+
return Buffer.from(base64, "base64");
|
|
118
|
+
},
|
|
119
|
+
async waitForTimeout(ms) {
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
let activeSession = null;
|
|
2
|
+
export function setActiveWebDriverSession(session) {
|
|
3
|
+
activeSession = session;
|
|
4
|
+
}
|
|
5
|
+
export function clearActiveWebDriverSession() {
|
|
6
|
+
activeSession = null;
|
|
7
|
+
}
|
|
8
|
+
export function getActiveWebDriverSession(browserSessionId = null) {
|
|
9
|
+
if (!activeSession) {
|
|
10
|
+
throw new Error("Not attached to a WebDriver session. Call connectToBrowser first.");
|
|
11
|
+
}
|
|
12
|
+
if (browserSessionId && activeSession.sessionId !== browserSessionId) {
|
|
13
|
+
throw new Error("Requested WebDriver session is no longer active.");
|
|
14
|
+
}
|
|
15
|
+
return activeSession;
|
|
16
|
+
}
|
|
17
|
+
export function getActiveWebDriverPage(browserSessionId = null) {
|
|
18
|
+
return getActiveWebDriverSession(browserSessionId).page;
|
|
19
|
+
}
|
|
20
|
+
export function reportRunningOnFirstUse(browserSessionId = null) {
|
|
21
|
+
let session = null;
|
|
22
|
+
try {
|
|
23
|
+
session = getActiveWebDriverSession(browserSessionId);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (!session.cloud || session.cloud.runningReported) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
session.cloud.runningReported = true;
|
|
32
|
+
session.cloud.client.reportStatus(session.cloud.sessionId, "RUNNING").catch((error) => {
|
|
33
|
+
console.error(`[loadmill-cloud] failed to report RUNNING status: ${error.message}`);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -224,7 +224,7 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
224
224
|
llmProviderMode: fileConfig?.llmProviderMode || readEnvDefaultLlmProviderMode(),
|
|
225
225
|
cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
|
|
226
226
|
aimAssistMode: fileConfig?.aimAssistMode || DEFAULT_AIM_ASSIST_MODE,
|
|
227
|
-
explainMode: fileConfig?.explainMode ??
|
|
227
|
+
explainMode: fileConfig?.explainMode ?? true,
|
|
228
228
|
promptCustomizations,
|
|
229
229
|
appContextEnabled: fileConfig?.appContextEnabled ?? true,
|
|
230
230
|
appContextPath: fileConfig?.appContextPath || null,
|
|
@@ -12,7 +12,9 @@ import { reportDroidCuaCiTestRunCompleted } from "../integrations/segment/droid-
|
|
|
12
12
|
import { printCliOutput } from "../utils/console-output.js";
|
|
13
13
|
import { emitDesktopDebug } from "../utils/desktop-debug.js";
|
|
14
14
|
import { loadHeadlessInstructions } from "./headless-instructions.js";
|
|
15
|
+
import { formatHeadlessTestSourceLabel } from "./headless-test-source-label.js";
|
|
15
16
|
import { buildCliRunReportModel, createCapturingAddOutput, createCliAggregateRunReport, createCliRunReport, renderCliCloudReport, writeCliRunReportArtifact, } from "./headless-report.js";
|
|
17
|
+
import { createRefuseReconnectConnectToBrowser } from "../desktop-shared/connection-predicates.js";
|
|
16
18
|
function sanitizePathSegment(value) {
|
|
17
19
|
return String(value || "test")
|
|
18
20
|
.replace(/\.dcua$/i, "")
|
|
@@ -80,7 +82,10 @@ function emitCliAppContextDebug(runId, appContext, appContextText) {
|
|
|
80
82
|
});
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
|
-
function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, onCuaResponse = null, secrets = null }) {
|
|
85
|
+
export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, onCuaResponse = null, secrets = null }) {
|
|
86
|
+
const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
|
|
87
|
+
const isBrowserRunTarget = Boolean(resolvedRunTarget.isBrowserTarget);
|
|
88
|
+
const isCloudRunTarget = Boolean(resolvedRunTarget.isCloudTarget);
|
|
84
89
|
const sendCUARequest = async (requestArgs) => {
|
|
85
90
|
const response = await runtimeContext.openAi.sendCUARequest({
|
|
86
91
|
...requestArgs,
|
|
@@ -94,8 +99,8 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
|
|
|
94
99
|
const executionEngineOptions = {
|
|
95
100
|
toolCapabilities: {
|
|
96
101
|
assert: true,
|
|
97
|
-
navigate:
|
|
98
|
-
closeKeyboard: !
|
|
102
|
+
navigate: isBrowserRunTarget,
|
|
103
|
+
closeKeyboard: !isBrowserRunTarget,
|
|
99
104
|
loadmill: true,
|
|
100
105
|
},
|
|
101
106
|
secretsByKey: secrets?.secretsByKey || {},
|
|
@@ -119,19 +124,24 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
|
|
|
119
124
|
reviseTestScript: runtimeContext.openAi.reviseTestScript,
|
|
120
125
|
...executionEngineOptions,
|
|
121
126
|
};
|
|
122
|
-
if (
|
|
127
|
+
if (isBrowserRunTarget) {
|
|
123
128
|
engineOptions.getScreenshotAsBase64 = (browserSessionId, browserInfo) => getBrowserScreenshotAsBase64(browserSessionId, browserInfo);
|
|
124
129
|
engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
|
|
125
130
|
engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
|
|
126
131
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
132
|
+
if (isCloudRunTarget) {
|
|
133
|
+
if (isBrowserRunTarget) {
|
|
134
|
+
engineOptions.connectToBrowser = createRefuseReconnectConnectToBrowser();
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
engineOptions.connectToDevice = async () => {
|
|
138
|
+
throw new Error("Cloud session disconnected; automatic CLI reconnect is not supported yet.");
|
|
139
|
+
};
|
|
140
|
+
}
|
|
131
141
|
}
|
|
132
142
|
return engineOptions;
|
|
133
143
|
}
|
|
134
|
-
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
|
|
144
|
+
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
|
|
135
145
|
let executionMode = null;
|
|
136
146
|
let loadmillSessionUser = null;
|
|
137
147
|
let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
|
|
@@ -181,7 +191,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
181
191
|
if (currentLogFilePath) {
|
|
182
192
|
console.log(`Debug logging enabled: ${currentLogFilePath}`);
|
|
183
193
|
}
|
|
184
|
-
console.log(`\nRunning test from: ${testPath}\n`);
|
|
194
|
+
console.log(`\nRunning test from: ${formatHeadlessTestSourceLabel(testPath)}\n`);
|
|
185
195
|
const appContextText = appContext.text;
|
|
186
196
|
emitCliAppContextDebug(runId, appContext, appContextText);
|
|
187
197
|
session.updateResponseId(undefined);
|
|
@@ -208,6 +218,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
208
218
|
screenshotDir,
|
|
209
219
|
isBrowserTarget,
|
|
210
220
|
isCloudTarget,
|
|
221
|
+
runTarget,
|
|
211
222
|
onCuaResponse: (response) => {
|
|
212
223
|
runUsageTotals = addOpenAiResponseToDroidCuaRunUsage(runUsageTotals, response);
|
|
213
224
|
},
|
|
@@ -414,7 +425,8 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
414
425
|
throw error;
|
|
415
426
|
}
|
|
416
427
|
}
|
|
417
|
-
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, report = null, runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
|
|
428
|
+
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, report = null, runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
|
|
429
|
+
const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
|
|
418
430
|
const isBatchRun = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
|
|
419
431
|
const cloudReportEnabled = report?.cloudEnabled !== false &&
|
|
420
432
|
Boolean(runtimeContext.loadmillClient?.getApiToken?.());
|
|
@@ -468,8 +480,9 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
468
480
|
secrets,
|
|
469
481
|
recordScreenshots,
|
|
470
482
|
screenshotDir: testScreenshotDir,
|
|
471
|
-
isBrowserTarget,
|
|
472
|
-
isCloudTarget,
|
|
483
|
+
isBrowserTarget: resolvedRunTarget.isBrowserTarget,
|
|
484
|
+
isCloudTarget: resolvedRunTarget.isCloudTarget,
|
|
485
|
+
runTarget: resolvedRunTarget,
|
|
473
486
|
report,
|
|
474
487
|
isBatchRun,
|
|
475
488
|
reportToLoadmill: !isBatchRun,
|
|
@@ -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";
|
|
@@ -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
|
+
}
|