@loadmill/droid-cua 2.24.0 → 2.26.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 +13 -5
- package/build/index.js +206 -2
- package/build/src/cli/cloud-status-line.js +58 -26
- package/build/src/cli/headless-runner.js +2 -1
- package/build/src/cli/headless-target.js +63 -7
- package/build/src/cli/loadmill-cloud-credentials.js +11 -0
- package/build/src/cli/loadmill-cloud-usage.js +18 -0
- package/build/src/commands/help.js +2 -0
- package/build/src/commands/run.js +5 -3
- package/build/src/core/cua-models.js +16 -0
- package/build/src/core/droid-cua-run-usage.js +8 -2
- package/build/src/core/execution-engine.js +8 -1
- package/build/src/core/runtime-config.js +8 -3
- package/build/src/core/session.js +2 -2
- package/build/src/core/tool-handlers/close-keyboard.js +103 -0
- package/build/src/device/actions.js +7 -0
- package/build/src/device/android/actions.js +16 -0
- package/build/src/device/cloud/actions.js +27 -0
- package/build/src/device/cloud/aws-device-farm/upload-cache.js +1 -1
- package/build/src/device/cloud/cloud-appium-client.js +15 -0
- package/build/src/device/cloud/connection.js +18 -6
- package/build/src/device/cloud/loadmill-cloud/adapter.js +537 -0
- package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +191 -0
- package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +423 -0
- package/build/src/device/cloud/loadmill-cloud/errors.js +16 -0
- package/build/src/device/cloud/loadmill-cloud/format-bytes.js +18 -0
- package/build/src/device/cloud/loadmill-cloud/timing.js +14 -0
- package/build/src/device/cloud/progress.js +29 -0
- package/build/src/device/cloud/registry.js +2 -1
- package/build/src/device/connection.js +8 -2
- package/build/src/device/factory.js +3 -0
- package/build/src/device/interface.js +4 -0
- package/build/src/device/ios/actions.js +13 -0
- package/build/src/device/ios/appium-client.js +16 -0
- package/build/src/device/keyboard.js +119 -0
- package/build/src/device/openai.js +20 -1
- package/build/src/modes/design-mode-ink.js +1 -0
- package/build/src/modes/design-mode.js +1 -0
- package/build/src/modes/execution-mode.js +88 -16
- package/build/src/prompts/base.js +29 -4
- package/build/src/prompts/execution.js +14 -3
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +6 -3
- package/skill/droid-cua/agents/openai.yaml +1 -1
- package/skill/droid-cua/references/project-and-auth.md +16 -13
- package/skill/droid-cua/references/running-and-healing.md +45 -5
|
@@ -26,6 +26,8 @@ export async function handleHelp(args, session, context) {
|
|
|
26
26
|
addOutput({ type: 'info', text: ' --device-name <name> LambdaTest real device name' });
|
|
27
27
|
addOutput({ type: 'info', text: ' --os-version <version> LambdaTest OS version' });
|
|
28
28
|
addOutput({ type: 'info', text: ' --app <path> LambdaTest app package path (.apk or .ipa)' });
|
|
29
|
+
addOutput({ type: 'info', text: ' --artifacts <kinds> Loadmill Cloud: download video,deviceLog,appiumLog (or all) after the run' });
|
|
30
|
+
addOutput({ type: 'info', text: ' --cloud-session-id <id> Loadmill Cloud: reconnect/retroactive-fetch by mtc_ id (implies --device-source loadmill-cloud)' });
|
|
29
31
|
addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
|
|
30
32
|
addOutput({ type: 'info', text: ' --scenario <prompt> Generate and run scenario cases for headless instructions' });
|
|
31
33
|
addOutput({ type: 'info', text: ' --test-data <file> Optional test-data.md file for --scenario' });
|
|
@@ -67,8 +67,7 @@ export async function handleRun(args, session, context) {
|
|
|
67
67
|
if (context.setAgentWorking) {
|
|
68
68
|
context.setAgentWorking(true, 'Executing test...');
|
|
69
69
|
}
|
|
70
|
-
// Reset session state to prevent context leak from design mode
|
|
71
|
-
// Each test instruction should execute in isolation
|
|
70
|
+
// Reset session state to prevent context leak from design mode.
|
|
72
71
|
session.updateResponseId(undefined);
|
|
73
72
|
session.clearMessages();
|
|
74
73
|
const appContext = await resolveExecutionAppContext({ contextPath: context.appContextPath });
|
|
@@ -89,7 +88,10 @@ export async function handleRun(args, session, context) {
|
|
|
89
88
|
}
|
|
90
89
|
// Set execution mode system prompt (replaces any design mode prompt)
|
|
91
90
|
const availableSecretKeys = context.engine.getAvailableSecretKeys();
|
|
92
|
-
const executionPrompt = buildExecutionModePrompt(session.deviceInfo, {}, appContextText, {
|
|
91
|
+
const executionPrompt = buildExecutionModePrompt(session.deviceInfo, {}, appContextText, {
|
|
92
|
+
availableSecretKeys,
|
|
93
|
+
cuaModel: context.runtimeConfig?.cuaModel,
|
|
94
|
+
});
|
|
93
95
|
session.setSystemPrompt(executionPrompt);
|
|
94
96
|
// Create execution mode
|
|
95
97
|
const executionMode = new ExecutionMode(session, context.engine, instructions);
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export const DEFAULT_OPENAI_CUA_MODEL = "gpt-5.4";
|
|
2
2
|
export const DEFAULT_LOADMILL_CUA_MODEL = "loadmill-smart";
|
|
3
3
|
export const DEFAULT_CUA_MODEL = DEFAULT_LOADMILL_CUA_MODEL;
|
|
4
|
+
export const CUA_CONVERSATION_POLICIES = Object.freeze({
|
|
5
|
+
CHAINED: "chained",
|
|
6
|
+
ISOLATED: "isolated",
|
|
7
|
+
});
|
|
4
8
|
export const CUA_MODEL_OPTIONS = Object.freeze([
|
|
5
9
|
Object.freeze({
|
|
6
10
|
id: "loadmill-smart",
|
|
@@ -9,6 +13,13 @@ export const CUA_MODEL_OPTIONS = Object.freeze([
|
|
|
9
13
|
name: "Loadmill - Smart",
|
|
10
14
|
label: "Loadmill - Smart (recommended)",
|
|
11
15
|
}),
|
|
16
|
+
Object.freeze({
|
|
17
|
+
id: "loadmill-pulse",
|
|
18
|
+
providerMode: "loadmill",
|
|
19
|
+
providerLabel: "Loadmill",
|
|
20
|
+
name: "Loadmill - Pulse",
|
|
21
|
+
label: "Loadmill - Pulse",
|
|
22
|
+
}),
|
|
12
23
|
Object.freeze({
|
|
13
24
|
id: "loadmill-beacon",
|
|
14
25
|
providerMode: "loadmill",
|
|
@@ -70,3 +81,8 @@ export function resolveDefaultCuaModelForProvider(providerMode) {
|
|
|
70
81
|
? DEFAULT_LOADMILL_CUA_MODEL
|
|
71
82
|
: DEFAULT_OPENAI_CUA_MODEL;
|
|
72
83
|
}
|
|
84
|
+
export function resolveConversationPolicy(cuaModel) {
|
|
85
|
+
return cuaModel === "loadmill-beacon" || cuaModel === "beacon"
|
|
86
|
+
? CUA_CONVERSATION_POLICIES.ISOLATED
|
|
87
|
+
: CUA_CONVERSATION_POLICIES.CHAINED;
|
|
88
|
+
}
|
|
@@ -18,10 +18,13 @@ const MODEL_PRICING = Object.freeze({
|
|
|
18
18
|
"gpt-5.4": Object.freeze({
|
|
19
19
|
...OPENAI_GPT_5_4_PRICING,
|
|
20
20
|
}),
|
|
21
|
-
// Loadmill Smart currently routes to GPT-5.
|
|
21
|
+
// Loadmill Smart currently routes to GPT-5.6 Terra behind the Loadmill LLM gateway.
|
|
22
22
|
// Keep this explicit so pricing can be updated here if the backing model changes.
|
|
23
23
|
"loadmill-smart": Object.freeze({
|
|
24
|
-
...
|
|
24
|
+
...OPENAI_GPT_5_6_TERRA_PRICING,
|
|
25
|
+
}),
|
|
26
|
+
"loadmill-pulse": Object.freeze({
|
|
27
|
+
...OPENAI_GPT_5_6_LUNA_PRICING,
|
|
25
28
|
}),
|
|
26
29
|
});
|
|
27
30
|
function finiteNumber(value) {
|
|
@@ -50,6 +53,9 @@ export function normalizeDroidCuaCostModel(model) {
|
|
|
50
53
|
if (model === "loadmill-smart" || model === "smart") {
|
|
51
54
|
return "loadmill-smart";
|
|
52
55
|
}
|
|
56
|
+
if (model === "loadmill-pulse" || model === "pulse") {
|
|
57
|
+
return "loadmill-pulse";
|
|
58
|
+
}
|
|
53
59
|
return null;
|
|
54
60
|
}
|
|
55
61
|
export function createDroidCuaRunUsageTotals({ model = null } = {}) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { getScreenshotAsBase64, getCurrentPlatform } from "../device/connection.js";
|
|
3
3
|
import { closeCurrentTab as closeCurrentBrowserTab, getBrowserInfo, navigateToUrl as navigateBrowserToUrl } from "../browser/connection.js";
|
|
4
|
-
import { handleModelAction } from "../device/actions.js";
|
|
4
|
+
import { closeKeyboard, handleModelAction } from "../device/actions.js";
|
|
5
5
|
import { sendCUARequest } from "../device/openai.js";
|
|
6
6
|
import { executeLoadmillInstruction } from "../device/loadmill.js";
|
|
7
7
|
import { emitDesktopDebug } from "../utils/desktop-debug.js";
|
|
@@ -14,6 +14,7 @@ import { ScreenshotStore } from "./screenshot-store.js";
|
|
|
14
14
|
import { handlePreviewClick } from "./tool-handlers/preview-click.js";
|
|
15
15
|
import { handleNavigateToolCall } from "./tool-handlers/navigate.js";
|
|
16
16
|
import { handleCloseCurrentTabToolCall } from "./tool-handlers/close-current-tab.js";
|
|
17
|
+
import { handleCloseKeyboardToolCall } from "./tool-handlers/close-keyboard.js";
|
|
17
18
|
import { handleAssertionToolCall } from "./tool-handlers/assert.js";
|
|
18
19
|
import { handleLoadmillToolCall } from "./tool-handlers/loadmill.js";
|
|
19
20
|
import { handleTestDesignCompleteToolCall } from "./tool-handlers/test-design-complete.js";
|
|
@@ -176,6 +177,8 @@ function formatActionForRecovery(action) {
|
|
|
176
177
|
}
|
|
177
178
|
case "scroll":
|
|
178
179
|
return "Scrolled";
|
|
180
|
+
case "close_keyboard":
|
|
181
|
+
return "Closed the keyboard";
|
|
179
182
|
default:
|
|
180
183
|
return null;
|
|
181
184
|
}
|
|
@@ -240,6 +243,7 @@ export class ExecutionEngine {
|
|
|
240
243
|
this.stepDelayMs = options.stepDelayMs ?? getConfiguredStepDelayMs();
|
|
241
244
|
this.getScreenshotAsBase64 = options.getScreenshotAsBase64 || getScreenshotAsBase64;
|
|
242
245
|
this.handleModelAction = options.handleModelAction || handleModelAction;
|
|
246
|
+
this.closeKeyboard = options.closeKeyboard || closeKeyboard;
|
|
243
247
|
this.sendCUARequest = options.sendCUARequest || sendCUARequest;
|
|
244
248
|
this.reviseTestScript = options.reviseTestScript || null;
|
|
245
249
|
this.getCurrentPlatform = options.getCurrentPlatform || getCurrentPlatform;
|
|
@@ -247,6 +251,7 @@ export class ExecutionEngine {
|
|
|
247
251
|
this.navigateToUrl = options.navigateToUrl || navigateBrowserToUrl;
|
|
248
252
|
this.closeCurrentTab = options.closeCurrentTab || closeCurrentBrowserTab;
|
|
249
253
|
this.executeLoadmillInstruction = options.executeLoadmillInstruction || executeLoadmillInstruction;
|
|
254
|
+
this.connectToDevice = options.connectToDevice || null;
|
|
250
255
|
this.toolCapabilities = options.toolCapabilities || {};
|
|
251
256
|
this.secretsByKey = options.secretsByKey && typeof options.secretsByKey === "object" ? options.secretsByKey : {};
|
|
252
257
|
this.baseSystemPrompt = options.baseSystemPrompt || session?.systemPrompt || null;
|
|
@@ -323,6 +328,8 @@ export class ExecutionEngine {
|
|
|
323
328
|
return await handleNavigateToolCall(handlerArgs);
|
|
324
329
|
case "close_current_tab":
|
|
325
330
|
return await handleCloseCurrentTabToolCall(handlerArgs);
|
|
331
|
+
case "close_keyboard":
|
|
332
|
+
return await handleCloseKeyboardToolCall(handlerArgs);
|
|
326
333
|
case "loadmill":
|
|
327
334
|
return await handleLoadmillToolCall(handlerArgs);
|
|
328
335
|
case "type_sensitive_text":
|
|
@@ -7,6 +7,7 @@ export const LOADMILL_MODEL_ALIASES = Object.freeze({
|
|
|
7
7
|
DEFAULT_AGENT_MODEL: "default-agent-model",
|
|
8
8
|
FAST: "fast",
|
|
9
9
|
SMART: "smart",
|
|
10
|
+
PULSE: "pulse",
|
|
10
11
|
BEACON: "beacon",
|
|
11
12
|
});
|
|
12
13
|
const LOADMILL_MODEL_ALIAS_SET = new Set(Object.values(LOADMILL_MODEL_ALIASES));
|
|
@@ -90,9 +91,13 @@ export function inferLlmProviderModeFromCredentials(credentials = {}) {
|
|
|
90
91
|
}
|
|
91
92
|
export function resolveGatewayCuaModelAlias({ cuaModel } = {}) {
|
|
92
93
|
const normalizedCuaModel = resolveCuaModel(cuaModel);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
if (normalizedCuaModel === "loadmill-pulse") {
|
|
95
|
+
return LOADMILL_MODEL_ALIASES.PULSE;
|
|
96
|
+
}
|
|
97
|
+
if (normalizedCuaModel === "loadmill-beacon") {
|
|
98
|
+
return LOADMILL_MODEL_ALIASES.BEACON;
|
|
99
|
+
}
|
|
100
|
+
return LOADMILL_MODEL_ALIASES.SMART;
|
|
96
101
|
}
|
|
97
102
|
export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaModel = null, } = {}) {
|
|
98
103
|
const providerMode = resolveLlmProviderMode(llmProviderMode);
|
|
@@ -45,7 +45,7 @@ export class Session {
|
|
|
45
45
|
this.messages = [{ role: "system", content: prompt }];
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
|
-
* Add a line to the transcript
|
|
48
|
+
* Add a line to the scenario transcript for assertions, recovery, and logging.
|
|
49
49
|
*/
|
|
50
50
|
addToTranscript(line) {
|
|
51
51
|
this.transcript.push(line);
|
|
@@ -57,7 +57,7 @@ export class Session {
|
|
|
57
57
|
return this.transcript.join("\n");
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
|
-
* Clear the
|
|
60
|
+
* Clear the scenario transcript at a run boundary.
|
|
61
61
|
*/
|
|
62
62
|
clearTranscript() {
|
|
63
63
|
this.transcript = [];
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { CAPTURE_SOURCES } from "../capture-sources.js";
|
|
2
|
+
import { KEYBOARD_CLOSE_RESULTS, buildKeyboardCloseResult } from "../../device/keyboard.js";
|
|
3
|
+
import { printCliOutput } from "../../utils/console-output.js";
|
|
4
|
+
import { emitDesktopDebug } from "../../utils/desktop-debug.js";
|
|
5
|
+
import { buildFunctionCallOutputItem, buildToolOutputContent, buildTextToolOutputItem, isDesignContext, parseJsonArguments, } from "./utils.js";
|
|
6
|
+
function parseDismissalPoint(argumentsText, deviceInfo = {}) {
|
|
7
|
+
const parsed = parseJsonArguments(argumentsText || "{}");
|
|
8
|
+
if (!parsed.ok || !parsed.value || typeof parsed.value !== "object" || Array.isArray(parsed.value)) {
|
|
9
|
+
return { ok: false, message: "Invalid close_keyboard arguments: expected a JSON object." };
|
|
10
|
+
}
|
|
11
|
+
const hasX = Object.prototype.hasOwnProperty.call(parsed.value, "x");
|
|
12
|
+
const hasY = Object.prototype.hasOwnProperty.call(parsed.value, "y");
|
|
13
|
+
if (!hasX && !hasY) {
|
|
14
|
+
return { ok: true, value: null };
|
|
15
|
+
}
|
|
16
|
+
if (!hasX || !hasY || !Number.isFinite(parsed.value.x) || !Number.isFinite(parsed.value.y)) {
|
|
17
|
+
return { ok: false, message: "Invalid close_keyboard arguments: x and y must both be finite numbers." };
|
|
18
|
+
}
|
|
19
|
+
const point = { x: parsed.value.x, y: parsed.value.y };
|
|
20
|
+
const width = Number(deviceInfo.scaled_width);
|
|
21
|
+
const height = Number(deviceInfo.scaled_height);
|
|
22
|
+
// We can validate coordinate shape and bounds, but only the model can judge
|
|
23
|
+
// whether a cloud-iOS point is a safe dismissal target in the current UI.
|
|
24
|
+
if (point.x < 0 || point.y < 0 ||
|
|
25
|
+
(Number.isFinite(width) && point.x >= width) ||
|
|
26
|
+
(Number.isFinite(height) && point.y >= height)) {
|
|
27
|
+
return { ok: false, message: "Invalid close_keyboard arguments: the dismissal point is outside the current screenshot." };
|
|
28
|
+
}
|
|
29
|
+
return { ok: true, value: point };
|
|
30
|
+
}
|
|
31
|
+
export async function handleCloseKeyboardToolCall({ engine, callId, argumentsText, context = null, stepContext = null }) {
|
|
32
|
+
const session = engine.session;
|
|
33
|
+
if (session?.targetKind === "browser" || session?.deviceInfo?.platform === "browser") {
|
|
34
|
+
return {
|
|
35
|
+
kind: "continue",
|
|
36
|
+
outputItem: buildFunctionCallOutputItem(callId, buildToolOutputContent({ text: "close_keyboard is only supported for mobile runs." })),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const parsedPoint = parseDismissalPoint(argumentsText, session.deviceInfo);
|
|
40
|
+
if (!parsedPoint.ok) {
|
|
41
|
+
return {
|
|
42
|
+
kind: "continue",
|
|
43
|
+
outputItem: buildTextToolOutputItem(callId, parsedPoint.message),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const dismissalPoint = parsedPoint.value;
|
|
47
|
+
let result;
|
|
48
|
+
try {
|
|
49
|
+
result = await engine.closeKeyboard(session.deviceId, {
|
|
50
|
+
dismissalPoint,
|
|
51
|
+
scale: session.deviceInfo?.scale || 1,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
result = buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.FAILED, error);
|
|
56
|
+
}
|
|
57
|
+
const addOutput = context?.addOutput || printCliOutput;
|
|
58
|
+
const outputText = result.status === KEYBOARD_CLOSE_RESULTS.FAILED
|
|
59
|
+
? `${result.message}. The preceding text entry was unaffected.`
|
|
60
|
+
: result.message;
|
|
61
|
+
const scope = isDesignContext(context) ? "design" : "execution";
|
|
62
|
+
const ids = {
|
|
63
|
+
...(scope === "design" ? { sessionId: context?.sessionId } : { runId: context?.runId }),
|
|
64
|
+
...(stepContext?.stepId ? { stepId: stepContext.stepId } : {}),
|
|
65
|
+
...(Number.isInteger(stepContext?.instructionIndex)
|
|
66
|
+
? { instructionIndex: stepContext.instructionIndex }
|
|
67
|
+
: {}),
|
|
68
|
+
};
|
|
69
|
+
const payload = {
|
|
70
|
+
platform: session.deviceInfo?.platform,
|
|
71
|
+
toolName: "close_keyboard",
|
|
72
|
+
callId,
|
|
73
|
+
status: result.status,
|
|
74
|
+
failed: result.status === KEYBOARD_CLOSE_RESULTS.FAILED,
|
|
75
|
+
...(dismissalPoint ? { dismissalPoint } : {}),
|
|
76
|
+
...(result.error ? { error: result.error } : {}),
|
|
77
|
+
};
|
|
78
|
+
emitDesktopDebug("tool_call", scope, ids, payload);
|
|
79
|
+
addOutput({
|
|
80
|
+
type: "action",
|
|
81
|
+
text: result.message,
|
|
82
|
+
eventType: "tool_call",
|
|
83
|
+
runId: context?.runId,
|
|
84
|
+
stepId: stepContext?.stepId,
|
|
85
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
86
|
+
payload,
|
|
87
|
+
});
|
|
88
|
+
session.addToTranscript(`[Close keyboard] ${outputText}`);
|
|
89
|
+
const screenshotBase64 = await engine.getScreenshotAsBase64(session.deviceId, session.deviceInfo);
|
|
90
|
+
const currentScreenshot = await engine.recordScreenshot(screenshotBase64, {
|
|
91
|
+
runId: context?.runId,
|
|
92
|
+
sessionId: context?.sessionId,
|
|
93
|
+
stepId: stepContext?.stepId,
|
|
94
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
95
|
+
captureSource: CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
kind: "continue",
|
|
99
|
+
outputItem: buildFunctionCallOutputItem(callId, buildToolOutputContent({ text: outputText, imageBase64: screenshotBase64 })),
|
|
100
|
+
currentScreenshot,
|
|
101
|
+
executedActions: [{ type: "close_keyboard", ...(dismissalPoint || {}) }],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -20,3 +20,10 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
20
20
|
const backend = getDeviceBackend(platform);
|
|
21
21
|
return backend.handleModelAction(deviceId, action, scale, context);
|
|
22
22
|
}
|
|
23
|
+
export async function closeKeyboard(deviceId, options = {}) {
|
|
24
|
+
const platform = getCurrentPlatform();
|
|
25
|
+
if (!platform) {
|
|
26
|
+
throw new Error("No platform set. Call connectToDevice first.");
|
|
27
|
+
}
|
|
28
|
+
return getDeviceBackend(platform).closeKeyboard(deviceId, options);
|
|
29
|
+
}
|
|
@@ -6,9 +6,25 @@ import { buildMobileKeypressPayload, getAndroidAdbTransport, normalizeMobileKeyp
|
|
|
6
6
|
import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSensitiveDebugFields, buildTypeActionPayload, buildTypeActionText, isSensitiveAction, } from "../sensitive-action.js";
|
|
7
7
|
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
8
8
|
import { execAdb } from "./tools.js";
|
|
9
|
+
import { closeAdbKeyboard, resolveAdbKeyboardVisibility } from "../keyboard.js";
|
|
9
10
|
function adbShell(deviceId, command) {
|
|
10
11
|
return execAdb(["-s", deviceId, "shell", command]);
|
|
11
12
|
}
|
|
13
|
+
async function getKeyboardVisibility(deviceId) {
|
|
14
|
+
const { stdout: inputMethodDump } = await adbShell(deviceId, "dumpsys input_method");
|
|
15
|
+
const { stdout: showImeWithHardwareKeyboard } = await adbShell(deviceId, "settings get secure show_ime_with_hard_keyboard");
|
|
16
|
+
return resolveAdbKeyboardVisibility(inputMethodDump, showImeWithHardwareKeyboard);
|
|
17
|
+
}
|
|
18
|
+
export async function closeKeyboard(deviceId) {
|
|
19
|
+
// KEYCODE_BACK dismisses the Android IME, but it would navigate if the app
|
|
20
|
+
// already hid the keyboard. getKeyboardVisibility also filters the hardware
|
|
21
|
+
// keyboard configuration where mInputShown is true without a software input
|
|
22
|
+
// view. closeAdbKeyboard invokes Back only for an affirmative result.
|
|
23
|
+
return closeAdbKeyboard({
|
|
24
|
+
getVisibility: () => getKeyboardVisibility(deviceId),
|
|
25
|
+
pressBack: () => adbShell(deviceId, "input keyevent KEYCODE_BACK"),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
12
28
|
export async function handleModelAction(deviceId, action, scale = 1.0, context = null) {
|
|
13
29
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
14
30
|
const meta = (payload = {}) => buildActionEventMeta({
|
|
@@ -6,6 +6,33 @@ import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSe
|
|
|
6
6
|
import { buildMobileKeypressPayload, getCloudAndroidTransport, getIosTransport, normalizeMobileKeypress } from "../mobile-keypress.js";
|
|
7
7
|
import { getActiveSession, getDevicePixelRatio } from "./connection.js";
|
|
8
8
|
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
9
|
+
import { closeAppiumKeyboard, closeAppiumKeyboardWithTap } from "../keyboard.js";
|
|
10
|
+
export async function closeKeyboard(_deviceId, { dismissalPoint = null, scale = 1 } = {}) {
|
|
11
|
+
const session = getActiveSession();
|
|
12
|
+
if (!session?.client) {
|
|
13
|
+
throw new Error("No active cloud session");
|
|
14
|
+
}
|
|
15
|
+
if (session.platform === "ios") {
|
|
16
|
+
// XCUITest hideKeyboard was unreliable on the tested AWS real devices, and
|
|
17
|
+
// Return/newline can submit forms. Trust the model to identify a dismissal
|
|
18
|
+
// control or inert background, but tap only after confirming the keyboard
|
|
19
|
+
// is open. With no trusted point, fail safely without touching the screen.
|
|
20
|
+
const dpr = getDevicePixelRatio();
|
|
21
|
+
const tap = dismissalPoint
|
|
22
|
+
? () => session.client.tap(session.sessionId, Math.round((dismissalPoint.x / scale) / dpr), Math.round((dismissalPoint.y / scale) / dpr))
|
|
23
|
+
: null;
|
|
24
|
+
return closeAppiumKeyboardWithTap({
|
|
25
|
+
isKeyboardShown: () => session.client.isKeyboardShown(session.sessionId),
|
|
26
|
+
tap,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
// UiAutomator2's native query/hide commands are reliable on cloud Android and
|
|
30
|
+
// avoid the navigation risk of treating Android Back as a generic fallback.
|
|
31
|
+
return closeAppiumKeyboard({
|
|
32
|
+
isKeyboardShown: () => session.client.isKeyboardShown(session.sessionId),
|
|
33
|
+
hideKeyboard: () => session.client.hideKeyboard(session.sessionId),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
9
36
|
export async function handleModelAction(deviceId, action, scale = 1.0, context = null) {
|
|
10
37
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
11
38
|
const session = getActiveSession();
|
|
@@ -7,7 +7,7 @@ import { reportCloudProgress } from "../progress.js";
|
|
|
7
7
|
export const DEFAULT_UPLOAD_CACHE_PATH = path.join(os.tmpdir(), "droid-cua", "aws-device-farm-upload-cache.json");
|
|
8
8
|
// Stream the binary through the hash rather than buffering the whole .apk/.ipa
|
|
9
9
|
// (tens–hundreds of MB) in one allocation just to checksum it.
|
|
10
|
-
async function hashFile(filePath) {
|
|
10
|
+
export async function hashFile(filePath) {
|
|
11
11
|
const hash = createHash("sha256");
|
|
12
12
|
for await (const chunk of createReadStream(filePath)) {
|
|
13
13
|
hash.update(chunk);
|
|
@@ -99,6 +99,21 @@ export class CloudAppiumClient {
|
|
|
99
99
|
]
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
|
+
async isKeyboardShown(sessionId) {
|
|
103
|
+
const response = await this.request("POST", `/session/${sessionId}/execute/sync`, {
|
|
104
|
+
script: "mobile: isKeyboardShown",
|
|
105
|
+
args: [{}]
|
|
106
|
+
});
|
|
107
|
+
return response.value === true;
|
|
108
|
+
}
|
|
109
|
+
async hideKeyboard(sessionId) {
|
|
110
|
+
// Currently called by cloud Android only. Cloud iOS deliberately routes to
|
|
111
|
+
// its guarded model-selected tap in cloud/actions.js.
|
|
112
|
+
await this.request("POST", `/session/${sessionId}/execute/sync`, {
|
|
113
|
+
script: "mobile: hideKeyboard",
|
|
114
|
+
args: [{}]
|
|
115
|
+
});
|
|
116
|
+
}
|
|
102
117
|
async scroll(sessionId, startX, startY, endX, endY, duration = 500) {
|
|
103
118
|
await this.request("POST", `/session/${sessionId}/actions`, {
|
|
104
119
|
actions: [
|
|
@@ -4,7 +4,7 @@ import { CloudAppiumClient } from "./cloud-appium-client.js";
|
|
|
4
4
|
import { reportCloudPhase } from "./progress.js";
|
|
5
5
|
let activeSession = null;
|
|
6
6
|
function buildCloudCapabilities(options) {
|
|
7
|
-
const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName } = options;
|
|
7
|
+
const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName, cloudSessionId, artifacts } = options;
|
|
8
8
|
const adapter = getCloudProviderAdapter(providerId);
|
|
9
9
|
if (!adapter) {
|
|
10
10
|
throw new Error(`Cloud provider "${providerId}" is not registered.`);
|
|
@@ -20,7 +20,9 @@ function buildCloudCapabilities(options) {
|
|
|
20
20
|
accessKey: credentials?.accessKey,
|
|
21
21
|
projectName: "droid-cua",
|
|
22
22
|
buildName: buildName || `desktop-${new Date().toISOString().slice(0, 10)}`,
|
|
23
|
-
sessionName: sessionName || `${deviceName} (${platform})
|
|
23
|
+
sessionName: sessionName || `${deviceName} (${platform})`,
|
|
24
|
+
cloudSessionId,
|
|
25
|
+
artifacts
|
|
24
26
|
}
|
|
25
27
|
};
|
|
26
28
|
}
|
|
@@ -29,11 +31,19 @@ export async function connectToDevice(options, logOptions = {}) {
|
|
|
29
31
|
if (!options || typeof options !== "object") {
|
|
30
32
|
throw new Error("Cloud device connection requires a provider payload.");
|
|
31
33
|
}
|
|
32
|
-
const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName } = options;
|
|
33
|
-
|
|
34
|
+
const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName, cloudSessionId, artifacts } = options;
|
|
35
|
+
const isReconnect = providerId === "loadmill-cloud" && Boolean(cloudSessionId);
|
|
36
|
+
if (!providerId ||
|
|
37
|
+
(!isReconnect && (!platform || !deviceName || !osVersion)) ||
|
|
38
|
+
(!appRef && !isReconnect)) {
|
|
34
39
|
throw new Error("Cloud device connection is missing required session details.");
|
|
35
40
|
}
|
|
36
|
-
if (
|
|
41
|
+
if (providerId === "loadmill-cloud") {
|
|
42
|
+
if (!credentials?.bearerToken) {
|
|
43
|
+
throw new Error("Loadmill Cloud connection is missing a Loadmill API token.");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (!credentials?.username || !credentials?.accessKey) {
|
|
37
47
|
throw new Error("Cloud device connection is missing provider credentials.");
|
|
38
48
|
}
|
|
39
49
|
const { adapter, capabilities } = buildCloudCapabilities({
|
|
@@ -44,7 +54,9 @@ export async function connectToDevice(options, logOptions = {}) {
|
|
|
44
54
|
appRef,
|
|
45
55
|
credentials,
|
|
46
56
|
buildName,
|
|
47
|
-
sessionName
|
|
57
|
+
sessionName,
|
|
58
|
+
cloudSessionId,
|
|
59
|
+
artifacts
|
|
48
60
|
});
|
|
49
61
|
// Optional AbortSignal (Stop/Pause during provisioning). It rides inside the
|
|
50
62
|
// cloud-options object — see the facade in src/device/connection.js — so no
|