@loadmill/droid-cua 2.22.0 → 2.23.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/build/index.js +31 -1
- package/build/src/browser/playwright/actions.js +65 -1
- package/build/src/commands/help.js +1 -1
- package/build/src/core/cua-models.js +11 -4
- package/build/src/core/execution-engine.js +334 -5
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/report/render-html-report.js +8 -3
- package/build/src/core/report/run-log-model.js +42 -0
- package/build/src/core/runtime-config.js +24 -39
- package/build/src/core/session.js +6 -0
- package/build/src/core/tool-handlers/loadmill.js +3 -0
- package/build/src/core/tool-handlers/test-design-complete.js +1 -0
- package/build/src/core/tool-handlers/type-sensitive-text.js +1 -0
- package/build/src/device/android/actions.js +6 -2
- package/build/src/device/android/connection.js +58 -23
- package/build/src/device/assertions.js +15 -8
- package/build/src/device/ios/connection.js +36 -30
- package/build/src/device/openai.js +68 -2
- package/build/src/device/scroll-gesture.js +22 -9
- package/build/src/integrations/loadmill/client.js +20 -4
- package/build/src/modes/design-mode-ink.js +10 -1
- package/build/src/modes/design-mode.js +7 -1
- package/build/src/modes/execution-mode.js +84 -8
- package/build/src/prompts/design.js +21 -1
- package/build/src/utils/console-output.js +5 -0
- package/build/src/utils/cua-debug-tracer.js +50 -7
- package/package.json +1 -1
- package/build/src/core/cua-backends.js +0 -11
|
@@ -98,6 +98,48 @@ export function runSummaryData(event) {
|
|
|
98
98
|
error,
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
|
+
export function stepCompletionData(eventOrText) {
|
|
102
|
+
const text = typeof eventOrText === "string"
|
|
103
|
+
? eventOrText
|
|
104
|
+
: typeof eventOrText?.text === "string"
|
|
105
|
+
? eventOrText.text
|
|
106
|
+
: null;
|
|
107
|
+
if (!text)
|
|
108
|
+
return null;
|
|
109
|
+
const normalized = text.replace(/\r/g, "").trim();
|
|
110
|
+
const prefix = "Step complete:";
|
|
111
|
+
if (!normalized.startsWith(prefix))
|
|
112
|
+
return null;
|
|
113
|
+
const rest = normalized.slice(prefix.length);
|
|
114
|
+
const reasonLabel = /\sReason:\s*/.exec(rest);
|
|
115
|
+
if (!reasonLabel)
|
|
116
|
+
return null;
|
|
117
|
+
const instruction = rest.slice(0, reasonLabel.index).trim();
|
|
118
|
+
const reasonStart = reasonLabel.index + reasonLabel[0].length;
|
|
119
|
+
const afterReason = rest.slice(reasonStart);
|
|
120
|
+
const visibleStateLabel = /\sVisible state:\s*/.exec(afterReason);
|
|
121
|
+
if (!visibleStateLabel)
|
|
122
|
+
return null;
|
|
123
|
+
const reason = afterReason.slice(0, visibleStateLabel.index).trim();
|
|
124
|
+
const visibleState = afterReason.slice(visibleStateLabel.index + visibleStateLabel[0].length).trim();
|
|
125
|
+
if (!instruction || !reason || !visibleState)
|
|
126
|
+
return null;
|
|
127
|
+
return { instruction, reason, visibleState };
|
|
128
|
+
}
|
|
129
|
+
export function stepCompletionReasonTooltip(stepCompletion, maxLength = 140) {
|
|
130
|
+
if (!stepCompletion?.reason)
|
|
131
|
+
return null;
|
|
132
|
+
const firstSentence = stepCompletion.reason.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim();
|
|
133
|
+
const text = (firstSentence || stepCompletion.reason).replace(/\s+/g, " ").trim();
|
|
134
|
+
if (!text)
|
|
135
|
+
return null;
|
|
136
|
+
if (text.length <= maxLength)
|
|
137
|
+
return `Reason: ${text}`;
|
|
138
|
+
const truncated = text.slice(0, Math.max(0, maxLength - 1)).trimEnd();
|
|
139
|
+
const wordBoundary = truncated.lastIndexOf(" ");
|
|
140
|
+
const cleanTruncated = wordBoundary > maxLength * 0.6 ? truncated.slice(0, wordBoundary).trimEnd() : truncated;
|
|
141
|
+
return `Reason: ${cleanTruncated}…`;
|
|
142
|
+
}
|
|
101
143
|
export function formatDuration(ms) {
|
|
102
144
|
if (ms === null || ms === undefined)
|
|
103
145
|
return "n/a";
|
|
@@ -2,13 +2,12 @@ import OpenAI from "openai";
|
|
|
2
2
|
import { normalizeOptionalAutomationUrl } from "../browser/playwright/url.js";
|
|
3
3
|
import { readContextOptimizationEnv, normalizeContextOptimizationEnabled, normalizeContextOptimizationThreshold, } from "./context-optimization.js";
|
|
4
4
|
import { DEFAULT_OPENAI_CUA_MODEL, isLoadmillCuaModel, resolveCuaModel, resolveCuaModelProviderMode, } from "./cua-models.js";
|
|
5
|
-
import { DEFAULT_FARA_BASE_URL, DEFAULT_FARA_MODEL, resolveCuaBackend, } from "./cua-backends.js";
|
|
6
5
|
import { normalizeStepDelayMs } from "../utils/step-delay.js";
|
|
7
6
|
export const LOADMILL_MODEL_ALIASES = Object.freeze({
|
|
8
7
|
DEFAULT_AGENT_MODEL: "default-agent-model",
|
|
9
8
|
FAST: "fast",
|
|
10
9
|
SMART: "smart",
|
|
11
|
-
|
|
10
|
+
BEACON: "beacon",
|
|
12
11
|
});
|
|
13
12
|
const LOADMILL_MODEL_ALIAS_SET = new Set(Object.values(LOADMILL_MODEL_ALIASES));
|
|
14
13
|
export const LLM_PROVIDER_MODES = Object.freeze(["loadmill", "openai"]);
|
|
@@ -89,19 +88,19 @@ export function inferLlmProviderModeFromCredentials(credentials = {}) {
|
|
|
89
88
|
}
|
|
90
89
|
return DEFAULT_LLM_PROVIDER_MODE;
|
|
91
90
|
}
|
|
92
|
-
export function resolveGatewayCuaModelAlias({
|
|
93
|
-
if (resolveCuaBackend(cuaBackend) === "fara") {
|
|
94
|
-
return LOADMILL_MODEL_ALIASES.COMPUTER_USE;
|
|
95
|
-
}
|
|
91
|
+
export function resolveGatewayCuaModelAlias({ cuaModel } = {}) {
|
|
96
92
|
const normalizedCuaModel = resolveCuaModel(cuaModel);
|
|
93
|
+
if (normalizedCuaModel === "loadmill-beacon") {
|
|
94
|
+
return LOADMILL_MODEL_ALIASES.BEACON;
|
|
95
|
+
}
|
|
97
96
|
return normalizedCuaModel === "gpt-5.4-mini"
|
|
98
97
|
? LOADMILL_MODEL_ALIASES.FAST
|
|
99
98
|
: LOADMILL_MODEL_ALIASES.SMART;
|
|
100
99
|
}
|
|
101
|
-
export function resolveEffectiveCuaModel({ llmProviderMode = null,
|
|
100
|
+
export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaModel = null, } = {}) {
|
|
102
101
|
const providerMode = resolveLlmProviderMode(llmProviderMode);
|
|
103
102
|
if (providerMode === "loadmill") {
|
|
104
|
-
return resolveGatewayCuaModelAlias({
|
|
103
|
+
return resolveGatewayCuaModelAlias({ cuaModel });
|
|
105
104
|
}
|
|
106
105
|
return resolveCuaModelProviderMode(cuaModel) === "openai"
|
|
107
106
|
? resolveCuaModel(cuaModel)
|
|
@@ -115,17 +114,14 @@ export function resolveEffectiveHelperModel({ llmProviderMode = null, cuaModel =
|
|
|
115
114
|
? resolveCuaModel(cuaModel)
|
|
116
115
|
: DEFAULT_OPENAI_CUA_MODEL;
|
|
117
116
|
}
|
|
118
|
-
export function resolveRuntimeCredentials({ openAiApiKey = null,
|
|
117
|
+
export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToken = null, loadmillBaseUrl = null, } = {}) {
|
|
119
118
|
return {
|
|
120
119
|
openAiApiKey: trimNullableString(openAiApiKey),
|
|
121
|
-
faraApiKey: trimNullableString(faraApiKey),
|
|
122
|
-
faraBaseUrl: trimNullableString(faraBaseUrl) || DEFAULT_FARA_BASE_URL,
|
|
123
|
-
faraModel: trimNullableString(faraModel) || DEFAULT_FARA_MODEL,
|
|
124
120
|
loadmillApiToken: trimNullableString(loadmillApiToken),
|
|
125
121
|
loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
|
|
126
122
|
};
|
|
127
123
|
}
|
|
128
|
-
export function resolveRuntimeConfig({ llmProviderMode = null,
|
|
124
|
+
export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
|
|
129
125
|
const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
|
|
130
126
|
? cuaModel
|
|
131
127
|
: resolveCuaModel(cuaModel);
|
|
@@ -134,7 +130,6 @@ export function resolveRuntimeConfig({ llmProviderMode = null, cuaBackend = null
|
|
|
134
130
|
llmProviderMode: llmProviderMode == null && isLoadmillCuaModel(normalizedCuaModel)
|
|
135
131
|
? "loadmill"
|
|
136
132
|
: normalizedProviderMode,
|
|
137
|
-
cuaBackend: resolveCuaBackend(cuaBackend),
|
|
138
133
|
cuaModel: normalizedCuaModel,
|
|
139
134
|
stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
|
|
140
135
|
contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
|
|
@@ -161,11 +156,6 @@ export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
|
|
|
161
156
|
const contextOptimization = readContextOptimizationEnv(env);
|
|
162
157
|
return resolveRuntimeConfig({
|
|
163
158
|
llmProviderMode: env.DROID_CUA_LLM_PROVIDER,
|
|
164
|
-
// Only the explicit backend flag should influence CUA model routing.
|
|
165
|
-
// Legacy screenshot-provider env vars must not silently remap Loadmill
|
|
166
|
-
// model aliases like "smart" to backend-specific aliases like
|
|
167
|
-
// "computer-use".
|
|
168
|
-
cuaBackend: env.DROID_CUA_BACKEND,
|
|
169
159
|
cuaModel: env.OPENAI_CUA_MODEL,
|
|
170
160
|
stepDelayMs: env.DROID_CUA_STEP_DELAY_MS,
|
|
171
161
|
contextOptimizationEnabled: contextOptimization.enabled,
|
|
@@ -176,9 +166,6 @@ export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
|
|
|
176
166
|
export function resolveRuntimeCredentialsFromEnv(env = process.env, overrides = {}) {
|
|
177
167
|
return resolveRuntimeCredentials({
|
|
178
168
|
openAiApiKey: env.OPENAI_API_KEY,
|
|
179
|
-
faraApiKey: env.DROID_CUA_FARA_API_KEY,
|
|
180
|
-
faraBaseUrl: env.DROID_CUA_FARA_BASE_URL,
|
|
181
|
-
faraModel: env.DROID_CUA_FARA_MODEL,
|
|
182
169
|
loadmillApiToken: env.LOADMILL_API_TOKEN,
|
|
183
170
|
loadmillBaseUrl: env.LOADMILL_BASE_URL,
|
|
184
171
|
...overrides,
|
|
@@ -278,19 +265,27 @@ export function createLlmClient({ loadmillApiToken, loadmillBaseUrl, clientName
|
|
|
278
265
|
},
|
|
279
266
|
});
|
|
280
267
|
}
|
|
281
|
-
export function
|
|
282
|
-
return new OpenAI({
|
|
283
|
-
apiKey: trimNullableString(apiKey) || "",
|
|
284
|
-
baseURL: trimNullableString(baseUrl) || DEFAULT_FARA_BASE_URL,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
export function createResolvedLlmClient({ llmProviderMode = null, credentials = null, clientName = "desktop", maxRetries = 1, } = {}) {
|
|
268
|
+
export function assertRuntimeCredentialsForProvider({ llmProviderMode = null, credentials = null, } = {}) {
|
|
288
269
|
const resolvedCredentials = resolveRuntimeCredentials(credentials);
|
|
289
270
|
const resolvedProviderMode = resolveLlmProviderMode(llmProviderMode);
|
|
290
271
|
if (resolvedProviderMode === "loadmill") {
|
|
291
272
|
if (!hasLoadmillLlmAccess(resolvedCredentials)) {
|
|
292
273
|
throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
|
|
293
274
|
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!hasOpenAiLlmAccess(resolvedCredentials)) {
|
|
278
|
+
throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
export function createResolvedLlmClient({ llmProviderMode = null, credentials = null, clientName = "desktop", maxRetries = 1, } = {}) {
|
|
282
|
+
const resolvedCredentials = resolveRuntimeCredentials(credentials);
|
|
283
|
+
const resolvedProviderMode = resolveLlmProviderMode(llmProviderMode);
|
|
284
|
+
assertRuntimeCredentialsForProvider({
|
|
285
|
+
llmProviderMode: resolvedProviderMode,
|
|
286
|
+
credentials: resolvedCredentials,
|
|
287
|
+
});
|
|
288
|
+
if (resolvedProviderMode === "loadmill") {
|
|
294
289
|
return createLlmClient({
|
|
295
290
|
loadmillApiToken: resolvedCredentials.loadmillApiToken,
|
|
296
291
|
loadmillBaseUrl: resolvedCredentials.loadmillBaseUrl,
|
|
@@ -298,9 +293,6 @@ export function createResolvedLlmClient({ llmProviderMode = null, credentials =
|
|
|
298
293
|
maxRetries,
|
|
299
294
|
});
|
|
300
295
|
}
|
|
301
|
-
if (!hasOpenAiLlmAccess(resolvedCredentials)) {
|
|
302
|
-
throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
|
|
303
|
-
}
|
|
304
296
|
return createOpenAiClient(resolvedCredentials.openAiApiKey);
|
|
305
297
|
}
|
|
306
298
|
export function createRunContext({ config, credentials }) {
|
|
@@ -312,7 +304,6 @@ export function createRunContext({ config, credentials }) {
|
|
|
312
304
|
llmProviderMode: resolvedProviderMode,
|
|
313
305
|
cuaModel: resolveEffectiveCuaModel({
|
|
314
306
|
llmProviderMode: resolvedProviderMode,
|
|
315
|
-
cuaBackend: resolvedConfig.cuaBackend,
|
|
316
307
|
cuaModel: resolvedConfig.cuaModel,
|
|
317
308
|
}),
|
|
318
309
|
};
|
|
@@ -328,12 +319,6 @@ export function createRunContext({ config, credentials }) {
|
|
|
328
319
|
credentials: resolvedCredentials,
|
|
329
320
|
clientName: "desktop",
|
|
330
321
|
}),
|
|
331
|
-
fara: resolvedConfig.cuaBackend === "fara"
|
|
332
|
-
? createFaraClient({
|
|
333
|
-
apiKey: resolvedCredentials.faraApiKey,
|
|
334
|
-
baseUrl: resolvedCredentials.faraBaseUrl,
|
|
335
|
-
})
|
|
336
|
-
: null,
|
|
337
322
|
},
|
|
338
323
|
};
|
|
339
324
|
}
|
|
@@ -56,6 +56,12 @@ export class Session {
|
|
|
56
56
|
getTranscriptText() {
|
|
57
57
|
return this.transcript.join("\n");
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Clear the per-instruction transcript.
|
|
61
|
+
*/
|
|
62
|
+
clearTranscript() {
|
|
63
|
+
this.transcript = [];
|
|
64
|
+
}
|
|
59
65
|
/**
|
|
60
66
|
* Update the previous response ID for chaining
|
|
61
67
|
*/
|
|
@@ -29,6 +29,9 @@ export async function handleLoadmillToolCall({ engine, callId, argumentsText, co
|
|
|
29
29
|
return {
|
|
30
30
|
kind: "close_turn",
|
|
31
31
|
outputItem: buildTextToolOutputItem(callId, "Loadmill command completed successfully."),
|
|
32
|
+
nextTurnConfig: {
|
|
33
|
+
attachFreshScreenshotOnContinuation: true,
|
|
34
|
+
},
|
|
32
35
|
summaryUpdates: result.runtimeParameterContext?.note
|
|
33
36
|
? { preserveSessionMessages: true }
|
|
34
37
|
: undefined,
|
|
@@ -51,8 +51,12 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
51
51
|
case "scroll":
|
|
52
52
|
const { scrollX, scrollY, startX, startY, endX, endY, hasAnchor } = resolveScrollGesture(action, {
|
|
53
53
|
scale,
|
|
54
|
-
fallbackStartX: 500,
|
|
55
|
-
fallbackStartY: 500
|
|
54
|
+
fallbackStartX: Number.isFinite(context?.fallbackScrollStartX) ? Math.round(context.fallbackScrollStartX) : 500,
|
|
55
|
+
fallbackStartY: Number.isFinite(context?.fallbackScrollStartY) ? Math.round(context.fallbackScrollStartY) : 500,
|
|
56
|
+
minX: 0,
|
|
57
|
+
maxX: Number.isFinite(context?.deviceWidth) ? Math.round(context.deviceWidth) - 1 : undefined,
|
|
58
|
+
minY: 0,
|
|
59
|
+
maxY: Number.isFinite(context?.deviceHeight) ? Math.round(context.deviceHeight) - 1 : undefined
|
|
56
60
|
});
|
|
57
61
|
addOutput({
|
|
58
62
|
type: 'action',
|
|
@@ -21,6 +21,18 @@ async function listConnectedDevices() {
|
|
|
21
21
|
})
|
|
22
22
|
.filter(entry => entry.id.length > 0);
|
|
23
23
|
}
|
|
24
|
+
export function getAndroidConnectErrorMessage({ state, avdName } = {}) {
|
|
25
|
+
if (state === "offline" && avdName) {
|
|
26
|
+
return `Emulator "${avdName}" is running but offline. Restart adb or cold boot the emulator, then retry.`;
|
|
27
|
+
}
|
|
28
|
+
if (state === "offline") {
|
|
29
|
+
return "Device is offline. Reconnect device or restart adb.";
|
|
30
|
+
}
|
|
31
|
+
if (state === "unauthorized") {
|
|
32
|
+
return "Authorize this computer on the device and retry.";
|
|
33
|
+
}
|
|
34
|
+
return "Selected device is no longer connected. Refresh and select again.";
|
|
35
|
+
}
|
|
24
36
|
function wait(ms) {
|
|
25
37
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
38
|
}
|
|
@@ -35,6 +47,14 @@ function parseTargetToken(rawTarget) {
|
|
|
35
47
|
}
|
|
36
48
|
return { kind: "legacy", value: rawTarget };
|
|
37
49
|
}
|
|
50
|
+
function normalizeAvdName(value) {
|
|
51
|
+
const parts = value
|
|
52
|
+
.split(/\r?\n/)
|
|
53
|
+
.map((line) => line.trim())
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.filter((line) => line.toUpperCase() !== "OK");
|
|
56
|
+
return parts[0] || "";
|
|
57
|
+
}
|
|
38
58
|
async function getEmulatorAvdName(serial) {
|
|
39
59
|
const candidates = [
|
|
40
60
|
["-s", serial, "emu", "avd", "name"],
|
|
@@ -45,7 +65,7 @@ async function getEmulatorAvdName(serial) {
|
|
|
45
65
|
for (const args of candidates) {
|
|
46
66
|
try {
|
|
47
67
|
const { stdout } = await execAdb(args);
|
|
48
|
-
const value = stdout
|
|
68
|
+
const value = normalizeAvdName(stdout);
|
|
49
69
|
if (value)
|
|
50
70
|
return value;
|
|
51
71
|
}
|
|
@@ -74,16 +94,23 @@ function emulatorLaunchTimeoutError() {
|
|
|
74
94
|
return new Error(`Emulator launch timed out after ${AVD_READY_TIMEOUT_SECONDS}s. Check AVD health and retry.`);
|
|
75
95
|
}
|
|
76
96
|
async function findRunningEmulatorByAvdName(avdName) {
|
|
97
|
+
const match = await findConnectedEmulatorByAvdName(avdName, { includeOffline: false });
|
|
98
|
+
return match?.state === "device" ? match.id : null;
|
|
99
|
+
}
|
|
100
|
+
async function findConnectedEmulatorByAvdName(avdName, options = {}) {
|
|
77
101
|
const devices = await listConnectedDevices();
|
|
78
|
-
const
|
|
79
|
-
for (const entry of
|
|
102
|
+
const emulatorEntries = devices.filter((entry) => entry.id.startsWith("emulator-") && (options.includeOffline || entry.state === "device"));
|
|
103
|
+
for (const entry of emulatorEntries) {
|
|
80
104
|
const runningAvdName = await getEmulatorAvdName(entry.id);
|
|
81
105
|
if (runningAvdName === avdName) {
|
|
82
|
-
return entry
|
|
106
|
+
return entry;
|
|
83
107
|
}
|
|
84
108
|
}
|
|
85
109
|
return null;
|
|
86
110
|
}
|
|
111
|
+
function offlineEmulatorError(avdName) {
|
|
112
|
+
return new Error(getAndroidConnectErrorMessage({ state: "offline", avdName }));
|
|
113
|
+
}
|
|
87
114
|
async function launchAndWaitForAvd(avdName, timeoutMs = AVD_READY_TIMEOUT_MS, options = {}) {
|
|
88
115
|
const log = options.log || console.log;
|
|
89
116
|
let avdExists = false;
|
|
@@ -99,6 +126,10 @@ async function launchAndWaitForAvd(avdName, timeoutMs = AVD_READY_TIMEOUT_MS, op
|
|
|
99
126
|
if (!avdExists) {
|
|
100
127
|
throw new Error(`AVD "${avdName}" was not found. Create it in Android Studio and retry.`);
|
|
101
128
|
}
|
|
129
|
+
const offlineMatch = await findConnectedEmulatorByAvdName(avdName, { includeOffline: true });
|
|
130
|
+
if (offlineMatch?.state === "offline") {
|
|
131
|
+
throw offlineEmulatorError(avdName);
|
|
132
|
+
}
|
|
102
133
|
const baseline = await listConnectedDevices();
|
|
103
134
|
const baselineReadyEmulators = new Set(baseline.filter((entry) => entry.state === "device" && entry.id.startsWith("emulator-")).map((entry) => entry.id));
|
|
104
135
|
log(`Launching Android emulator "${avdName}"...`);
|
|
@@ -128,12 +159,16 @@ export async function connectToDevice(deviceTargetId, options = {}) {
|
|
|
128
159
|
const readyDevices = devices.filter((entry) => entry.state === "device");
|
|
129
160
|
if (parsedTarget.kind === "auto") {
|
|
130
161
|
if (readyDevices.length === 0) {
|
|
162
|
+
const offlineEmulator = devices.find((entry) => entry.state === "offline" && entry.id.startsWith("emulator-"));
|
|
163
|
+
const message = offlineEmulator
|
|
164
|
+
? "Android emulator is running but offline. Restart adb or cold boot the emulator, then retry."
|
|
165
|
+
: "No ready Android devices found. Connect a device (or start an emulator), authorize adb, and retry.";
|
|
131
166
|
emitDesktopDebug("device.error", "device", {}, {
|
|
132
167
|
platform: "android",
|
|
133
168
|
operation: "connect",
|
|
134
|
-
message
|
|
169
|
+
message
|
|
135
170
|
});
|
|
136
|
-
throw new Error(
|
|
171
|
+
throw new Error(message);
|
|
137
172
|
}
|
|
138
173
|
const selected = readyDevices[0].id;
|
|
139
174
|
log(`Using connected Android device: ${selected}`);
|
|
@@ -142,15 +177,18 @@ export async function connectToDevice(deviceTargetId, options = {}) {
|
|
|
142
177
|
}
|
|
143
178
|
if (parsedTarget.kind === "avd") {
|
|
144
179
|
const deadline = Date.now() + AVD_READY_TIMEOUT_MS;
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
|
|
180
|
+
const existingEmulator = await findConnectedEmulatorByAvdName(parsedTarget.avdName, { includeOffline: true });
|
|
181
|
+
if (existingEmulator?.state === "offline") {
|
|
182
|
+
throw offlineEmulatorError(parsedTarget.avdName);
|
|
183
|
+
}
|
|
184
|
+
if (existingEmulator?.state === "device") {
|
|
185
|
+
const ready = await waitForWindowServiceReady(existingEmulator.id, remainingTimeoutMs(deadline));
|
|
148
186
|
if (!ready) {
|
|
149
187
|
throw emulatorLaunchTimeoutError();
|
|
150
188
|
}
|
|
151
|
-
log(`Using already-running emulator for AVD "${parsedTarget.avdName}": ${
|
|
152
|
-
emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId:
|
|
153
|
-
return
|
|
189
|
+
log(`Using already-running emulator for AVD "${parsedTarget.avdName}": ${existingEmulator.id}`);
|
|
190
|
+
emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: existingEmulator.id, reused: true });
|
|
191
|
+
return existingEmulator.id;
|
|
154
192
|
}
|
|
155
193
|
const launchedId = await launchAndWaitForAvd(parsedTarget.avdName, remainingTimeoutMs(deadline), { log });
|
|
156
194
|
const ready = await waitForWindowServiceReady(launchedId, remainingTimeoutMs(deadline));
|
|
@@ -179,19 +217,16 @@ export async function connectToDevice(deviceTargetId, options = {}) {
|
|
|
179
217
|
return targetId;
|
|
180
218
|
}
|
|
181
219
|
if (parsedTarget.kind === "legacy" && targetId) {
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
220
|
+
const matchedLegacyAvd = await findConnectedEmulatorByAvdName(targetId, { includeOffline: true });
|
|
221
|
+
if (matchedLegacyAvd?.state === "offline") {
|
|
222
|
+
throw offlineEmulatorError(targetId);
|
|
223
|
+
}
|
|
224
|
+
if (matchedLegacyAvd?.state === "device") {
|
|
225
|
+
emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: matchedLegacyAvd.id, reused: true });
|
|
226
|
+
return matchedLegacyAvd.id;
|
|
186
227
|
}
|
|
187
228
|
}
|
|
188
|
-
|
|
189
|
-
if (state === "offline") {
|
|
190
|
-
message = "Device is offline. Reconnect device or restart adb.";
|
|
191
|
-
}
|
|
192
|
-
else if (state === "unauthorized") {
|
|
193
|
-
message = "Authorize this computer on the device and retry.";
|
|
194
|
-
}
|
|
229
|
+
const message = getAndroidConnectErrorMessage({ state });
|
|
195
230
|
emitDesktopDebug("device.error", "device", {}, { platform: "android", operation: "connect", message, targetId: targetId || deviceTargetId, state: state || "missing" });
|
|
196
231
|
throw new Error(message);
|
|
197
232
|
}
|
|
@@ -3,21 +3,28 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { printCliOutput } from "../utils/console-output.js";
|
|
5
5
|
export { buildAssertionSystemPrompt } from "../prompts/execution.js";
|
|
6
|
+
const ASSERTION_PREFIXES = ["assert", "verify", "validate", "ensure"];
|
|
7
|
+
function hasAssertionPrefix(lower) {
|
|
8
|
+
return ASSERTION_PREFIXES.some((prefix) => lower === prefix || lower.startsWith(`${prefix}:`) || lower.startsWith(`${prefix} `));
|
|
9
|
+
}
|
|
6
10
|
export function isAssertion(userInput) {
|
|
7
11
|
const trimmed = userInput.trim();
|
|
8
12
|
const lower = trimmed.toLowerCase();
|
|
9
|
-
return
|
|
13
|
+
return hasAssertionPrefix(lower);
|
|
10
14
|
}
|
|
11
15
|
export function extractAssertionPrompt(userInput) {
|
|
12
16
|
const trimmed = userInput.trim();
|
|
13
17
|
const lower = trimmed.toLowerCase();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
for (const prefix of ASSERTION_PREFIXES) {
|
|
19
|
+
if (lower === prefix) {
|
|
20
|
+
return trimmed;
|
|
21
|
+
}
|
|
22
|
+
if (lower.startsWith(`${prefix}:`)) {
|
|
23
|
+
return trimmed.substring(prefix.length + 1).trim();
|
|
24
|
+
}
|
|
25
|
+
if (lower.startsWith(`${prefix} `)) {
|
|
26
|
+
return trimmed.substring(prefix.length).trim();
|
|
27
|
+
}
|
|
21
28
|
}
|
|
22
29
|
return trimmed;
|
|
23
30
|
}
|
|
@@ -14,11 +14,11 @@ const execAsync = promisify(exec);
|
|
|
14
14
|
// Active session state
|
|
15
15
|
let activeSession = null;
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
* @param {string}
|
|
19
|
-
* @returns {Promise<string|null>}
|
|
17
|
+
* Resolve simulator by UDID or name (name matches prefer already-booted simulators).
|
|
18
|
+
* @param {string} simulatorNameOrUdid
|
|
19
|
+
* @returns {Promise<{name: string, udid: string, state: string}|null>}
|
|
20
20
|
*/
|
|
21
|
-
async function
|
|
21
|
+
async function resolveSimulator(simulatorNameOrUdid) {
|
|
22
22
|
try {
|
|
23
23
|
const { stdout } = await execAsync("xcrun simctl list devices --json");
|
|
24
24
|
const data = JSON.parse(stdout);
|
|
@@ -26,25 +26,31 @@ async function getSimulatorUDID(simulatorName) {
|
|
|
26
26
|
let bootedMatch = null;
|
|
27
27
|
for (const runtime of Object.values(data.devices)) {
|
|
28
28
|
for (const device of runtime) {
|
|
29
|
-
if (device.
|
|
29
|
+
if (!device.isAvailable) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (device.udid === simulatorNameOrUdid) {
|
|
33
|
+
return { name: device.name, udid: device.udid, state: device.state };
|
|
34
|
+
}
|
|
35
|
+
if (device.name === simulatorNameOrUdid) {
|
|
36
|
+
const match = { name: device.name, udid: device.udid, state: device.state };
|
|
30
37
|
if (!firstMatch) {
|
|
31
|
-
firstMatch =
|
|
38
|
+
firstMatch = match;
|
|
32
39
|
}
|
|
33
|
-
// Prefer the already-booted one
|
|
34
40
|
if (device.state === "Booted") {
|
|
35
|
-
bootedMatch =
|
|
41
|
+
bootedMatch = match;
|
|
36
42
|
}
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
45
|
}
|
|
40
46
|
if (bootedMatch) {
|
|
41
|
-
logger.debug(`Found booted simulator: ${bootedMatch}`);
|
|
47
|
+
logger.debug(`Found booted simulator: ${bootedMatch.udid}`);
|
|
42
48
|
return bootedMatch;
|
|
43
49
|
}
|
|
44
50
|
return firstMatch;
|
|
45
51
|
}
|
|
46
52
|
catch (error) {
|
|
47
|
-
logger.error("Failed to
|
|
53
|
+
logger.error("Failed to resolve simulator", { error: error.message });
|
|
48
54
|
}
|
|
49
55
|
return null;
|
|
50
56
|
}
|
|
@@ -84,33 +90,33 @@ async function findAnySimulator() {
|
|
|
84
90
|
*/
|
|
85
91
|
async function bootSimulator(simulatorName, options = {}) {
|
|
86
92
|
const log = options.log || console.log;
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
93
|
+
const simulator = await resolveSimulator(simulatorName);
|
|
94
|
+
if (!simulator) {
|
|
89
95
|
throw new Error(`Simulator "${simulatorName}" not found. Run "xcrun simctl list devices" to see available simulators.`);
|
|
90
96
|
}
|
|
91
|
-
log(`Found simulator "${
|
|
97
|
+
log(`Found simulator "${simulator.name}" with UDID: ${simulator.udid}`);
|
|
92
98
|
// Check if already booted
|
|
93
99
|
const { stdout } = await execAsync("xcrun simctl list devices --json");
|
|
94
100
|
const data = JSON.parse(stdout);
|
|
95
101
|
for (const runtime of Object.values(data.devices)) {
|
|
96
102
|
for (const device of runtime) {
|
|
97
|
-
if (device.udid === udid) {
|
|
103
|
+
if (device.udid === simulator.udid) {
|
|
98
104
|
if (device.state === "Booted") {
|
|
99
|
-
log(`Simulator "${
|
|
100
|
-
return
|
|
105
|
+
log(`Simulator "${simulator.name}" is already booted`);
|
|
106
|
+
return simulator;
|
|
101
107
|
}
|
|
102
108
|
break;
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
111
|
}
|
|
106
|
-
log(`Booting simulator "${
|
|
107
|
-
await execAsync(`xcrun simctl boot ${udid}`);
|
|
112
|
+
log(`Booting simulator "${simulator.name}"...`);
|
|
113
|
+
await execAsync(`xcrun simctl boot ${simulator.udid}`);
|
|
108
114
|
// Open Simulator app to show the device
|
|
109
115
|
await execAsync("open -a Simulator");
|
|
110
116
|
// Wait for boot to complete
|
|
111
|
-
await waitForSimulatorBoot(udid);
|
|
112
|
-
log(`Simulator "${
|
|
113
|
-
return
|
|
117
|
+
await waitForSimulatorBoot(simulator.udid);
|
|
118
|
+
log(`Simulator "${simulator.name}" is fully booted`);
|
|
119
|
+
return simulator;
|
|
114
120
|
}
|
|
115
121
|
/**
|
|
116
122
|
* Wait for simulator to finish booting
|
|
@@ -163,30 +169,30 @@ export async function connectToDevice(simulatorName, options = {}) {
|
|
|
163
169
|
log(`No simulator specified, using: ${simulatorName}`);
|
|
164
170
|
}
|
|
165
171
|
// Boot simulator
|
|
166
|
-
const
|
|
172
|
+
const simulator = await bootSimulator(simulatorName, { log });
|
|
167
173
|
// Create Appium session
|
|
168
174
|
log("Creating Appium session...");
|
|
169
175
|
const session = await appium.createSession({
|
|
170
176
|
platformName: "iOS",
|
|
171
177
|
"appium:automationName": "XCUITest",
|
|
172
|
-
"appium:deviceName":
|
|
173
|
-
"appium:udid": udid,
|
|
178
|
+
"appium:deviceName": simulator.name,
|
|
179
|
+
"appium:udid": simulator.udid,
|
|
174
180
|
"appium:noReset": true,
|
|
175
181
|
"appium:shouldTerminateApp": false,
|
|
176
182
|
});
|
|
177
183
|
activeSession = {
|
|
178
184
|
sessionId: session.sessionId,
|
|
179
|
-
udid,
|
|
180
|
-
simulatorName,
|
|
185
|
+
udid: simulator.udid,
|
|
186
|
+
simulatorName: simulator.name,
|
|
181
187
|
};
|
|
182
|
-
log(`Connected to simulator "${
|
|
188
|
+
log(`Connected to simulator "${simulator.name}" (${simulator.udid})`);
|
|
183
189
|
emitDesktopDebug("device.connect", "device", {}, {
|
|
184
190
|
platform: "ios",
|
|
185
191
|
stage: "success",
|
|
186
|
-
simulatorName,
|
|
187
|
-
udid
|
|
192
|
+
simulatorName: simulator.name,
|
|
193
|
+
udid: simulator.udid
|
|
188
194
|
});
|
|
189
|
-
return udid;
|
|
195
|
+
return simulator.udid;
|
|
190
196
|
}
|
|
191
197
|
/**
|
|
192
198
|
* Get device info (screen dimensions and scale)
|