@loadmill/droid-cua 2.43.0 → 2.45.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 +123 -8
- package/build/index.js +46 -17
- package/build/src/cli/device-selector.js +13 -6
- package/build/src/cli/headless-execution-config.js +9 -0
- package/build/src/cli/headless-report.js +40 -11
- package/build/src/cli/headless-runner.js +28 -14
- package/build/src/cli/headless-target.js +24 -0
- package/build/src/cli/ios-device-command.js +121 -0
- package/build/src/cli/ios-device-list.js +32 -0
- package/build/src/cli/network-capture-device.js +21 -0
- package/build/src/commands/help.js +15 -3
- package/build/src/commands/stop.js +1 -0
- package/build/src/core/capture-sources.js +4 -0
- package/build/src/core/droid-cua-run-usage.js +9 -2
- package/build/src/core/execution-context.js +8 -0
- package/build/src/core/execution-engine.js +3 -0
- package/build/src/core/report/render-html-report.js +299 -94
- package/build/src/core/report/run-log-model.js +84 -1
- package/build/src/core/report/run-report-model.js +4 -1
- package/build/src/core/report/run-report-sections.js +10 -0
- package/build/src/core/runtime-config.js +11 -1
- package/build/src/core/step-watchdog.js +61 -0
- package/build/src/desktop-shared/ios-real-device.js +82 -0
- package/build/src/device/factory.js +4 -0
- package/build/src/device/ios/actions.js +48 -12
- package/build/src/device/ios/appium-server.js +7 -18
- package/build/src/device/ios/connection.js +71 -12
- package/build/src/device/ios/node-cli-env.js +55 -0
- package/build/src/device/ios/real-device/device-state.js +40 -0
- package/build/src/device/ios/real-device/devicectl.js +154 -0
- package/build/src/device/ios/real-device/readiness.js +439 -0
- package/build/src/device/ios/real-device/session.js +176 -0
- package/build/src/device/ios/real-device/signing.js +466 -0
- package/build/src/device/ios/real-device/wda-provisioning.js +229 -0
- package/build/src/modes/execution-mode.js +157 -3
- package/build/src/network/certificate-authority.js +21 -1
- package/build/src/network/certificate-install.js +38 -0
- package/build/src/network/local-proxy.js +21 -1
- package/build/src/network/run-network-capture.js +16 -5
- package/build/src/prompts/step-watchdog.js +22 -0
- package/build/src/utils/desktop-debug.js +2 -2
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +2 -2
- package/skill/droid-cua/references/authoring-tests.md +1 -1
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +33 -2
- package/skill/droid-cua/references/running-and-healing.md +27 -2
|
@@ -6,6 +6,37 @@ export function isNetworkCaptureStarted(line) {
|
|
|
6
6
|
return (line.kind === "system" || line.kind === "info") &&
|
|
7
7
|
/^Network capture started(?::|$)/.test(line.text || "");
|
|
8
8
|
}
|
|
9
|
+
// Run-level capture notices belong in the report header, not instruction actions.
|
|
10
|
+
export function isNetworkCaptureNotice(line) {
|
|
11
|
+
return ["system", "info", "warning"].includes(line.kind) &&
|
|
12
|
+
/^(Network capture (?:started|incomplete|could not finish)|Captured \d+ (?:browser )?requests|No browser traffic observed|No traffic observed;|HTTPS reached the proxy)/i.test(line.text || "");
|
|
13
|
+
}
|
|
14
|
+
// Presentation shared by desktop and exported reports; original events remain intact.
|
|
15
|
+
export function runNotice(line) {
|
|
16
|
+
const text = line.text || "";
|
|
17
|
+
if (!["warning", "info", "system"].includes(line.kind))
|
|
18
|
+
return null;
|
|
19
|
+
if (/^Network capture (?:incomplete|could not finish):/.test(text)) {
|
|
20
|
+
return { message: "Network capture couldn’t finish. Some traffic may be missing.", details: text.replace(/^Network capture (?:incomplete|could not finish):\s*/, ""), warning: true };
|
|
21
|
+
}
|
|
22
|
+
if (text.startsWith("HTTPS reached the proxy but no requests were decrypted")) {
|
|
23
|
+
return { message: "HTTPS traffic couldn’t be decrypted. Check certificate trust on the device.", details: text, warning: true };
|
|
24
|
+
}
|
|
25
|
+
if (line.kind === "warning" && text.startsWith("Warning: could not load app context")) {
|
|
26
|
+
return { message: "App context couldn’t be loaded. This run continued without it.", warning: true };
|
|
27
|
+
}
|
|
28
|
+
const reportFailure = /^Failed to (write JUnit report|report run to Loadmill):\s*(.*)$/s.exec(text);
|
|
29
|
+
if (line.kind === "warning" && reportFailure) {
|
|
30
|
+
return { message: reportFailure[1] === "write JUnit report" ? "Couldn’t save the run report." : "Couldn’t report this run to Loadmill.", details: reportFailure[2], warning: true };
|
|
31
|
+
}
|
|
32
|
+
if (line.kind === "warning" && text === "Failed to upload Droid run report. The local report is still available.") {
|
|
33
|
+
return { message: "Couldn’t upload the report to Loadmill. The local report is still available.", warning: true };
|
|
34
|
+
}
|
|
35
|
+
if (text === "Network Capture supports local mobile devices and Chrome/Edge browsers only; this run continues without capture.") {
|
|
36
|
+
return { message: "Network capture isn’t supported on this device. This run continued without it.", warning: false };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
9
40
|
export function captureSourceOf(line) {
|
|
10
41
|
const captureSource = line.payload?.captureSource;
|
|
11
42
|
return typeof captureSource === "string" && captureSource.trim().length > 0 ? captureSource : null;
|
|
@@ -90,7 +121,7 @@ export function runSummaryData(event) {
|
|
|
90
121
|
const success = Boolean(payload.success);
|
|
91
122
|
const error = typeof payload.error === "string" && payload.error.trim().length > 0 ? payload.error : null;
|
|
92
123
|
const stopped = !success && Boolean(error && error.toLowerCase().includes("stopped by user"));
|
|
93
|
-
const isAssertionFailure = Boolean(error && error.startsWith("Assertion failed:"));
|
|
124
|
+
const isAssertionFailure = payload.failureKind === "step_watchdog" || Boolean(error && error.startsWith("Assertion failed:"));
|
|
94
125
|
return {
|
|
95
126
|
status: success ? "passed" : stopped ? "stopped" : isAssertionFailure ? "failed" : "errored",
|
|
96
127
|
durationMs: typeof payload.durationMs === "number" ? payload.durationMs : null,
|
|
@@ -255,3 +286,55 @@ export function networkCardData(lines) {
|
|
|
255
286
|
answer: networkAnswerPresentation(answer),
|
|
256
287
|
};
|
|
257
288
|
}
|
|
289
|
+
export function stepFailureData(event) {
|
|
290
|
+
if (event.eventType !== "error" && event.kind !== "error")
|
|
291
|
+
return null;
|
|
292
|
+
// Older saved runs identify this failure only by its message.
|
|
293
|
+
if (event.payload?.failureKind !== "step_watchdog" && event.text !== "Execution stopped: the last step could not be completed as instructed.")
|
|
294
|
+
return null;
|
|
295
|
+
const instruction = typeof event.payload?.instruction === "string" ? event.payload.instruction.trim() : "";
|
|
296
|
+
const details = typeof event.payload?.details === "string" ? event.payload.details.trim() : "";
|
|
297
|
+
return {
|
|
298
|
+
title: "Step Failed",
|
|
299
|
+
passed: false,
|
|
300
|
+
assertion: instruction || "Complete this step as instructed.",
|
|
301
|
+
details: stepCompletionData(details)?.reason || details || "This step could not be completed as instructed.",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
export function isRepeatedStepFailureMessage(events, index) {
|
|
305
|
+
const event = events[index];
|
|
306
|
+
if (event.eventType !== "assistant_message" && event.kind !== "assistant")
|
|
307
|
+
return false;
|
|
308
|
+
for (let i = index + 1; i < events.length; i++) {
|
|
309
|
+
const next = events[i];
|
|
310
|
+
if (next.eventType === "instruction_started" || next.kind === "user" || next.eventType === "run_finished")
|
|
311
|
+
break;
|
|
312
|
+
if (stepFailureData(next) && next.payload?.details === event.text.trim())
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
// A run's final error often repeats an error or assertion already shown in the timeline.
|
|
318
|
+
export function isRepeatedRunError(error, events) {
|
|
319
|
+
if (!error)
|
|
320
|
+
return false;
|
|
321
|
+
return events.some((event) => {
|
|
322
|
+
if (event.eventType !== "run_finished" && (event.eventType === "error" || event.kind === "error") && event.text === error)
|
|
323
|
+
return true;
|
|
324
|
+
const assertion = assertionData(event);
|
|
325
|
+
return assertion?.passed === false && (error === `Assertion failed: ${assertion.assertion}` ||
|
|
326
|
+
error === assertion.details);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
// Counts from child completions, excluding the aggregate suite completion event.
|
|
330
|
+
export function runSummaryCounts(events) {
|
|
331
|
+
const results = events.filter((event) => event.eventType === "run_finished" && typeof event.payload?.testsTotal !== "number").map(runSummaryData);
|
|
332
|
+
if (!results.length)
|
|
333
|
+
return null;
|
|
334
|
+
const counts = { instructionsTotal: 0, instructionsCompleted: 0, assertionsPassed: 0, assertionsFailed: 0, retries: 0 };
|
|
335
|
+
for (const result of results) {
|
|
336
|
+
for (const key of Object.keys(counts))
|
|
337
|
+
counts[key] += result[key] ?? 0;
|
|
338
|
+
}
|
|
339
|
+
return counts;
|
|
340
|
+
}
|
|
@@ -29,6 +29,7 @@ const VALID_EVENTS = new Set([
|
|
|
29
29
|
"tool_result",
|
|
30
30
|
"screenshot_captured",
|
|
31
31
|
"assertion_result",
|
|
32
|
+
"step_watchdog_result",
|
|
32
33
|
"retry",
|
|
33
34
|
"input_request",
|
|
34
35
|
"run_finished",
|
|
@@ -112,10 +113,12 @@ export function makeFailureOrError({ kind, type, message, body } = {}) {
|
|
|
112
113
|
}
|
|
113
114
|
// The single run_finished payload contract — the shape the shared renderer's
|
|
114
115
|
// runSummaryData() consumes. Producers map their own stat names to these canonical fields.
|
|
115
|
-
export function buildRunFinishedPayload({ success, error, durationMs, instructionsTotal, instructionsCompleted, actionsTotal, assertionsPassed, assertionsFailed, retries, } = {}) {
|
|
116
|
+
export function buildRunFinishedPayload({ success, error, failureKind, stepWatchdogMode, stepWatchdogUsage, durationMs, instructionsTotal, instructionsCompleted, actionsTotal, assertionsPassed, assertionsFailed, retries, } = {}) {
|
|
116
117
|
return {
|
|
117
118
|
success: Boolean(success),
|
|
118
119
|
error: error ?? null,
|
|
120
|
+
...(failureKind ? { failureKind } : {}),
|
|
121
|
+
...(stepWatchdogMode ? { stepWatchdogMode, stepWatchdogUsage } : {}),
|
|
119
122
|
durationMs,
|
|
120
123
|
instructionsTotal,
|
|
121
124
|
instructionsCompleted: instructionsCompleted ?? 0,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isNetworkCaptureStarted } from "./run-log-model.js";
|
|
1
2
|
const CLOUD_PROVIDERS = [
|
|
2
3
|
["browserstack", "BrowserStack", "BrowserStack"],
|
|
3
4
|
["lambdatest", "LambdaTest", "LambdaTest"],
|
|
@@ -149,3 +150,12 @@ export function buildRunReportSections(logs) {
|
|
|
149
150
|
}
|
|
150
151
|
return sections;
|
|
151
152
|
}
|
|
153
|
+
// Stable order, independent of execution order; each activity appears only once.
|
|
154
|
+
export function instructionActivities(lines) {
|
|
155
|
+
const sections = buildRunReportSections(lines);
|
|
156
|
+
return [
|
|
157
|
+
{ type: "assertion", label: "Assertion", present: lines.some((line) => line.eventType === "assertion_result") },
|
|
158
|
+
{ type: "loadmill", label: "Loadmill flow", present: sections.some((section) => section.kind === "loadmill") },
|
|
159
|
+
{ type: "network", label: "Network capture / query", present: lines.some(isNetworkCaptureStarted) || sections.some((section) => section.kind === "network") },
|
|
160
|
+
].filter((activity) => activity.present);
|
|
161
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { getExecutionRunId } from "./execution-context.js";
|
|
1
2
|
import OpenAI from "openai";
|
|
3
|
+
import { parseStepWatchdogMode } from "./step-watchdog.js";
|
|
2
4
|
import { normalizeOptionalAutomationUrl } from "../browser/playwright/url.js";
|
|
3
5
|
import { readContextOptimizationEnv, normalizeContextOptimizationEnabled, normalizeContextOptimizationThreshold, } from "./context-optimization.js";
|
|
4
6
|
import { DEFAULT_OPENAI_CUA_MODEL, isLoadmillCuaModel, resolveCuaModel, resolveCuaModelProviderMode, } from "./cua-models.js";
|
|
@@ -123,7 +125,7 @@ export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToke
|
|
|
123
125
|
loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
|
|
124
126
|
};
|
|
125
127
|
}
|
|
126
|
-
export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, explainMode = true, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
|
|
128
|
+
export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, explainMode = true, stepWatchdogMode = "off", stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
|
|
127
129
|
const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
|
|
128
130
|
? cuaModel
|
|
129
131
|
: resolveCuaModel(cuaModel);
|
|
@@ -134,6 +136,7 @@ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null,
|
|
|
134
136
|
: normalizedProviderMode,
|
|
135
137
|
cuaModel: normalizedCuaModel,
|
|
136
138
|
explainMode: explainMode !== false,
|
|
139
|
+
stepWatchdogMode: parseStepWatchdogMode(stepWatchdogMode),
|
|
137
140
|
stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
|
|
138
141
|
contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
|
|
139
142
|
contextOptimizationThreshold: normalizeContextOptimizationThreshold(contextOptimizationThreshold),
|
|
@@ -237,6 +240,13 @@ async function normalizeLoadmillGatewayErrorResponse(response) {
|
|
|
237
240
|
}
|
|
238
241
|
function createLoadmillGatewayFetch(baseFetch = fetch) {
|
|
239
242
|
return async (input, init) => {
|
|
243
|
+
const runId = getExecutionRunId();
|
|
244
|
+
if (runId) {
|
|
245
|
+
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
|
246
|
+
new Headers(init?.headers).forEach((value, key) => headers.set(key, value));
|
|
247
|
+
headers.set("X-Loadmill-Run-Id", runId);
|
|
248
|
+
init = { ...init, headers };
|
|
249
|
+
}
|
|
240
250
|
const response = await baseFetch(input, init);
|
|
241
251
|
if (response.ok) {
|
|
242
252
|
return response;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { buildStepWatchdogEvidence, STEP_WATCHDOG_INSTRUCTIONS } from "../prompts/step-watchdog.js";
|
|
2
|
+
export const STEP_WATCHDOG_MODES = ["off", "on"];
|
|
3
|
+
export const STEP_WATCHDOG_MODEL = "gpt-6-luna";
|
|
4
|
+
// Provisional threshold; calibrate against labeled runs before enabling by default.
|
|
5
|
+
export const STEP_WATCHDOG_THRESHOLD = 0.65;
|
|
6
|
+
export const STEP_WATCHDOG_TIMEOUT_MS = 10000;
|
|
7
|
+
/** @param {unknown} [value] */
|
|
8
|
+
export function parseStepWatchdogMode(value = "off") {
|
|
9
|
+
if (!STEP_WATCHDOG_MODES.includes(value)) {
|
|
10
|
+
throw new Error("stepWatchdogMode must be off or on.");
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
export async function evaluateStepWatchdog({ client, screenshotBase64, signal, onResponse, secretsByKey = {}, ...evidence }) {
|
|
15
|
+
if (!screenshotBase64)
|
|
16
|
+
throw new Error("Step Watchdog requires a current screenshot.");
|
|
17
|
+
let text = buildStepWatchdogEvidence(evidence);
|
|
18
|
+
for (const value of Object.values(secretsByKey).filter((value) => typeof value === "string" && value).sort((a, b) => b.length - a.length)) {
|
|
19
|
+
text = text.replaceAll(JSON.stringify(value).slice(1, -1), "[REDACTED]");
|
|
20
|
+
}
|
|
21
|
+
let response;
|
|
22
|
+
try {
|
|
23
|
+
response = await client.post("/decisions", {
|
|
24
|
+
body: {
|
|
25
|
+
model: STEP_WATCHDOG_MODEL,
|
|
26
|
+
input: [{ role: "user", content: [
|
|
27
|
+
{ type: "input_text", text },
|
|
28
|
+
{ type: "input_image", image_url: `data:image/png;base64,${screenshotBase64}` },
|
|
29
|
+
] }],
|
|
30
|
+
questions: [{ type: "predicate", name: "should_stop", instructions: STEP_WATCHDOG_INSTRUCTIONS }],
|
|
31
|
+
},
|
|
32
|
+
timeout: STEP_WATCHDOG_TIMEOUT_MS,
|
|
33
|
+
maxRetries: 0,
|
|
34
|
+
signal,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (signal?.aborted)
|
|
39
|
+
throw error;
|
|
40
|
+
if (error?.status === 404 || error?.status === 405) {
|
|
41
|
+
throw new Error("The selected AI provider does not support the Decisions endpoint. Loadmill runs require gateway support for /decisions.");
|
|
42
|
+
}
|
|
43
|
+
if (error?.name === "APIConnectionTimeoutError")
|
|
44
|
+
throw new Error("Step Watchdog timed out after 10 seconds.");
|
|
45
|
+
throw new Error(`Step Watchdog request failed${Number.isInteger(error?.status) ? ` (HTTP ${error.status})` : ""}. Check the selected AI provider's connection, credentials, and Decisions support.`);
|
|
46
|
+
}
|
|
47
|
+
onResponse?.(response);
|
|
48
|
+
const answers = Array.isArray(response?.answers) ? response.answers : [];
|
|
49
|
+
const answer = answers.find((item) => item?.name === "should_stop");
|
|
50
|
+
if (answer?.type === "refusal")
|
|
51
|
+
throw new Error("The Step Watchdog evaluator declined to answer.");
|
|
52
|
+
if (answers.length !== 1 || answer?.type !== "predicate" || !Number.isFinite(answer.probability) || answer.probability < 0 || answer.probability > 1) {
|
|
53
|
+
throw new Error("The Step Watchdog evaluator returned an invalid decision.");
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
probability: answer.probability,
|
|
57
|
+
shouldStop: answer.probability >= STEP_WATCHDOG_THRESHOLD,
|
|
58
|
+
model: response.model || STEP_WATCHDOG_MODEL,
|
|
59
|
+
usage: response.usage,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { iosDeviceLabel, iosDeviceStateHint } from "../cli/ios-device-list.js";
|
|
2
|
+
import { describeFailure } from "../device/ios/real-device/readiness.js";
|
|
3
|
+
import { IOS_DEVICE_PREFIX, parseIosDeviceToken } from "../device/ios/real-device/session.js";
|
|
4
|
+
export const IPHONE_STATUS_EVENT = "ios_device.status";
|
|
5
|
+
const TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/;
|
|
6
|
+
const CAUSE_REMEDIATION = {
|
|
7
|
+
"command-line-tools-selected": [{ kind: "run_fix", label: "Set Xcode Developer Path", fixId: "ios_set_xcode_developer_path" }],
|
|
8
|
+
"xcode-license": [{ kind: "run_fix", label: "Accept Xcode License", fixId: "ios_accept_xcode_license" }],
|
|
9
|
+
"ios-platform-missing": [{ kind: "run_fix", label: "Download iOS Platform", fixId: "ios_download_ios_platform" }],
|
|
10
|
+
};
|
|
11
|
+
const XCODE_CHECK_IDS = ["xcode_devicectl", "ios_platform"];
|
|
12
|
+
export function iphoneDeviceOptions(devices) {
|
|
13
|
+
return devices
|
|
14
|
+
.filter((device) => device.transport === "wired" && device.udid)
|
|
15
|
+
.map((device) => {
|
|
16
|
+
const hint = iosDeviceStateHint(device);
|
|
17
|
+
return {
|
|
18
|
+
name: `${IOS_DEVICE_PREFIX}${device.udid}`,
|
|
19
|
+
label: iosDeviceLabel(device),
|
|
20
|
+
platform: "ios",
|
|
21
|
+
running: true,
|
|
22
|
+
iosKind: "device",
|
|
23
|
+
iosUdid: device.udid,
|
|
24
|
+
...(hint ? { iosStateHint: hint } : {}),
|
|
25
|
+
};
|
|
26
|
+
})
|
|
27
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
28
|
+
}
|
|
29
|
+
export function isIphoneDeviceName(deviceName) {
|
|
30
|
+
return parseIosDeviceToken(deviceName) !== null;
|
|
31
|
+
}
|
|
32
|
+
export function toSetupCheckResult(result) {
|
|
33
|
+
const remediation = result.remediation ?? CAUSE_REMEDIATION[result.cause];
|
|
34
|
+
return {
|
|
35
|
+
id: result.id,
|
|
36
|
+
status: result.status,
|
|
37
|
+
title: result.title,
|
|
38
|
+
summary: result.summary,
|
|
39
|
+
...(result.details ? { details: result.details } : {}),
|
|
40
|
+
blocking: result.blocking,
|
|
41
|
+
checkedAt: result.checkedAt,
|
|
42
|
+
...(remediation ? { remediation } : {}),
|
|
43
|
+
...(result.kind ? { kind: result.kind } : {}),
|
|
44
|
+
...(result.blockedBy ? { blockedBy: result.blockedBy } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function orderIphoneSetupChecks(deviceResults, toolResults) {
|
|
48
|
+
const isXcode = (result) => XCODE_CHECK_IDS.includes(result.id);
|
|
49
|
+
return [...deviceResults.filter(isXcode), ...toolResults, ...deviceResults.filter((result) => !isXcode(result))];
|
|
50
|
+
}
|
|
51
|
+
export function iphoneFixResult(checkId, results) {
|
|
52
|
+
const result = results.find((entry) => entry.id === checkId);
|
|
53
|
+
if (result?.status === "pass") {
|
|
54
|
+
return { ok: true, summary: "Fix completed successfully.", details: result.summary };
|
|
55
|
+
}
|
|
56
|
+
return { ok: false, summary: "Fix failed.", details: result ? describeFailure(result) : `Unknown iPhone check: ${checkId}.` };
|
|
57
|
+
}
|
|
58
|
+
export function normalizeTeamId(value) {
|
|
59
|
+
if (typeof value !== "string")
|
|
60
|
+
return null;
|
|
61
|
+
const trimmed = value.trim().toUpperCase();
|
|
62
|
+
return TEAM_ID_PATTERN.test(trimmed) ? trimmed : null;
|
|
63
|
+
}
|
|
64
|
+
export function iphoneTeamOptions(teams) {
|
|
65
|
+
return teams.map((team) => ({
|
|
66
|
+
teamId: team.teamId,
|
|
67
|
+
label: team.teamName ? `${team.teamName} (${team.teamId})` : team.teamId,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
export function iphoneConnectOptions(deviceName, teamId) {
|
|
71
|
+
if (!isIphoneDeviceName(deviceName))
|
|
72
|
+
return {};
|
|
73
|
+
const normalized = normalizeTeamId(teamId);
|
|
74
|
+
return normalized ? { teamId: normalized } : {};
|
|
75
|
+
}
|
|
76
|
+
export function iphoneStatusLogEvent({ status, message }) {
|
|
77
|
+
return {
|
|
78
|
+
kind: status === "unlocked" ? "success" : "warning",
|
|
79
|
+
text: message,
|
|
80
|
+
payload: { event: IPHONE_STATUS_EVENT, status },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -9,6 +9,7 @@ import * as cloudConnection from "./cloud/connection.js";
|
|
|
9
9
|
import * as cloudActions from "./cloud/actions.js";
|
|
10
10
|
import * as iosConnection from "./ios/connection.js";
|
|
11
11
|
import * as iosActions from "./ios/actions.js";
|
|
12
|
+
import { parseIosDeviceToken } from "./ios/real-device/session.js";
|
|
12
13
|
// Current platform state
|
|
13
14
|
let currentPlatform = null;
|
|
14
15
|
let currentSource = "local";
|
|
@@ -25,6 +26,9 @@ export function detectPlatform(deviceName) {
|
|
|
25
26
|
if (process.env.DROID_CUA_PLATFORM === "android") {
|
|
26
27
|
return "android";
|
|
27
28
|
}
|
|
29
|
+
if (parseIosDeviceToken(deviceName)) {
|
|
30
|
+
return "ios";
|
|
31
|
+
}
|
|
28
32
|
// Auto-detect from device name
|
|
29
33
|
if (deviceName) {
|
|
30
34
|
const lower = deviceName.toLowerCase();
|
|
@@ -7,25 +7,55 @@ import * as appium from "./appium-client.js";
|
|
|
7
7
|
import { buildActionEventMeta } from "../action-event-meta.js";
|
|
8
8
|
import { buildIosAppSwitcherGesture, buildMobileKeypressPayload, getIosTransport, normalizeMobileKeypress } from "../mobile-keypress.js";
|
|
9
9
|
import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSensitiveDebugFields, buildTypeActionPayload, buildTypeActionText, isSensitiveAction, } from "../sensitive-action.js";
|
|
10
|
-
import { getActiveSession, getDevicePixelRatio } from "./connection.js";
|
|
10
|
+
import { getActiveSession, getDevicePixelRatio, recoverRealDevice } from "./connection.js";
|
|
11
11
|
import { logger } from "../../utils/logger.js";
|
|
12
12
|
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
13
13
|
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
14
14
|
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
15
|
-
import { closeAppiumKeyboardWithTap } from "../keyboard.js";
|
|
15
|
+
import { KEYBOARD_CLOSE_RESULTS, closeAppiumKeyboardWithTap } from "../keyboard.js";
|
|
16
|
+
export function realDeviceScreenBounds(session) {
|
|
17
|
+
const size = session?.realDevice ? session.windowSize : null;
|
|
18
|
+
if (!Number.isFinite(size?.width) || !Number.isFinite(size?.height)) {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
return { minX: 0, maxX: Math.round(size.width) - 1, minY: 0, maxY: Math.round(size.height) - 1 };
|
|
22
|
+
}
|
|
23
|
+
function clampToBounds(value, min, max) {
|
|
24
|
+
return Number.isFinite(min) && Number.isFinite(max) ? Math.min(Math.max(value, min), max) : value;
|
|
25
|
+
}
|
|
16
26
|
export async function closeKeyboard(_deviceId, { dismissalPoint = null, scale = 1 } = {}) {
|
|
17
27
|
const session = getActiveSession();
|
|
18
28
|
if (!session) {
|
|
19
29
|
throw new Error("No active iOS session");
|
|
20
30
|
}
|
|
21
31
|
const dpr = getDevicePixelRatio();
|
|
22
|
-
const
|
|
23
|
-
?
|
|
32
|
+
const tapPoint = dismissalPoint
|
|
33
|
+
? { x: Math.round((dismissalPoint.x / scale) / dpr), y: Math.round((dismissalPoint.y / scale) / dpr) }
|
|
24
34
|
: null;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
const keyboardShownReadings = [];
|
|
36
|
+
const result = await closeAppiumKeyboardWithTap({
|
|
37
|
+
isKeyboardShown: async () => {
|
|
38
|
+
const shown = await appium.isKeyboardShown(session.sessionId);
|
|
39
|
+
keyboardShownReadings.push(shown);
|
|
40
|
+
return shown;
|
|
41
|
+
},
|
|
42
|
+
tap: tapPoint ? () => appium.tap(session.sessionId, tapPoint.x, tapPoint.y) : null,
|
|
28
43
|
});
|
|
44
|
+
if (result.status === KEYBOARD_CLOSE_RESULTS.FAILED) {
|
|
45
|
+
const details = {
|
|
46
|
+
platform: "ios",
|
|
47
|
+
realDevice: session.realDevice === true,
|
|
48
|
+
dismissalPoint,
|
|
49
|
+
tapPoint,
|
|
50
|
+
unit: "points",
|
|
51
|
+
keyboardShownBefore: keyboardShownReadings[0] ?? null,
|
|
52
|
+
keyboardShownAfter: keyboardShownReadings[1] ?? null,
|
|
53
|
+
error: result.error ?? null,
|
|
54
|
+
};
|
|
55
|
+
logger.debug("iOS keyboard close failed", details);
|
|
56
|
+
emitDesktopDebug("device.keyboard.close_failed", "device", {}, details);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
29
59
|
}
|
|
30
60
|
/**
|
|
31
61
|
* Handle an action from the CUA model
|
|
@@ -100,7 +130,8 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
100
130
|
scale,
|
|
101
131
|
dpr,
|
|
102
132
|
fallbackStartX: 197,
|
|
103
|
-
fallbackStartY: 426
|
|
133
|
+
fallbackStartY: 426,
|
|
134
|
+
...realDeviceScreenBounds(session)
|
|
104
135
|
});
|
|
105
136
|
addOutput({
|
|
106
137
|
type: "action",
|
|
@@ -117,10 +148,11 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
117
148
|
const start = path[0];
|
|
118
149
|
const end = path[path.length - 1];
|
|
119
150
|
// Convert to pixels then to logical points
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const
|
|
151
|
+
const { minX, maxX, minY, maxY } = realDeviceScreenBounds(session);
|
|
152
|
+
const startX = clampToBounds(Math.round((start.x / scale) / dpr), minX, maxX);
|
|
153
|
+
const startY = clampToBounds(Math.round((start.y / scale) / dpr), minY, maxY);
|
|
154
|
+
const endX = clampToBounds(Math.round((end.x / scale) / dpr), minX, maxX);
|
|
155
|
+
const endY = clampToBounds(Math.round((end.y / scale) / dpr), minY, maxY);
|
|
124
156
|
addOutput({
|
|
125
157
|
type: "action",
|
|
126
158
|
text: `Dragging from (${startX}, ${startY}) to (${endX}, ${endY}) points`,
|
|
@@ -213,6 +245,10 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
213
245
|
}
|
|
214
246
|
});
|
|
215
247
|
addOutput({ type: "info", text: "Full error details have been logged to the debug log." });
|
|
248
|
+
if (session.realDevice) {
|
|
249
|
+
await recoverRealDevice(error, { log: (text) => addOutput({ type: "info", text }) })
|
|
250
|
+
.catch((recoveryError) => addOutput({ type: "error", text: recoveryError.message }));
|
|
251
|
+
}
|
|
216
252
|
return { success: false, error: outputError.message };
|
|
217
253
|
}
|
|
218
254
|
}
|
|
@@ -5,30 +5,19 @@
|
|
|
5
5
|
* Auto-starts if not running, cleans up on process exit.
|
|
6
6
|
*/
|
|
7
7
|
import { spawn } from "child_process";
|
|
8
|
-
import { existsSync
|
|
8
|
+
import { existsSync } from "fs";
|
|
9
|
+
import os from "os";
|
|
9
10
|
import path from "path";
|
|
10
|
-
import { COMMON_NODE_BIN_DIRS, makeNodeCliCommandEnv } from "./node-cli-env.js";
|
|
11
|
+
import { COMMON_NODE_BIN_DIRS, listNvmNodeBinDirs, makeNodeCliCommandEnv } from "./node-cli-env.js";
|
|
11
12
|
let appiumProcess = null;
|
|
12
13
|
let cleanupRegistered = false;
|
|
13
14
|
function getAppiumBaseUrl() {
|
|
14
15
|
return process.env.APPIUM_URL || "http://localhost:4723";
|
|
15
16
|
}
|
|
16
|
-
function collectNvmAppiumCandidates() {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const nvmNodeVersionsDir = path.join(home, ".nvm", "versions", "node");
|
|
21
|
-
if (!existsSync(nvmNodeVersionsDir))
|
|
22
|
-
return [];
|
|
23
|
-
try {
|
|
24
|
-
return readdirSync(nvmNodeVersionsDir)
|
|
25
|
-
.map((versionDir) => path.join(nvmNodeVersionsDir, versionDir, "bin", "appium"))
|
|
26
|
-
.filter((candidate) => existsSync(candidate))
|
|
27
|
-
.reverse();
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
return [];
|
|
31
|
-
}
|
|
17
|
+
export function collectNvmAppiumCandidates(home = os.homedir()) {
|
|
18
|
+
return listNvmNodeBinDirs(home)
|
|
19
|
+
.map((binDir) => path.join(binDir, "appium"))
|
|
20
|
+
.filter((candidate) => existsSync(candidate));
|
|
32
21
|
}
|
|
33
22
|
export function resolveAppiumCommand() {
|
|
34
23
|
const explicit = process.env.DROID_CUA_APPIUM_PATH?.trim() || process.env.APPIUM_PATH?.trim();
|
|
@@ -10,6 +10,7 @@ import { startAppium, setupAppiumCleanup } from "./appium-server.js";
|
|
|
10
10
|
import * as appium from "./appium-client.js";
|
|
11
11
|
import { logger } from "../../utils/logger.js";
|
|
12
12
|
import { emitDesktopDebug } from "../../utils/desktop-debug.js";
|
|
13
|
+
import { IOS_DEVICE_PREFIX, createRealDeviceSession, parseIosDeviceToken, recoverIfLocked, recoverRealDeviceSession, startRealDeviceSession, } from "./real-device/session.js";
|
|
13
14
|
const execAsync = promisify(exec);
|
|
14
15
|
// Active session state
|
|
15
16
|
let activeSession = null;
|
|
@@ -157,6 +158,13 @@ export async function connectToDevice(simulatorName, options = {}) {
|
|
|
157
158
|
setupAppiumCleanup();
|
|
158
159
|
// Start Appium if not running
|
|
159
160
|
await startAppium({ log });
|
|
161
|
+
const realDevice = parseIosDeviceToken(simulatorName);
|
|
162
|
+
if (realDevice) {
|
|
163
|
+
return connectToRealDevice(realDevice.udid, {
|
|
164
|
+
log,
|
|
165
|
+
teamId: resolveRealDeviceTeamId(realDevice.udid, options.teamId),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
160
168
|
// If no simulator specified, auto-detect
|
|
161
169
|
if (!simulatorName) {
|
|
162
170
|
const found = await findAnySimulator();
|
|
@@ -194,6 +202,22 @@ export async function connectToDevice(simulatorName, options = {}) {
|
|
|
194
202
|
});
|
|
195
203
|
return simulator.udid;
|
|
196
204
|
}
|
|
205
|
+
export function resolveRealDeviceTeamId(udid, explicitTeamId = null) {
|
|
206
|
+
const previous = activeSession?.realDevice && activeSession.udid === udid ? activeSession.teamId : null;
|
|
207
|
+
return explicitTeamId || previous || process.env.DROID_CUA_IOS_TEAM_ID || null;
|
|
208
|
+
}
|
|
209
|
+
async function connectToRealDevice(udid, { log, teamId }) {
|
|
210
|
+
activeSession = await startRealDeviceSession({ udid, teamId, log });
|
|
211
|
+
log(`Connected to iPhone "${activeSession.deviceName}" (${activeSession.udid})`);
|
|
212
|
+
emitDesktopDebug("device.connect", "device", {}, {
|
|
213
|
+
platform: "ios",
|
|
214
|
+
stage: "success",
|
|
215
|
+
realDevice: true,
|
|
216
|
+
deviceName: activeSession.deviceName,
|
|
217
|
+
udid: activeSession.udid
|
|
218
|
+
});
|
|
219
|
+
return `${IOS_DEVICE_PREFIX}${activeSession.udid}`;
|
|
220
|
+
}
|
|
197
221
|
/**
|
|
198
222
|
* Get device info (screen dimensions and scale)
|
|
199
223
|
* @param {string} simulatorId - The UDID returned from connectToDevice
|
|
@@ -216,15 +240,18 @@ export async function getDeviceInfo(simulatorId, options = {}) {
|
|
|
216
240
|
log(`Device pixel ratio: ${devicePixelRatio}x (${pixelWidth}x${pixelHeight} pixels, ${windowSize.width}x${windowSize.height} points)`);
|
|
217
241
|
// Store the pixel ratio for coordinate conversion
|
|
218
242
|
activeSession.devicePixelRatio = devicePixelRatio;
|
|
243
|
+
activeSession.windowSize = windowSize;
|
|
219
244
|
// Use pixel dimensions as the "real" resolution (like Android does)
|
|
220
245
|
const targetWidth = 400;
|
|
221
246
|
const scale = pixelWidth > targetWidth ? targetWidth / pixelWidth : 1.0;
|
|
222
247
|
const scaledWidth = Math.round(pixelWidth * scale);
|
|
223
248
|
const scaledHeight = Math.round(pixelHeight * scale);
|
|
249
|
+
const name = activeSession.realDevice ? activeSession.deviceName : activeSession.simulatorName;
|
|
250
|
+
const model = activeSession.realDevice ? activeSession.model : activeSession.simulatorName;
|
|
224
251
|
return {
|
|
225
252
|
platform: "ios",
|
|
226
|
-
device_name:
|
|
227
|
-
model:
|
|
253
|
+
device_name: name || simulatorId,
|
|
254
|
+
model: model || simulatorId,
|
|
228
255
|
device_width: pixelWidth,
|
|
229
256
|
device_height: pixelHeight,
|
|
230
257
|
scaled_width: scaledWidth,
|
|
@@ -238,9 +265,24 @@ export async function getDeviceInfo(simulatorId, options = {}) {
|
|
|
238
265
|
* @param {object} deviceInfo
|
|
239
266
|
* @returns {Promise<string>}
|
|
240
267
|
*/
|
|
241
|
-
export async function getScreenshotAsBase64(simulatorId, deviceInfo) {
|
|
268
|
+
export async function getScreenshotAsBase64(simulatorId, deviceInfo, { deps } = {}) {
|
|
242
269
|
await ensureSessionAlive();
|
|
243
|
-
|
|
270
|
+
if (activeSession.realDevice) {
|
|
271
|
+
const sessionId = await recoverIfLocked(activeSession, { deps });
|
|
272
|
+
if (sessionId) {
|
|
273
|
+
activeSession.sessionId = sessionId;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
let rawBase64;
|
|
277
|
+
try {
|
|
278
|
+
rawBase64 = await appium.getScreenshot(activeSession.sessionId);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (!await recoverRealDevice(error)) {
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
rawBase64 = await appium.getScreenshot(activeSession.sessionId);
|
|
285
|
+
}
|
|
244
286
|
let buffer = Buffer.from(rawBase64, "base64");
|
|
245
287
|
logger.debug(`iOS screenshot captured: ${buffer.length} bytes before scaling`);
|
|
246
288
|
if (deviceInfo.scale < 1.0) {
|
|
@@ -279,14 +321,20 @@ async function ensureSessionAlive() {
|
|
|
279
321
|
reason: "appium session no longer active"
|
|
280
322
|
});
|
|
281
323
|
console.log("Session died, recreating...");
|
|
282
|
-
const session =
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
324
|
+
const session = activeSession.realDevice
|
|
325
|
+
? await createRealDeviceSession({
|
|
326
|
+
udid: activeSession.udid,
|
|
327
|
+
bundleIdBase: activeSession.bundleIdBase,
|
|
328
|
+
deviceName: activeSession.deviceName,
|
|
329
|
+
})
|
|
330
|
+
: await appium.createSession({
|
|
331
|
+
platformName: "iOS",
|
|
332
|
+
"appium:automationName": "XCUITest",
|
|
333
|
+
"appium:deviceName": activeSession.simulatorName,
|
|
334
|
+
"appium:udid": activeSession.udid,
|
|
335
|
+
"appium:noReset": true,
|
|
336
|
+
"appium:shouldTerminateApp": false,
|
|
337
|
+
});
|
|
290
338
|
activeSession.sessionId = session.sessionId;
|
|
291
339
|
emitDesktopDebug("reconnect.attempt", "device", {}, {
|
|
292
340
|
platform: "ios",
|
|
@@ -295,6 +343,17 @@ async function ensureSessionAlive() {
|
|
|
295
343
|
});
|
|
296
344
|
}
|
|
297
345
|
}
|
|
346
|
+
export async function recoverRealDevice(error, { log = console.log, deps } = {}) {
|
|
347
|
+
if (!activeSession?.realDevice) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
const sessionId = await recoverRealDeviceSession(activeSession, error, { log, deps });
|
|
351
|
+
if (!sessionId) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
activeSession.sessionId = sessionId;
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
298
357
|
/**
|
|
299
358
|
* Get the active session (for use by actions module)
|
|
300
359
|
* @returns {object|null}
|