@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.
Files changed (46) hide show
  1. package/README.md +13 -5
  2. package/build/index.js +206 -2
  3. package/build/src/cli/cloud-status-line.js +58 -26
  4. package/build/src/cli/headless-runner.js +2 -1
  5. package/build/src/cli/headless-target.js +63 -7
  6. package/build/src/cli/loadmill-cloud-credentials.js +11 -0
  7. package/build/src/cli/loadmill-cloud-usage.js +18 -0
  8. package/build/src/commands/help.js +2 -0
  9. package/build/src/commands/run.js +5 -3
  10. package/build/src/core/cua-models.js +16 -0
  11. package/build/src/core/droid-cua-run-usage.js +8 -2
  12. package/build/src/core/execution-engine.js +8 -1
  13. package/build/src/core/runtime-config.js +8 -3
  14. package/build/src/core/session.js +2 -2
  15. package/build/src/core/tool-handlers/close-keyboard.js +103 -0
  16. package/build/src/device/actions.js +7 -0
  17. package/build/src/device/android/actions.js +16 -0
  18. package/build/src/device/cloud/actions.js +27 -0
  19. package/build/src/device/cloud/aws-device-farm/upload-cache.js +1 -1
  20. package/build/src/device/cloud/cloud-appium-client.js +15 -0
  21. package/build/src/device/cloud/connection.js +18 -6
  22. package/build/src/device/cloud/loadmill-cloud/adapter.js +537 -0
  23. package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +191 -0
  24. package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +423 -0
  25. package/build/src/device/cloud/loadmill-cloud/errors.js +16 -0
  26. package/build/src/device/cloud/loadmill-cloud/format-bytes.js +18 -0
  27. package/build/src/device/cloud/loadmill-cloud/timing.js +14 -0
  28. package/build/src/device/cloud/progress.js +29 -0
  29. package/build/src/device/cloud/registry.js +2 -1
  30. package/build/src/device/connection.js +8 -2
  31. package/build/src/device/factory.js +3 -0
  32. package/build/src/device/interface.js +4 -0
  33. package/build/src/device/ios/actions.js +13 -0
  34. package/build/src/device/ios/appium-client.js +16 -0
  35. package/build/src/device/keyboard.js +119 -0
  36. package/build/src/device/openai.js +20 -1
  37. package/build/src/modes/design-mode-ink.js +1 -0
  38. package/build/src/modes/design-mode.js +1 -0
  39. package/build/src/modes/execution-mode.js +88 -16
  40. package/build/src/prompts/base.js +29 -4
  41. package/build/src/prompts/execution.js +14 -3
  42. package/package.json +1 -1
  43. package/skill/droid-cua/SKILL.md +6 -3
  44. package/skill/droid-cua/agents/openai.yaml +1 -1
  45. package/skill/droid-cua/references/project-and-auth.md +16 -13
  46. 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, { availableSecretKeys });
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.4 behind the Loadmill LLM gateway.
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
- ...OPENAI_GPT_5_4_PRICING,
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
- return normalizedCuaModel === "loadmill-beacon"
94
- ? LOADMILL_MODEL_ALIASES.BEACON
95
- : LOADMILL_MODEL_ALIASES.SMART;
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 (for error recovery and logging)
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 per-instruction transcript.
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
- if (!providerId || !platform || !deviceName || !osVersion || !appRef) {
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 (!credentials?.username || !credentials?.accessKey) {
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